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,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
|
|
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
|
|
127
|
-
*
|
|
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
|
-
|
|
132
|
+
pageId: string,
|
|
132
133
|
dependencies: readonly PageNotationDependency[],
|
|
133
134
|
): ManifestLessonNotation[] {
|
|
134
|
-
const
|
|
135
|
-
|
|
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(
|
|
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
|
|
162
|
+
`Notation payload error on page "${pageId}": ${dependency.context} ` +
|
|
160
163
|
`emitted data-notation-key="${dependency.key}", but that key is not ` +
|
|
161
|
-
`defined
|
|
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
|
|
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
|
-
|
|
226
|
+
page.notation.definitions.map((definition) => [definition.id, definition]),
|
|
224
227
|
);
|
|
225
228
|
return [...ids]
|
|
226
229
|
.sort()
|