vitrine

diagrams: render ```dot fenced blocks to inline SVG via go-graphviz

commit 66ff02c7fb7ae4021af3ebe8ef4e8e406c058ba2 · parent 84ae9471ab · browse this tree
Ximianth, 2026-08-09 14:08:06 UTC

Author-written DOT (tag `dot`, alias `graphviz`) is rendered server-side to
inline SVG, so diagrams work with no JavaScript in the page.  Rendering runs
in-process through goccy/go-graphviz, which executes Graphviz as an embedded
WebAssembly module under wazero: no cgo, no external `dot`, no browser.

A goldmark AST transformer swaps matching fenced code blocks for a diagram
node; the node renderer emits `<div class="diagram">` wrapping the SVG.  Any
other code language is left as an ordinary code block, unchanged.

Two properties needed care and drove the design:

- Concurrency.  go-graphviz drives a single process-global wasm module with
  shared mutable state and no internal locking, so concurrent renders corrupt
  it and abort the process ("concurrent map writes").  vitrine serves requests
  concurrently, so every render is serialized under one mutex around a single
  long-lived engine reused across renders.  A test fires many diagrams at once
  and fails without the lock; renders are ~1-2ms, so serializing costs nothing
  that matters at our volume.

- Fallback.  DOT that does not compile returns an error rather than panicking,
  and takes a fail-visible path: the source is echoed in a code block flagged
  with the parse error, so a bad diagram never takes the page down and the
  author sees their input.  Matches the fail-stable-at-request-time stance.

The engine emits SVG only; graphviz escapes text content, so a hostile label
cannot break out of the inline SVG into live HTML.  A coarse source-size cap
bounds the most obvious way one diagram could stall others under the shared
lock.
diff --git a/diagram.go b/diagram.go
new file mode 100644
index 0000000..fde923f
--- /dev/null
+++ b/diagram.go
@@ -0,0 +1,209 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"sync"
+
+	graphviz "github.com/goccy/go-graphviz"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/text"
+	"github.com/yuin/goldmark/util"
+)
+
+// Diagrams turn a fenced code block tagged as a graph language into inline SVG,
+// rendered on the server so the page needs no JavaScript:
+//
+//	```dot
+//	digraph { a -> b -> c }
+//	```
+//
+// The rendering engine is go-graphviz, which runs Graphviz as an embedded
+// WebAssembly module under wazero: no cgo, no external dot binary, no browser.
+//
+// A diagram whose source does not compile falls back to echoing that source in
+// a failure-tagged code block rather than panicking or failing the whole page,
+// matching vitrine's fail-stable-at-request-time stance.
+
+// diagramTags are the fenced-code languages rendered as diagrams.  dot is the
+// canonical Graphviz language; graphviz is an accepted alias, mirroring how
+// callouts fold warn onto warning.  Both feed the same DOT engine.
+var diagramTags = map[string]bool{
+	"dot":      true,
+	"graphviz": true,
+}
+
+// maxDiagramSource caps the DOT handed to the engine.  A render holds a
+// process-wide lock (see diagramEngine), so an oversized diagram would stall
+// every other diagram render while Graphviz lays it out; this bounds the obvious
+// axis of that exposure.  It does not bound layout cost in general -- a small but
+// adversarial graph can still be expensive -- so it is a coarse guard, not a
+// complete one.  Legitimate diagrams are far smaller than this.
+const maxDiagramSource = 64 << 10
+
+// diagramNode is the block node produced by diagramTransformer in place of a
+// fenced code block, and consumed by diagramRenderer.  It carries the DOT source
+// captured at transform time; it has no children.
+type diagramNode struct {
+	ast.BaseBlock
+	Source []byte
+}
+
+var kindDiagram = ast.NewNodeKind("Diagram")
+
+func (n *diagramNode) Kind() ast.NodeKind { return kindDiagram }
+
+func (n *diagramNode) Dump(src []byte, level int) { ast.DumpHelper(n, src, level, nil, nil) }
+
+// diagramEngine renders DOT to SVG through one long-lived go-graphviz instance,
+// serialized by a mutex.
+//
+// go-graphviz drives Graphviz as a single process-global WebAssembly module
+// (one instance, built in the library's init, with shared mutable state and no
+// internal locking).  Concurrent renders through it corrupt that state and abort
+// the process with "fatal error: concurrent map writes".  vitrine serves
+// requests concurrently, so holding engineMu across every render is load-bearing,
+// not a nicety.  Renders are a couple of milliseconds and diagrams are rare, so
+// serializing them costs nothing that matters here.
+//
+// The *graphviz.Graphviz is created once and reused: reuse is safe under the
+// lock and deterministic across renders, and it avoids re-running plugin setup
+// against the wasm module for every diagram.  If creation ever fails the error
+// is remembered rather than retried, since that failure is deterministic; every
+// diagram then takes the fail-visible source-echo path.
+type diagramEngine struct {
+	mu    sync.Mutex
+	gv    *graphviz.Graphviz
+	err   error
+	ready bool
+}
+
+var engine diagramEngine
+
+func (e *diagramEngine) renderDOT(src []byte) ([]byte, error) {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	if !e.ready {
+		e.gv, e.err = graphviz.New(context.Background())
+		e.ready = true
+	}
+	if e.err != nil {
+		return nil, e.err
+	}
+	graph, err := graphviz.ParseBytes(src)
+	if err != nil {
+		return nil, err
+	}
+	defer graph.Close()
+	var buf bytes.Buffer
+	if err := e.gv.Render(context.Background(), graph, graphviz.SVG, &buf); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// diagramTransformer swaps every diagram-tagged fenced code block for a
+// diagramNode.  Matching blocks are collected first and replaced after the walk,
+// so the tree is not mutated while it is being traversed.
+type diagramTransformer struct{}
+
+func (diagramTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
+	source := reader.Source()
+	var targets []*ast.FencedCodeBlock
+	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+		if !entering {
+			return ast.WalkContinue, nil
+		}
+		if fc, ok := n.(*ast.FencedCodeBlock); ok {
+			if lang := fc.Language(source); lang != nil && diagramTags[string(lang)] {
+				targets = append(targets, fc)
+			}
+		}
+		return ast.WalkContinue, nil
+	})
+	for _, fc := range targets {
+		d := &diagramNode{Source: fencedText(fc, source)}
+		fc.Parent().ReplaceChild(fc.Parent(), fc, d)
+	}
+}
+
+// fencedText concatenates the raw lines of a fenced code block back into one
+// buffer, the DOT source to render.
+func fencedText(fc *ast.FencedCodeBlock, source []byte) []byte {
+	var buf bytes.Buffer
+	lines := fc.Lines()
+	for i := 0; i < lines.Len(); i++ {
+		seg := lines.At(i)
+		buf.Write(seg.Value(source))
+	}
+	return buf.Bytes()
+}
+
+// diagramRenderer writes a diagramNode as inline SVG in a "diagram" container,
+// so style.css can inset and scale it consistently with other article blocks.
+type diagramRenderer struct{}
+
+func (diagramRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(kindDiagram, renderDiagram)
+}
+
+func renderDiagram(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+	n := node.(*diagramNode)
+	if len(n.Source) > maxDiagramSource {
+		writeDiagramFallback(w, n.Source, "diagram source exceeds the size limit")
+		return ast.WalkContinue, nil
+	}
+	svg, err := engine.renderDOT(n.Source)
+	if err != nil {
+		writeDiagramFallback(w, n.Source, err.Error())
+		return ast.WalkContinue, nil
+	}
+	_, _ = w.WriteString(`<div class="diagram">` + "\n")
+	_, _ = w.Write(stripSVGProlog(svg))
+	_, _ = w.WriteString("</div>\n")
+	return ast.WalkContinue, nil
+}
+
+// writeDiagramFallback renders a diagram that could not be produced as its raw
+// source in a code block, with a visible note carrying the reason.  Echoing the
+// source keeps a bad diagram from taking the page down and shows the author
+// exactly what did not render.  The source and reason are HTML-escaped, so a
+// hostile diagram cannot inject structure through the failure path.
+func writeDiagramFallback(w util.BufWriter, src []byte, reason string) {
+	_, _ = w.WriteString(`<div class="diagram diagram-error">` + "\n")
+	_, _ = w.WriteString(`<p class="diagram-error-note">diagram failed to render: `)
+	_, _ = w.Write(util.EscapeHTML([]byte(reason)))
+	_, _ = w.WriteString("</p>\n<pre><code>")
+	_, _ = w.Write(util.EscapeHTML(src))
+	_, _ = w.WriteString("</code></pre>\n</div>\n")
+}
+
+// stripSVGProlog trims graphviz's XML declaration, DOCTYPE, and generator
+// comments, returning the document from the opening <svg tag onward.  Inline SVG
+// in an HTML page needs no XML prolog.  If no <svg tag is found the input is
+// returned unchanged, so an unexpected output still reaches the page.
+func stripSVGProlog(svg []byte) []byte {
+	if i := bytes.Index(svg, []byte("<svg")); i >= 0 {
+		return svg[i:]
+	}
+	return svg
+}
+
+// diagrams is the goldmark extender that wires the diagram transformer and
+// renderer into the pipeline, at the same priority callouts use.
+type diagrams struct{}
+
+func (diagrams) Extend(m goldmark.Markdown) {
+	m.Parser().AddOptions(parser.WithASTTransformers(
+		util.Prioritized(diagramTransformer{}, 100),
+	))
+	m.Renderer().AddOptions(renderer.WithNodeRenderers(
+		util.Prioritized(diagramRenderer{}, 100),
+	))
+}
diff --git a/diagram_test.go b/diagram_test.go
new file mode 100644
index 0000000..a6dfd93
--- /dev/null
+++ b/diagram_test.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+	"bytes"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/yuin/goldmark/parser"
+)
+
+func TestDiagramDotProducesSVG(t *testing.T) {
+	got := renderMD(t, "```dot\ndigraph { a -> b }\n```\n")
+	if !strings.Contains(got, `<div class="diagram">`) {
+		t.Errorf("missing diagram wrapper; got:\n%s", got)
+	}
+	if !strings.Contains(got, "<svg") {
+		t.Errorf("expected inline <svg>; got:\n%s", got)
+	}
+	// The block must be consumed as a diagram, not left as a code block.
+	if strings.Contains(got, "<pre><code") {
+		t.Errorf("dot block leaked through as a code block; got:\n%s", got)
+	}
+}
+
+func TestDiagramGraphvizAlias(t *testing.T) {
+	// The graphviz tag is an alias for dot; both feed the same engine.
+	got := renderMD(t, "```graphviz\ndigraph { a -> b }\n```\n")
+	if !strings.Contains(got, `<div class="diagram">`) || !strings.Contains(got, "<svg") {
+		t.Errorf("graphviz alias did not render a diagram; got:\n%s", got)
+	}
+}
+
+func TestDiagramBrokenDotFallsBackVisibly(t *testing.T) {
+	// A diagram that does not compile must not panic or 500; it echoes its
+	// source in a failure-tagged box so the author still sees their input.
+	got := renderMD(t, "```dot\ndigraph { a -> \n```\n")
+	if strings.Contains(got, "<svg") {
+		t.Errorf("broken dot unexpectedly produced an svg; got:\n%s", got)
+	}
+	if !strings.Contains(got, "diagram-error") {
+		t.Errorf("broken dot lacked the failure marker; got:\n%s", got)
+	}
+	if !strings.Contains(got, "digraph { a -&gt;") {
+		t.Errorf("broken dot did not echo its source; got:\n%s", got)
+	}
+}
+
+func TestDiagramPlainCodeBlockUntouched(t *testing.T) {
+	// A non-diagram language must render exactly as before: a plain code block.
+	got := renderMD(t, "```go\nfunc main() {}\n```\n")
+	if strings.Contains(got, "diagram") || strings.Contains(got, "<svg") {
+		t.Errorf("a go block was treated as a diagram; got:\n%s", got)
+	}
+	if !strings.Contains(got, "<pre><code") {
+		t.Errorf("a go block should render as a code block; got:\n%s", got)
+	}
+}
+
+func TestDiagramLabelIsEscaped(t *testing.T) {
+	// vitrine serves trees it may not have authored, so a hostile label must not
+	// break out of the SVG into live HTML.  Graphviz escapes text content, so the
+	// inline SVG carries no injected <script> and no premature </svg>.
+	got := renderMD(t, "```dot\ndigraph { a [label=\"</text></svg><script>x</script>\"] }\n```\n")
+	if strings.Contains(got, "<script>") {
+		t.Errorf("hostile label injected a live script; got:\n%s", got)
+	}
+	if strings.Count(got, "</svg>") != 1 {
+		t.Errorf("hostile label produced a stray </svg>; got:\n%s", got)
+	}
+}
+
+func TestDiagramConcurrentRenders(t *testing.T) {
+	// go-graphviz drives one process-global wasm module with no internal locking,
+	// so renders must be serialized.  This exercises that: many diagrams rendered
+	// at once must all succeed.  Without the serializing lock the shared module
+	// corrupts and the process aborts with "concurrent map writes".
+	const src = "```dot\ndigraph { a -> b -> c }\n```\n"
+	var wg sync.WaitGroup
+	fails := make(chan string, 16)
+	for i := 0; i < 16; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			var buf bytes.Buffer
+			ctx := parser.NewContext(parser.WithIDs(newSlugIDs()))
+			if err := markdown.Convert([]byte(src), &buf, parser.WithContext(ctx)); err != nil {
+				fails <- err.Error()
+				return
+			}
+			if !strings.Contains(buf.String(), "<svg") {
+				fails <- "no svg in output"
+			}
+		}()
+	}
+	wg.Wait()
+	close(fails)
+	for f := range fails {
+		t.Errorf("concurrent render failed: %s", f)
+	}
+}
diff --git a/go.mod b/go.mod
index 2ea9747..249522c 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,17 @@ module vitrine
 go 1.26.5
 
 require (
+	github.com/goccy/go-graphviz v0.2.10
 	github.com/yuin/goldmark v1.8.4
 	gopkg.in/yaml.v3 v3.0.1
 )
+
+require (
+	github.com/disintegration/imaging v1.6.2 // indirect
+	github.com/flopp/go-findfont v0.1.0 // indirect
+	github.com/fogleman/gg v1.3.0 // indirect
+	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+	github.com/tetratelabs/wazero v1.10.1 // indirect
+	golang.org/x/image v0.21.0 // indirect
+	golang.org/x/text v0.19.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 5ed7a65..17e8b4c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,27 @@
+github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
+github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
+github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
+github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
+github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
+github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g=
+github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
+golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/render.go b/render.go
index 3e6e3a4..f133a32 100644
--- a/render.go
+++ b/render.go
@@ -20,7 +20,7 @@ import (
 const maxRenderSize = 4 << 20
 
 var markdown = goldmark.New(
-	goldmark.WithExtensions(gmext.GFM, callouts{}),
+	goldmark.WithExtensions(gmext.GFM, callouts{}, diagrams{}),
 	goldmark.WithParserOptions(
 		parser.WithAutoHeadingID(),
 		parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),