From e250e61c92297f2b1a1d4682dd46dc59fb7d2d57 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:25:45 -0400 Subject: [PATCH 01/10] Static math rendering on by default, via texmath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #626. Uses the new RenderFeatures API from heist-extra PR #11: build-time LaTeX → MathML via texmath. Modern browsers render MathML natively, so the default config no longer needs any math JS bundle. - flake.nix: pin heist-extra to the static-math branch (until PR #11 merges and a tagged release cuts). - Pandoc/Renderer.hs: mkRenderCtxWithPandocRenderers takes RenderFeatures instead of a lone Bool flag. - View/Common.hs: build RenderFeatures from emanote.syntaxHighlighting and the new emanote.staticMath config key (both default true). - index.yaml: staticMath:true default, and drop the js.katex snippet (docs show how to paste KaTeX directly if a site still wants it). - docs/tips/js/math.md: rewritten; static is the primary story now. --- docs/tips/js/math.md | 39 +++++++++++++++----------- emanote/CHANGELOG.md | 1 + emanote/default/index.yaml | 20 ++++--------- emanote/src/Emanote/Pandoc/Renderer.hs | 4 +-- emanote/src/Emanote/View/Common.hs | 19 +++++++++---- flake.lock | 7 +++-- flake.nix | 2 +- 7 files changed, 51 insertions(+), 41 deletions(-) diff --git a/docs/tips/js/math.md b/docs/tips/js/math.md index a0ec091c6..aa0c30baf 100644 --- a/docs/tips/js/math.md +++ b/docs/tips/js/math.md @@ -1,39 +1,46 @@ --- slug: math -page: - headHtml: | - --- # Math -## MathJax +Emanote renders `$...$` and `$$...$$` to **MathML at build time** by default via [`texmath`](https://hackage.haskell.org/package/texmath). Modern browsers (Firefox, Safari, Chrome ≥109) render MathML natively, so the page ships no math JS bundle. + +### Demo + +When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are +$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ -[MathJax](https://www.mathjax.org) can be used to render Math formulas. For example, $a^2 + b ^ 2 = c$. +## Opting out -To enable it, add the following to `page.headHtml` of [[yaml-config|YAML configuration]] or Markdown frontmatter. +If you prefer KaTeX's typography or need to support a very old browser, disable static rendering in your site's `index.yaml`: ```yaml -page: - headHtml: | - +emanote: + staticMath: false ``` -### Demo +Then enable a client-side renderer per page (or globally via `page.headHtml` in your root `index.yaml`). -When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are -$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ +### MathJax +```yaml +page: + headHtml: | + +``` -## KaTeX +The `js.mathjax` snippet is shipped in the default config. -[KaTeX](https://katex.org/) can be used as an alternative to MathJax. Just like MathJax, it renders math specified between dollar signs. +### KaTeX -To enable it: +Paste the KaTeX loader directly into `page.headHtml` — Emanote's default config no longer defines a `js.katex` snippet: ```yaml page: headHtml: | - + + + ``` diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index e7e2e2bea..2e5b92519 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -6,6 +6,7 @@ - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633)) - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624)) +- Built-in static math rendering (LaTeX → MathML at build time via `texmath`), closes [#626](https://github.com/srid/emanote/issues/626). KaTeX snippet removed from default config; MathJax snippet retained. - UI revamp (#622, [#636](https://github.com/srid/emanote/pull/636)) - New self-hosted typography: Lora + Space Grotesk + Space Mono. - Manual dark/light theme toggle (#605, #617) with `localStorage` persistence. diff --git a/emanote/default/index.yaml b/emanote/default/index.yaml index 47dd24bcd..52eddf1b7 100644 --- a/emanote/default/index.yaml +++ b/emanote/default/index.yaml @@ -137,20 +137,6 @@ js: }; - katex: | - - - emanote: # Whether to automatically treat folder notes as a folgezettel parent of its contents @@ -160,3 +146,9 @@ emanote: # Enable server-side syntax highlighting using skylighting (default: true) # Set to false to use client-side highlighters like highlight.js instead syntaxHighlighting: true + + # Render `$...$` / `$$...$$` to MathML at build time via texmath (default: true). + # Modern browsers render MathML natively (Firefox, Safari, Chrome >=109), so + # no runtime JS is needed. Set to false to fall back to client-side JS + # (MathJax snippet, or a custom KaTeX snippet — see docs/tips/js/math). + staticMath: true diff --git a/emanote/src/Emanote/Pandoc/Renderer.hs b/emanote/src/Emanote/Pandoc/Renderer.hs index b17d623a1..6bd28fca3 100644 --- a/emanote/src/Emanote/Pandoc/Renderer.hs +++ b/emanote/src/Emanote/Pandoc/Renderer.hs @@ -50,8 +50,8 @@ mkRenderCtxWithPandocRenderers :: Map Text Text -> model -> route -> - -- | Enable syntax highlighting for code blocks - Bool -> + -- | Rendering feature selection (code highlighting, static math, …) + Splices.RenderFeatures -> HeistT Identity m Splices.RenderCtx mkRenderCtxWithPandocRenderers nr@PandocRenderers {..} classRules model x = Splices.mkRenderCtx diff --git a/emanote/src/Emanote/View/Common.hs b/emanote/src/Emanote/View/Common.hs index fae6e55b1..d5a2ae8e4 100644 --- a/emanote/src/Emanote/View/Common.hs +++ b/emanote/src/Emanote/View/Common.hs @@ -31,7 +31,7 @@ import Emanote.View.LiveServerFiles qualified as LiveServerFiles import Emanote.View.Tailwind (generatedCssFile, tailwindBrowserConfig, themeRemapStyle) import Heist qualified as H import Heist.Extra.Splices.List qualified as Splices -import Heist.Extra.Splices.Pandoc.Ctx (RenderCtx) +import Heist.Extra.Splices.Pandoc.Ctx (CodeBackend (..), MathBackend (..), RenderCtx, RenderFeatures (..)) import Heist.Extra.TemplateState qualified as Tmpl import Heist.Interpreted qualified as HI import Heist.Splices.Apply qualified as HA @@ -90,13 +90,22 @@ mkTemplateRenderCtx model r meta = classRules model r - enableSyntaxHighlighting + renderFeatures classRules :: Map Text Text classRules = SData.lookupAeson mempty ("pandoc" :| ["rewriteClass"]) meta - enableSyntaxHighlighting :: Bool - enableSyntaxHighlighting = - SData.lookupAeson True ("emanote" :| ["syntaxHighlighting"]) meta + renderFeatures :: RenderFeatures + renderFeatures = + RenderFeatures + { codeHighlighting = + if SData.lookupAeson True ("emanote" :| ["syntaxHighlighting"]) meta + then Skylighting + else NoHighlighting + , mathRendering = + if SData.lookupAeson True ("emanote" :| ["staticMath"]) meta + then StaticMathML + else NoStaticMath + } defaultRouteMeta :: Model -> (LMLRoute, Aeson.Value) defaultRouteMeta model = diff --git a/flake.lock b/flake.lock index 1295aef92..b3566a620 100644 --- a/flake.lock +++ b/flake.lock @@ -136,15 +136,16 @@ "heist-extra": { "flake": false, "locked": { - "lastModified": 1766109391, - "narHash": "sha256-ytHgIoRlkI5K0SDq33znlY0wjlqcwoQCe1z9JfHT/Fw=", + "lastModified": 1776809601, + "narHash": "sha256-GYuo4mAlxB3wq5l8dFbhRRJEJei6vHkdIHOoKGp35FM=", "owner": "srid", "repo": "heist-extra", - "rev": "81f1ea0cf1226215430171dbe613a2988c6cc46a", + "rev": "9e00d21a84bf146ff3ec784f69f3a776f89573aa", "type": "github" }, "original": { "owner": "srid", + "ref": "static-math", "repo": "heist-extra", "type": "github" } diff --git a/flake.nix b/flake.nix index 647d753e3..6637a142b 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ ema.flake = false; lvar.url = "github:srid/lvar/0.2.0.0"; lvar.flake = false; - heist-extra.url = "github:srid/heist-extra"; + heist-extra.url = "github:srid/heist-extra/static-math"; heist-extra.flake = false; unionmount.url = "github:srid/unionmount/0.3.0.0"; unionmount.flake = false; From 551eabb83f4a1c3f825ab24d31b3efd9f8b05072 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:28:33 -0400 Subject: [PATCH 02/10] refactor(lowy): mark heist-extra branch pin as temporary Branch pins are moving targets; tag this one as deliberate development state so a rebuild months from now doesn't silently drift. Replace with a tagged release once heist-extra PR #11 merges. --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 6637a142b..7ef0a8e1d 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,7 @@ ema.flake = false; lvar.url = "github:srid/lvar/0.2.0.0"; lvar.flake = false; + # TODO: replace with tagged release once srid/heist-extra#11 merges. heist-extra.url = "github:srid/heist-extra/static-math"; heist-extra.flake = false; unionmount.url = "github:srid/unionmount/0.3.0.0"; From de311c08822a1f9663499ad35030cbba7e2282bb Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:34:18 -0400 Subject: [PATCH 03/10] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94?= =?UTF-8?q?=20use=20bool=20over=20if/then/else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unanimous /simplify finding: if b then X else Y on plain Bool over nullary sum constructors is textbook 'bool' territory. Relude re-exports Data.Bool.bool; existing precedent at emanote/src/Emanote/Source/Patch.hs:124. --- emanote/src/Emanote/View/Common.hs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/emanote/src/Emanote/View/Common.hs b/emanote/src/Emanote/View/Common.hs index d5a2ae8e4..07af5c474 100644 --- a/emanote/src/Emanote/View/Common.hs +++ b/emanote/src/Emanote/View/Common.hs @@ -98,13 +98,11 @@ mkTemplateRenderCtx model r meta = renderFeatures = RenderFeatures { codeHighlighting = - if SData.lookupAeson True ("emanote" :| ["syntaxHighlighting"]) meta - then Skylighting - else NoHighlighting + bool NoHighlighting Skylighting + $ SData.lookupAeson True ("emanote" :| ["syntaxHighlighting"]) meta , mathRendering = - if SData.lookupAeson True ("emanote" :| ["staticMath"]) meta - then StaticMathML - else NoStaticMath + bool NoStaticMath StaticMathML + $ SData.lookupAeson True ("emanote" :| ["staticMath"]) meta } defaultRouteMeta :: Model -> (LMLRoute, Aeson.Value) From f199b17d68f451b88384ac24296e635b6856ea7b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:35:27 -0400 Subject: [PATCH 04/10] test: e2e smoke scenario for static math rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /math.html fixture and asserts the rendered page contains a element. Guards the emanote.staticMath:true default — if the flag regresses, texmath won't fire and the locator will report 0 hits. --- tests/features/smoke.feature | 4 ++++ tests/fixtures/notebook/math.md | 9 +++++++++ tests/step_definitions/smoke_steps.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/fixtures/notebook/math.md diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature index 12bc47731..7aaa1e716 100644 --- a/tests/features/smoke.feature +++ b/tests/features/smoke.feature @@ -11,3 +11,7 @@ Feature: Smoke Given I note the resolved primary palette at "/" When I open "/themed.html" Then the resolved primary palette differs from the noted value + + Scenario: Static math renders to a MathML element + When I open "/math.html" + Then the page contains a MathML element diff --git a/tests/fixtures/notebook/math.md b/tests/fixtures/notebook/math.md new file mode 100644 index 000000000..63de3eaa7 --- /dev/null +++ b/tests/fixtures/notebook/math.md @@ -0,0 +1,9 @@ +--- +page: + siteTitle: Math Fixture +--- + +# Math + +Static-math probe: the equation $x^2 + y^2 = z^2$ should serialize as a +`` element because `emanote.staticMath` defaults to `true`. diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts index 46ca514be..7879860a2 100644 --- a/tests/step_definitions/smoke_steps.ts +++ b/tests/step_definitions/smoke_steps.ts @@ -27,6 +27,19 @@ Then( }, ); +Then( + "the page contains a MathML element", + async function (this: EmanoteWorld) { + // texmath writes `` for every expression; a single + // locator hit is enough to prove the build-time pipeline fired. + const count = await this.page.locator("math").count(); + assert.ok( + count > 0, + "Expected at least one element; found 0 — emanote.staticMath default may have regressed.", + ); + }, +); + Then( "the resolved primary palette differs from the noted value", async function (this: EmanoteWorld) { From 95e9f2c726b940eb9c88a7bf29b5a17240d0afcf Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:37:03 -0400 Subject: [PATCH 05/10] changelog: expand static-math entry with breaking-change and API notes --- emanote/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index 2e5b92519..4bb134e81 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -6,7 +6,10 @@ - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633)) - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624)) -- Built-in static math rendering (LaTeX → MathML at build time via `texmath`), closes [#626](https://github.com/srid/emanote/issues/626). KaTeX snippet removed from default config; MathJax snippet retained. +- **Built-in static math rendering**: `$...$` / `$$...$$` are now converted to MathML at build time via `texmath`, so no runtime math JS is needed on a default site ([#639](https://github.com/srid/emanote/pull/639), closes [#626](https://github.com/srid/emanote/issues/626)). + - Controlled by `emanote.staticMath` (default: `true`). Set to `false` to fall back to client-side JS renderers. + - **Breaking**: the `js.katex` snippet has been removed from the default config. Existing sites referencing `` must inline the KaTeX loader into `page.headHtml` — see `docs/tips/js/math` for the exact lines. The `js.mathjax` snippet is retained. + - **API change** (only affects consumers of the `heist-extra` library directly): `mkRenderCtxWithPandocRenderers` now takes a `RenderFeatures` record instead of a lone `Bool`; `CodeBackend` / `MathBackend` sum types replace per-feature booleans. - UI revamp (#622, [#636](https://github.com/srid/emanote/pull/636)) - New self-hosted typography: Lora + Space Grotesk + Space Mono. - Manual dark/light theme toggle (#605, #617) with `localStorage` persistence. From 5d8503e9ca21a621c304b701baad046e322e1c6b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Tue, 21 Apr 2026 18:42:35 -0400 Subject: [PATCH 06/10] docs: fix \over and wrap KaTeX snippet lines - texmath's TeX reader rejects the plain-TeX \over primitive; use \frac{num}{den} instead so the Demo equation renders instead of hitting the error span. - The KaTeX loader lines in the opt-in code block were too long, causing the
 to overflow its container horizontally. Break
  each attribute onto its own line.
