Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/markup: render LaTeX math in markdown

Render $...$ / $$...$$ LaTeX in markdown (READMEs, issues, comments) via
client-side MathJax, with detection handled server-side by the Hugo
passthrough extension plus a custom renderer.

Pipeline:
- extension/math.go wraps each math span in <span class="math inline|
display"> carrying the raw LaTeX with MathJax \( \) / \[ \] delimiters.
passthrough handles single-line "$$...$$" blocks and protects markdown
inside math; a Pandoc-style guard (no space-padding, no digit after the
closing $) keeps currency like "$5 ... $10" from being parsed as math.
- layouts/base.html loads MathJax (v4.1.2, tex-svg) on demand, only when a
page contains span.math, and typesets just those nodes. Accessibility
options are disabled as an initial, conservative implementation.
- the sanitizer preserves the math carrier spans.
- MathJax is vendored at build time alongside mermaid (flake.nix, the nix
static-files package, and the localinfra script).

Signed-off-by: oscillatory.net <nick@oscillatory.net>

authored by

oscillatory.net and committed by
Anirudh Oppiliappan
(Jul 3, 2026, 3:49 PM +0530) bae49935 fc4221c0

+287 -7
+109
appview/pages/markup/extension/math.go
··· 1 + package extension 2 + 3 + import ( 4 + "bytes" 5 + 6 + "github.com/gohugoio/hugo-goldmark-extensions/passthrough" 7 + "github.com/yuin/goldmark" 8 + "github.com/yuin/goldmark/ast" 9 + "github.com/yuin/goldmark/renderer" 10 + "github.com/yuin/goldmark/util" 11 + ) 12 + 13 + // MathExt renders LaTeX math for client-side typesetting. 14 + // 15 + // Detection is delegated to the Hugo passthrough extension, which correctly 16 + // handles single-line "$$...$$" display blocks and protects markdown inside 17 + // math (e.g. "$a_1$" is not emphasis). 18 + // We override passthrough's verbatim renderers so each span is wrapped in 19 + // <span class="math inline|display"> carrying the raw LaTeX with MathJax 20 + // \( \) / \[ \] delimiters. MathJax (loaded on demand in layouts/base.html) 21 + // typesets only these spans, so surrounding prose is never scanned — which is 22 + // what keeps a stray "$" in body text from being interpreted as math. 23 + var MathExt = &mathExt{} 24 + 25 + type mathExt struct{} 26 + 27 + func (e *mathExt) Extend(m goldmark.Markdown) { 28 + // Installs the passthrough parsers (and its own verbatim renderers, which 29 + // we override below). 30 + passthrough.New(passthrough.Config{ 31 + InlineDelimiters: []passthrough.Delimiters{ 32 + {Open: "$", Close: "$"}, 33 + {Open: `\(`, Close: `\)`}, 34 + }, 35 + BlockDelimiters: []passthrough.Delimiters{ 36 + {Open: "$$", Close: "$$"}, 37 + {Open: `\[`, Close: `\]`}, 38 + }, 39 + }).Extend(m) 40 + 41 + // Higher priority than passthrough's default renderers (priority 100), so 42 + // ours win for the passthrough node kinds. 43 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 44 + util.Prioritized(&mathRenderer{}, 1), 45 + )) 46 + } 47 + 48 + type mathRenderer struct{} 49 + 50 + func (r *mathRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 51 + reg.Register(passthrough.KindPassthroughInline, r.renderInline) 52 + reg.Register(passthrough.KindPassthroughBlock, r.renderBlock) 53 + } 54 + 55 + func (r *mathRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 56 + if !entering { 57 + return ast.WalkSkipChildren, nil 58 + } 59 + node := n.(*passthrough.PassthroughInline) 60 + open, closing := node.Delimiters.Open, node.Delimiters.Close 61 + raw := node.Segment.Value(source) 62 + inner := raw[len(open) : len(raw)-len(closing)] 63 + 64 + // Currency guard for "$"-delimited inline math (Pandoc's rules): a "$...$" 65 + // run is not math if the inner text is empty or space-padded, or if the 66 + // closing "$" is immediately followed by a digit (e.g. "$5 and $10"). In 67 + // those cases emit the run verbatim so MathJax never sees it. 68 + if open == "$" { 69 + var after byte 70 + if node.Segment.Stop < len(source) { 71 + after = source[node.Segment.Stop] 72 + } 73 + if len(inner) == 0 || inner[0] == ' ' || inner[len(inner)-1] == ' ' || isASCIIDigit(after) { 74 + w.Write(raw) 75 + return ast.WalkSkipChildren, nil 76 + } 77 + } 78 + 79 + w.WriteString(`<span class="math inline">\(`) 80 + w.Write(util.EscapeHTML(inner)) 81 + w.WriteString(`\)</span>`) 82 + return ast.WalkSkipChildren, nil 83 + } 84 + 85 + func (r *mathRenderer) renderBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 86 + if !entering { 87 + return ast.WalkSkipChildren, nil 88 + } 89 + node := n.(*passthrough.PassthroughBlock) 90 + open, closing := node.Delimiters.Open, node.Delimiters.Close 91 + 92 + var buf bytes.Buffer 93 + for i := 0; i < node.Lines().Len(); i++ { 94 + seg := node.Lines().At(i) 95 + buf.Write(seg.Value(source)) 96 + } 97 + 98 + inner := bytes.TrimSpace(buf.Bytes()) 99 + inner = bytes.TrimPrefix(inner, []byte(open)) 100 + inner = bytes.TrimSuffix(inner, []byte(closing)) 101 + inner = bytes.TrimSpace(inner) 102 + 103 + w.WriteString(`<p><span class="math display">\[`) 104 + w.Write(util.EscapeHTML(inner)) 105 + w.WriteString(`\]</span></p>`) 106 + return ast.WalkSkipChildren, nil 107 + } 108 + 109 + func isASCIIDigit(b byte) bool { return b >= '0' && b <= '9' }
+1
appview/pages/markup/markdown.go
··· 69 69 extension.WithFootnoteIDPrefix([]byte("footnote")), 70 70 ), 71 71 callout.CalloutExtention, 72 + textension.MathExt, 72 73 textension.AtExt, 73 74 textension.NewTangledLinkExt(hostname), 74 75 emoji.Emoji,
+92
appview/pages/markup/markdown_test.go
··· 4 4 "bytes" 5 5 "strings" 6 6 "testing" 7 + 8 + "tangled.org/core/appview/pages/markup/sanitizer" 7 9 ) 8 10 9 11 func TestMermaidExtension(t *testing.T) { ··· 48 50 t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) 49 51 } 50 52 }) 53 + } 54 + } 55 + 56 + func TestMathExtension(t *testing.T) { 57 + tests := []struct { 58 + name string 59 + markdown string 60 + contains string 61 + notContains string 62 + }{ 63 + { 64 + name: "inline math produces span with mathjax delimiters", 65 + markdown: "the famous $E = mc^2$ equation", 66 + contains: `<span class="math inline">\(E = mc^2\)</span>`, 67 + }, 68 + { 69 + name: "block math produces display span", 70 + markdown: "$$\n\\frac{a}{b}\n$$", 71 + contains: `<span class="math display">\[`, 72 + }, 73 + { 74 + name: "underscores inside math are not treated as emphasis", 75 + markdown: "$a_1 + a_2$", 76 + contains: `\(a_1 + a_2\)`, 77 + notContains: "<em>", 78 + }, 79 + { 80 + name: "non-math dollar usage is left alone", 81 + markdown: "it costs $5 today", 82 + notContains: `class="math`, 83 + }, 84 + { 85 + // regression: two currency amounts must not be parsed as one 86 + // inline math span (the "$5 and $" .. "10" case). 87 + name: "currency pair is not math", 88 + markdown: "it costs $5 today and $10 tomorrow", 89 + contains: "it costs $5 today and $10 tomorrow", 90 + notContains: `class="math`, 91 + }, 92 + { 93 + // regression: single-line $$...$$ must keep both the math and the 94 + // trailing prose. 95 + name: "single-line block keeps trailing prose", 96 + markdown: "$$x^2$$ and then prose", 97 + contains: "and then prose", 98 + }, 99 + { 100 + // math content with < / & must be escaped so the sanitizer keeps 101 + // the span and MathJax reads the literal source. 102 + name: "angle brackets in math are escaped", 103 + markdown: "$a < b$", 104 + contains: `\(a &lt; b\)`, 105 + }, 106 + } 107 + 108 + for _, tt := range tests { 109 + t.Run(tt.name, func(t *testing.T) { 110 + md := NewMarkdown("tangled.org") 111 + 112 + var buf bytes.Buffer 113 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 114 + t.Fatalf("failed to convert markdown: %v", err) 115 + } 116 + 117 + result := buf.String() 118 + if tt.contains != "" && !strings.Contains(result, tt.contains) { 119 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 120 + } 121 + if tt.notContains != "" && strings.Contains(result, tt.notContains) { 122 + t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) 123 + } 124 + }) 125 + } 126 + } 127 + 128 + // The sanitizer must preserve the carrier spans that MathJax renders client-side. 129 + func TestMathSurvivesSanitizer(t *testing.T) { 130 + md := NewMarkdown("tangled.org") 131 + 132 + var buf bytes.Buffer 133 + if err := md.Convert([]byte("inline $x^2$ and block\n\n$$\ny^2\n$$"), &buf); err != nil { 134 + t.Fatalf("failed to convert markdown: %v", err) 135 + } 136 + 137 + out := sanitizer.SanitizeDefault(buf.String()) 138 + 139 + for _, want := range []string{`class="math inline"`, `class="math display"`} { 140 + if !strings.Contains(out, want) { 141 + t.Errorf("sanitizer stripped math span; expected %q in:\n%s", want, out) 142 + } 51 143 } 52 144 } 53 145
+8 -1
appview/pages/markup/sanitizer/sanitizer.go
··· 107 107 "margin-bottom", 108 108 ) 109 109 110 - // math 110 + // math: the math extension emits <span class="math inline|display"> wrapping 111 + // the raw LaTeX (delimited by \( \) / \[ \]). MathJax renders it client-side, 112 + // so the sanitizer only needs to preserve these carrier spans. 113 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^math (inline|display)$`)).OnElements("span") 114 + 115 + // raw MathML: markdown is rendered with html.WithUnsafe(), so hand-authored 116 + // <math>...</math> in source passes through to here. Browsers render 117 + // presentation MathML natively, so preserve the elements and their attributes. 111 118 mathAttrs := []string{ 112 119 "accent", "columnalign", "columnlines", "columnspan", "dir", "display", 113 120 "displaystyle", "encoding", "fence", "form", "largeop", "linebreak",
+36
appview/pages/templates/layouts/base.html
··· 58 58 }); 59 59 </script> 60 60 <script> 61 + document.addEventListener('DOMContentLoaded', () => { 62 + const nodes = document.querySelectorAll('span.math'); 63 + if (!nodes.length) return; 64 + window.MathJax = { 65 + tex: { 66 + inlineMath: [['\\(', '\\)']], 67 + displayMath: [['\\[', '\\]']], 68 + }, 69 + options: { 70 + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'], 71 + // v4 turns on semantic enrichment + speech by default, which 72 + // spins up a speech webworker. These are gated by the menu 73 + // settings (even when the menu itself is disabled), so turn 74 + // them off here to keep rendering self-contained. 75 + enableMenu: false, 76 + menuOptions: { 77 + settings: { 78 + enrich: false, 79 + speech: false, 80 + braille: false, 81 + assistiveMml: false, 82 + collapsible: false, 83 + }, 84 + }, 85 + }, 86 + startup: { typeset: false }, 87 + }; 88 + const script = document.createElement('script'); 89 + script.src = '/static/mathjax.min.js'; 90 + script.onload = () => { 91 + MathJax.typesetPromise(Array.from(nodes)); 92 + }; 93 + document.head.appendChild(script); 94 + }); 95 + </script> 96 + <script> 61 97 window.addEventListener('scroll', function() { 62 98 document.querySelectorAll('[data-profile-popover]:not(.hidden)').forEach(function(p) { 63 99 p.classList.add('hidden');
+13
flake.lock
··· 201 201 "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 202 202 } 203 203 }, 204 + "mathjax-src": { 205 + "flake": false, 206 + "locked": { 207 + "narHash": "sha256-eqg25bKfiDzynRMR2RfeWNfo0EefHF/eRRFafl9RnNI=", 208 + "type": "file", 209 + "url": "https://cdn.jsdelivr.net/npm/mathjax@4.1.2/tex-svg.js" 210 + }, 211 + "original": { 212 + "type": "file", 213 + "url": "https://cdn.jsdelivr.net/npm/mathjax@4.1.2/tex-svg.js" 214 + } 215 + }, 204 216 "mermaid-src": { 205 217 "flake": false, 206 218 "locked": { ··· 264 276 "indigo": "indigo", 265 277 "inter-fonts-src": "inter-fonts-src", 266 278 "lucide-src": "lucide-src", 279 + "mathjax-src": "mathjax-src", 267 280 "mermaid-src": "mermaid-src", 268 281 "microvm": "microvm", 269 282 "nixpkgs": "nixpkgs",
+6 -1
flake.nix
··· 53 53 url = "https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"; 54 54 flake = false; 55 55 }; 56 + mathjax-src = { 57 + url = "https://cdn.jsdelivr.net/npm/mathjax@4.1.2/tex-svg.js"; 58 + flake = false; 59 + }; 56 60 ibm-plex-mono-src = { 57 61 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 58 62 flake = false; ··· 84 88 hls-src, 85 89 microvm, 86 90 fetch-tangled, 91 + mathjax-src, 87 92 ... 88 93 }: let 89 94 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 145 150 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 146 151 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 147 152 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 148 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src mermaid-src hls-src; 153 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src mermaid-src hls-src mathjax-src; 149 154 }; 150 155 appview = self.callPackage ./nix/pkgs/appview.nix {}; 151 156 blog = self.callPackage ./nix/pkgs/blog.nix {};
+2 -1
go.mod
··· 42 42 github.com/go-chi/chi/v5 v5.2.0 43 43 github.com/go-enry/go-enry/v2 v2.9.6 44 44 github.com/go-git/go-git/v5 v5.19.0 45 + github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 45 46 github.com/google/uuid v1.6.0 46 47 github.com/gorilla/feeds v1.2.0 47 48 github.com/gorilla/sessions v1.4.0 ··· 71 72 github.com/stretchr/testify v1.11.1 72 73 github.com/urfave/cli/v3 v3.6.2 73 74 github.com/whyrusleeping/cbor-gen v0.3.1 74 - github.com/yuin/goldmark v1.7.13 75 + github.com/yuin/goldmark v1.8.2 75 76 github.com/yuin/goldmark-emoji v1.0.6 76 77 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 77 78 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
+4 -2
go.sum
··· 358 358 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 359 359 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 360 360 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 361 + github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI= 362 + github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8= 361 363 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 362 364 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 363 365 github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= ··· 840 842 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 841 843 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 842 844 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 843 - github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 844 - github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 845 + github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= 846 + github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 845 847 github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 846 848 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 847 849 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
+7
input.css
··· 1662 1662 right: calc(-1 * var(--r)); 1663 1663 } 1664 1664 } 1665 + 1666 + /* Tailwind Preflight resets svg to display:block; MathJax v4 emits inline SVGs 1667 + (one or more per inline equation), so that forces each onto its own line. 1668 + Restore inline flow for MathJax output only, leaving icon svgs untouched. */ 1669 + mjx-container svg { 1670 + display: inline-block; 1671 + }
+2
localinfra/scripts/appview-static-files.sh
··· 4 4 HTMX_URL="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 5 5 HTMX_WS_URL="https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2" 6 6 MERMAID_URL="https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 7 + MATHJAX_URL="https://cdn.jsdelivr.net/npm/mathjax@4.1.2/tex-svg.js" 7 8 LUCIDE_URL="https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 8 9 INTER_URL="https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip" 9 10 PLEX_MONO_URL="https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip" ··· 19 20 curl -fsSL -o "$OUT/htmx.min.js" "$HTMX_URL" 20 21 curl -fsSL -o "$OUT/htmx-ext-ws.min.js" "$HTMX_WS_URL" 21 22 curl -fsSL -o "$OUT/mermaid.min.js" "$MERMAID_URL" 23 + curl -fsSL -o "$OUT/mathjax.min.js" "$MATHJAX_URL" 22 24 23 25 curl -fsSL -o "$TMP/lucide.zip" "$LUCIDE_URL" 24 26 unzip -q "$TMP/lucide.zip" -d "$TMP/lucide"
+5 -2
nix/gomod2nix.toml
··· 407 407 [mod."github.com/gogo/protobuf"] 408 408 version = "v1.3.2" 409 409 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 410 + [mod."github.com/gohugoio/hugo-goldmark-extensions/passthrough"] 411 + version = "v0.5.0" 412 + hash = "sha256-8kQnLGnNYUkm3q/cFgf+9P++91OUbMJJNNgUQ1Bl3fg=" 410 413 [mod."github.com/golang-jwt/jwt"] 411 414 version = "v3.2.2+incompatible" 412 415 hash = "sha256-LOkpuXhWrFayvVf1GOaOmZI5YKEsgqVSb22aF8LnCEM=" ··· 826 829 version = "v0.0.0-20220910002029-abceb7e1c41e" 827 830 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 828 831 [mod."github.com/yuin/goldmark"] 829 - version = "v1.7.13" 830 - hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 832 + version = "v1.8.2" 833 + hash = "sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q=" 831 834 [mod."github.com/yuin/goldmark-emoji"] 832 835 version = "v1.0.6" 833 836 hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
+2
nix/pkgs/appview-static-files.nix
··· 8 8 actor-typeahead-src, 9 9 mermaid-src, 10 10 hls-src, 11 + mathjax-src, 11 12 tailwindcss, 12 13 dolly, 13 14 src, ··· 24 25 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 25 26 cp -f ${mermaid-src} mermaid.min.js 26 27 cp -f ${hls-src} hls.min.js 28 + cp -f ${mathjax-src} mathjax.min.js 27 29 cp -rf ${lucide-src}/*.svg icons/ 28 30 cp -rf ${src}/icons/*.svg icons/ 29 31 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/