vitrine

Merge branch 'diagrams-graphviz'...!

commit bd3b610b14c936dbca2e58d1fa6ea6a718f61a3f · parent 9d8280db2a · parent 5530376427 · browse this tree
warpfork, 2026-08-09 16:10:44 UTC

diff --git a/README.md b/README.md
index 48c71ee..cb2b1ed 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,28 @@ Callouts do not nest, by design,
 and an unterminated callout simply runs to the end of the document rather than breaking it.
 
 
+Diagrams
+--------
+
+A fenced code block tagged `dot` (or its alias `graphviz`)
+renders server-side to inline SVG, so diagrams need no JavaScript in the page.
+Write the DOT source in a normal fenced block:
+
+````
+```dot
+digraph { rankdir=LR; a -> b -> c; b [shape=box]; }
+```
+````
+
+- The source language is Graphviz's DOT.
+  Rendering runs entirely in-process via an embedded Graphviz WebAssembly build,
+  so there is no external `dot` binary to install and no browser involved.
+- A diagram whose DOT fails to compile does not break the page:
+  it falls back to showing its own source in a code block, flagged with the parse error,
+  so the author sees exactly what did not render.
+- Any other code language (or none) renders as an ordinary code block, unchanged.
+
+
 expose-git
 ----------
 
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)),
diff --git a/style.css b/style.css
index 42fcaf2..381d21d 100644
--- a/style.css
+++ b/style.css
@@ -160,3 +160,31 @@ article .callout-warning { --callout: #ef7c00; --callout-fill: 18%; --callout-ic
 article .callout-danger { --callout: #cf222e; --callout-icon: "\1F9E8"; }   /* firecracker, red */
 article .callout-reject { --callout: #cf222e; --callout-icon: "\274C"; }    /* cross mark, red */
 
+
+/* Diagrams: server-rendered inline SVG from ```dot fenced blocks.
+   Inset like a code block and centered; a wide diagram scales down to the
+   content width rather than overflowing, while a small one keeps its size. */
+article .diagram {
+	margin: 1.25rem 0 1.25rem 3rem;
+	text-align: center;
+}
+article .diagram svg {
+	max-width: 100%;
+	height: auto;
+}
+
+/* A diagram that failed to render echoes its source in a bordered well,
+   flagged in the danger hue so the failure is visible at a glance. */
+article .diagram-error {
+	text-align: left;
+	border: 1px solid #cf222e;
+	border-radius: .25rem;
+	background: color-mix(in srgb, #cf222e 8%, var(--paper));
+	padding: .4rem .8rem;
+}
+article .diagram-error-note {
+	margin: .2rem 0;
+	font-weight: bold;
+	color: #cf222e;
+}
+article .diagram-error pre { margin-left: 0; }