From bae4993598a975ef642e6e3e9cfa41e7270246e0 Mon Sep 17 00:00:00 2001 From: oscillatory.net Date: Wed, 01 Jul 2026 13:59:27 +0000 Subject: [PATCH] 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 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 --- appview/pages/markup/extension/math.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/markup/markdown.go | 1 + appview/pages/markup/markdown_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/markup/sanitizer/sanitizer.go | 9 ++++++++- appview/pages/templates/layouts/base.html | 36 ++++++++++++++++++++++++++++++++++++ flake.lock | 13 +++++++++++++ flake.nix | 7 ++++++- go.mod | 3 ++- go.sum | 6 ++++-- input.css | 7 +++++++ localinfra/scripts/appview-static-files.sh | 2 ++ nix/gomod2nix.toml | 7 +++++-- nix/pkgs/appview-static-files.nix | 2 ++ 13 file(s) changed, 287 insertion(s)(+), 7 deletion(s)(-) diff --git a/appview/pages/markup/extension/math.go b/appview/pages/markup/extension/math.go new file mode 100644 --- /dev/null +++ b/appview/pages/markup/extension/math.go @@ -0,0 +1,109 @@ +package extension + +import ( + "bytes" + + "github.com/gohugoio/hugo-goldmark-extensions/passthrough" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// MathExt renders LaTeX math for client-side typesetting. +// +// Detection is delegated to the Hugo passthrough extension, which correctly +// handles single-line "$$...$$" display blocks and protects markdown inside +// math (e.g. "$a_1$" is not emphasis). +// We override passthrough's verbatim renderers so each span is wrapped in +// carrying the raw LaTeX with MathJax +// \( \) / \[ \] delimiters. MathJax (loaded on demand in layouts/base.html) +// typesets only these spans, so surrounding prose is never scanned — which is +// what keeps a stray "$" in body text from being interpreted as math. +var MathExt = &mathExt{} + +type mathExt struct{} + +func (e *mathExt) Extend(m goldmark.Markdown) { + // Installs the passthrough parsers (and its own verbatim renderers, which + // we override below). + passthrough.New(passthrough.Config{ + InlineDelimiters: []passthrough.Delimiters{ + {Open: "$", Close: "$"}, + {Open: `\(`, Close: `\)`}, + }, + BlockDelimiters: []passthrough.Delimiters{ + {Open: "$$", Close: "$$"}, + {Open: `\[`, Close: `\]`}, + }, + }).Extend(m) + + // Higher priority than passthrough's default renderers (priority 100), so + // ours win for the passthrough node kinds. + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(&mathRenderer{}, 1), + )) +} + +type mathRenderer struct{} + +func (r *mathRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(passthrough.KindPassthroughInline, r.renderInline) + reg.Register(passthrough.KindPassthroughBlock, r.renderBlock) +} + +func (r *mathRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + node := n.(*passthrough.PassthroughInline) + open, closing := node.Delimiters.Open, node.Delimiters.Close + raw := node.Segment.Value(source) + inner := raw[len(open) : len(raw)-len(closing)] + + // Currency guard for "$"-delimited inline math (Pandoc's rules): a "$...$" + // run is not math if the inner text is empty or space-padded, or if the + // closing "$" is immediately followed by a digit (e.g. "$5 and $10"). In + // those cases emit the run verbatim so MathJax never sees it. + if open == "$" { + var after byte + if node.Segment.Stop < len(source) { + after = source[node.Segment.Stop] + } + if len(inner) == 0 || inner[0] == ' ' || inner[len(inner)-1] == ' ' || isASCIIDigit(after) { + w.Write(raw) + return ast.WalkSkipChildren, nil + } + } + + w.WriteString(`\(`) + w.Write(util.EscapeHTML(inner)) + w.WriteString(`\)`) + return ast.WalkSkipChildren, nil +} + +func (r *mathRenderer) renderBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + node := n.(*passthrough.PassthroughBlock) + open, closing := node.Delimiters.Open, node.Delimiters.Close + + var buf bytes.Buffer + for i := 0; i < node.Lines().Len(); i++ { + seg := node.Lines().At(i) + buf.Write(seg.Value(source)) + } + + inner := bytes.TrimSpace(buf.Bytes()) + inner = bytes.TrimPrefix(inner, []byte(open)) + inner = bytes.TrimSuffix(inner, []byte(closing)) + inner = bytes.TrimSpace(inner) + + w.WriteString(`

\[`) + w.Write(util.EscapeHTML(inner)) + w.WriteString(`\]

`) + return ast.WalkSkipChildren, nil +} + +func isASCIIDigit(b byte) bool { return b >= '0' && b <= '9' } 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 @@ -69,6 +69,7 @@ extension.NewFootnote( extension.WithFootnoteIDPrefix([]byte("footnote")), ), callout.CalloutExtention, + textension.MathExt, textension.AtExt, textension.NewTangledLinkExt(hostname), emoji.Emoji, 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 @@ -4,6 +4,8 @@ import ( "bytes" "strings" "testing" + + "tangled.org/core/appview/pages/markup/sanitizer" ) func TestMermaidExtension(t *testing.T) { @@ -48,6 +50,96 @@ if tt.notContains != "" && strings.Contains(result, tt.notContains) { t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) } }) + } +} + +func TestMathExtension(t *testing.T) { + tests := []struct { + name string + markdown string + contains string + notContains string + }{ + { + name: "inline math produces span with mathjax delimiters", + markdown: "the famous $E = mc^2$ equation", + contains: `\(E = mc^2\)`, + }, + { + name: "block math produces display span", + markdown: "$$\n\\frac{a}{b}\n$$", + contains: `\[`, + }, + { + name: "underscores inside math are not treated as emphasis", + markdown: "$a_1 + a_2$", + contains: `\(a_1 + a_2\)`, + notContains: "", + }, + { + name: "non-math dollar usage is left alone", + markdown: "it costs $5 today", + notContains: `class="math`, + }, + { + // regression: two currency amounts must not be parsed as one + // inline math span (the "$5 and $" .. "10" case). + name: "currency pair is not math", + markdown: "it costs $5 today and $10 tomorrow", + contains: "it costs $5 today and $10 tomorrow", + notContains: `class="math`, + }, + { + // regression: single-line $$...$$ must keep both the math and the + // trailing prose. + name: "single-line block keeps trailing prose", + markdown: "$$x^2$$ and then prose", + contains: "and then prose", + }, + { + // math content with < / & must be escaped so the sanitizer keeps + // the span and MathJax reads the literal source. + name: "angle brackets in math are escaped", + markdown: "$a < b$", + contains: `\(a < b\)`, + }, + } + + 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 tt.contains != "" && !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) + } + }) + } +} + +// The sanitizer must preserve the carrier spans that MathJax renders client-side. +func TestMathSurvivesSanitizer(t *testing.T) { + md := NewMarkdown("tangled.org") + + var buf bytes.Buffer + if err := md.Convert([]byte("inline $x^2$ and block\n\n$$\ny^2\n$$"), &buf); err != nil { + t.Fatalf("failed to convert markdown: %v", err) + } + + out := sanitizer.SanitizeDefault(buf.String()) + + for _, want := range []string{`class="math inline"`, `class="math display"`} { + if !strings.Contains(out, want) { + t.Errorf("sanitizer stripped math span; expected %q in:\n%s", want, out) + } } } diff --git a/appview/pages/markup/sanitizer/sanitizer.go b/appview/pages/markup/sanitizer/sanitizer.go --- a/appview/pages/markup/sanitizer/sanitizer.go +++ b/appview/pages/markup/sanitizer/sanitizer.go @@ -107,7 +107,14 @@ "margin-top", "margin-bottom", ) - // math + // math: the math extension emits wrapping + // the raw LaTeX (delimited by \( \) / \[ \]). MathJax renders it client-side, + // so the sanitizer only needs to preserve these carrier spans. + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^math (inline|display)$`)).OnElements("span") + + // raw MathML: markdown is rendered with html.WithUnsafe(), so hand-authored + // ... in source passes through to here. Browsers render + // presentation MathML natively, so preserve the elements and their attributes. mathAttrs := []string{ "accent", "columnalign", "columnlines", "columnspan", "dir", "display", "displaystyle", "encoding", "fence", "form", "largeop", "linebreak", 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 @@ -58,6 +58,42 @@ document.head.appendChild(script); }); +