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', diff --git a/package-lock.json b/package-lock.json index f7742db..e8d023b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,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", @@ -1812,6 +1813,15 @@ "node": ">=0.10.0" } }, + "node_modules/temml": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/temml/-/temml-0.13.3.tgz", + "integrity": "sha512-GLNEdf5qBWux3adbOxFus4jlds8nCdEIkkKq99m/4GGTfqnsjlVlK/i371Ux7yYSg/WNmOyAkNT/GJlZoJ0v+w==", + "license": "MIT", + "engines": { + "node": ">=18.13.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 1a14a1f..81b49a5 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "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:generated": "node --test tests/generated-site.test.mjs tests/textbook-generated.test.mjs && node tests/links.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 tests/math-generated.test.mjs && node tests/links.test.mjs", "test:browser": "playwright test", "test:e2e": "playwright test tests/site.spec.mjs", "test:a11y": "playwright test tests/accessibility.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", diff --git a/playwright.config.mjs b/playwright.config.mjs index 5c90df0..88836b1 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -4,7 +4,7 @@ // id: generated_site_browser_harness // module_name: playwright-config // module_kind: instrument -// summary: Configures browser, route, and automated accessibility checks against the generated site. +// summary: Configures browser, route, static-math, and automated accessibility checks against the generated site. // owner: Erin Spencer // public_surface: npm run test:browser, npm run test:e2e, npm run test:a11y // internal_surface: Playwright webServer and Chromium test configuration @@ -13,7 +13,7 @@ // network_boundary: internal // user_data_boundary: none // admin_only: false -// tests: tests/site.spec.mjs, tests/accessibility.spec.mjs +// tests: tests/site.spec.mjs, tests/math.spec.mjs, tests/accessibility.spec.mjs // rollout: required by pull-request and Pages workflows // rollback: remove browser scripts, workflow steps, and static test server together // === END MODULE_BUILD === diff --git a/scripts/markdown-math.mjs b/scripts/markdown-math.mjs new file mode 100644 index 0000000..232991e --- /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; 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; + + 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; +} 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 }}