Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .eleventy.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ===
Expand Down
153 changes: 153 additions & 0 deletions scripts/markdown-math.mjs
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*$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept alignat's required column argument

When a chapter uses a valid standalone AMS environment such as \begin{alignat}{2}, this anchored expression rejects the opener because alignat requires a column-count argument after the environment name. Consequently mathBlock declines the block and Markdown publishes the raw TeX instead of MathML, despite the renderer explicitly advertising alignat support; recognize and preserve the mandatory {n} argument for both alignat and alignat*.

Useful? React with 👍 / 👎.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail on unclosed inline TeX fences

When inline source contains an unclosed recognized opener such as Let \(x, returning false hands it to Markdown's escape rule, which silently publishes Let (x instead of failing the build. This contradicts the adapter's fail-closed behavior and makes a delimiter typo corrupt the displayed notation; once an unambiguous \( or \[ opener is encountered, report the missing closing fence rather than treating it as ordinary Markdown.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve formulas in image alternative text

When a chapter uses math in an image label, such as ![Graph of \(f(x)\)](/graph.svg), this custom token is parsed inside the label but MarkdownIt's image renderer omits unknown token types while constructing alt, producing only alt="Graph of ". The formula therefore disappears for screen-reader users even though it remains part of the source; provide a plain-text representation of math tokens when rendering image alternative text.

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;
}
3 changes: 2 additions & 1 deletion src/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<meta name="description" content="{{ description or site.description }}">
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
<link rel="stylesheet" href="/assets/css/site.css">
<link rel="stylesheet" href="/assets/css/math.css">
<script src="/assets/js/site.js" defer></script>
</head>
<body>
Expand All @@ -32,7 +33,7 @@
<a href="/search/">Search</a>
</nav>
</header>
<noscript><p class="noscript">The complete reading experience remains available without JavaScript. Only the compact mobile menu enhancement is disabled.</p></noscript>
<noscript><p class="noscript">The complete reading experience, including textbook mathematics, remains available without JavaScript. Only the compact mobile menu enhancement is disabled.</p></noscript>
<main id="content" class="site-main">{{ content | safe }}</main>
<footer class="site-footer">
<div>
Expand Down
60 changes: 60 additions & 0 deletions src/assets/css/math.css
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;
}
}
30 changes: 30 additions & 0 deletions tests/math-generated.test.mjs
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/);
});
Loading
Loading