Kosh renders D2 diagrams and LaTeX math at build time using native Go libraries, eliminating client-side JavaScript dependencies. This improves page load speed and works without JavaScript enabled.
Architecture
The native renderer uses a worker pool to parallelize rendering:
builder/renderer/native/
├── renderer.go # Worker pool manager
├── d2.go # D2 diagram rendering
├── math.go # KaTeX math rendering
└── katex.min.js # Embedded KaTeX library
From builder/renderer/native/renderer.go:
type Renderer struct {
pool chan *workerInstance // Pool of workers
once sync.Once
}
type workerInstance struct {
// D2 worker
ruler *d2svg.Ruler
// KaTeX worker
vm *goja.Runtime // JavaScript VM
katex goja.Value // KaTeX object
renderFn func(goja.Value, goja.Value, goja.Value) (goja.Value, error)
}
D2 Diagram Rendering
What is D2?
D2 (Declarative Diagramming) is a modern diagram scripting language created by Terrastruct. It’s simpler than Graphviz and more powerful than Mermaid.
Example D2 code:
x -> y: Hello
y -> z: World
x.shape: circle
z.shape: hexagon
Rendered output: An SVG diagram with shapes and arrows.
Implementation
From builder/renderer/native/d2.go:14-54:
func (r *Renderer) RenderD2(code string, themeID int64) (string, error) {
r.ensureInitialized()
// Acquire worker from pool
instance := <-r.pool
defer func() { r.pool <- instance }() // Release worker
// Configure layout (using Dagre algorithm)
layout := func(ctx context.Context, g *d2graph.Graph) error {
return d2dagrelayout.Layout(ctx, g, nil)
}
compileOpts := &d2lib.CompileOptions{
Layout: nil,
Ruler: instance.ruler,
}
compileOpts.LayoutResolver = func(engine string) (d2graph.LayoutGraph, error) {
return layout, nil
}
renderOpts := &d2svg.RenderOpts{
ThemeID: &themeID,
Pad: go2.Pointer(int64(0)),
}
ctx := d2log.WithDefault(context.Background())
// Compile D2 code to graph
diagram, _, err := d2lib.Compile(ctx, code, compileOpts, renderOpts)
if err != nil {
return "", fmt.Errorf("d2 compile failed: %w", err)
}
// Render graph to SVG
out, err := d2svg.Render(diagram, renderOpts)
if err != nil {
return "", fmt.Errorf("d2 render failed: %w", err)
}
return string(out), nil
}
Markdown Usage
In your Markdown files, use fenced code blocks with the d2 language:
```d2
user -> server: HTTP Request
server -> db: SQL Query
db -> server: Result Set
server -> user: HTML Response
```
Kosh automatically detects these blocks and renders them to SVG at build time.
Theming
D2 supports multiple themes:
themeID := 0 // Neutral (default)
themeID := 1 // Dark
themeID := 3 // Cool
themeID := 4 // Grape
Themes are configured in kosh.yaml:
d2Theme: 0 # Neutral theme
Caching
D2 diagrams are cached by input hash in builder/cache/types.go:44-53:
type SSRArtifact struct {
Type string // "d2"
InputHash string // BLAKE3 of D2 code
OutputHash string // BLAKE3 of SVG output
RefCount int
Size int64
Compressed bool // Zstd compression for large diagrams
}
Cache lookup:
inputHash := HashContent([]byte(d2Code))
if cached, err := cache.GetSSRArtifact(inputHash); cached != nil {
return cached.Output // Use cached SVG
}
// Otherwise, render and cache
svg := RenderD2(d2Code, themeID)
cache.StoreSSRArtifact(inputHash, svg)
Changing a D2 diagram only re-renders that specific diagram—the rest of the page uses cached HTML.
LaTeX Math Rendering
KaTeX Integration
Kosh uses KaTeX (via goja JavaScript runtime) to render LaTeX math server-side.
From builder/renderer/native/math.go:9-32:
func (r *Renderer) RenderMath(latex string, displayMode bool) (string, error) {
r.ensureInitialized()
// Acquire worker from pool
instance := <-r.pool
defer func() { r.pool <- instance }()
if instance.vm == nil || instance.renderFn == nil {
return "", fmt.Errorf("KaTeX not initialized in worker")
}
// Configure KaTeX options
opts := instance.vm.NewObject()
_ = opts.Set("displayMode", displayMode)
_ = opts.Set("throwOnError", false)
_ = opts.Set("output", "html")
// Call katex.renderToString(latex, opts)
result, err := instance.renderFn(instance.katex, instance.vm.ToValue(latex), opts)
if err != nil {
return "", fmt.Errorf("KaTeX render failed: %w", err)
}
return result.String(), nil
}
Markdown Syntax
Inline math (using $...$):
The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.
Display math (using $$...$$):
Kosh’s Goldmark parser detects these patterns and calls RenderMath().
Batch Rendering
For pages with many equations, Kosh renders them in parallel:
From builder/renderer/native/math.go:42-89:
func (r *Renderer) RenderAllMath(expressions []MathExpression, cache map[string]string) (map[string]string, error) {
results := make(map[string]string)
var mu sync.Mutex
var wg sync.WaitGroup
for _, expr := range expressions {
if _, exists := cache[expr.Hash]; exists {
continue // Skip cached expressions
}
wg.Add(1)
go func(e MathExpression) {
defer wg.Done()
// Acquire worker from pool
instance := <-r.pool
defer func() { r.pool <- instance }()
opts := instance.vm.NewObject()
_ = opts.Set("displayMode", e.DisplayMode)
_ = opts.Set("throwOnError", false)
_ = opts.Set("output", "html")
res, err := instance.renderFn(instance.katex, instance.vm.ToValue(e.LaTeX), opts)
if err != nil {
log.Printf("⚠️ LaTeX render failed for %s: %v", e.Hash[:8], err)
return
}
mu.Lock()
results[e.Hash] = res.String()
mu.Unlock()
}(expr)
}
wg.Wait()
return results, nil
}
Performance: 100 equations render in ~200ms using 4 workers.
Caching
Like D2 diagrams, LaTeX equations are cached:
type SSRArtifact struct {
Type string // "katex"
InputHash string // BLAKE3 of LaTeX code
OutputHash string // BLAKE3 of HTML output
// ...
}
Example:
inputHash := HashContent([]byte("E = mc^2"))
if cached, err := cache.GetSSRArtifact(inputHash); cached != nil {
return cached.Output // Use cached HTML
}
html := RenderMath("E = mc^2", true)
cache.StoreSSRArtifact(inputHash, html)
CSS Requirements
KaTeX generates HTML with CSS classes. Include KaTeX CSS in your theme:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
Or bundle it locally in themes/<theme>/static/css/katex.css.
Worker Pool
Both D2 and KaTeX use a shared worker pool to limit concurrency:
type Renderer struct {
pool chan *workerInstance // Buffered channel = pool size
}
func NewRenderer(poolSize int) *Renderer {
return &Renderer{
pool: make(chan *workerInstance, poolSize),
}
}
Default pool size: 4 workers (matches typical CPU cores)
Worker lifecycle:
- Acquire:
instance := <-r.pool
- Use:
svg := RenderD2(code, theme)
- Release:
r.pool <- instance
This prevents overwhelming the CPU with too many parallel renders.
Increasing pool size beyond CPU core count provides diminishing returns. D2 and KaTeX are CPU-bound tasks.
SSR Hash Tracking (v1.2.1)
From AGENTS.md:
SSR Hash Tracking (v1.2.1): D2 diagrams and LaTeX math hashes now tracked in SSRInputHashes field for proper cache management
In PostMeta, SSR hashes are stored:
type PostMeta struct {
// ...
SSRInputHashes []string // Hashes of all D2/LaTeX inputs
}
Why track hashes?
Previously, changing a diagram didn’t invalidate the post cache. Now:
oldHashes := cachedPost.SSRInputHashes
newHashes := extractSSRHashes(newContent)
if !hashesEqual(oldHashes, newHashes) {
return "cache miss" // Re-render post
}
This ensures diagrams and math always stay in sync with content.
Error Handling
D2 Errors
If D2 compilation fails, Kosh logs the error and includes the raw code:
diagram, _, err := d2lib.Compile(ctx, code, compileOpts, renderOpts)
if err != nil {
log.Printf("⚠️ D2 compilation failed: %v", err)
return fmt.Sprintf("<pre>D2 Error:\n%s</pre>", err)
}
Common D2 errors:
- Syntax errors (missing
:, unclosed quotes)
- Invalid shapes
- Circular dependencies
KaTeX Errors
KaTeX errors are handled gracefully:
opts.Set("throwOnError", false) // Don't crash on invalid LaTeX
result, err := renderFn(...)
if err != nil {
return fmt.Sprintf("<span class='katex-error'>%s</span>", latex)
}
Common LaTeX errors:
- Undefined control sequences (
\foo)
- Mismatched braces
- Invalid environments
Always test D2 and LaTeX syntax during development. Build-time errors are easier to fix than silent rendering failures.
100 posts with 5 diagrams each:
| Operation | Time | Notes |
|---|
| D2 render (cold) | 2.5s | 500 diagrams, 4 workers |
| D2 render (warm) | 50ms | All cached |
| KaTeX render (cold) | 800ms | 500 equations, 4 workers |
| KaTeX render (warm) | 20ms | All cached |
Single complex diagram:
| Diagram Complexity | Render Time |
|---|
| 10 nodes, 15 edges | 30ms |
| 50 nodes, 75 edges | 150ms |
| 200 nodes, 300 edges | 800ms |
Complex diagrams can slow builds significantly. Use caching and limit diagram size for best performance.
Alternatives
Client-Side Rendering
Kosh previously used client-side JS for D2/KaTeX. Why switch to SSR?
| Metric | Client-Side | Server-Side (SSR) |
|---|
| Page load | Slow (wait for JS) | Fast (pre-rendered) |
| JavaScript required | Yes | No |
| SEO | Poor (content not in HTML) | Excellent |
| Build time | Fast | Slower |
| Caching | Browser only | Build + browser |
Verdict: SSR is better for static sites. The build-time cost is offset by caching and faster user experience.
Dependencies
From AGENTS.md:
// D2
import "oss.terrastruct.com/d2/d2lib"
import "oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
// KaTeX (embedded)
//go:embed katex.min.js
var katexJS string
// JavaScript runtime
import "github.com/dop251/goja"
All dependencies are vendored in the Go binary—no external tools needed.