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.
- package/CHANGELOG.md +171 -1
- package/dist/compiler/astro.d.ts +7 -1
- package/dist/compiler/astro.js +5 -2
- package/dist/compiler/cli.d.ts +1 -0
- package/dist/compiler/collections.d.ts +25 -2
- package/dist/compiler/collections.js +37 -4
- package/dist/compiler/exports/standalone-html.d.ts +1 -0
- package/dist/compiler/manifest.d.ts +30 -4
- package/dist/compiler/manifest.js +49 -10
- package/dist/compiler/resolution-report.d.ts +1 -0
- package/dist/reference/base-operators.d.mts +6 -0
- package/dist/reference/base-operators.mjs +6 -8
- package/dist/reference/figure-symbols.d.ts +32 -0
- package/dist/reference/figure-symbols.js +55 -0
- package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
- package/dist/reference/gate-non-chapter-pages.js +156 -0
- package/dist/reference/gate-part-pages.d.ts +3 -1
- package/dist/reference/gate-part-pages.js +2 -1
- package/dist/reference/lab-katex-validate.d.ts +43 -0
- package/dist/reference/lab-katex-validate.js +96 -0
- package/dist/reference/lint-ignore.js +1 -1
- package/dist/reference/page-notation.d.ts +5 -3
- package/dist/reference/page-notation.js +8 -8
- package/dist/reference/raw-floats.mjs +37 -13
- package/dist/reference/registry.js +11 -1
- package/dist/reference/remark-notation.mjs +195 -7
- package/dist/reference/render-lab-math.d.ts +11 -2
- package/dist/reference/render-lab-math.js +2 -2
- package/dist/rules/catalog.js +33 -3
- package/dist/rules/rules.schema.json +22 -4
- package/docs/authoring/README.md +2 -1
- package/docs/authoring/components.md +4 -4
- package/docs/authoring/migrating.md +215 -0
- package/docs/authoring/notation.md +4 -1
- package/docs/authoring/structure-rules.md +75 -5
- package/docs/authoring/structure.md +14 -0
- package/docs/authoring/visualizations.md +26 -10
- package/package.json +1 -1
- package/src/compiler/astro.ts +17 -6
- package/src/compiler/collections.ts +67 -9
- package/src/compiler/manifest.ts +89 -13
- package/src/components/CourseContents.astro +6 -0
- package/src/components/PartOverview.astro +6 -0
- package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
- package/src/components/disclosure/print-as-shown.ts +42 -6
- package/src/components/figure/Figure.astro +6 -1
- package/src/components/figure/figure-state.ts +26 -17
- package/src/components/starlight/LessonFooter.astro +27 -7
- package/src/components/table/Table.astro +1 -1
- package/src/reference/base-operators.mjs +6 -8
- package/src/reference/figure-symbols.ts +117 -0
- package/src/reference/gate-non-chapter-pages.ts +241 -0
- package/src/reference/gate-part-pages.ts +1 -1
- package/src/reference/lab-katex-validate.ts +156 -0
- package/src/reference/lint-ignore.ts +4 -1
- package/src/reference/page-notation.ts +14 -11
- package/src/reference/raw-floats.mjs +37 -13
- package/src/reference/registry.ts +16 -1
- package/src/reference/remark-notation.mjs +195 -7
- package/src/reference/render-lab-math.ts +12 -1
- package/src/rules/catalog.ts +37 -3
- 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 };
|
|
@@ -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
|
|
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
|
|
48
|
-
*
|
|
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,
|
|
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,
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* `<
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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(
|
|
72
|
-
const htmlTableCount = findHtmlTableTags(
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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) {
|