Skip to content

Static math rendering via texmath#11

Merged
srid merged 5 commits intomasterfrom
static-math
Apr 21, 2026
Merged

Static math rendering via texmath#11
srid merged 5 commits intomasterfrom
static-math

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 21, 2026

LaTeX math now renders to MathML at build time, so documents with $...$ / $$...$$ no longer need a KaTeX or MathJax JS bundle to become readable. Mirrors the skylighting approach from #10: texmath tokenizes and emits the tree at build time; the browser renders the resulting <math> element natively.

Along the way the per-feature Bool scheme in RenderCtx was replaced with a single RenderFeatures record carrying sum-typed backends (CodeBackend = NoHighlighting | Skylighting, MathBackend = NoStaticMath | StaticMathML). A post-implement /hickey + /lowy pass flagged that bolting another trailing Bool onto mkRenderCtx for every new feature would scale linearly with features and break call sites each time. Named record + sum-typed axes make adding a third backend (Typst, server-side KaTeX, whatever) zero-cost at the call site.

Breaking change for consumers (Emanote): enableSyntaxHighlighting :: Bool is gone. Callers pass a RenderFeatures { codeHighlighting = Skylighting | NoHighlighting, mathRendering = StaticMathML | NoStaticMath } to mkRenderCtx. The mapping from the old Bool is trivial (True → Skylighting, False → NoHighlighting).

The actual XML↔XmlHtml conversion went through a /simplify pass too — the first cut hand-rolled a recursive Text.XML.Light.Element → XmlHtml.Node walker, which turned out to be unnecessary once you notice xmlhtml's parseXML will happily re-parse the serialized MathML. Dropped ~20 lines and the CRef-handling branch.

Not listed as a dependency bump because 0.5.0.0 is still unreleased — the changelog folds this into the existing unreleased entry.

srid added 5 commits April 21, 2026 18:03
Mirrors the skylighting pattern from #10: a new
Heist.Extra.Splices.Pandoc.Texmath module converts LaTeX math to MathML
at build time using texmath's readTeX + writeMathML, producing XmlHtml
nodes browsers render natively without a client-side JS dependency.

Intercept B.Math in Render.hs, gated by a new enableStaticMath field on
RenderCtx (alongside enableSyntaxHighlighting). mkRenderCtx now takes
the extra Bool.
Hickey H1 + Lowy A1: replace positional Bool flags (enableSyntaxHighlighting,
enableStaticMath) with a named RenderFeatures record carrying sum-typed
backends (CodeBackend = NoHighlighting | Skylighting, MathBackend = NoStaticMath
| StaticMathML). mkRenderCtx now takes one RenderFeatures arg instead of two
trailing Bools, so adding a third backend is zero-cost at call sites and the
'which backend' axis is named rather than positional.

Hickey H2: extract renderMathPassthrough so the B.Math case is purely backend
selection, with error handling as a single local case in the static arm.
Hickey H3: fromContent previously returned [] for XL.CRef, which would have
silently dropped character references. In practice texmath's MathML writer
emits Unicode directly so this branch is unreachable, but preserve the
entity literally so any future regression is visible rather than invisible.
Earlier append via heredoc lost the trailing newline and glued
.direnv and .do-results.json into one broken token. Restore both
entries on their own lines.
/simplify reuse pass: serialize texmath's Text.XML.Light.Element via
showElement, then let xmlhtml's own parseXML rebuild the tree. Drops
the fromElement/fromAttr/fromContent helpers (~20 lines) and the
now-moot CRef-handling branch. Mirrors the existing pattern at
Heist/Extra/TemplateState.hs:69.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 21, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey Positional Bool flags in mkRenderCtx complect config with arity Fixed in this PR
2 Hickey B.Math handler braids backend/error/mathType dispatch Fixed in this PR
3 Hickey fromContent silently drops CRef nodes Fixed in this PR
4 Lowy Bool flags encode wrong axis (presence vs. backend choice) Fixed in this PR
5 Lowy B.Math dispatch policy lives in generic library, not consumer Deferred
6 Lowy No per-page / per-block backend override Deferred

Hickey rationale

H1mkRenderCtx classMap bS iS enableHighlighting enableStatic had two trailing anonymous Bools; emptyRenderCtx ended in False False. Adding a third feature would have broken every call site. Fix: introduced RenderFeatures record and passed it as a single named argument.

H2 — the B.Math branch braided three concerns (backend selection, error-vs-success dispatch in the static arm, inline-vs-display dispatch in the fallback arm) so you couldn't reason about one without understanding the others. Fix: extracted renderMathPassthrough so the outer case is purely backend selection.

