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,32 @@
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
+ interface UnboundFigureSymbols {
19
+ /** The figure's anchor id, e.g. `fig-latent-factor-model`. */
20
+ readonly figure: string;
21
+ /** Each unbound symbol's text, in document order, once. */
22
+ readonly symbols: readonly string[];
23
+ }
24
+ /** The figures in `html` that draw a symbol with no card, and those symbols. */
25
+ declare function unboundFigureSymbols(html: string): UnboundFigureSymbols[];
26
+ /**
27
+ * Fail a build whose rendered page shows a symbol in a figure that opens no
28
+ * card, naming the figure, the symbols, and the way to bind them.
29
+ */
30
+ declare function assertFigureSymbolsBound(page: string, html: string): void;
31
+
32
+ export { type UnboundFigureSymbols, assertFigureSymbolsBound, unboundFigureSymbols };
@@ -0,0 +1,55 @@
1
+ import { parse } from "parse5";
2
+ const UPRIGHT = /* @__PURE__ */ new Set(["text", "mathrm", "textrm", "mop"]);
3
+ const LETTER = new RegExp("\\p{L}", "u");
4
+ function isElement(node) {
5
+ return "tagName" in node;
6
+ }
7
+ function attribute(node, name) {
8
+ return node.attrs.find((attr) => attr.name === name)?.value;
9
+ }
10
+ function classes(node) {
11
+ return (attribute(node, "class") ?? "").split(/\s+/u).filter(Boolean);
12
+ }
13
+ function childrenOf(node) {
14
+ if (isElement(node) && node.tagName === "template") {
15
+ return node.content.childNodes;
16
+ }
17
+ return "childNodes" in node ? node.childNodes : [];
18
+ }
19
+ function unboundFigureSymbols(html) {
20
+ if (!html.includes("keyed-figure") || !html.includes("katex-html")) return [];
21
+ const found = /* @__PURE__ */ new Map();
22
+ const visit = (node, figure, inKatex, covered) => {
23
+ if (isElement(node)) {
24
+ const names = classes(node);
25
+ if (node.tagName === "figure" && names.includes("keyed-figure")) {
26
+ figure = attribute(node, "id") ?? "(a figure with no id)";
27
+ }
28
+ if (names.includes("katex-html")) inKatex = true;
29
+ if (attribute(node, "data-notation-key") !== void 0 || inKatex && names.some((name) => UPRIGHT.has(name))) {
30
+ covered = true;
31
+ }
32
+ } else if (node.nodeName === "#text" && figure !== void 0 && inKatex && !covered) {
33
+ const text = node.value.trim();
34
+ if (LETTER.test(text)) {
35
+ const symbols = found.get(figure) ?? [];
36
+ if (!symbols.includes(text)) symbols.push(text);
37
+ found.set(figure, symbols);
38
+ }
39
+ }
40
+ for (const child of childrenOf(node)) visit(child, figure, inKatex, covered);
41
+ };
42
+ visit(parse(html), void 0, false, false);
43
+ return [...found].map(([figure, symbols]) => ({ figure, symbols }));
44
+ }
45
+ function assertFigureSymbolsBound(page, html) {
46
+ const unbound = unboundFigureSymbols(html);
47
+ if (unbound.length === 0) return;
48
+ throw new Error(
49
+ `Explico figure symbols without a notation card on rendered page "${page}": ` + unbound.map(({ figure, symbols }) => `#${figure} shows ${symbols.map((s) => `"${s}"`).join(", ")}`).join("; ") + ". Every symbol a figure shows opens its card (rule N4): render its math with renderLabMath (`{ display: false }` for a legend, an axis title or a label; a `{ math }` run for the caption clause), never with katex directly. Write a unit as `\\text{\u2026}`."
50
+ );
51
+ }
52
+ export {
53
+ assertFigureSymbolsBound,
54
+ unboundFigureSymbols
55
+ };
@@ -0,0 +1,39 @@
1
+ import { MathGateDiagnostic } from './gate-math.js';
2
+ import './types.js';
3
+
4
+ /**
5
+ * The non-chapter page gate (rule S10). A page that is not a chapter — the
6
+ * landing page, a part's overview page, any other page outside a part
7
+ * directory — explains its symbols like a chapter does, but numbers nothing:
8
+ *
9
+ * - no numbered block: `<Table>`, `<Figure>`, `<Diagram>`, `<CompactExamples>`,
10
+ * `<AssessmentSet>`, and no lab (a component imported from a `labs/` module,
11
+ * or any hydrated `client:*` island) — code `non-chapter-float`;
12
+ * - no `\label{eq:…}` on a display equation — the same code;
13
+ * - no `notation.local` / `notation.suspend`: the page recalls symbols and
14
+ * defines none — code `non-chapter-local-notation`;
15
+ * - no `$…$` or `[[…]]` in the plain-text strings a page shows outside its
16
+ * Markdown body (`hero.title`, `hero.tagline`, a `<PartOverview chain>`
17
+ * step), where they would print literally — code `non-chapter-plain-text`.
18
+ *
19
+ * A float needs a chapter number, which such a page does not have; linking
20
+ * the chapter's float (`[[lesson#tab-key]]`) shows it in the reference card
21
+ * instead. The rule is locked: turning it off would not give the page a
22
+ * number, so each finding is an error with no waiver. See
23
+ * docs/authoring/structure-rules.md#s10.
24
+ */
25
+
26
+ /** One non-chapter page, as the manifest compiler loads it. */
27
+ interface NonChapterPageInput {
28
+ readonly file: string;
29
+ readonly data: Readonly<Record<string, unknown>>;
30
+ readonly body: string;
31
+ readonly bodyLine: number;
32
+ }
33
+ /**
34
+ * Check every non-chapter page for what rule S10 keeps off it. Returns one
35
+ * `MathGateDiagnostic` (context `'structure'`, always an error) per finding.
36
+ */
37
+ declare function gateNonChapterPages(pages: readonly NonChapterPageInput[]): MathGateDiagnostic[];
38
+
39
+ export { type NonChapterPageInput, gateNonChapterPages };
@@ -0,0 +1,156 @@
1
+ import { scannable } from "./gate-part-pages.js";
2
+ import { lessonLine } from "./gate-prose-notation.js";
3
+ const NUMBERED_BLOCKS = {
4
+ Table: { noun: "a table", ref: "tab" },
5
+ Figure: { noun: "a figure", ref: "fig" },
6
+ Diagram: { noun: "a diagram", ref: "dia" },
7
+ CompactExamples: { noun: "a worked-example set", ref: "ex" },
8
+ AssessmentSet: { noun: "a knowledge check", ref: "check" }
9
+ };
10
+ const NUMBERED_BLOCK_TAG = new RegExp(
11
+ `<(${Object.keys(NUMBERED_BLOCKS).join("|")})(?=[\\s/>])`,
12
+ "g"
13
+ );
14
+ const EQUATION_LABEL = /\\label\s*\{\s*eq:([^}]*)\}/g;
15
+ const CLIENT_DIRECTIVE = /<([A-Z][\w.]*)\b[^<>]*?\sclient:[a-z]+/g;
16
+ const IMPORT = /^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/gm;
17
+ const PLAIN_TEXT_MARKUP = /\$|\[\[/;
18
+ function labComponentNames(text) {
19
+ const names = /* @__PURE__ */ new Set();
20
+ for (const match of text.matchAll(IMPORT)) {
21
+ const [, clause, source] = match;
22
+ if (!/(^|\/)labs\//.test(source)) continue;
23
+ const named = /\{([^}]*)\}/.exec(clause);
24
+ const defaultName = clause.replace(/\{[^}]*\}/, "").replace(/,/g, " ").trim();
25
+ if (/^[A-Za-z_$][\w$]*$/.test(defaultName)) names.add(defaultName);
26
+ for (const part of named?.[1].split(",") ?? []) {
27
+ const alias = part.trim().split(/\s+as\s+/).pop()?.trim();
28
+ if (alias) names.add(alias);
29
+ }
30
+ }
31
+ return names;
32
+ }
33
+ function floatFindings(text) {
34
+ const findings = [];
35
+ for (const match of text.matchAll(NUMBERED_BLOCK_TAG)) {
36
+ const tag = match[1];
37
+ const { noun, ref } = NUMBERED_BLOCKS[tag];
38
+ findings.push({
39
+ index: match.index ?? 0,
40
+ code: "non-chapter-float",
41
+ token: `<${tag}>`,
42
+ message: `<${tag}> on a page that is not a chapter (rule S10): only a chapter numbers ${noun}. Move it into the chapter it belongs to, and link it from here with [[<chapter>#${ref}-<key>]]: the link shows it in its card.`
43
+ });
44
+ }
45
+ const labs = labComponentNames(text);
46
+ const flagged = /* @__PURE__ */ new Set();
47
+ for (const name of labs) {
48
+ if (name in NUMBERED_BLOCKS) continue;
49
+ const usage = new RegExp(`<${name.replace(/\./g, "\\.")}(?=[\\s/>])`, "g");
50
+ for (const match of text.matchAll(usage)) {
51
+ const index = match.index ?? 0;
52
+ flagged.add(index);
53
+ findings.push({
54
+ index,
55
+ code: "non-chapter-float",
56
+ token: `<${name}>`,
57
+ message: `<${name}> is a lab, on a page that is not a chapter (rule S10): a plot belongs in a chapter\u2019s <Figure>. Move it there, and link it from here with [[<chapter>#fig-<key>]]: the link shows it in its card.`
58
+ });
59
+ }
60
+ }
61
+ for (const match of text.matchAll(CLIENT_DIRECTIVE)) {
62
+ const index = match.index ?? 0;
63
+ if (flagged.has(index) || match[1] in NUMBERED_BLOCKS) continue;
64
+ findings.push({
65
+ index,
66
+ code: "non-chapter-float",
67
+ token: `<${match[1]}>`,
68
+ message: `<${match[1]}> is an interactive island, on a page that is not a chapter (rule S10): an interactive figure belongs in a chapter\u2019s <Figure>. Move it there, and link it from here with [[<chapter>#fig-<key>]]: the link shows it in its card.`
69
+ });
70
+ }
71
+ for (const match of text.matchAll(EQUATION_LABEL)) {
72
+ const key = match[1].trim();
73
+ findings.push({
74
+ index: match.index ?? 0,
75
+ code: "non-chapter-float",
76
+ token: `\\label{eq:${key}}`,
77
+ message: `\\label{eq:${key}} on a page that is not a chapter (rule S10): only a chapter numbers an equation. Drop the label to show the equation unnumbered, or link the chapter\u2019s own with [[<chapter>#eq-<key>]].`
78
+ });
79
+ }
80
+ return findings;
81
+ }
82
+ function record(value) {
83
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
84
+ }
85
+ function frontmatterFindings(data) {
86
+ const findings = [];
87
+ const notation = record(data.notation);
88
+ for (const field of ["local", "suspend"]) {
89
+ if (notation?.[field] === void 0) continue;
90
+ findings.push({
91
+ index: 0,
92
+ inFrontmatter: true,
93
+ code: "non-chapter-local-notation",
94
+ token: `notation.${field}`,
95
+ message: `notation.${field} on a page that is not a chapter (rule S10): the page recalls symbols and defines none. Move the entry to the chapter that introduces the symbol, or to shared notation, and name it here with [[key]].`
96
+ });
97
+ }
98
+ const hero = record(data.hero);
99
+ for (const field of ["title", "tagline"]) {
100
+ const value = hero?.[field];
101
+ if (typeof value !== "string" || !PLAIN_TEXT_MARKUP.test(value)) continue;
102
+ findings.push({
103
+ index: 0,
104
+ inFrontmatter: true,
105
+ code: "non-chapter-plain-text",
106
+ token: `hero.${field}`,
107
+ message: `hero.${field} is plain text, so its $\u2026$ or [[\u2026]] prints literally (rule S10). Write the words here, and put the symbol in the page body, where it gets its card.`
108
+ });
109
+ }
110
+ return findings;
111
+ }
112
+ function chainFindings(text) {
113
+ const findings = [];
114
+ const overview = /<PartOverview\b[\s\S]*?\bchain=\{\s*\[([\s\S]*?)\]\s*\}/g;
115
+ for (const match of text.matchAll(overview)) {
116
+ const listStart = (match.index ?? 0) + match[0].indexOf(match[1]);
117
+ const step = /'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|`((?:[^`\\]|\\.)*)`/g;
118
+ for (const item of match[1].matchAll(step)) {
119
+ const value = item[1] ?? item[2] ?? item[3] ?? "";
120
+ if (!PLAIN_TEXT_MARKUP.test(value)) continue;
121
+ findings.push({
122
+ index: listStart + (item.index ?? 0),
123
+ code: "non-chapter-plain-text",
124
+ token: "chain",
125
+ message: "A <PartOverview chain> step is plain text, so its $\u2026$ or [[\u2026]] prints literally (rule S10). Write the step in words; a symbol with its card goes in <PartScope>."
126
+ });
127
+ }
128
+ }
129
+ return findings;
130
+ }
131
+ function gateNonChapterPages(pages) {
132
+ const diagnostics = [];
133
+ for (const page of pages) {
134
+ const text = scannable(page.body);
135
+ const findings = [
136
+ ...frontmatterFindings(page.data),
137
+ ...floatFindings(text),
138
+ ...chainFindings(text)
139
+ ].sort((a, b) => a.index - b.index);
140
+ for (const finding of findings) {
141
+ diagnostics.push({
142
+ context: "structure",
143
+ severity: "error",
144
+ code: finding.code,
145
+ file: page.file,
146
+ line: finding.inFrontmatter ? 1 : lessonLine(page, finding.index),
147
+ token: finding.token,
148
+ message: finding.message
149
+ });
150
+ }
151
+ }
152
+ return diagnostics;
153
+ }
154
+ export {
155
+ gateNonChapterPages
156
+ };
@@ -34,6 +34,8 @@ interface PartPageInput {
34
34
  readonly body: string;
35
35
  readonly bodyLine: number;
36
36
  }
37
+ /** Blank MDX comments (`{/* … *\/}`) and code, keeping every newline. */
38
+ declare function scannable(body: string): string;
37
39
  /**
38
40
  * Words a reader reads in a paragraph of MDX prose. A math span, a `[[key]]`
39
41
  * reference, or a link each count by what they render as: one token for the
@@ -47,4 +49,4 @@ declare function proseWordCount(markdown: string): number;
47
49
  */
48
50
  declare function gatePartPages(pages: readonly PartPageInput[], limits: NormalizedPartPages): MathGateDiagnostic[];
49
51
 
50
- export { type PartPageInput, gatePartPages, proseWordCount };
52
+ export { type PartPageInput, gatePartPages, proseWordCount, scannable };
@@ -114,5 +114,6 @@ function gatePartPages(pages, limits) {
114
114
  }
115
115
  export {
116
116
  gatePartPages,
117
- proseWordCount
117
+ proseWordCount,
118
+ scannable
118
119
  };
@@ -0,0 +1,43 @@
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
+ interface LabKatexDiagnostic {
18
+ readonly code: 'lab-katex-direct';
19
+ readonly severity: 'error';
20
+ readonly message: string;
21
+ /** The file that imports `katex`, repository-relative. */
22
+ readonly file: string;
23
+ /** The first lesson (by id) whose imports reach it. */
24
+ readonly lessonId: string;
25
+ }
26
+ interface LabKatexLessonInput {
27
+ readonly lessonId: string;
28
+ /** Repository-relative path of the lesson file. */
29
+ readonly file: string;
30
+ readonly body: string;
31
+ }
32
+ /**
33
+ * Every module a source file imports at run time, in order: static, re-exported,
34
+ * dynamic and `require`d. A type-only import is left out.
35
+ */
36
+ declare function importSpecifiers(source: string): string[];
37
+ /**
38
+ * Each course file that a lesson's imports reach and that imports `katex`.
39
+ * `root` is the repository root the lesson paths are relative to.
40
+ */
41
+ declare function validateLabKatexImports(lessons: readonly LabKatexLessonInput[], root?: string): LabKatexDiagnostic[];
42
+
43
+ export { type LabKatexDiagnostic, type LabKatexLessonInput, importSpecifiers, validateLabKatexImports };
@@ -0,0 +1,96 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, sep } from "node:path";
3
+ import { stripFencedCode } from "./equations.mjs";
4
+ const EXTENSIONS = [".astro", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".mts", ".cjs"];
5
+ const SOURCE_FILE = /\.(?:astro|tsx|ts|jsx|js|mjs|mts|cjs|mdx)$/u;
6
+ const IMPORT_PATTERNS = [
7
+ // import x from 'y' · import { x } from 'y' · import 'y' · import type … from 'y'
8
+ /\bimport\s+(type\s+)?(?:[\w*{}\s,$]+?\s+from\s+)?(['"])([^'"\n]+)\2/gu,
9
+ // export { x } from 'y' · export * from 'y' · export type { x } from 'y'
10
+ /\bexport\s+(type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+(['"])([^'"\n]+)\2/gu,
11
+ // import('y') · require('y')
12
+ /\b(?:import|require)\s*\(\s*()(['"])([^'"\n]+)\2\s*\)/gu
13
+ ];
14
+ function importSpecifiers(source) {
15
+ const found = [];
16
+ for (const pattern of IMPORT_PATTERNS) {
17
+ for (const match of source.matchAll(pattern)) {
18
+ if (match[1]) continue;
19
+ found.push({ index: match.index ?? 0, specifier: match[3] });
20
+ }
21
+ }
22
+ return found.sort((a, b) => a.index - b.index).map(({ specifier }) => specifier);
23
+ }
24
+ function isKatexModule(specifier) {
25
+ if (specifier !== "katex" && !specifier.startsWith("katex/")) return false;
26
+ return !/\.(?:css|woff2?|ttf)$/u.test(specifier);
27
+ }
28
+ function isFile(path) {
29
+ try {
30
+ return statSync(path).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ function resolveRelative(fromFile, specifier) {
36
+ const base = join(dirname(fromFile), specifier);
37
+ const candidates = [
38
+ base,
39
+ // `./chart.js` written for `./chart.ts`, the TypeScript ESM convention.
40
+ .../\.[cm]?js$/u.test(base) ? [".ts", ".tsx", ".mts", ".cts"].map((ext) => base.replace(/\.[cm]?js$/u, ext)) : [],
41
+ ...EXTENSIONS.map((ext) => `${base}${ext}`),
42
+ ...EXTENSIONS.map((ext) => join(base, `index${ext}`))
43
+ ];
44
+ return candidates.find(isFile);
45
+ }
46
+ function validateLabKatexImports(lessons, root = process.cwd()) {
47
+ const read = (path) => {
48
+ try {
49
+ return readFileSync(path, "utf8");
50
+ } catch {
51
+ return void 0;
52
+ }
53
+ };
54
+ const repoPath = (path) => relative(root, path).split(sep).join("/");
55
+ const diagnostics = /* @__PURE__ */ new Map();
56
+ const seen = /* @__PURE__ */ new Set();
57
+ for (const lesson of [...lessons].sort((a, b) => a.lessonId.localeCompare(b.lessonId))) {
58
+ const lessonPath = join(root, lesson.file);
59
+ const queue = [
60
+ { path: lessonPath, source: stripFencedCode(lesson.body), chain: [lesson.file] }
61
+ ];
62
+ while (queue.length > 0) {
63
+ const { path, source, chain } = queue.shift();
64
+ for (const specifier of importSpecifiers(source)) {
65
+ if (isKatexModule(specifier)) {
66
+ const file = repoPath(path);
67
+ if (!diagnostics.has(file)) {
68
+ diagnostics.set(file, {
69
+ code: "lab-katex-direct",
70
+ severity: "error",
71
+ file,
72
+ lessonId: lesson.lessonId,
73
+ message: `${file} imports '${specifier}' and draws math whose symbols open no notation card (reached by ${chain.join(" \u2192 ")}). Render a lab's math with renderLabMath(template, scope) \u2014 { display: false } for a legend entry, an axis title or a label \u2014 so every symbol is bound to the page's notation (rule V3).`
74
+ });
75
+ }
76
+ continue;
77
+ }
78
+ if (!specifier.startsWith(".")) continue;
79
+ const target = resolveRelative(path, specifier);
80
+ if (!target || !SOURCE_FILE.test(target) || target.includes(`${sep}node_modules${sep}`)) {
81
+ continue;
82
+ }
83
+ if (seen.has(target)) continue;
84
+ seen.add(target);
85
+ const text = read(target);
86
+ if (text === void 0) continue;
87
+ queue.push({ path: target, source: text, chain: [...chain, repoPath(target)] });
88
+ }
89
+ }
90
+ }
91
+ return [...diagnostics.values()].sort((a, b) => a.file.localeCompare(b.file));
92
+ }
93
+ export {
94
+ importSpecifiers,
95
+ validateLabKatexImports
96
+ };
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  const IGNORE_PATH = join(process.cwd(), "lint-ignore.yml");
4
- const LINE = /^-\s*([a-z0-9-]+)(?::([^\s—-][^—-]*?))?\s*(?:—|--|\s-\s)\s*(.+?)\s*$/;
4
+ const LINE = /^-\s*([a-z0-9-]+)(?::([^\s—][^—]*?))?\s*(?:—|--|\s-\s)\s*(.+?)\s*$/;
5
5
  function parseLintIgnore(text) {
6
6
  const entries = [];
7
7
  for (const rawLine of text.split("\n")) {
@@ -16,6 +16,7 @@ import './examples-validate.js';
16
16
  import './folds-validate.js';
17
17
  import './knowledge-checks-validate.js';
18
18
  import './raw-floats-validate.js';
19
+ import './lab-katex-validate.js';
19
20
  import './gate-math.js';
20
21
  import './mixed-content.js';
21
22
  import './gloss.js';
@@ -44,10 +45,11 @@ declare function notationKeysInMathNodes(nodes: readonly {
44
45
  readonly children?: readonly unknown[];
45
46
  }[]): string[];
46
47
  /**
47
- * Extend one compiled lesson bundle with only the definitions requested by
48
- * components rendered on that page (plus those definitions' normal closure).
48
+ * Extend one compiled page bundle — a lesson's, or a non-chapter page's
49
+ * (rule S10) — with only the definitions requested by components rendered on
50
+ * that page (plus those definitions' normal closure).
49
51
  */
50
- declare function notationDefinitionsForRenderedPage(manifest: Manifest, lessonId: string, dependencies: readonly PageNotationDependency[]): ManifestLessonNotation[];
52
+ declare function notationDefinitionsForRenderedPage(manifest: Manifest, pageId: string, dependencies: readonly PageNotationDependency[]): ManifestLessonNotation[];
51
53
  /**
52
54
  * Structural backstop over final HTML: every trigger must have a source entry
53
55
  * from which the popup enhancer can populate a card.
@@ -66,14 +66,14 @@ function manifestDefinition(definition, cardsByKey) {
66
66
  ...definition.formula === void 0 ? {} : { formula: definition.formula }
67
67
  };
68
68
  }
69
- function notationDefinitionsForRenderedPage(manifest, lessonId, dependencies) {
70
- const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
71
- if (!lesson) return [];
69
+ function notationDefinitionsForRenderedPage(manifest, pageId, dependencies) {
70
+ const page = manifest.lessons.find((entry) => entry.id === pageId) ?? manifest.pages.find((entry) => entry.id === pageId);
71
+ if (!page) return [];
72
72
  const definitionsById = new Map(
73
73
  manifest.notation.definitions.map((definition) => [definition.id, definition])
74
74
  );
75
75
  const availableIds = new Set(definitionsById.keys());
76
- const ids = new Set(lesson.notation.definitionIds);
76
+ const ids = new Set(page.notation.definitionIds);
77
77
  const contextsByKey = /* @__PURE__ */ new Map();
78
78
  for (const dependency of dependencies) {
79
79
  let contexts = contextsByKey.get(dependency.key);
@@ -84,12 +84,12 @@ function notationDefinitionsForRenderedPage(manifest, lessonId, dependencies) {
84
84
  contexts.add(dependency.context);
85
85
  const definitionId = resolveReferenceId(
86
86
  dependency.key,
87
- { kind: "page", lessonId },
87
+ { kind: "page", lessonId: pageId },
88
88
  availableIds
89
89
  );
90
90
  if (!definitionId) {
91
91
  throw new Error(
92
- `Notation payload error in lesson "${lessonId}": ${dependency.context} emitted data-notation-key="${dependency.key}", but that key is not defined in this lesson or the shared notation registry.`
92
+ `Notation payload error on page "${pageId}": ${dependency.context} emitted data-notation-key="${dependency.key}", but that key is not defined on this page or in the shared notation registry.`
93
93
  );
94
94
  }
95
95
  ids.add(definitionId);
@@ -124,7 +124,7 @@ function notationDefinitionsForRenderedPage(manifest, lessonId, dependencies) {
124
124
  if (previous && previous !== id) {
125
125
  const context = [...contextsByKey.get(definition.key) ?? []].join(", ");
126
126
  throw new Error(
127
- `Notation payload error in lesson "${lessonId}": key "${definition.key}" would have two meanings (${previous} and ${id})` + (context ? ` after ${context} emitted it.` : ".")
127
+ `Notation payload error on page "${pageId}": key "${definition.key}" would have two meanings (${previous} and ${id})` + (context ? ` after ${context} emitted it.` : ".")
128
128
  );
129
129
  }
130
130
  definitionIdByKey.set(definition.key, id);
@@ -138,7 +138,7 @@ function notationDefinitionsForRenderedPage(manifest, lessonId, dependencies) {
138
138
  });
139
139
  }
140
140
  const existingById = new Map(
141
- lesson.notation.definitions.map((definition) => [definition.id, definition])
141
+ page.notation.definitions.map((definition) => [definition.id, definition])
142
142
  );
143
143
  return [...ids].sort().flatMap((id) => {
144
144
  const existing = existingById.get(id);
@@ -12,11 +12,13 @@
12
12
  * `keyed-jsx-floats.mjs`'s top-level-only scan entirely, so it never even
13
13
  * reaches diagram numbering.
14
14
  *
15
- * A raw table nested in `<Table>`/`<Figure>` is the intended, wrapped case
16
- * and is not flagged; nesting inside anything else (`<CompactExample>`,
17
- * `<Aside>`, `<Memo>`, …) still is, because a raw table has no numbering
18
- * scheme of its own to opt into the way a `<Diagram>`'s top-level/`<Memo>`-
19
- * transparent scan does.
15
+ * A raw table counts only where wrapping it would number it: at the top level
16
+ * of the lesson body or in a `TRANSPARENT_COMPONENTS` body (`<Memo>`,
17
+ * `<ShortNote>`, `<Fold>`), exactly where `keyed-jsx-floats.mjs` numbers a
18
+ * `<Table>`. A raw table in `<Table>`/`<Figure>` is the intended, wrapped
19
+ * case; one in any other component (a worked example's `<CompactExample>`,
20
+ * an `<Aside>`) is illustrative, like an equation there, and a `<Table>`
21
+ * there would get no number — so neither is flagged.
20
22
  *
21
23
  * `scanRawFloats` (raw Markdown, for the manifest builder) and
22
24
  * `collectRawFloats` (mdast, for `remark-notation.mjs`) MUST agree on counts
@@ -24,7 +26,11 @@
24
26
  * `keyed-jsx-floats.mjs` holds `scanKeyedJsxFloats`/`collectKeyedJsxFloats` to.
25
27
  */
26
28
 
27
- import { stripFencedCode } from './equations.mjs';
29
+ import {
30
+ stripFencedCode,
31
+ stripJsxComponents,
32
+ TRANSPARENT_COMPONENTS,
33
+ } from './equations.mjs';
28
34
  import { collectKeyedJsxFloats, scanKeyedJsxFloats } from './keyed-jsx-floats.mjs';
29
35
 
30
36
  /** Blank `<Table>…</Table>` and `<Figure>…</Figure>` bodies — legitimately wrapped, not raw. */
@@ -67,9 +73,16 @@ function findDiagramTags(text) {
67
73
  export function scanRawFloats(markdown) {
68
74
  const codeStripped = stripFencedCode(String(markdown));
69
75
  const withoutWrappedFloats = blankFloatWrappers(codeStripped);
76
+ // Every component body but a transparent one is blanked — `<Table>` and
77
+ // `<Figure>` among them — so only the tables a `<Table>` would number remain.
78
+ const numberableText = stripJsxComponents(
79
+ codeStripped,
80
+ undefined,
81
+ TRANSPARENT_COMPONENTS,
82
+ );
70
83
 
71
- const markdownTableCount = findMarkdownTableStarts(withoutWrappedFloats).length;
72
- const htmlTableCount = findHtmlTableTags(withoutWrappedFloats).length;
84
+ const markdownTableCount = findMarkdownTableStarts(numberableText).length;
85
+ const htmlTableCount = findHtmlTableTags(numberableText).length;
73
86
 
74
87
  const diagramsOutsideWrappers = findDiagramTags(withoutWrappedFloats).length;
75
88
  const visibleDiagrams = scanKeyedJsxFloats(markdown, { tagName: 'Diagram' });
@@ -95,7 +108,10 @@ export function collectRawFloats(tree) {
95
108
  const htmlTableNodes = [];
96
109
  const allDiagramNodes = [];
97
110
 
98
- const walk = (node) => {
111
+ // `numberable`: a `<Table>` here would be numbered — the top level, or a
112
+ // transparent component's body. Any other MDX component (a capitalized
113
+ // name, the ones `stripJsxComponents` blanks) ends it for its subtree.
114
+ const walk = (node, numberable) => {
99
115
  if (!node || typeof node !== 'object') return;
100
116
  if (
101
117
  node.type === 'mdxJsxFlowElement' &&
@@ -103,18 +119,26 @@ export function collectRawFloats(tree) {
103
119
  ) {
104
120
  return; // legitimately wrapped — don't descend
105
121
  }
106
- if (node.type === 'table') markdownTableNodes.push(node);
107
- if (node.type === 'html' && /<table\b/i.test(String(node.value ?? ''))) {
122
+ if (numberable && node.type === 'table') markdownTableNodes.push(node);
123
+ if (
124
+ numberable &&
125
+ node.type === 'html' &&
126
+ /<table\b/i.test(String(node.value ?? ''))
127
+ ) {
108
128
  htmlTableNodes.push(node);
109
129
  }
110
130
  if (node.type === 'mdxJsxFlowElement' && node.name === 'Diagram') {
111
131
  allDiagramNodes.push(node);
112
132
  }
133
+ const component =
134
+ (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
135
+ /^[A-Z]/.test(String(node.name ?? '')) &&
136
+ !TRANSPARENT_COMPONENTS.includes(node.name);
113
137
  if (Array.isArray(node.children)) {
114
- for (const child of node.children) walk(child);
138
+ for (const child of node.children) walk(child, numberable && !component);
115
139
  }
116
140
  };
117
- walk(tree);
141
+ walk(tree, true);
118
142
 
119
143
  const visibleDiagrams = collectKeyedJsxFloats(tree, { tagName: 'Diagram' });
120
144
  const visibleNodes = new Set(visibleDiagrams.map((diagram) => diagram.node));
@@ -106,6 +106,13 @@ function buildNotationRegistry(rawInput) {
106
106
  );
107
107
  }
108
108
  detectCycles(definitions, edgeIndex, diagnostics);
109
+ const localIdsByLesson = /* @__PURE__ */ new Map();
110
+ for (const candidate of definitions.values()) {
111
+ if (candidate.kind !== "local") continue;
112
+ const ids = localIdsByLesson.get(candidate.lesson.lessonId) ?? [];
113
+ ids.push(candidate.id);
114
+ localIdsByLesson.set(candidate.lesson.lessonId, ids);
115
+ }
109
116
  const bundles = [];
110
117
  const backlinks = [];
111
118
  const reachableDefinitions = /* @__PURE__ */ new Set();
@@ -114,7 +121,10 @@ function buildNotationRegistry(rawInput) {
114
121
  const directIds = new Set(
115
122
  references.map((reference) => reference.definitionId)
116
123
  );
117
- const definitionIds = transitiveClosure(directIds, edgeIndex);
124
+ const definitionIds = transitiveClosure(
125
+ /* @__PURE__ */ new Set([...directIds, ...localIdsByLesson.get(lesson.lessonId) ?? []]),
126
+ edgeIndex
127
+ );
118
128
  for (const id of definitionIds) reachableDefinitions.add(id);
119
129
  const bindingByKey = /* @__PURE__ */ new Map();
120
130
  for (const reference of references) {