- CHANGELOG: collapse static-math entry to a single bullet.
---
 docs/tips/js/math.md | 15 +++++++++++----
 emanote/CHANGELOG.md |  5 +----
 2 files changed, 12 insertions(+), 8 deletions(-)

diff --git a/docs/tips/js/math.md b/docs/tips/js/math.md
index aa0c30baf..a27932af0 100644
--- a/docs/tips/js/math.md
+++ b/docs/tips/js/math.md
@@ -9,7 +9,7 @@ Emanote renders `$...$` and `$$...$$` to **MathML at build time** by default via
 ### Demo
 
 When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
-$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
+$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}.$$
 
 ## Opting out
 
@@ -39,8 +39,15 @@ Paste the KaTeX loader directly into `page.headHtml` — Emanote's default confi
 ```yaml
 page:
   headHtml: |
-    
-    
-    
+    
+    
+    
 ```
 
diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md
index 4bb134e81..27f1b4734 100644
--- a/emanote/CHANGELOG.md
+++ b/emanote/CHANGELOG.md
@@ -6,10 +6,7 @@
 
 - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633))
 - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624))
-- **Built-in static math rendering**: `$...$` / `$$...$$` are now converted to MathML at build time via `texmath`, so no runtime math JS is needed on a default site ([#639](https://github.com/srid/emanote/pull/639), closes [#626](https://github.com/srid/emanote/issues/626)).
-  - Controlled by `emanote.staticMath` (default: `true`). Set to `false` to fall back to client-side JS renderers.
-  - **Breaking**: the `js.katex` snippet has been removed from the default config. Existing sites referencing `` must inline the KaTeX loader into `page.headHtml` — see `docs/tips/js/math` for the exact lines. The `js.mathjax` snippet is retained.
-  - **API change** (only affects consumers of the `heist-extra` library directly): `mkRenderCtxWithPandocRenderers` now takes a `RenderFeatures` record instead of a lone `Bool`; `CodeBackend` / `MathBackend` sum types replace per-feature booleans.
+- Built-in static math rendering (LaTeX → MathML at build time via `texmath`) ([#639](https://github.com/srid/emanote/pull/639), closes [#626](https://github.com/srid/emanote/issues/626))
 - UI revamp (#622, [#636](https://github.com/srid/emanote/pull/636))
   - New self-hosted typography: Lora + Space Grotesk + Space Mono.
   - Manual dark/light theme toggle (#605, #617) with `localStorage` persistence.

From 22cc2d9980741f0ac62001d51d9b65b2ded41b05 Mon Sep 17 00:00:00 2001
From: Sridhar Ratnakumar 
Date: Tue, 21 Apr 2026 18:43:41 -0400
Subject: [PATCH 07/10] docs+tests: promote Demo to H2; beef up e2e; ignore
 scheduled-tasks lock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- docs/tips/js/math.md: 'Demo' was H3 directly under H1 with no H2
  between. Bump to H2 so the TOC hierarchy makes sense.
- tests: split the math scenario into inline/display variants, assert
  the MathML namespace + display attribute, and add a negative
  scenario that no KaTeX asset is referenced by the default config.
  Fixture gains a $$…$$ display-math line.
- .gitignore: add .claude/scheduled_tasks.lock (Claude Code local
  scheduler artifact, not source).
---
 .gitignore                            |  1 +
 docs/tips/js/math.md                  |  2 +-
 tests/features/smoke.feature          | 12 +++++--
 tests/fixtures/notebook/math.md       |  9 ++++--
 tests/step_definitions/smoke_steps.ts | 46 ++++++++++++++++++++++++---
 5 files changed, 60 insertions(+), 10 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1a413c509..056f5e00b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ apm_modules/
 
 /.worktrees
 /.do-results.json
+/.claude/scheduled_tasks.lock
diff --git a/docs/tips/js/math.md b/docs/tips/js/math.md
index a27932af0..577f42d74 100644
--- a/docs/tips/js/math.md
+++ b/docs/tips/js/math.md
@@ -6,7 +6,7 @@ slug: math
 
 Emanote renders `$...$` and `$$...$$` to **MathML at build time** by default via [`texmath`](https://hackage.haskell.org/package/texmath). Modern browsers (Firefox, Safari, Chrome ≥109) render MathML natively, so the page ships no math JS bundle.
 
-### Demo
+## Demo
 
 When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
 $$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}.$$
diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature
index 7aaa1e716..75d405db2 100644
--- a/tests/features/smoke.feature
+++ b/tests/features/smoke.feature
@@ -12,6 +12,14 @@ Feature: Smoke
     When I open "/themed.html"
     Then the resolved primary palette differs from the noted value
 
-  Scenario: Static math renders to a MathML element
+  Scenario: Inline math renders to MathML at build time
     When I open "/math.html"
-    Then the page contains a MathML element
+    Then the page contains an inline  element in the MathML namespace
+
+  Scenario: Display math renders to block MathML at build time
+    When I open "/math.html"
+    Then the page contains a block  element in the MathML namespace
+
+  Scenario: KaTeX is not loaded by default
+    When I open "/math.html"
+    Then no KaTeX stylesheet is referenced
diff --git a/tests/fixtures/notebook/math.md b/tests/fixtures/notebook/math.md
index 63de3eaa7..a4c65bea7 100644
--- a/tests/fixtures/notebook/math.md
+++ b/tests/fixtures/notebook/math.md
@@ -5,5 +5,10 @@ page:
 
 # Math
 
-Static-math probe: the equation $x^2 + y^2 = z^2$ should serialize as a
-`` element because `emanote.staticMath` defaults to `true`.
+Inline math probe: $x^2 + y^2 = z^2$ should become a `` element
+with `display="inline"` (texmath's default for InlineMath).
+
+Display math probe: the quadratic formula below should become a ``
+element with `display="block"`.
+
+$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts
index 7879860a2..62e0e8e11 100644
--- a/tests/step_definitions/smoke_steps.ts
+++ b/tests/step_definitions/smoke_steps.ts
@@ -27,15 +27,51 @@ Then(
   },
 );
 
+// texmath emits ``
+// for every expression. Assert on the namespace *and* display attribute so a
+// regression to raw `\(…\)` delimiters (for client-side MathJax/KaTeX) — which
+// would produce 0 math elements — fails loudly.
+const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
+
 Then(
-  "the page contains a MathML element",
+  "the page contains an inline  element in the MathML namespace",
   async function (this: EmanoteWorld) {
-    // texmath writes `` for every expression; a single
-    // locator hit is enough to prove the build-time pipeline fired.
-    const count = await this.page.locator("math").count();
+    const count = await this.page
+      .locator(`math[xmlns="${MATHML_NS}"][display="inline"]`)
+      .count();
     assert.ok(
       count > 0,
-      "Expected at least one  element; found 0 — emanote.staticMath default may have regressed.",
+      `Expected at least one inline MathML element; found ${count}. emanote.staticMath default may have regressed, or texmath's InlineMath → display="inline" mapping changed.`,
+    );
+  },
+);
+
+Then(
+  "the page contains a block  element in the MathML namespace",
+  async function (this: EmanoteWorld) {
+    const count = await this.page
+      .locator(`math[xmlns="${MATHML_NS}"][display="block"]`)
+      .count();
+    assert.ok(
+      count > 0,
+      `Expected at least one block MathML element; found ${count}. The $$…$$ display-math path is broken.`,
+    );
+  },
+);
+
+Then(
+  "no KaTeX stylesheet is referenced",
+  async function (this: EmanoteWorld) {
+    // The js.katex snippet was removed from the default config in the same PR
+    // that flipped staticMath on. Default-config sites should therefore never
+    // emit a katex stylesheet link.
+    const count = await this.page
+      .locator('link[href*="katex"], script[src*="katex"]')
+      .count();
+    assert.strictEqual(
+      count,
+      0,
+      `Default config should not pull in KaTeX; found ${count} katex asset reference(s). Did the js.katex snippet or a default \`page.headHtml\` leak back in?`,
     );
   },
 );

From 3a29b5eaaf2931b4a948d518a6adcf0e6cf41113 Mon Sep 17 00:00:00 2001
From: Sridhar Ratnakumar 
Date: Tue, 21 Apr 2026 18:44:56 -0400
Subject: [PATCH 08/10] =?UTF-8?q?docs:=20fix=20broken=20orgmode.org=20?=
 =?UTF-8?q?=E2=86=92=20syntax-highlighting=20link?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The page lives at docs/tips/syntax-highlighting.md, not under js/.
This has been broken since the page moved; surfaced while verifying
the adjacent Math link during static-math PR #639 review.
---
 docs/guide/orgmode.org | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/guide/orgmode.org b/docs/guide/orgmode.org
index e24e6cdf1..5bf395a5a 100644
--- a/docs/guide/orgmode.org
+++ b/docs/guide/orgmode.org
@@ -15,7 +15,7 @@ Here is a handpicked selection of syntatic features of Org Mode as particularly
 
 *** Code blocks 
 
-See [[file:../tips/js/syntax-highlighting.md][Syntax Highlighting]] for general information.
+See [[file:../tips/syntax-highlighting.md][Syntax Highlighting]] for general information.
 
 #+NAME: factorial
 #+BEGIN_SRC haskell :results silent :exports code :var n=0

From 2e890aff75778ca6fa947b9aba60b0d8763275e1 Mon Sep 17 00:00:00 2001
From: Sridhar Ratnakumar 
Date: Tue, 21 Apr 2026 18:50:02 -0400
Subject: [PATCH 09/10] Unpin heist-extra; drop redundant js.mathjax headHtml
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

heist-extra PR #11 is merged, so flake.nix can go back to tracking
the default branch. flake.lock bumps to heist-extra master head
13c70e9.

With static math now default-on, the js.mathjax snippet in
docs/guide/orgmode.yaml and docs/guide/html-template/external-links.md
became redundant (texmath renders the equations at build time; MathJax
would then scan a document with no remaining $…$ delimiters and
no-op). Drop it. orgmode.yaml also gains a slug so the org page is
reachable at /orgmode, matching other docs.
---
 docs/guide/html-template/external-links.md | 3 ---
 docs/guide/orgmode.yaml                    | 5 ++---
 flake.lock                                 | 7 +++----
 flake.nix                                  | 3 +--
 4 files changed, 6 insertions(+), 12 deletions(-)

diff --git a/docs/guide/html-template/external-links.md b/docs/guide/html-template/external-links.md
index 0db3c745d..64b855538 100644
--- a/docs/guide/html-template/external-links.md
+++ b/docs/guide/html-template/external-links.md
@@ -1,8 +1,5 @@
 ---
 slug: external-links
-page:
-  headHtml: |
-    
 ---
 
 # External links
diff --git a/docs/guide/orgmode.yaml b/docs/guide/orgmode.yaml
index c03ee751f..7128739f6 100644
--- a/docs/guide/orgmode.yaml
+++ b/docs/guide/orgmode.yaml
@@ -1,3 +1,2 @@
-page:
-  headHtml: |
-    
+slug: orgmode
+
diff --git a/flake.lock b/flake.lock
index b3566a620..92da794d2 100644
--- a/flake.lock
+++ b/flake.lock
@@ -136,16 +136,15 @@
     "heist-extra": {
       "flake": false,
       "locked": {
-        "lastModified": 1776809601,
-        "narHash": "sha256-GYuo4mAlxB3wq5l8dFbhRRJEJei6vHkdIHOoKGp35FM=",
+        "lastModified": 1776811520,
+        "narHash": "sha256-FCWJVrXjIu/du69h/rfkqXYcjA2+1CpJcxNVyDR6eyA=",
         "owner": "srid",
         "repo": "heist-extra",
-        "rev": "9e00d21a84bf146ff3ec784f69f3a776f89573aa",
+        "rev": "13c70e98621740fda1d93f4a5bd766b357535377",
         "type": "github"
       },
       "original": {
         "owner": "srid",
-        "ref": "static-math",
         "repo": "heist-extra",
         "type": "github"
       }
diff --git a/flake.nix b/flake.nix
index 7ef0a8e1d..647d753e3 100644
--- a/flake.nix
+++ b/flake.nix
@@ -19,8 +19,7 @@
     ema.flake = false;
     lvar.url = "github:srid/lvar/0.2.0.0";
     lvar.flake = false;
-    # TODO: replace with tagged release once srid/heist-extra#11 merges.
-    heist-extra.url = "github:srid/heist-extra/static-math";
+    heist-extra.url = "github:srid/heist-extra";
     heist-extra.flake = false;
     unionmount.url = "github:srid/unionmount/0.3.0.0";
     unionmount.flake = false;

From 57a448df70c67460bed006413fbeecf3b6216ece Mon Sep 17 00:00:00 2001
From: Sridhar Ratnakumar 
Date: Tue, 21 Apr 2026 18:57:38 -0400
Subject: [PATCH 10/10] =?UTF-8?q?fix(police):=20fact-check=20=E2=80=94=20u?=
 =?UTF-8?q?se=20namespaceURI=20for=20MathML,=20not=20[xmlns=3D]=20selector?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

HTML parsing places  in the MathML namespace automatically; the
xmlns=… attribute declaration is special and not visible to CSS
[xmlns=…] selectors, even though getAttribute('xmlns') returns the
value. Assert the namespace via element.namespaceURI inside
page.evaluate, matching the display attribute via the remaining CSS
bits. Also collapse CHANGELOG bullet.
---
 emanote/CHANGELOG.md                  |  2 +-
 tests/step_definitions/smoke_steps.ts | 47 ++++++++++++++++++---------
 2 files changed, 32 insertions(+), 17 deletions(-)

diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md
index 27f1b4734..b5ddf2e24 100644
--- a/emanote/CHANGELOG.md
+++ b/emanote/CHANGELOG.md
@@ -6,7 +6,7 @@
 
 - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633))
 - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624))
-- Built-in static math rendering (LaTeX → MathML at build time via `texmath`) ([#639](https://github.com/srid/emanote/pull/639), closes [#626](https://github.com/srid/emanote/issues/626))
+- Built-in static math rendering (LaTeX → MathML at build time via `texmath`) ([#639](https://github.com/srid/emanote/pull/639))
 - UI revamp (#622, [#636](https://github.com/srid/emanote/pull/636))
   - New self-hosted typography: Lora + Space Grotesk + Space Mono.
   - Manual dark/light theme toggle (#605, #617) with `localStorage` persistence.
diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts
index 62e0e8e11..8cd5ef5fa 100644
--- a/tests/step_definitions/smoke_steps.ts
+++ b/tests/step_definitions/smoke_steps.ts
@@ -27,21 +27,38 @@ Then(
   },
 );
 
-// texmath emits ``
-// for every expression. Assert on the namespace *and* display attribute so a
-// regression to raw `\(…\)` delimiters (for client-side MathJax/KaTeX) — which
-// would produce 0 math elements — fails loudly.
+// texmath emits `` for every expression. The HTML
+// parser places `` in the MathML namespace automatically, so
+// `element.namespaceURI` is the reliable check — the `xmlns=` attribute is a
+// namespace declaration that CSS `[xmlns=…]` selectors don't see. We assert
+// the `display` attribute via a CSS selector and the namespace via JS.
 const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
 
+async function assertMathMLCount(
+  page: EmanoteWorld["page"],
+  display: "inline" | "block",
+  msgSuffix: string,
+) {
+  const count = await page.evaluate(
+    ({ display, ns }) =>
+      [...document.querySelectorAll(`math[display="${display}"]`)].filter(
+        (el) => el.namespaceURI === ns,
+      ).length,
+    { display, ns: MATHML_NS },
+  );
+  assert.ok(
+    count > 0,
+    `Expected at least one ${display} MathML element; found ${count}. ${msgSuffix}`,
+  );
+}
+
 Then(
   "the page contains an inline  element in the MathML namespace",
   async function (this: EmanoteWorld) {
-    const count = await this.page
-      .locator(`math[xmlns="${MATHML_NS}"][display="inline"]`)
-      .count();
-    assert.ok(
-      count > 0,
-      `Expected at least one inline MathML element; found ${count}. emanote.staticMath default may have regressed, or texmath's InlineMath → display="inline" mapping changed.`,
+    await assertMathMLCount(
+      this.page,
+      "inline",
+      "emanote.staticMath default may have regressed, or texmath's InlineMath → display=\"inline\" mapping changed.",
     );
   },
 );
@@ -49,12 +66,10 @@ Then(
 Then(
   "the page contains a block  element in the MathML namespace",
   async function (this: EmanoteWorld) {
-    const count = await this.page
-      .locator(`math[xmlns="${MATHML_NS}"][display="block"]`)
-      .count();
-    assert.ok(
-      count > 0,
-      `Expected at least one block MathML element; found ${count}. The $$…$$ display-math path is broken.`,
+    await assertMathMLCount(
+      this.page,
+      "block",
+      "The $$…$$ display-math path is broken.",
     );
   },
 );