Monorepo for Tangled

appview/markup: add mermaid diagram rendering support

Add MermaidJS diagram rendering for markdown content (READMEs, issues,
PRs) using goldmark-mermaid with client-side rendering. Mermaid fenced
code blocks are transformed to <pre class="mermaid"> elements, which
MermaidJS picks up and renders in the browser.

Changes:
- Add go.abhg.dev/goldmark/mermaid dependency with client-side render mode
- Add mermaid extender before highlighting in goldmark pipeline
- Allow "mermaid" class on <pre> elements through bluemonday sanitizer
- Conditionally load MermaidJS from CDN only on pages with diagrams
- Add basic mermaid container styling with dark mode support
- Add tests verifying mermaid blocks produce correct HTML output

Closes https://tangled.org/tangled.org/core/issues/424

Signed-off-by: Nate Spilman <nate.spilman@gmail.com>
Signed-off-by: Seongmin Lee <git@boltless.me>

authored by

Nate Spilman and committed by
Seongmin Lee
a1e6f903 36f120a7

+115 -2
+5
appview/pages/markup/markdown.go
··· 22 22 "github.com/yuin/goldmark/text" 23 23 "github.com/yuin/goldmark/util" 24 24 callout "gitlab.com/staticnoise/goldmark-callout" 25 + "go.abhg.dev/goldmark/mermaid" 25 26 htmlparse "golang.org/x/net/html" 26 27 27 28 "tangled.org/core/api/tangled" ··· 56 57 md := goldmark.New( 57 58 goldmark.WithExtensions( 58 59 extension.GFM, 60 + &mermaid.Extender{ 61 + RenderMode: mermaid.RenderModeClient, 62 + NoScript: true, 63 + }, 59 64 highlighting.NewHighlighting( 60 65 highlighting.WithFormatOptions( 61 66 chromahtml.Standalone(false),
+46
appview/pages/markup/markdown_test.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "strings" 5 6 "testing" 6 7 ) 8 + 9 + func TestMermaidExtension(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + markdown string 13 + contains string 14 + notContains string 15 + }{ 16 + { 17 + name: "mermaid block produces pre.mermaid", 18 + markdown: "```mermaid\ngraph TD\n A-->B\n```", 19 + contains: `<pre class="mermaid">`, 20 + notContains: `<code class="language-mermaid"`, 21 + }, 22 + { 23 + name: "mermaid block contains diagram source", 24 + markdown: "```mermaid\ngraph TD\n A-->B\n```", 25 + contains: "graph TD", 26 + }, 27 + { 28 + name: "non-mermaid code block is not affected", 29 + markdown: "```go\nfunc main() {}\n```", 30 + contains: `<pre class="chroma">`, 31 + }, 32 + } 33 + 34 + for _, tt := range tests { 35 + t.Run(tt.name, func(t *testing.T) { 36 + md := NewMarkdown("tangled.org") 37 + 38 + var buf bytes.Buffer 39 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 40 + t.Fatalf("failed to convert markdown: %v", err) 41 + } 42 + 43 + result := buf.String() 44 + if !strings.Contains(result, tt.contains) { 45 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 46 + } 47 + if tt.notContains != "" && strings.Contains(result, tt.notContains) { 48 + t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) 49 + } 50 + }) 51 + } 52 + } 7 53 8 54 func TestAtExtension_Rendering(t *testing.T) { 9 55 tests := []struct {
+1 -1
appview/pages/markup/sanitizer.go
··· 72 72 policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 73 74 74 // for code blocks 75 - policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma|mermaid`)).OnElements("pre") 76 76 policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
+17
appview/pages/templates/layouts/base.html
··· 39 39 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 40 40 41 41 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 42 + 43 + <script> 44 + document.addEventListener('DOMContentLoaded', () => { 45 + const nodes = document.querySelectorAll('pre.mermaid'); 46 + if (!nodes.length) return; 47 + const script = document.createElement('script'); 48 + script.src = '/static/mermaid.min.js'; 49 + script.onload = async () => { 50 + mermaid.initialize({ 51 + startOnLoad: true, 52 + theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default', 53 + }); 54 + await mermaid.run({ nodes }); 55 + }; 56 + document.head.appendChild(script); 57 + }); 58 + </script> 42 59 <title>{{ block "title" . }}{{ end }}</title> 43 60 {{ block "extrameta" . }}{{ end }} 44 61 </head>
+13
flake.lock
··· 148 148 "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 149 149 } 150 150 }, 151 + "mermaid-src": { 152 + "flake": false, 153 + "locked": { 154 + "narHash": "sha256-/YOdECG2V5c3kJ1QfGvhziTT6K/Dx/4mOk2mr3Fs/do=", 155 + "type": "file", 156 + "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 157 + }, 158 + "original": { 159 + "type": "file", 160 + "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 161 + } 162 + }, 151 163 "nixpkgs": { 152 164 "locked": { 153 165 "lastModified": 1766070988, ··· 175 187 "indigo": "indigo", 176 188 "inter-fonts-src": "inter-fonts-src", 177 189 "lucide-src": "lucide-src", 190 + "mermaid-src": "mermaid-src", 178 191 "nixpkgs": "nixpkgs", 179 192 "sqlite-lib-src": "sqlite-lib-src" 180 193 }
+6 -1
flake.nix
··· 37 37 url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 38 flake = false; 39 39 }; 40 + mermaid-src = { 41 + url = "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js"; 42 + flake = false; 43 + }; 40 44 ibm-plex-mono-src = { 41 45 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 42 46 flake = false; ··· 59 63 sqlite-lib-src, 60 64 ibm-plex-mono-src, 61 65 actor-typeahead-src, 66 + mermaid-src, 62 67 ... 63 68 }: let 64 69 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 85 90 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 86 91 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 87 92 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 88 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 93 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src mermaid-src; 89 94 }; 90 95 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 96 docs = self.callPackage ./nix/pkgs/docs.nix {
+1
go.mod
··· 48 48 github.com/yuin/goldmark-emoji v1.0.6 49 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 + go.abhg.dev/goldmark/mermaid v0.6.0 51 52 golang.org/x/crypto v0.40.0 52 53 golang.org/x/image v0.31.0 53 54 golang.org/x/net v0.42.0
+16
go.sum
··· 105 105 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 106 106 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 107 107 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 108 + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= 109 + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 110 + github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= 111 + github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= 112 + github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 113 + github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 108 114 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 109 115 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 110 116 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 175 181 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 176 182 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 177 183 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 184 + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= 185 + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 178 186 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 179 187 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 180 188 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= ··· 189 197 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 190 198 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 191 199 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 200 + github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 201 + github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 202 + github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 203 + github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 204 + github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 205 + github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 192 206 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 193 207 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 194 208 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= ··· 516 530 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 517 531 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 518 532 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 533 + go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY= 534 + go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I= 519 535 go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 520 536 go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 521 537 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+5
input.css
··· 215 215 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 216 216 } 217 217 218 + /* Mermaid diagrams */ 219 + .prose pre.mermaid { 220 + @apply flex justify-center my-4 overflow-x-auto bg-transparent border-0; 221 + } 222 + 218 223 /* Base callout */ 219 224 details[data-callout] { 220 225 @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
+3
nix/gomod2nix.toml
··· 545 545 [mod."gitlab.com/yawning/tuplehash"] 546 546 version = "v0.0.0-20230713102510-df83abbf9a02" 547 547 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 548 + [mod."go.abhg.dev/goldmark/mermaid"] 549 + version = "v0.6.0" 550 + hash = "sha256-JmjaCfzJU/M/R0TnXSzNwBaHmoLLooiXwQJeVRbZ3AQ=" 548 551 [mod."go.etcd.io/bbolt"] 549 552 version = "v1.4.0" 550 553 hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
+2
nix/pkgs/appview-static-files.nix
··· 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 8 actor-typeahead-src, 9 + mermaid-src, 9 10 sqlite-lib, 10 11 tailwindcss, 11 12 dolly, ··· 21 22 mkdir -p $out/{fonts,icons,logos} && cd $out 22 23 cp -f ${htmx-src} htmx.min.js 23 24 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 25 + cp -f ${mermaid-src} mermaid.min.js 24 26 cp -rf ${lucide-src}/*.svg icons/ 25 27 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 26 28 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/