explico 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/dist/compiler/astro.d.ts +7 -1
  3. package/dist/compiler/astro.js +5 -2
  4. package/dist/compiler/cli.d.ts +1 -0
  5. package/dist/compiler/collections.d.ts +25 -2
  6. package/dist/compiler/collections.js +37 -4
  7. package/dist/compiler/exports/standalone-html.d.ts +1 -0
  8. package/dist/compiler/manifest.d.ts +30 -4
  9. package/dist/compiler/manifest.js +49 -10
  10. package/dist/compiler/resolution-report.d.ts +1 -0
  11. package/dist/reference/base-operators.d.mts +6 -0
  12. package/dist/reference/base-operators.mjs +6 -8
  13. package/dist/reference/figure-symbols.d.ts +32 -0
  14. package/dist/reference/figure-symbols.js +55 -0
  15. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  16. package/dist/reference/gate-non-chapter-pages.js +156 -0
  17. package/dist/reference/gate-part-pages.d.ts +3 -1
  18. package/dist/reference/gate-part-pages.js +2 -1
  19. package/dist/reference/lab-katex-validate.d.ts +43 -0
  20. package/dist/reference/lab-katex-validate.js +96 -0
  21. package/dist/reference/lint-ignore.js +1 -1
  22. package/dist/reference/page-notation.d.ts +5 -3
  23. package/dist/reference/page-notation.js +8 -8
  24. package/dist/reference/raw-floats.mjs +37 -13
  25. package/dist/reference/registry.js +11 -1
  26. package/dist/reference/remark-notation.mjs +195 -7
  27. package/dist/reference/render-lab-math.d.ts +11 -2
  28. package/dist/reference/render-lab-math.js +2 -2
  29. package/dist/rules/catalog.js +33 -3
  30. package/dist/rules/rules.schema.json +22 -4
  31. package/docs/authoring/README.md +2 -1
  32. package/docs/authoring/components.md +4 -4
  33. package/docs/authoring/migrating.md +215 -0
  34. package/docs/authoring/notation.md +4 -1
  35. package/docs/authoring/structure-rules.md +75 -5
  36. package/docs/authoring/structure.md +14 -0
  37. package/docs/authoring/visualizations.md +26 -10
  38. package/package.json +1 -1
  39. package/src/compiler/astro.ts +17 -6
  40. package/src/compiler/collections.ts +67 -9
  41. package/src/compiler/manifest.ts +89 -13
  42. package/src/components/CourseContents.astro +6 -0
  43. package/src/components/PartOverview.astro +6 -0
  44. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  45. package/src/components/disclosure/print-as-shown.ts +42 -6
  46. package/src/components/figure/Figure.astro +6 -1
  47. package/src/components/figure/figure-state.ts +26 -17
  48. package/src/components/starlight/LessonFooter.astro +27 -7
  49. package/src/components/table/Table.astro +1 -1
  50. package/src/reference/base-operators.mjs +6 -8
  51. package/src/reference/figure-symbols.ts +117 -0
  52. package/src/reference/gate-non-chapter-pages.ts +241 -0
  53. package/src/reference/gate-part-pages.ts +1 -1
  54. package/src/reference/lab-katex-validate.ts +156 -0
  55. package/src/reference/lint-ignore.ts +4 -1
  56. package/src/reference/page-notation.ts +14 -11
  57. package/src/reference/raw-floats.mjs +37 -13
  58. package/src/reference/registry.ts +16 -1
  59. package/src/reference/remark-notation.mjs +195 -7
  60. package/src/reference/render-lab-math.ts +12 -1
  61. package/src/rules/catalog.ts +37 -3
  62. package/src/styles/global.css +32 -9
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Every symbol a figure shows opens its notation card (rule N4). Lesson
3
+ * math, lab math (`renderLabMath`), a diagram's formulas and a caption
4
+ * clause's `{ math }` run all go through the glyph resolver, which binds
5
+ * each symbol to its key: KaTeX then wraps it in a `[data-notation-key]`
6
+ * span. KaTeX that a course renders itself — `katex.renderToString` in a
7
+ * lab's legend, axis title or slider label — skips the resolver, so its
8
+ * symbols have no card and nothing else would report them.
9
+ *
10
+ * `unboundFigureSymbols` reads rendered HTML: inside each
11
+ * `figure.keyed-figure` (a lab's tools included), every letter KaTeX draws
12
+ * must sit in a `[data-notation-key]`. Upright text is not a symbol:
13
+ * `\text{…}`, `\mathrm{…}` (the resolver skips its body, rule N10) and a
14
+ * named operator (`\sin`, `\max`). `compiler/astro.ts` runs it on every
15
+ * built page, and a course's browser test can run it on a hydrated figure's
16
+ * `outerHTML`, which also holds what an island draws only in the browser.
17
+ */
18
+
19
+ import { parse, type DefaultTreeAdapterMap } from 'parse5';
20
+
21
+ type HtmlNode = DefaultTreeAdapterMap['node'];
22
+ type HtmlElement = DefaultTreeAdapterMap['element'];
23
+
24
+ export interface UnboundFigureSymbols {
25
+ /** The figure's anchor id, e.g. `fig-latent-factor-model`. */
26
+ readonly figure: string;
27
+ /** Each unbound symbol's text, in document order, once. */
28
+ readonly symbols: readonly string[];
29
+ }
30
+
31
+ /** KaTeX classes of upright text, which is never a symbol. */
32
+ const UPRIGHT = new Set(['text', 'mathrm', 'textrm', 'mop']);
33
+ const LETTER = /\p{L}/u;
34
+
35
+ function isElement(node: HtmlNode): node is HtmlElement {
36
+ return 'tagName' in node;
37
+ }
38
+
39
+ function attribute(node: HtmlElement, name: string): string | undefined {
40
+ return node.attrs.find((attr) => attr.name === name)?.value;
41
+ }
42
+
43
+ function classes(node: HtmlElement): string[] {
44
+ return (attribute(node, 'class') ?? '').split(/\s+/u).filter(Boolean);
45
+ }
46
+
47
+ function childrenOf(node: HtmlNode): readonly HtmlNode[] {
48
+ // A `<template>`'s content is a fragment of its own in parse5.
49
+ if (isElement(node) && node.tagName === 'template') {
50
+ return (node as HtmlElement & { content: { childNodes: HtmlNode[] } }).content
51
+ .childNodes;
52
+ }
53
+ return 'childNodes' in node ? node.childNodes : [];
54
+ }
55
+
56
+ /** The figures in `html` that draw a symbol with no card, and those symbols. */
57
+ export function unboundFigureSymbols(html: string): UnboundFigureSymbols[] {
58
+ // Most pages have no figure; they need no parse.
59
+ if (!html.includes('keyed-figure') || !html.includes('katex-html')) return [];
60
+ const found = new Map<string, string[]>();
61
+
62
+ const visit = (
63
+ node: HtmlNode,
64
+ figure: string | undefined,
65
+ inKatex: boolean,
66
+ covered: boolean,
67
+ ): void => {
68
+ if (isElement(node)) {
69
+ const names = classes(node);
70
+ if (node.tagName === 'figure' && names.includes('keyed-figure')) {
71
+ figure = attribute(node, 'id') ?? '(a figure with no id)';
72
+ }
73
+ if (names.includes('katex-html')) inKatex = true;
74
+ if (
75
+ attribute(node, 'data-notation-key') !== undefined ||
76
+ (inKatex && names.some((name) => UPRIGHT.has(name)))
77
+ ) {
78
+ covered = true;
79
+ }
80
+ } else if (
81
+ node.nodeName === '#text' &&
82
+ figure !== undefined &&
83
+ inKatex &&
84
+ !covered
85
+ ) {
86
+ const text = (node as DefaultTreeAdapterMap['textNode']).value.trim();
87
+ if (LETTER.test(text)) {
88
+ const symbols = found.get(figure) ?? [];
89
+ if (!symbols.includes(text)) symbols.push(text);
90
+ found.set(figure, symbols);
91
+ }
92
+ }
93
+ for (const child of childrenOf(node)) visit(child, figure, inKatex, covered);
94
+ };
95
+
96
+ visit(parse(html), undefined, false, false);
97
+ return [...found].map(([figure, symbols]) => ({ figure, symbols }));
98
+ }
99
+
100
+ /**
101
+ * Fail a build whose rendered page shows a symbol in a figure that opens no
102
+ * card, naming the figure, the symbols, and the way to bind them.
103
+ */
104
+ export function assertFigureSymbolsBound(page: string, html: string): void {
105
+ const unbound = unboundFigureSymbols(html);
106
+ if (unbound.length === 0) return;
107
+ throw new Error(
108
+ `Explico figure symbols without a notation card on rendered page "${page}": ` +
109
+ unbound
110
+ .map(({ figure, symbols }) => `#${figure} shows ${symbols.map((s) => `"${s}"`).join(', ')}`)
111
+ .join('; ') +
112
+ '. Every symbol a figure shows opens its card (rule N4): render its math ' +
113
+ 'with renderLabMath (`{ display: false }` for a legend, an axis title or ' +
114
+ 'a label; a `{ math }` run for the caption clause), never with katex ' +
115
+ 'directly. Write a unit as `\\text{…}`.',
116
+ );
117
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The non-chapter page gate (rule S10). A page that is not a chapter — the
3
+ * landing page, a part's overview page, any other page outside a part
4
+ * directory — explains its symbols like a chapter does, but numbers nothing:
5
+ *
6
+ * - no numbered block: `<Table>`, `<Figure>`, `<Diagram>`, `<CompactExamples>`,
7
+ * `<AssessmentSet>`, and no lab (a component imported from a `labs/` module,
8
+ * or any hydrated `client:*` island) — code `non-chapter-float`;
9
+ * - no `\label{eq:…}` on a display equation — the same code;
10
+ * - no `notation.local` / `notation.suspend`: the page recalls symbols and
11
+ * defines none — code `non-chapter-local-notation`;
12
+ * - no `$…$` or `[[…]]` in the plain-text strings a page shows outside its
13
+ * Markdown body (`hero.title`, `hero.tagline`, a `<PartOverview chain>`
14
+ * step), where they would print literally — code `non-chapter-plain-text`.
15
+ *
16
+ * A float needs a chapter number, which such a page does not have; linking
17
+ * the chapter's float (`[[lesson#tab-key]]`) shows it in the reference card
18
+ * instead. The rule is locked: turning it off would not give the page a
19
+ * number, so each finding is an error with no waiver. See
20
+ * docs/authoring/structure-rules.md#s10.
21
+ */
22
+ import type { MathGateDiagnostic } from './gate-math';
23
+ import { scannable } from './gate-part-pages';
24
+ import { lessonLine } from './gate-prose-notation';
25
+
26
+ /** One non-chapter page, as the manifest compiler loads it. */
27
+ export interface NonChapterPageInput {
28
+ readonly file: string;
29
+ readonly data: Readonly<Record<string, unknown>>;
30
+ readonly body: string;
31
+ readonly bodyLine: number;
32
+ }
33
+
34
+ /** A numbered block, with the reference prefix of the float a chapter would give it. */
35
+ const NUMBERED_BLOCKS: Readonly<Record<string, { noun: string; ref: string }>> = {
36
+ Table: { noun: 'a table', ref: 'tab' },
37
+ Figure: { noun: 'a figure', ref: 'fig' },
38
+ Diagram: { noun: 'a diagram', ref: 'dia' },
39
+ CompactExamples: { noun: 'a worked-example set', ref: 'ex' },
40
+ AssessmentSet: { noun: 'a knowledge check', ref: 'check' },
41
+ };
42
+
43
+ const NUMBERED_BLOCK_TAG = new RegExp(
44
+ `<(${Object.keys(NUMBERED_BLOCKS).join('|')})(?=[\\s/>])`,
45
+ 'g',
46
+ );
47
+ const EQUATION_LABEL = /\\label\s*\{\s*eq:([^}]*)\}/g;
48
+ const CLIENT_DIRECTIVE = /<([A-Z][\w.]*)\b[^<>]*?\sclient:[a-z]+/g;
49
+ const IMPORT = /^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/gm;
50
+ const PLAIN_TEXT_MARKUP = /\$|\[\[/;
51
+
52
+ interface Finding {
53
+ readonly index: number;
54
+ readonly code: 'non-chapter-float' | 'non-chapter-local-notation' | 'non-chapter-plain-text';
55
+ readonly token: string;
56
+ readonly message: string;
57
+ /** Frontmatter findings have no body offset; they report the file's first line. */
58
+ readonly inFrontmatter?: true;
59
+ }
60
+
61
+ /** Local names an `import … from '<…>labs/…'` binds, e.g. `{ A, B as C }` → A, C. */
62
+ function labComponentNames(text: string): Set<string> {
63
+ const names = new Set<string>();
64
+ for (const match of text.matchAll(IMPORT)) {
65
+ const [, clause, source] = match;
66
+ if (!/(^|\/)labs\//.test(source)) continue;
67
+ const named = /\{([^}]*)\}/.exec(clause);
68
+ const defaultName = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim();
69
+ if (/^[A-Za-z_$][\w$]*$/.test(defaultName)) names.add(defaultName);
70
+ for (const part of named?.[1].split(',') ?? []) {
71
+ const alias = part.trim().split(/\s+as\s+/).pop()?.trim();
72
+ if (alias) names.add(alias);
73
+ }
74
+ }
75
+ return names;
76
+ }
77
+
78
+ function floatFindings(text: string): Finding[] {
79
+ const findings: Finding[] = [];
80
+
81
+ for (const match of text.matchAll(NUMBERED_BLOCK_TAG)) {
82
+ const tag = match[1];
83
+ const { noun, ref } = NUMBERED_BLOCKS[tag];
84
+ findings.push({
85
+ index: match.index ?? 0,
86
+ code: 'non-chapter-float',
87
+ token: `<${tag}>`,
88
+ message:
89
+ `<${tag}> on a page that is not a chapter (rule S10): only a chapter ` +
90
+ `numbers ${noun}. Move it into the chapter it belongs to, and link it ` +
91
+ `from here with [[<chapter>#${ref}-<key>]]: the link shows it in its card.`,
92
+ });
93
+ }
94
+
95
+ const labs = labComponentNames(text);
96
+ const flagged = new Set<number>();
97
+ for (const name of labs) {
98
+ // A lab bound to a numbered block's name (`import Diagram from
99
+ // '../labs/…'`) was reported above, under that name.
100
+ if (name in NUMBERED_BLOCKS) continue;
101
+ const usage = new RegExp(`<${name.replace(/\./g, '\\.')}(?=[\\s/>])`, 'g');
102
+ for (const match of text.matchAll(usage)) {
103
+ const index = match.index ?? 0;
104
+ flagged.add(index);
105
+ findings.push({
106
+ index,
107
+ code: 'non-chapter-float',
108
+ token: `<${name}>`,
109
+ message:
110
+ `<${name}> is a lab, on a page that is not a chapter (rule S10): a ` +
111
+ 'plot belongs in a chapter’s <Figure>. Move it there, and link it ' +
112
+ 'from here with [[<chapter>#fig-<key>]]: the link shows it in its card.',
113
+ });
114
+ }
115
+ }
116
+ for (const match of text.matchAll(CLIENT_DIRECTIVE)) {
117
+ const index = match.index ?? 0;
118
+ if (flagged.has(index) || match[1] in NUMBERED_BLOCKS) continue;
119
+ findings.push({
120
+ index,
121
+ code: 'non-chapter-float',
122
+ token: `<${match[1]}>`,
123
+ message:
124
+ `<${match[1]}> is an interactive island, on a page that is not a ` +
125
+ 'chapter (rule S10): an interactive figure belongs in a chapter’s ' +
126
+ '<Figure>. Move it there, and link it from here with ' +
127
+ '[[<chapter>#fig-<key>]]: the link shows it in its card.',
128
+ });
129
+ }
130
+
131
+ for (const match of text.matchAll(EQUATION_LABEL)) {
132
+ const key = match[1].trim();
133
+ findings.push({
134
+ index: match.index ?? 0,
135
+ code: 'non-chapter-float',
136
+ token: `\\label{eq:${key}}`,
137
+ message:
138
+ `\\label{eq:${key}} on a page that is not a chapter (rule S10): only a ` +
139
+ 'chapter numbers an equation. Drop the label to show the equation ' +
140
+ 'unnumbered, or link the chapter’s own with [[<chapter>#eq-<key>]].',
141
+ });
142
+ }
143
+
144
+ return findings;
145
+ }
146
+
147
+ function record(value: unknown): Record<string, unknown> | undefined {
148
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
149
+ ? (value as Record<string, unknown>)
150
+ : undefined;
151
+ }
152
+
153
+ function frontmatterFindings(data: Readonly<Record<string, unknown>>): Finding[] {
154
+ const findings: Finding[] = [];
155
+ const notation = record(data.notation);
156
+ for (const field of ['local', 'suspend'] as const) {
157
+ if (notation?.[field] === undefined) continue;
158
+ findings.push({
159
+ index: 0,
160
+ inFrontmatter: true,
161
+ code: 'non-chapter-local-notation',
162
+ token: `notation.${field}`,
163
+ message:
164
+ `notation.${field} on a page that is not a chapter (rule S10): the ` +
165
+ 'page recalls symbols and defines none. Move the entry to the chapter ' +
166
+ 'that introduces the symbol, or to shared notation, and name it here ' +
167
+ 'with [[key]].',
168
+ });
169
+ }
170
+
171
+ const hero = record(data.hero);
172
+ for (const field of ['title', 'tagline'] as const) {
173
+ const value = hero?.[field];
174
+ if (typeof value !== 'string' || !PLAIN_TEXT_MARKUP.test(value)) continue;
175
+ findings.push({
176
+ index: 0,
177
+ inFrontmatter: true,
178
+ code: 'non-chapter-plain-text',
179
+ token: `hero.${field}`,
180
+ message:
181
+ `hero.${field} is plain text, so its $…$ or [[…]] prints literally ` +
182
+ '(rule S10). Write the words here, and put the symbol in the page ' +
183
+ 'body, where it gets its card.',
184
+ });
185
+ }
186
+ return findings;
187
+ }
188
+
189
+ /** `<PartOverview chain={[ '…', "…" ]}>`: each quoted step is plain text. */
190
+ function chainFindings(text: string): Finding[] {
191
+ const findings: Finding[] = [];
192
+ const overview = /<PartOverview\b[\s\S]*?\bchain=\{\s*\[([\s\S]*?)\]\s*\}/g;
193
+ for (const match of text.matchAll(overview)) {
194
+ const listStart = (match.index ?? 0) + match[0].indexOf(match[1]);
195
+ const step = /'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|`((?:[^`\\]|\\.)*)`/g;
196
+ for (const item of match[1].matchAll(step)) {
197
+ const value = item[1] ?? item[2] ?? item[3] ?? '';
198
+ if (!PLAIN_TEXT_MARKUP.test(value)) continue;
199
+ findings.push({
200
+ index: listStart + (item.index ?? 0),
201
+ code: 'non-chapter-plain-text',
202
+ token: 'chain',
203
+ message:
204
+ 'A <PartOverview chain> step is plain text, so its $…$ or [[…]] ' +
205
+ 'prints literally (rule S10). Write the step in words; a symbol ' +
206
+ 'with its card goes in <PartScope>.',
207
+ });
208
+ }
209
+ }
210
+ return findings;
211
+ }
212
+
213
+ /**
214
+ * Check every non-chapter page for what rule S10 keeps off it. Returns one
215
+ * `MathGateDiagnostic` (context `'structure'`, always an error) per finding.
216
+ */
217
+ export function gateNonChapterPages(
218
+ pages: readonly NonChapterPageInput[],
219
+ ): MathGateDiagnostic[] {
220
+ const diagnostics: MathGateDiagnostic[] = [];
221
+ for (const page of pages) {
222
+ const text = scannable(page.body);
223
+ const findings = [
224
+ ...frontmatterFindings(page.data),
225
+ ...floatFindings(text),
226
+ ...chainFindings(text),
227
+ ].sort((a, b) => a.index - b.index);
228
+ for (const finding of findings) {
229
+ diagnostics.push({
230
+ context: 'structure',
231
+ severity: 'error',
232
+ code: finding.code,
233
+ file: page.file,
234
+ line: finding.inFrontmatter ? 1 : lessonLine(page, finding.index),
235
+ token: finding.token,
236
+ message: finding.message,
237
+ });
238
+ }
239
+ }
240
+ return diagnostics;
241
+ }
@@ -52,7 +52,7 @@ interface Occurrence {
52
52
  }
53
53
 
54
54
  /** Blank MDX comments (`{/* … *\/}`) and code, keeping every newline. */
55
- function scannable(body: string): string {
55
+ export function scannable(body: string): string {
56
56
  return stripFencedCode(body).replace(/\{\/\*[\s\S]*?\*\/\}/g, (comment) =>
57
57
  comment.replace(/[^\n]/g, ' '),
58
58
  );
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Rule V3's seam, checked in the source: a component a lesson renders draws
3
+ * its math through `renderLabMath`, which binds every symbol to the page's
4
+ * notation, never by calling KaTeX itself — a legend helper around
5
+ * `katex.renderToString` binds none, so its symbols open no card.
6
+ *
7
+ * Starting at each lesson's own imports, this follows every relative import
8
+ * — the course's visuals, wherever they live — and reports each file that
9
+ * imports `katex` as `lab-katex-direct`, with the chain of imports that
10
+ * reaches it. A package import (`explico/…`, `react`) is not followed:
11
+ * explico's own KaTeX use is the seam. A type-only import and the KaTeX
12
+ * stylesheet draw nothing and are not reported.
13
+ *
14
+ * `figure-symbols.ts` checks the same promise on the rendered page, which
15
+ * also catches math a lab draws some other way.
16
+ */
17
+
18
+ import { readFileSync, statSync } from 'node:fs';
19
+ import { dirname, join, relative, sep } from 'node:path';
20
+
21
+ import { stripFencedCode } from './equations.mjs';
22
+
23
+ export interface LabKatexDiagnostic {
24
+ readonly code: 'lab-katex-direct';
25
+ readonly severity: 'error';
26
+ readonly message: string;
27
+ /** The file that imports `katex`, repository-relative. */
28
+ readonly file: string;
29
+ /** The first lesson (by id) whose imports reach it. */
30
+ readonly lessonId: string;
31
+ }
32
+
33
+ export interface LabKatexLessonInput {
34
+ readonly lessonId: string;
35
+ /** Repository-relative path of the lesson file. */
36
+ readonly file: string;
37
+ readonly body: string;
38
+ }
39
+
40
+ /** Extensions tried, in order, for an import written without one. */
41
+ const EXTENSIONS = ['.astro', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.cjs'];
42
+ /** Files worth reading for imports: scripts, components and MDX. */
43
+ const SOURCE_FILE = /\.(?:astro|tsx|ts|jsx|js|mjs|mts|cjs|mdx)$/u;
44
+
45
+ const IMPORT_PATTERNS = [
46
+ // import x from 'y' · import { x } from 'y' · import 'y' · import type … from 'y'
47
+ /\bimport\s+(type\s+)?(?:[\w*{}\s,$]+?\s+from\s+)?(['"])([^'"\n]+)\2/gu,
48
+ // export { x } from 'y' · export * from 'y' · export type { x } from 'y'
49
+ /\bexport\s+(type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+(['"])([^'"\n]+)\2/gu,
50
+ // import('y') · require('y')
51
+ /\b(?:import|require)\s*\(\s*()(['"])([^'"\n]+)\2\s*\)/gu,
52
+ ];
53
+
54
+ /**
55
+ * Every module a source file imports at run time, in order: static, re-exported,
56
+ * dynamic and `require`d. A type-only import is left out.
57
+ */
58
+ export function importSpecifiers(source: string): string[] {
59
+ const found: { index: number; specifier: string }[] = [];
60
+ for (const pattern of IMPORT_PATTERNS) {
61
+ for (const match of source.matchAll(pattern)) {
62
+ if (match[1]) continue;
63
+ found.push({ index: match.index ?? 0, specifier: match[3]! });
64
+ }
65
+ }
66
+ return found.sort((a, b) => a.index - b.index).map(({ specifier }) => specifier);
67
+ }
68
+
69
+ /** `katex`, or a script inside it — not its stylesheet or its fonts. */
70
+ function isKatexModule(specifier: string): boolean {
71
+ if (specifier !== 'katex' && !specifier.startsWith('katex/')) return false;
72
+ return !/\.(?:css|woff2?|ttf)$/u.test(specifier);
73
+ }
74
+
75
+ function isFile(path: string): boolean {
76
+ try {
77
+ return statSync(path).isFile();
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /** The file a relative import names, trying the extensions a bundler would. */
84
+ function resolveRelative(fromFile: string, specifier: string): string | undefined {
85
+ const base = join(dirname(fromFile), specifier);
86
+ const candidates = [
87
+ base,
88
+ // `./chart.js` written for `./chart.ts`, the TypeScript ESM convention.
89
+ ...(/\.[cm]?js$/u.test(base) ? ['.ts', '.tsx', '.mts', '.cts'].map((ext) => base.replace(/\.[cm]?js$/u, ext)) : []),
90
+ ...EXTENSIONS.map((ext) => `${base}${ext}`),
91
+ ...EXTENSIONS.map((ext) => join(base, `index${ext}`)),
92
+ ];
93
+ return candidates.find(isFile);
94
+ }
95
+
96
+ /**
97
+ * Each course file that a lesson's imports reach and that imports `katex`.
98
+ * `root` is the repository root the lesson paths are relative to.
99
+ */
100
+ export function validateLabKatexImports(
101
+ lessons: readonly LabKatexLessonInput[],
102
+ root: string = process.cwd(),
103
+ ): LabKatexDiagnostic[] {
104
+ const read = (path: string): string | undefined => {
105
+ try {
106
+ return readFileSync(path, 'utf8');
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ };
111
+ const repoPath = (path: string) => relative(root, path).split(sep).join('/');
112
+
113
+ const diagnostics = new Map<string, LabKatexDiagnostic>();
114
+ const seen = new Set<string>();
115
+ for (const lesson of [...lessons].sort((a, b) => a.lessonId.localeCompare(b.lessonId))) {
116
+ const lessonPath = join(root, lesson.file);
117
+ // Breadth first, so a reported chain is the shortest one.
118
+ const queue: { path: string; source: string; chain: readonly string[] }[] = [
119
+ { path: lessonPath, source: stripFencedCode(lesson.body), chain: [lesson.file] },
120
+ ];
121
+ while (queue.length > 0) {
122
+ const { path, source, chain } = queue.shift()!;
123
+ for (const specifier of importSpecifiers(source)) {
124
+ if (isKatexModule(specifier)) {
125
+ const file = repoPath(path);
126
+ if (!diagnostics.has(file)) {
127
+ diagnostics.set(file, {
128
+ code: 'lab-katex-direct',
129
+ severity: 'error',
130
+ file,
131
+ lessonId: lesson.lessonId,
132
+ message:
133
+ `${file} imports '${specifier}' and draws math whose symbols open no ` +
134
+ `notation card (reached by ${chain.join(' → ')}). Render a lab's math ` +
135
+ `with renderLabMath(template, scope) — { display: false } for a legend ` +
136
+ `entry, an axis title or a label — so every symbol is bound to the ` +
137
+ `page's notation (rule V3).`,
138
+ });
139
+ }
140
+ continue;
141
+ }
142
+ if (!specifier.startsWith('.')) continue;
143
+ const target = resolveRelative(path, specifier);
144
+ if (!target || !SOURCE_FILE.test(target) || target.includes(`${sep}node_modules${sep}`)) {
145
+ continue;
146
+ }
147
+ if (seen.has(target)) continue;
148
+ seen.add(target);
149
+ const text = read(target);
150
+ if (text === undefined) continue;
151
+ queue.push({ path: target, source: text, chain: [...chain, repoPath(target)] });
152
+ }
153
+ }
154
+ }
155
+ return [...diagnostics.values()].sort((a, b) => a.file.localeCompare(b.file));
156
+ }
@@ -34,8 +34,11 @@ export interface LintIgnore {
34
34
  matches(code: string, detail?: string): boolean;
35
35
  }
36
36
 
37
+ // `<detail>` may hold a hyphen — a notation key is kebab-case — since the
38
+ // separator is an em dash, `--`, or a spaced ` - `, none of which a key's
39
+ // single, unspaced hyphens can form.
37
40
  const LINE =
38
- /^-\s*([a-z0-9-]+)(?::([^\s—-][^—-]*?))?\s*(?:—|--|\s-\s)\s*(.+?)\s*$/;
41
+ /^-\s*([a-z0-9-]+)(?::([^\s—][^—]*?))?\s*(?:—|--|\s-\s)\s*(.+?)\s*$/;
39
42
 
40
43
  export function parseLintIgnore(text: string): LintIgnoreEntry[] {
41
44
  const entries: LintIgnoreEntry[] = [];
@@ -123,22 +123,25 @@ function manifestDefinition(
123
123
  }
124
124
 
125
125
  /**
126
- * Extend one compiled lesson bundle with only the definitions requested by
127
- * components rendered on that page (plus those definitions' normal closure).
126
+ * Extend one compiled page bundle — a lesson's, or a non-chapter page's
127
+ * (rule S10) — with only the definitions requested by components rendered on
128
+ * that page (plus those definitions' normal closure).
128
129
  */
129
130
  export function notationDefinitionsForRenderedPage(
130
131
  manifest: Manifest,
131
- lessonId: string,
132
+ pageId: string,
132
133
  dependencies: readonly PageNotationDependency[],
133
134
  ): ManifestLessonNotation[] {
134
- const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
135
- if (!lesson) return [];
135
+ const page =
136
+ manifest.lessons.find((entry) => entry.id === pageId) ??
137
+ manifest.pages.find((entry) => entry.id === pageId);
138
+ if (!page) return [];
136
139
 
137
140
  const definitionsById = new Map(
138
141
  manifest.notation.definitions.map((definition) => [definition.id, definition]),
139
142
  );
140
143
  const availableIds = new Set(definitionsById.keys());
141
- const ids = new Set(lesson.notation.definitionIds);
144
+ const ids = new Set(page.notation.definitionIds);
142
145
  const contextsByKey = new Map<string, Set<string>>();
143
146
 
144
147
  for (const dependency of dependencies) {
@@ -151,14 +154,14 @@ export function notationDefinitionsForRenderedPage(
151
154
 
152
155
  const definitionId = resolveReferenceId(
153
156
  dependency.key,
154
- { kind: 'page', lessonId },
157
+ { kind: 'page', lessonId: pageId },
155
158
  availableIds,
156
159
  );
157
160
  if (!definitionId) {
158
161
  throw new Error(
159
- `Notation payload error in lesson "${lessonId}": ${dependency.context} ` +
162
+ `Notation payload error on page "${pageId}": ${dependency.context} ` +
160
163
  `emitted data-notation-key="${dependency.key}", but that key is not ` +
161
- `defined in this lesson or the shared notation registry.`,
164
+ `defined on this page or in the shared notation registry.`,
162
165
  );
163
166
  }
164
167
  ids.add(definitionId);
@@ -202,7 +205,7 @@ export function notationDefinitionsForRenderedPage(
202
205
  if (previous && previous !== id) {
203
206
  const context = [...(contextsByKey.get(definition.key) ?? [])].join(', ');
204
207
  throw new Error(
205
- `Notation payload error in lesson "${lessonId}": key "${definition.key}" ` +
208
+ `Notation payload error on page "${pageId}": key "${definition.key}" ` +
206
209
  `would have two meanings (${previous} and ${id})` +
207
210
  (context ? ` after ${context} emitted it.` : '.'),
208
211
  );
@@ -220,7 +223,7 @@ export function notationDefinitionsForRenderedPage(
220
223
  }
221
224
 
222
225
  const existingById = new Map(
223
- lesson.notation.definitions.map((definition) => [definition.id, definition]),
226
+ page.notation.definitions.map((definition) => [definition.id, definition]),
224
227
  );
225
228
  return [...ids]
226
229
  .sort()