Monorepo for Tangled

appview/markup: add mermaid diagram rendering support #1

open opened by natespilman.com targeting master from feat/mermaid-rendering

Summary#

  • Add MermaidJS diagram rendering for markdown content (READMEs, issues, PRs)
  • Uses goldmark-mermaid with client-side rendering
  • Mermaid fenced code blocks are transformed to <pre class="mermaid"> elements
  • MermaidJS library (~2.5MB) is only loaded on pages that contain mermaid diagrams
  • Dark mode support via prefers-color-scheme detection

Test plan#

  • go test ./appview/pages/markup/ — all 16 tests pass, including 3 new mermaid tests
  • Mermaid blocks produce <pre class="mermaid"> (not syntax-highlighted code blocks)
  • Regular code blocks still get chroma highlighting
  • No regressions in existing tests
  • Visual verification on dev server with mermaid diagram in README/issue

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

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:c7frv4rcitff3p2nh7of5bcv/sh.tangled.repo.pull/3mg67nxcjxy22
+86 -1
Diff #0
+5
appview/pages/markup/markdown.go
··· 21 21 "github.com/yuin/goldmark/renderer/html" 22 22 "github.com/yuin/goldmark/text" 23 23 "github.com/yuin/goldmark/util" 24 + "go.abhg.dev/goldmark/mermaid" 24 25 callout "gitlab.com/staticnoise/goldmark-callout" 25 26 htmlparse "golang.org/x/net/html" 26 27 ··· 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 ) 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 + } 53 + 8 54 func TestAtExtension_Rendering(t *testing.T) { 9 55 tests := []struct { 10 56 name string
+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")
+12
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 type="module"> 44 + document.addEventListener('DOMContentLoaded', async () => { 45 + if (!document.querySelector('pre.mermaid')) return; 46 + const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'); 47 + mermaid.initialize({ 48 + startOnLoad: true, 49 + theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default', 50 + }); 51 + await mermaid.run({ nodes: document.querySelectorAll('pre.mermaid') }); 52 + }); 53 + </script> 42 54 <title>{{ block "title" . }}{{ end }}</title> 43 55 {{ block "extrameta" . }}{{ end }} 44 56 </head>
+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;

History

1 round 0 comments
sign up or login to add to the discussion
natespilman.com submitted #0
1 commit
expand
appview/markup: add mermaid diagram rendering support
no conflicts, ready to merge
expand 0 comments