H3fromContent's XL.CRef _ -> [] dropped data invisibly without any indication. Subsequently moot: the entire hand-rolled walker was replaced during the /simplify pass by showElement + parseXML, which handles entity references correctly by construction.

Lowy rationale

L4 (fixed)enableSyntaxHighlighting :: Bool / enableStaticMath :: Bool encoded the binary "use this backend or not." The volatile axis is which backend, and Bool forces a new field per backend. Fix: sum-typed CodeBackend / MathBackend make each axis independently extensible.

L5 (deferred) — intercepting B.Math inside heist-extra's generic Render.hs puts rendering policy (which math backend) inside the library. The existing blockSplice / inlineSplice hooks in RenderCtx are exactly the escape hatch for consumers to override AST rendering. A future PR should move the if enableStaticMath-equivalent dispatch out of heist-extra into Emanote's inlineSplice callback, calling Texmath.renderMath from there, and re-evaluate skylighting the same way. Deferring because the user explicitly asked this PR to mirror the skylighting interception pattern, and the retargeting is a structural change that deserves its own PR covering both features.

L6 (deferred) — the flags are site-wide; per-page/per-block override is a deferred problem, not a now-problem, since the inlineSplice hook already provides a future path.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 21, 2026

/do results

Step Status Duration Verification
sync 0s git fetch ok; forge=github
research 1m 28s texmath + xml API; studied skylighting PR #10 as template
branch 0s static-math off origin/master
implement 58s New Texmath.hs, B.Math interception, cabal/changelog updates
check 2m 9s cabal build all clean
docs 5s CHANGELOG updated
fmt 14s fourmolu clean
commit 20s fbc81d9 — primary feature commit
hickey+lowy 6m 14s 3 findings fixed (8e29493, 86a0c83); 2 deferred
police 7m 8s fact-check caught .gitignore bug (06b5d47); elegance dropped hand-rolled walker (9e00d21)
test 5s no test suite
create-pr 54s this PR
ci 17s nix flake check green against HEAD
Total 20m 48s

Slowest step: police (7m 8s) — the /simplify elegance pass discovered the hand-rolled XL.Element → XmlHtml.Node walker could be collapsed into showElement + parseXML, which required a rebuild and reformat.

Optimization suggestions

  • Police dominated (34% of total). The elegance sub-pass was the expensive part because it surfaced a structural rewrite mid-workflow. Pre-reading the xmlhtml API surface (or checking adjacent modules like TemplateState.hs) during research would have landed the parseXML approach on the first pass and avoided the re-build/re-commit cycle.
  • Hickey+lowy at 6m 14s is within normal range for a structural-critique pass that actually found findings to commit. On a trivial PR, --skip-setup + deselecting this step would trim wall-clock.
  • No CI retries, no test flakes, no police retries: the 3-hop commit cascade (primary → hickey fixes → police fixes) landed cleanly on the first attempt of each.

Workflow completed at 2026-04-21T22:22:13Z.

@srid srid marked this pull request as ready for review April 21, 2026 22:43
@srid srid merged commit 13c70e9 into master Apr 21, 2026
2 checks passed
@srid srid deleted the static-math branch April 21, 2026 22:45
srid added a commit to srid/emanote that referenced this pull request Apr 21, 2026
**`$...$` / `$$...$$` now render to MathML at build time**, so a minimal
Emanote site ships zero math JavaScript by default. Uses the new
`RenderFeatures` API from
[heist-extra#11](srid/heist-extra#11), which
swaps a grab-bag of per-feature `Bool`s on `RenderCtx` for a single
record with sum-typed backends (`CodeBackend`, `MathBackend`). Closes
#626.

Modern browsers (Firefox, Safari, Chrome ≥109) render MathML natively,
so the default flips to `emanote.staticMath: true`. The `js.katex`
snippet is **removed** from the default config — KaTeX is a minor enough
alternative path that shipping a pinned CDN loader with SRI hashes that
diverge from the KaTeX docs was doing readers a disservice. The MathJax
snippet stays because it's the documented fallback. *Existing sites
referencing `<snippet var="js.katex" />` will need to paste the loader
directly into `page.headHtml` — see the rewritten `docs/tips/js/math.md`
for the exact lines.*

An e2e smoke scenario asserts every build produces a `<math
display="inline">` and a `<math display="block">` on the math fixture
page, and that no KaTeX asset is referenced on a default-config page.

### Try it locally

```sh
nix run github:srid/emanote/static-math -- run -L docs
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant