diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -22,6 +22,7 @@ "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" callout "gitlab.com/staticnoise/goldmark-callout" + "go.abhg.dev/goldmark/mermaid" htmlparse "golang.org/x/net/html" "tangled.org/core/api/tangled" @@ -56,6 +57,10 @@ func NewMarkdown(hostname string) goldmark.Markdown { md := goldmark.New( goldmark.WithExtensions( extension.GFM, + &mermaid.Extender{ + RenderMode: mermaid.RenderModeClient, + NoScript: true, + }, highlighting.NewHighlighting( highlighting.WithFormatOptions( chromahtml.Standalone(false), diff --git a/appview/pages/markup/markdown_test.go b/appview/pages/markup/markdown_test.go --- a/appview/pages/markup/markdown_test.go +++ b/appview/pages/markup/markdown_test.go @@ -2,8 +2,54 @@ package markup import ( "bytes" + "strings" "testing" ) + +func TestMermaidExtension(t *testing.T) { + tests := []struct { + name string + markdown string + contains string + notContains string + }{ + { + name: "mermaid block produces pre.mermaid", + markdown: "```mermaid\ngraph TD\n A-->B\n```", + contains: `
`,
+			notContains: `B\n```",
+			contains: "graph TD",
+		},
+		{
+			name:     "non-mermaid code block is not affected",
+			markdown: "```go\nfunc main() {}\n```",
+			contains: `
`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			md := NewMarkdown("tangled.org")
+
+			var buf bytes.Buffer
+			if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
+				t.Fatalf("failed to convert markdown: %v", err)
+			}
+
+			result := buf.String()
+			if !strings.Contains(result, tt.contains) {
+				t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result)
+			}
+			if tt.notContains != "" && strings.Contains(result, tt.notContains) {
+				t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result)
+			}
+		})
+	}
+}
 
 func TestAtExtension_Rendering(t *testing.T) {
 	tests := []struct {
diff --git a/appview/pages/markup/sanitizer.go b/appview/pages/markup/sanitizer.go
--- a/appview/pages/markup/sanitizer.go
+++ b/appview/pages/markup/sanitizer.go
@@ -72,7 +72,7 @@ policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
 	policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
 
 	// for code blocks
-	policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre")
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma|mermaid`)).OnElements("pre")
 	policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a")
 	policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
 	policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
diff --git a/appview/pages/templates/layouts/base.html b/appview/pages/templates/layouts/base.html
--- a/appview/pages/templates/layouts/base.html
+++ b/appview/pages/templates/layouts/base.html
@@ -39,6 +39,23 @@ 
             
 
             
+
+            
             {{ block "title" . }}{{ end }}
             {{ block "extrameta" . }}{{ end }}
         
diff --git a/flake.lock b/flake.lock
--- a/flake.lock
+++ b/flake.lock
@@ -148,6 +148,18 @@ "type": "tarball",
         "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
       }
     },
+    "mermaid-src": {
+      "flake": false,
+      "locked": {
+        "narHash": "sha256-/YOdECG2V5c3kJ1QfGvhziTT6K/Dx/4mOk2mr3Fs/do=",
+        "type": "file",
+        "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js"
+      },
+      "original": {
+        "type": "file",
+        "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js"
+      }
+    },
     "nixpkgs": {
       "locked": {
         "lastModified": 1766070988,
@@ -175,6 +187,7 @@ "ibm-plex-mono-src": "ibm-plex-mono-src",
         "indigo": "indigo",
         "inter-fonts-src": "inter-fonts-src",
         "lucide-src": "lucide-src",
+        "mermaid-src": "mermaid-src",
         "nixpkgs": "nixpkgs",
         "sqlite-lib-src": "sqlite-lib-src"
       }
diff --git a/flake.nix b/flake.nix
--- a/flake.nix
+++ b/flake.nix
@@ -37,6 +37,10 @@ actor-typeahead-src = {
       url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
       flake = false;
     };
+    mermaid-src = {
+      url = "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js";
+      flake = false;
+    };
     ibm-plex-mono-src = {
       url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
       flake = false;
@@ -59,6 +63,7 @@ inter-fonts-src,
     sqlite-lib-src,
     ibm-plex-mono-src,
     actor-typeahead-src,
+    mermaid-src,
     ...
   }: let
     supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
@@ -85,7 +90,7 @@ };
         lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
         goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
         appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
-          inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
+          inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src mermaid-src;
         };
         appview = self.callPackage ./nix/pkgs/appview.nix {};
         docs = self.callPackage ./nix/pkgs/docs.nix {
diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -48,6 +48,7 @@ github.com/yuin/goldmark v1.7.13
 	github.com/yuin/goldmark-emoji v1.0.6
 	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
 	gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
+	go.abhg.dev/goldmark/mermaid v0.6.0
 	golang.org/x/crypto v0.40.0
 	golang.org/x/image v0.31.0
 	golang.org/x/net v0.42.0
diff --git a/go.sum b/go.sum
--- a/go.sum
+++ b/go.sum
@@ -105,6 +105,12 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
+github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -175,6 +181,8 @@ github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -189,6 +197,12 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -516,6 +530,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
+go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
+go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
 go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
 go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
diff --git a/input.css b/input.css
--- a/input.css
+++ b/input.css
@@ -215,6 +215,11 @@ .prose input[type="checkbox"] {
             @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
         }
 
+        /* Mermaid diagrams */
+        .prose pre.mermaid {
+            @apply flex justify-center my-4 overflow-x-auto bg-transparent border-0;
+        }
+
         /* Base callout */
         details[data-callout] {
             @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
--- a/nix/gomod2nix.toml
+++ b/nix/gomod2nix.toml
@@ -545,6 +545,9 @@ hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
   [mod."gitlab.com/yawning/tuplehash"]
     version = "v0.0.0-20230713102510-df83abbf9a02"
     hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
+  [mod."go.abhg.dev/goldmark/mermaid"]
+    version = "v0.6.0"
+    hash = "sha256-JmjaCfzJU/M/R0TnXSzNwBaHmoLLooiXwQJeVRbZ3AQ="
   [mod."go.etcd.io/bbolt"]
     version = "v1.4.0"
     hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
diff --git a/nix/pkgs/appview-static-files.nix b/nix/pkgs/appview-static-files.nix
--- a/nix/pkgs/appview-static-files.nix
+++ b/nix/pkgs/appview-static-files.nix
@@ -6,6 +6,7 @@ lucide-src,
   inter-fonts-src,
   ibm-plex-mono-src,
   actor-typeahead-src,
+  mermaid-src,
   sqlite-lib,
   tailwindcss,
   dolly,
@@ -21,6 +22,7 @@ } ''
   mkdir -p $out/{fonts,icons,logos} && cd $out
   cp -f ${htmx-src} htmx.min.js
   cp -f ${htmx-ws-src} htmx-ext-ws.min.js
+  cp -f ${mermaid-src} mermaid.min.js
   cp -rf ${lucide-src}/*.svg icons/
   cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
   cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/