-
Notifications
You must be signed in to change notification settings - Fork 0
Render textbook LaTeX as static MathML #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc3cf59
3793245
d2a1783
5eb168c
0016da3
f202ef0
d849760
ace17cb
7ab992b
9f81ccb
56ec624
2e31010
3033ef3
2c4c384
4739cdd
4735149
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; emitted MathML is final HTML and needs no browser renderer. | ||
| // 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; | ||
|
Comment on lines
+127
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When inline source contains an unclosed recognized opener such as Useful? React with 👍 / 👎. |
||
|
|
||
| if (!silent) { | ||
| const token = state.push('math_inline', 'math', 0); | ||
| token.content = state.src.slice(contentStart, contentEnd); | ||
| token.meta = { displayMode: fence.displayMode }; | ||
|
Comment on lines
+131
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a chapter uses math in an image label, such as Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <math> 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // Usage: run after `npm run build`; proves the public Chapter One artifact contains static MathML rather than escaped or visibly raw TeX delimiters. | ||
| // Evidence boundary: verifies rendering fidelity and no-JavaScript delivery, not the truth of the chapter's mathematics. | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { readFile } from 'node:fs/promises'; | ||
|
|
||
| test('Chapter One publishes its source LaTeX as static annotated MathML', async () => { | ||
| const [textbook, html, mathCss] = await Promise.all([ | ||
| readFile('src/_data/generated/textbook.json', 'utf8').then(JSON.parse), | ||
| readFile('_site/chapters/chapter-one/index.html', 'utf8'), | ||
| readFile('_site/assets/css/math.css', 'utf8') | ||
| ]); | ||
| const chapter = textbook.chapters.find(candidate => candidate.number === 1); | ||
|
|
||
| assert.ok(chapter?.content, 'generated Chapter One source is missing'); | ||
| assert.match(chapter.content, /\\\(/, 'source fixture must retain inline TeX delimiters'); | ||
| assert.match(chapter.content, /\\\[/, 'source fixture must retain display TeX delimiters'); | ||
|
|
||
| const mathCount = (html.match(/<math\b/g) || []).length; | ||
| assert.ok(mathCount >= 10, `expected substantial MathML coverage, found ${mathCount} math elements`); | ||
| assert.match(html, /<link rel="stylesheet" href="\/assets\/css\/math\.css">/); | ||
| assert.match(html, /xmlns="http:\/\/www\.w3\.org\/1998\/Math\/MathML"/); | ||
| assert.match(html, /class="tml-display"/); | ||
| assert.match(html, /<annotation encoding="application\/x-tex">/); | ||
| assert.doesNotMatch(html, /\\\(|\\\)|\\\[|\\\]/, 'recognized TeX delimiters must not leak into rendered prose'); | ||
| assert.doesNotMatch(html, /temml(?:\.min)?\.js|cdn\.jsdelivr|unpkg/i, 'math must not depend on runtime third-party JavaScript'); | ||
|
|
||
| assert.match(mathCss, /math\.tml-display/); | ||
| assert.match(mathCss, /overflow-x:\s*auto/); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a chapter uses a valid standalone AMS environment such as
\begin{alignat}{2}, this anchored expression rejects the opener becausealignatrequires a column-count argument after the environment name. ConsequentlymathBlockdeclines the block and Markdown publishes the raw TeX instead of MathML, despite the renderer explicitly advertisingalignatsupport; recognize and preserve the mandatory{n}argument for bothalignatandalignat*.Useful? React with 👍 / 👎.