From bc3cf59f25dbf297776b70e50fee908f37f4b88a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:28:55 -0700 Subject: [PATCH 01/16] Add static MathML renderer --- scripts/markdown-math.mjs | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 scripts/markdown-math.mjs diff --git a/scripts/markdown-math.mjs b/scripts/markdown-math.mjs new file mode 100644 index 0000000..b44ef6c --- /dev/null +++ b/scripts/markdown-math.mjs @@ -0,0 +1,153 @@ +import temml from 'temml'; + +// === MODULE_BUILD === +// id: static_textbook_math_renderer +// module_name: markdown-math +// module_kind: adapter +// summary: Recognizes the textbook's TeX delimiters before Markdown escaping and emits static MathML during the Eleventy build. +// owner: Erin Spencer +// public_surface: installMathRenderer(markdownIt) +// internal_surface: bracket and AMS block rules, inline delimiter rule, fail-closed Temml rendering +// auth_boundary: none +// storage_boundary: none +// network_boundary: none +// user_data_boundary: none +// admin_only: false +// tests: tests/math-rendering.test.mjs, tests/generated-site.test.mjs, tests/site.spec.mjs +// rollout: installed into the repository-owned markdown-it instance in .eleventy.js +// rollback: remove installMathRenderer and the Temml dependency; exact chapter Markdown remains available through provenance links +// === END MODULE_BUILD === +// Usage: call installMathRenderer(md) before rendering source Markdown containing \(...\), \[...\], $$...$$, or supported AMS display environments. +// Limits: renders TeX math syntax only; it does not evaluate equations, validate mathematical claims, or alter owning-repository source text. + +// === BOUNDARIES === +// id: static_textbook_math_rendering_boundary +// summary: Converts repository-controlled TeX expressions to static MathML with untrusted commands disabled and bounded expansion and size. +// auth_boundary: none +// storage_boundary: none +// network_boundary: none +// user_data_boundary: none +// admin_only: false +// pii: none +// secrets: none +// side_effects: build fails when a recognized math expression cannot be parsed +// owner: Erin Spencer +// === END BOUNDARIES === + +const BRACKET_FENCES = new Map([ + ['\\[', '\\]'], + ['$$', '$$'] +]); + +const INLINE_FENCES = [ + { open: '\\(', close: '\\)', displayMode: false }, + { open: '\\[', close: '\\]', displayMode: true }, + { open: '$$', close: '$$', displayMode: true } +]; + +const AMS_ENVIRONMENT = /^\\begin\{(equation\*?|align\*?|alignat\*?|gather\*?|CD)\}\s*$/; + +const TEMML_OPTIONS = Object.freeze({ + annotate: true, + maxExpand: 1000, + maxSize: [100, 100], + strict: false, + throwOnError: true, + trust: false, + xml: true +}); + +function lineContent(state, line) { + return state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]); +} + +function locationFor(token) { + if (!Array.isArray(token.map)) return 'an inline expression'; + const start = token.map[0] + 1; + const end = token.map[1]; + return start === end ? `source line ${start}` : `source lines ${start}-${end}`; +} + +function renderMath(token) { + const tex = String(token.content || '').trim(); + if (!tex) throw new Error(`empty textbook LaTeX at ${locationFor(token)}`); + + try { + return temml.renderToString(tex, { + ...TEMML_OPTIONS, + displayMode: Boolean(token.meta?.displayMode) + }); + } catch (error) { + const excerpt = tex.length > 180 ? `${tex.slice(0, 177)}...` : tex; + throw new Error( + `textbook LaTeX failed at ${locationFor(token)}: ${excerpt}\n${String(error?.message || error)}`, + { cause: error } + ); + } +} + +function mathBlock(state, startLine, endLine, silent) { + const first = lineContent(state, startLine).trim(); + const bracketClose = BRACKET_FENCES.get(first); + const environment = first.match(AMS_ENVIRONMENT)?.[1] || null; + if (!bracketClose && !environment) return false; + + const close = bracketClose || `\\end{${environment}}`; + const body = []; + let nextLine = startLine + 1; + + for (; nextLine < endLine; nextLine += 1) { + const current = lineContent(state, nextLine); + if (current.trim() === close) break; + body.push(current); + } + + if (nextLine >= endLine) { + if (silent) return true; + throw new Error(`unclosed textbook LaTeX block beginning at source line ${startLine + 1}`); + } + + if (silent) return true; + + const token = state.push('math_block', 'math', 0); + token.block = true; + token.content = environment + ? [first, ...body, close].join('\n') + : body.join('\n'); + token.map = [startLine, nextLine + 1]; + token.meta = { displayMode: true }; + state.line = nextLine + 1; + return true; +} + +function mathInline(state, silent) { + const fence = INLINE_FENCES.find(candidate => state.src.startsWith(candidate.open, state.pos)); + if (!fence) return false; + + const contentStart = state.pos + fence.open.length; + const contentEnd = state.src.indexOf(fence.close, contentStart); + if (contentEnd < 0) return false; + + if (!silent) { + const token = state.push('math_inline', 'math', 0); + token.content = state.src.slice(contentStart, contentEnd); + token.meta = { displayMode: fence.displayMode }; + } + + state.pos = contentEnd + fence.close.length; + return true; +} + +export function installMathRenderer(md) { + if (!md?.block?.ruler || !md?.inline?.ruler || !md?.renderer?.rules) { + throw new TypeError('installMathRenderer requires a markdown-it instance'); + } + + md.block.ruler.before('fence', 'math_block', mathBlock, { + alt: ['paragraph', 'reference', 'blockquote', 'list'] + }); + md.inline.ruler.before('escape', 'math_inline', mathInline); + md.renderer.rules.math_block = (tokens, index) => `${renderMath(tokens[index])}\n`; + md.renderer.rules.math_inline = (tokens, index) => renderMath(tokens[index]); + return md; +} From 37932458eaca021cdfeb951fdb063f96c0659dc7 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:29:11 -0700 Subject: [PATCH 02/16] Render textbook LaTeX during build --- .eleventy.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.eleventy.js b/.eleventy.js index b9bcf6d..2e39dce 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -1,14 +1,15 @@ import markdownIt from 'markdown-it'; +import { installMathRenderer } from './scripts/markdown-math.mjs'; // === MODULE_BUILD === // id: eleventy_site_configuration -// purpose: Build the static-first public knowledge system, render exact distributed-textbook Markdown, and copy deliberate fallback artifacts. +// purpose: Build the static-first public knowledge system, render exact distributed-textbook Markdown and LaTeX as static MathML, and copy deliberate fallback artifacts. // entrypoint: npm run build -// tests: tests/site-contract.test.mjs, tests/generated-site.test.mjs +// tests: tests/site-contract.test.mjs, tests/math-rendering.test.mjs, tests/generated-site.test.mjs // === END MODULE_BUILD === export default function configureEleventy(eleventyConfig) { - const md = markdownIt({ html: false, linkify: true, typographer: true }); + const md = installMathRenderer(markdownIt({ html: false, linkify: true, typographer: true })); eleventyConfig.setLibrary('md', md); eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets', From d2a1783c419163c78840b2f7de4b12f174de34e6 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:29:26 -0700 Subject: [PATCH 03/16] Pin Temml math renderer --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1a14a1f..f3454bc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "validate": "node scripts/validate-content.mjs && node scripts/verify-generated-routes.mjs && node scripts/verify-article-canon.mjs", "build": "npm run validate && eleventy && pagefind --site _site && node scripts/write-build-info.mjs", "pretest": "node scripts/prepare-tests.mjs", - "test": "node --test tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/site-contract.test.mjs", + "test": "node --test tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/math-rendering.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/site-contract.test.mjs", "test:generated": "node --test tests/generated-site.test.mjs tests/textbook-generated.test.mjs && node tests/links.test.mjs", "test:browser": "playwright test", "test:e2e": "playwright test tests/site.spec.mjs", @@ -31,7 +31,8 @@ "js-yaml": "4.1.0", "markdown-it": "14.1.0", "sanitize-html": "2.17.0", - "slugify": "1.6.6" + "slugify": "1.6.6", + "temml": "0.13.3" }, "devDependencies": { "@playwright/test": "1.54.1", From 5eb168cb529017f556d0d5c7cdb938611e0d8046 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:29:53 -0700 Subject: [PATCH 04/16] Test static textbook math rendering --- tests/math-rendering.test.mjs | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/math-rendering.test.mjs diff --git a/tests/math-rendering.test.mjs b/tests/math-rendering.test.mjs new file mode 100644 index 0000000..72e5263 --- /dev/null +++ b/tests/math-rendering.test.mjs @@ -0,0 +1,65 @@ +// Usage: run through `npm test`; verifies that textbook TeX is intercepted before Markdown escaping and rendered to static MathML. +// Evidence boundary: confirms syntax rendering and fail-closed behavior, not the mathematical truth of any expression. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import MarkdownIt from 'markdown-it'; +import { installMathRenderer } from '../scripts/markdown-math.mjs'; + +function renderer() { + return installMathRenderer(markdownIt({ html: false, linkify: true, typographer: true })); +} + +test('renders inline and display textbook LaTeX as annotated static MathML', () => { + const html = renderer().render(String.raw`Let \(A \in \mathcal{O}\) retain faithful breadth. + +\[ +\bigl|p^{-1}(x)\bigr| = +\begin{cases} +1, & x = 0, \\ +2, & x \ne 0. +\end{cases} +\] +`); + + assert.equal((html.match(//); + assert.doesNotMatch(html, /\\\(|\\\)|\\\[|\\\]/); +}); + +test('renders supported AMS display environments', () => { + const html = renderer().render(String.raw`\begin{align} +a &= b \\ +c &= d +\end{align} +`); + + assert.match(html, / { + const html = renderer().render(String.raw`\`\`\` +\[ +x = 1 +\] +\`\`\` +`); + + assert.doesNotMatch(html, //); + assert.match(html, /\\\[/); +}); + +test('fails the build on invalid recognized LaTeX', () => { + assert.throws( + () => renderer().render(String.raw`\[ +\frac{a}{ +\] +`), + /textbook LaTeX failed/ + ); +}); From 0016da35ba8dc8d1566add14fa5e5ca6ef836544 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:30:30 -0700 Subject: [PATCH 05/16] Style static textbook MathML --- src/assets/css/math.css | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/assets/css/math.css diff --git a/src/assets/css/math.css b/src/assets/css/math.css new file mode 100644 index 0000000..42348d9 --- /dev/null +++ b/src/assets/css/math.css @@ -0,0 +1,60 @@ +/* === MODULE_BUILD === + id: static_mathml_presentation + module_name: math-css + module_kind: presentation + summary: Presents build-time Temml MathML accessibly across desktop, mobile, and print without requiring client JavaScript or external fonts. + owner: Erin Spencer + public_surface: /assets/css/math.css + internal_surface: native MathML font selection, display overflow containment, print behavior + auth_boundary: none + storage_boundary: none + network_boundary: none + user_data_boundary: none + admin_only: false + tests: tests/generated-site.test.mjs, tests/site.spec.mjs, tests/accessibility.spec.mjs + rollout: linked by layouts/base.njk + rollback: remove the stylesheet link; semantic MathML remains in generated HTML +=== END MODULE_BUILD === */ +/* Usage: loaded on the generated knowledge-system pages; Temml emits the semantic tree during the build. */ +/* Limits: uses locally available math fonts and does not bundle or fetch a font file. */ + +math { + color: inherit; + direction: ltr; + font-family: "Cambria Math", "STIX Two Math", "Noto Sans Math", math; + font-size-adjust: none; + font-style: normal; + font-weight: normal; + letter-spacing: normal; + line-height: normal; + text-indent: 0; + text-transform: none; + word-wrap: normal; + font-feature-settings: "dtls" off; +} + +math * { + border-color: currentColor; +} + +math.tml-display { + display: block; + width: 100%; + max-width: 100%; + margin: 1.4rem 0; + padding: .35rem 0 .55rem; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-inline: contain; +} + +.textbook-chapter math:not(.tml-display) { + white-space: nowrap; +} + +@media print { + math.tml-display { + overflow: visible; + break-inside: avoid; + } +} From f202ef053d48c0083c51dfa702c56578d88aa9bb Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Fri, 24 Jul 2026 19:31:08 -0700 Subject: [PATCH 06/16] Load static MathML presentation --- src/_includes/layouts/base.njk | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 7a306e0..ff2eccf 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -9,6 +9,7 @@ + @@ -32,7 +33,7 @@ Search - +
{{ content | safe }}