Skip to main content
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 $$...$$):
$$
E = mc^2
$$
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:
  1. Acquire: instance := <-r.pool
  2. Use: svg := RenderD2(code, theme)
  3. 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.

Performance Benchmarks

100 posts with 5 diagrams each:
OperationTimeNotes
D2 render (cold)2.5s500 diagrams, 4 workers
D2 render (warm)50msAll cached
KaTeX render (cold)800ms500 equations, 4 workers
KaTeX render (warm)20msAll cached
Single complex diagram:
Diagram ComplexityRender Time
10 nodes, 15 edges30ms
50 nodes, 75 edges150ms
200 nodes, 300 edges800ms
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?
MetricClient-SideServer-Side (SSR)
Page loadSlow (wait for JS)Fast (pre-rendered)
JavaScript requiredYesNo
SEOPoor (content not in HTML)Excellent
Build timeFastSlower
CachingBrowser onlyBuild + 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.

Build docs developers (and LLMs) love