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 ->") { + 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; }