explico 0.2.0 → 0.4.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 +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +51 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/keyed-float-validate.d.ts +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/remark-notation.d.mts +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Equation identity (Step D7, extended F3) — shared, framework-free helpers.
|
|
3
3
|
*
|
|
4
|
-
* Every display equation (`$$…$$`) in a lesson is numbered and given an
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
|
|
5
|
+
* from appearance order. When the caller supplies `numbering`
|
|
6
|
+
* (`{ prefix, offset }` — see `resolveNumbering`), the number is
|
|
7
|
+
* `‹part›.‹offset + n›`: the prefix is the **part**'s ordinal among the sidebar
|
|
8
|
+
* groups, `n` counts every display equation in the lesson, and `offset` carries
|
|
9
|
+
* the count from earlier lessons of the same part so the sequence runs
|
|
10
|
+
* continuously across a part rather than restarting on every page. `##` and
|
|
11
|
+
* `###` headings never affect it — `(2.4)` is the fourth display equation of
|
|
12
|
+
* part 2, wherever in that part it sits. See `docs/authoring/structure.md` for
|
|
13
|
+
* the level names. With no `numbering` the number falls back to the
|
|
14
|
+
* `##`-section scheme `‹section›.‹indexInSection›` (used by hand-built test
|
|
15
|
+
* trees and non-lesson pages). An author additionally
|
|
7
16
|
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
8
17
|
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
9
18
|
* they want a *stable* anchor that survives edits and cross-page references:
|
|
@@ -58,12 +67,51 @@ function equationNumber(section, index) {
|
|
|
58
67
|
return section > 0 ? `${section}.${index}` : String(index);
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Normalise the numbering input to `{ prefix, offset }`.
|
|
72
|
+
*
|
|
73
|
+
* `prefix` is the number before the dot — the **part**'s ordinal (its position
|
|
74
|
+
* among the sidebar groups). `offset` is how many display equations already
|
|
75
|
+
* appeared in earlier lessons of that same part, so `n` runs continuously
|
|
76
|
+
* across a part instead of restarting on every page. A bare number is the
|
|
77
|
+
* legacy per-lesson form (`prefix = n`, `offset = 0`) and is still accepted so
|
|
78
|
+
* hand-built trees and unit tests keep working. `undefined`/`null`/`0` ⇒ the
|
|
79
|
+
* `##`-section fallback for non-lesson pages.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveNumbering(input) {
|
|
82
|
+
if (input == null) return { prefix: undefined, offset: 0 };
|
|
83
|
+
if (typeof input === 'number') {
|
|
84
|
+
return { prefix: input || undefined, offset: 0 };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
prefix: input.prefix || undefined,
|
|
88
|
+
offset: Number(input.offset) || 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The visible number for one display equation. With a `prefix` (the part's
|
|
94
|
+
* ordinal) the scheme is `‹part›.‹offset + indexInLesson›` and `##` headings
|
|
95
|
+
* are ignored; without it, the legacy `##`-section scheme.
|
|
96
|
+
*/
|
|
97
|
+
export function displayEquationNumber({
|
|
98
|
+
prefix,
|
|
99
|
+
offset = 0,
|
|
100
|
+
section,
|
|
101
|
+
indexInSection,
|
|
102
|
+
indexInLesson,
|
|
103
|
+
}) {
|
|
104
|
+
return prefix == null
|
|
105
|
+
? equationNumber(section, indexInSection)
|
|
106
|
+
: `${prefix}.${offset + indexInLesson}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
61
109
|
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
62
110
|
export function equationAnchorId({ key, number }) {
|
|
63
111
|
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
64
112
|
}
|
|
65
113
|
|
|
66
|
-
function stripFencedCode(markdown) {
|
|
114
|
+
export function stripFencedCode(markdown) {
|
|
67
115
|
return String(markdown)
|
|
68
116
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
69
117
|
.replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
@@ -76,22 +124,31 @@ function stripFencedCode(markdown) {
|
|
|
76
124
|
* (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
|
|
77
125
|
* outermost match wins, so nested components are blanked in one pass;
|
|
78
126
|
* newlines are kept so line/section structure is unchanged.
|
|
127
|
+
*
|
|
128
|
+
* `keep`, when given, leaves a component of that name untouched instead of
|
|
129
|
+
* blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
|
|
130
|
+
* component while still finding `<Table>`/`<Figure>` tags at the top level.
|
|
79
131
|
*/
|
|
80
|
-
function stripJsxComponents(markdown) {
|
|
132
|
+
export function stripJsxComponents(markdown, keep) {
|
|
81
133
|
return String(markdown).replace(
|
|
82
134
|
/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
|
|
83
|
-
(block) => block.replace(/[^\n]/g, ' '),
|
|
135
|
+
(block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
|
|
84
136
|
);
|
|
85
137
|
}
|
|
86
138
|
|
|
87
139
|
/**
|
|
88
|
-
* Every
|
|
89
|
-
* its assigned number. `$$…$$` blocks only
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
140
|
+
* Every display equation in a Markdown body, keyed or not, in appearance order
|
|
141
|
+
* with its assigned number. `$$…$$` blocks only. With `numbering`
|
|
142
|
+
* (`{ prefix, offset }`, the part's ordinal and the count of display equations
|
|
143
|
+
* in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
|
|
144
|
+
* sequential over every display block in the lesson; without it, `##` headings
|
|
145
|
+
* advance a section counter and the number is `‹section›.‹indexInSection›`.
|
|
146
|
+
* Either way every display block advances the index, so a keyed equation's
|
|
147
|
+
* number reflects its position among *all* display equations, not only the
|
|
148
|
+
* keyed ones.
|
|
93
149
|
*/
|
|
94
|
-
export function
|
|
150
|
+
export function scanDisplayEquations(markdown, numbering) {
|
|
151
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
95
152
|
const text = stripJsxComponents(stripFencedCode(markdown));
|
|
96
153
|
const tokens = [];
|
|
97
154
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
@@ -113,6 +170,7 @@ export function scanEquationLabels(markdown) {
|
|
|
113
170
|
const labels = [];
|
|
114
171
|
let section = 0;
|
|
115
172
|
let indexInSection = 0;
|
|
173
|
+
let indexInLesson = 0;
|
|
116
174
|
for (const token of tokens) {
|
|
117
175
|
if (token.kind === 'section') {
|
|
118
176
|
section += 1;
|
|
@@ -120,10 +178,16 @@ export function scanEquationLabels(markdown) {
|
|
|
120
178
|
continue;
|
|
121
179
|
}
|
|
122
180
|
indexInSection += 1;
|
|
123
|
-
|
|
181
|
+
indexInLesson += 1;
|
|
124
182
|
labels.push({
|
|
125
183
|
key: token.key,
|
|
126
|
-
number:
|
|
184
|
+
number: displayEquationNumber({
|
|
185
|
+
prefix,
|
|
186
|
+
offset,
|
|
187
|
+
section,
|
|
188
|
+
indexInSection,
|
|
189
|
+
indexInLesson,
|
|
190
|
+
}),
|
|
127
191
|
section,
|
|
128
192
|
indexInSection,
|
|
129
193
|
});
|
|
@@ -131,16 +195,35 @@ export function scanEquationLabels(markdown) {
|
|
|
131
195
|
return labels;
|
|
132
196
|
}
|
|
133
197
|
|
|
198
|
+
/** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
|
|
199
|
+
export function scanEquationLabels(markdown, numbering) {
|
|
200
|
+
return scanDisplayEquations(markdown, numbering).filter(
|
|
201
|
+
(equation) => equation.key != null,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* How many display equations a lesson body holds. This is what makes `n` run
|
|
207
|
+
* continuously across a part: a lesson's `offset` is the sum of this over every
|
|
208
|
+
* earlier lesson in the same part.
|
|
209
|
+
*/
|
|
210
|
+
export function countDisplayEquations(markdown) {
|
|
211
|
+
return scanDisplayEquations(markdown).length;
|
|
212
|
+
}
|
|
213
|
+
|
|
134
214
|
/**
|
|
135
215
|
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
136
216
|
* walk the tree in document order, advancing the section counter on every
|
|
137
217
|
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
138
|
-
* `\label{eq:…}` payload when present, else `null`.
|
|
218
|
+
* `\label{eq:…}` payload when present, else `null`. `numbering` is threaded the
|
|
219
|
+
* same way as in `scanEquationLabels`.
|
|
139
220
|
*/
|
|
140
|
-
export function collectDisplayEquations(tree) {
|
|
221
|
+
export function collectDisplayEquations(tree, numbering) {
|
|
222
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
141
223
|
const equations = [];
|
|
142
224
|
let section = 0;
|
|
143
225
|
let indexInSection = 0;
|
|
226
|
+
let indexInLesson = 0;
|
|
144
227
|
|
|
145
228
|
// Only top-level blocks: a `$$…$$` nested in an MDX component (a
|
|
146
229
|
// `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
|
|
@@ -155,11 +238,18 @@ export function collectDisplayEquations(tree) {
|
|
|
155
238
|
}
|
|
156
239
|
if (node.type === 'math') {
|
|
157
240
|
indexInSection += 1;
|
|
241
|
+
indexInLesson += 1;
|
|
158
242
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
159
243
|
const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
|
|
160
244
|
equations.push({
|
|
161
245
|
key: label ? label[1] : null,
|
|
162
|
-
number:
|
|
246
|
+
number: displayEquationNumber({
|
|
247
|
+
prefix,
|
|
248
|
+
offset,
|
|
249
|
+
section,
|
|
250
|
+
indexInSection,
|
|
251
|
+
indexInLesson,
|
|
252
|
+
}),
|
|
163
253
|
section,
|
|
164
254
|
indexInSection,
|
|
165
255
|
node,
|
|
@@ -173,8 +263,8 @@ export function collectDisplayEquations(tree) {
|
|
|
173
263
|
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
174
264
|
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
175
265
|
*/
|
|
176
|
-
export function collectEquationLabels(tree) {
|
|
177
|
-
return collectDisplayEquations(tree).filter(
|
|
266
|
+
export function collectEquationLabels(tree, numbering) {
|
|
267
|
+
return collectDisplayEquations(tree, numbering).filter(
|
|
178
268
|
(equation) => equation.key != null,
|
|
179
269
|
);
|
|
180
270
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `validateKeyedFloats` for `<Figure>` — see `keyed-float-validate.ts`. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
validateKeyedFloats,
|
|
5
|
+
type KeyedFloatLabelRecord,
|
|
6
|
+
type KeyedFloatLessonInput,
|
|
7
|
+
type KeyedFloatValidation,
|
|
8
|
+
} from './keyed-float-validate';
|
|
9
|
+
|
|
10
|
+
export type FigureDiagnosticCode =
|
|
11
|
+
| 'fig-invalid-key'
|
|
12
|
+
| 'fig-duplicate-key'
|
|
13
|
+
| 'fig-ref-resolves'
|
|
14
|
+
| 'fig-key-unused';
|
|
15
|
+
|
|
16
|
+
export interface FigureDiagnostic {
|
|
17
|
+
readonly code: FigureDiagnosticCode;
|
|
18
|
+
readonly severity: 'error' | 'warning';
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
readonly lessonId?: string;
|
|
22
|
+
readonly key?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FigureLabelRecord = KeyedFloatLabelRecord;
|
|
26
|
+
export type FigureLessonInput = KeyedFloatLessonInput;
|
|
27
|
+
|
|
28
|
+
export interface FigureValidation extends KeyedFloatValidation {
|
|
29
|
+
readonly diagnostics: readonly FigureDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateFigures(
|
|
33
|
+
lessons: readonly FigureLessonInput[],
|
|
34
|
+
): FigureValidation {
|
|
35
|
+
return validateKeyedFloats(lessons, {
|
|
36
|
+
tagName: 'Figure',
|
|
37
|
+
prefix: 'fig',
|
|
38
|
+
noun: 'figure',
|
|
39
|
+
}) as FigureValidation;
|
|
40
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed-float validation for `<Table>`/`<Figure>`, run inside
|
|
3
|
+
* `compileManifest()` — the same three checks `equations-validate.ts` runs
|
|
4
|
+
* for equations, parameterized over the float kind so the logic exists once:
|
|
5
|
+
*
|
|
6
|
+
* - `<prefix>-invalid-key` (error): an `id` must match the key grammar.
|
|
7
|
+
* - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
|
|
8
|
+
* - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
|
|
9
|
+
* `[[slug#<prefix>-key]]` points at a `<Table>`/`<Figure>` with that `id`.
|
|
10
|
+
* - `<prefix>-key-unused` (warning): a keyed float never referenced.
|
|
11
|
+
*
|
|
12
|
+
* `tables-validate.ts` and `figures-validate.ts` are thin wrappers over this
|
|
13
|
+
* so callers import `validateTables` / `validateFigures` with their own
|
|
14
|
+
* narrow diagnostic-code types, while `equations-validate.ts` stays
|
|
15
|
+
* untouched — equations predate this and keep their own dedicated path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
KEYED_FLOAT_KEY_PATTERN,
|
|
20
|
+
parseKeyedFloatRef,
|
|
21
|
+
scanKeyedJsxFloats,
|
|
22
|
+
} from './keyed-jsx-floats.mjs';
|
|
23
|
+
|
|
24
|
+
export interface KeyedFloatKind {
|
|
25
|
+
/** The MDX component tag, e.g. `Table` or `Figure`. */
|
|
26
|
+
readonly tagName: string;
|
|
27
|
+
/** The `[[<prefix>-key]]` reference prefix, e.g. `tab` or `fig`. */
|
|
28
|
+
readonly prefix: string;
|
|
29
|
+
/** Lowercase noun used in diagnostic messages, e.g. `table` or `figure`. */
|
|
30
|
+
readonly noun: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KeyedFloatLabelRecord {
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly number: string;
|
|
36
|
+
readonly lessonId: string;
|
|
37
|
+
readonly slug: string;
|
|
38
|
+
readonly section: number;
|
|
39
|
+
readonly indexInSection: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KeyedFloatRefRecord {
|
|
43
|
+
readonly key: string;
|
|
44
|
+
readonly lessonId: string;
|
|
45
|
+
/** Target lesson slug for a cross-page reference; `undefined` = same page. */
|
|
46
|
+
readonly targetSlug?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KeyedFloatDiagnostic {
|
|
50
|
+
readonly code: string;
|
|
51
|
+
readonly severity: 'error' | 'warning';
|
|
52
|
+
readonly message: string;
|
|
53
|
+
readonly file?: string;
|
|
54
|
+
readonly lessonId?: string;
|
|
55
|
+
readonly key?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface KeyedFloatValidation {
|
|
59
|
+
/** Every keyed float, sorted by lesson then appearance. */
|
|
60
|
+
readonly labels: readonly KeyedFloatLabelRecord[];
|
|
61
|
+
/** `slug -> { key -> number }` — the render path's cross-page lookup table. */
|
|
62
|
+
readonly numbersBySlug: Record<string, Record<string, string>>;
|
|
63
|
+
readonly diagnostics: readonly KeyedFloatDiagnostic[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface KeyedFloatLessonInput {
|
|
67
|
+
readonly lessonId: string;
|
|
68
|
+
readonly slug: string;
|
|
69
|
+
readonly file: string;
|
|
70
|
+
readonly body: string;
|
|
71
|
+
/**
|
|
72
|
+
* `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
|
|
73
|
+
* and how many floats of this kind earlier lessons of that part already used.
|
|
74
|
+
* When set, floats are numbered `‹part›.‹offset + n›`; when omitted, the
|
|
75
|
+
* legacy `##`-section scheme. Each kind counts on its own, so the caller
|
|
76
|
+
* passes a different `offset` for `<Table>` than for `<Figure>`.
|
|
77
|
+
*/
|
|
78
|
+
readonly numbering?: { readonly prefix: number; readonly offset: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
82
|
+
|
|
83
|
+
function stripCode(text: string): string {
|
|
84
|
+
return text
|
|
85
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
86
|
+
.replace(/~~~[\s\S]*?~~~/g, ' ')
|
|
87
|
+
.replace(/`[^`\n]*`/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function bodyFloatRefs(
|
|
91
|
+
lesson: KeyedFloatLessonInput,
|
|
92
|
+
prefix: string,
|
|
93
|
+
): KeyedFloatRefRecord[] {
|
|
94
|
+
const refs: KeyedFloatRefRecord[] = [];
|
|
95
|
+
for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
|
|
96
|
+
const parsed = parseKeyedFloatRef((match[1] ?? '').trim(), { prefix });
|
|
97
|
+
if (!parsed) continue;
|
|
98
|
+
refs.push({
|
|
99
|
+
key: parsed.key,
|
|
100
|
+
lessonId: lesson.lessonId,
|
|
101
|
+
...(parsed.lesson ? { targetSlug: parsed.lesson } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return refs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateKeyedFloats(
|
|
108
|
+
lessons: readonly KeyedFloatLessonInput[],
|
|
109
|
+
kind: KeyedFloatKind,
|
|
110
|
+
): KeyedFloatValidation {
|
|
111
|
+
const { tagName, prefix, noun } = kind;
|
|
112
|
+
const labels: KeyedFloatLabelRecord[] = [];
|
|
113
|
+
const diagnostics: KeyedFloatDiagnostic[] = [];
|
|
114
|
+
const numbersBySlug: Record<string, Record<string, string>> = {};
|
|
115
|
+
|
|
116
|
+
for (const lesson of [...lessons].sort((a, b) =>
|
|
117
|
+
a.lessonId.localeCompare(b.lessonId),
|
|
118
|
+
)) {
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const table: Record<string, string> = {};
|
|
121
|
+
for (const raw of scanKeyedJsxFloats(lesson.body, {
|
|
122
|
+
tagName,
|
|
123
|
+
numbering: lesson.numbering,
|
|
124
|
+
}) as {
|
|
125
|
+
key: string | null;
|
|
126
|
+
number: string;
|
|
127
|
+
section: number;
|
|
128
|
+
indexInSection: number;
|
|
129
|
+
}[]) {
|
|
130
|
+
if (raw.key == null) continue;
|
|
131
|
+
if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
|
|
132
|
+
diagnostics.push({
|
|
133
|
+
code: `${prefix}-invalid-key`,
|
|
134
|
+
severity: 'error',
|
|
135
|
+
message: `${lesson.lessonId} has a <${tagName} id="${raw.key}"> whose id is not a valid key — expected lowercase letters, digits, and hyphens only.`,
|
|
136
|
+
file: lesson.file,
|
|
137
|
+
lessonId: lesson.lessonId,
|
|
138
|
+
key: raw.key,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (seen.has(raw.key)) {
|
|
143
|
+
diagnostics.push({
|
|
144
|
+
code: `${prefix}-duplicate-key`,
|
|
145
|
+
severity: 'error',
|
|
146
|
+
message: `${lesson.lessonId} has two <${tagName}> elements with id="${raw.key}"; an id is unique per lesson.`,
|
|
147
|
+
file: lesson.file,
|
|
148
|
+
lessonId: lesson.lessonId,
|
|
149
|
+
key: raw.key,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
seen.add(raw.key);
|
|
154
|
+
table[raw.key] = raw.number;
|
|
155
|
+
labels.push({
|
|
156
|
+
key: raw.key,
|
|
157
|
+
number: raw.number,
|
|
158
|
+
lessonId: lesson.lessonId,
|
|
159
|
+
slug: lesson.slug,
|
|
160
|
+
section: raw.section,
|
|
161
|
+
indexInSection: raw.indexInSection,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
numbersBySlug[lesson.slug] = table;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const referenced = new Set<string>();
|
|
168
|
+
for (const lesson of lessons) {
|
|
169
|
+
const slug = lesson.slug;
|
|
170
|
+
for (const ref of bodyFloatRefs(lesson, prefix)) {
|
|
171
|
+
const targetSlug = ref.targetSlug ?? slug;
|
|
172
|
+
const number = numbersBySlug[targetSlug]?.[ref.key];
|
|
173
|
+
referenced.add(`${targetSlug}#${ref.key}`);
|
|
174
|
+
if (number === undefined) {
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
code: `${prefix}-ref-resolves`,
|
|
177
|
+
severity: 'error',
|
|
178
|
+
message: ref.targetSlug
|
|
179
|
+
? `${lesson.lessonId} references [[${ref.targetSlug}#${prefix}-${ref.key}]] but that lesson has no <${tagName} id="${ref.key}">`
|
|
180
|
+
: `${lesson.lessonId} references [[${prefix}-${ref.key}]] but no <${tagName}> on the page carries id="${ref.key}"`,
|
|
181
|
+
file: lesson.file,
|
|
182
|
+
lessonId: lesson.lessonId,
|
|
183
|
+
key: ref.key,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const label of labels) {
|
|
190
|
+
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
code: `${prefix}-key-unused`,
|
|
193
|
+
severity: 'warning',
|
|
194
|
+
message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
|
|
195
|
+
file: label.slug,
|
|
196
|
+
lessonId: label.lessonId,
|
|
197
|
+
key: label.key,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
diagnostics.sort(
|
|
202
|
+
(a, b) =>
|
|
203
|
+
a.code.localeCompare(b.code) ||
|
|
204
|
+
(a.lessonId ?? '').localeCompare(b.lessonId ?? '') ||
|
|
205
|
+
(a.key ?? '').localeCompare(b.key ?? '') ||
|
|
206
|
+
a.message.localeCompare(b.message),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return { labels, numbersBySlug, diagnostics };
|
|
210
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed-float identity for a JSX-wrapped table or figure, shared between
|
|
3
|
+
* `<Table>` and `<Figure>` (Step F-tables-figures).
|
|
4
|
+
*
|
|
5
|
+
* This mirrors `equations.mjs`, but the key comes from a plain `id` prop on
|
|
6
|
+
* the wrapper component rather than a `\label{eq:key}` embedded in shared
|
|
7
|
+
* LaTeX source — a `<Table>`/`<Figure>` is purpose-built, so there is no
|
|
8
|
+
* ambiguity an embedded marker would need to resolve. An author writes the
|
|
9
|
+
* bare key (`id="kinematics-summary"`); the rendered DOM id and the `[[…]]`
|
|
10
|
+
* reference prefix are computed the same way `\label{eq:key}` becomes
|
|
11
|
+
* `#eq-key` — `keyedFloatAnchorId` prepends `tab-`/`fig-`.
|
|
12
|
+
*
|
|
13
|
+
* Only top-level occurrences count, exactly like `collectDisplayEquations`:
|
|
14
|
+
* a `<Table>` nested inside a `<CompactExample>` (or any other component) is
|
|
15
|
+
* illustrative, not part of the lesson's official sequence, so both the
|
|
16
|
+
* mdast walker and the raw-text scanner only look at the document's direct
|
|
17
|
+
* top-level blocks.
|
|
18
|
+
*
|
|
19
|
+
* `scanKeyedJsxFloats` (raw Markdown, for the manifest builder) and
|
|
20
|
+
* `collectKeyedJsxFloats` (mdast, for `remark-notation.mjs`) MUST agree —
|
|
21
|
+
* `tests/notation/keyed-floats.test.ts` checks that, the same contract
|
|
22
|
+
* `equations.test.ts` holds equations to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
resolveNumbering,
|
|
27
|
+
stripFencedCode,
|
|
28
|
+
stripJsxComponents,
|
|
29
|
+
} from './equations.mjs';
|
|
30
|
+
|
|
31
|
+
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
32
|
+
export const KEYED_FLOAT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
33
|
+
|
|
34
|
+
/** The visible number for the `index`-th float of one kind under `section`. */
|
|
35
|
+
function floatNumber(section, index) {
|
|
36
|
+
return section > 0 ? `${section}.${index}` : String(index);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The visible number for one float. With a `prefix` (the part's ordinal) the
|
|
41
|
+
* scheme is `‹part›.‹offset + indexInLesson›` and `##` headings are ignored;
|
|
42
|
+
* without it, the legacy `##`-section scheme. Mirrors `displayEquationNumber`
|
|
43
|
+
* in `equations.mjs`. Each kind of float counts on its own — a `<Table>` never
|
|
44
|
+
* advances the `<Figure>` sequence — so `offset` is per kind too.
|
|
45
|
+
*/
|
|
46
|
+
function keyedFloatNumber({
|
|
47
|
+
prefix,
|
|
48
|
+
offset = 0,
|
|
49
|
+
section,
|
|
50
|
+
indexInSection,
|
|
51
|
+
indexInLesson,
|
|
52
|
+
}) {
|
|
53
|
+
return prefix == null
|
|
54
|
+
? floatNumber(section, indexInSection)
|
|
55
|
+
: `${prefix}.${offset + indexInLesson}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
|
|
59
|
+
export function keyedFloatAnchorId({ key, number, prefix }) {
|
|
60
|
+
return key ? `${prefix}-${key}` : `${prefix}-${String(number).replace(/\./g, '-')}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
|
|
65
|
+
* the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
|
|
66
|
+
*/
|
|
67
|
+
export function parseKeyedFloatRef(raw, { prefix }) {
|
|
68
|
+
const pattern = new RegExp(
|
|
69
|
+
`^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?${prefix}-([a-z0-9]+(?:-[a-z0-9]+)*)$`,
|
|
70
|
+
);
|
|
71
|
+
const match = pattern.exec(String(raw).trim());
|
|
72
|
+
if (!match) return undefined;
|
|
73
|
+
return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `id="…"` attribute value of a JSX opening tag's attribute source. */
|
|
77
|
+
function readIdAttribute(attributesSource) {
|
|
78
|
+
const match = /\bid\s*=\s*(["'])([^"']*)\1/.exec(attributesSource);
|
|
79
|
+
return match ? match[2] : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
|
|
84
|
+
* `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
|
|
85
|
+
* With `numbering` (`{ prefix, offset }` — the part's ordinal and the count of
|
|
86
|
+
* this kind of float in earlier lessons of that part) the number is
|
|
87
|
+
* `‹part›.‹offset + n›`, `n` sequential over the whole lesson; without it, `##`
|
|
88
|
+
* headings advance a section counter. Other JSX components' bodies are blanked
|
|
89
|
+
* first so a `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like
|
|
90
|
+
* text inside an unrelated component) is never counted.
|
|
91
|
+
*/
|
|
92
|
+
export function scanKeyedJsxFloats(markdown, { tagName, numbering }) {
|
|
93
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
94
|
+
const text = stripJsxComponents(stripFencedCode(markdown), tagName);
|
|
95
|
+
const tokens = [];
|
|
96
|
+
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
97
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
98
|
+
for (const match of text.matchAll(heading)) {
|
|
99
|
+
tokens.push({ kind: 'section', index: match.index });
|
|
100
|
+
}
|
|
101
|
+
for (const match of text.matchAll(opening)) {
|
|
102
|
+
const key = readIdAttribute(match[1] ?? '');
|
|
103
|
+
tokens.push({ kind: 'float', index: match.index, key: key ?? null });
|
|
104
|
+
}
|
|
105
|
+
tokens.sort((a, b) => a.index - b.index);
|
|
106
|
+
|
|
107
|
+
const labels = [];
|
|
108
|
+
let section = 0;
|
|
109
|
+
let indexInSection = 0;
|
|
110
|
+
let indexInLesson = 0;
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
if (token.kind === 'section') {
|
|
113
|
+
section += 1;
|
|
114
|
+
indexInSection = 0;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
indexInSection += 1;
|
|
118
|
+
indexInLesson += 1;
|
|
119
|
+
labels.push({
|
|
120
|
+
key: token.key,
|
|
121
|
+
number: keyedFloatNumber({
|
|
122
|
+
prefix,
|
|
123
|
+
offset,
|
|
124
|
+
section,
|
|
125
|
+
indexInSection,
|
|
126
|
+
indexInLesson,
|
|
127
|
+
}),
|
|
128
|
+
section,
|
|
129
|
+
indexInSection,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return labels;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many floats of this kind a lesson body holds — the input to the next
|
|
137
|
+
* lesson's `offset` within the same part.
|
|
138
|
+
*/
|
|
139
|
+
export function countKeyedJsxFloats(markdown, { tagName }) {
|
|
140
|
+
return scanKeyedJsxFloats(markdown, { tagName }).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
|
|
145
|
+
* children in document order, advancing the section counter on every
|
|
146
|
+
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
147
|
+
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `numbering`
|
|
148
|
+
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
149
|
+
*/
|
|
150
|
+
export function collectKeyedJsxFloats(tree, { tagName, numbering }) {
|
|
151
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
152
|
+
const floats = [];
|
|
153
|
+
let section = 0;
|
|
154
|
+
let indexInSection = 0;
|
|
155
|
+
let indexInLesson = 0;
|
|
156
|
+
|
|
157
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
158
|
+
for (const node of children) {
|
|
159
|
+
if (!node || typeof node !== 'object') continue;
|
|
160
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
161
|
+
section += 1;
|
|
162
|
+
indexInSection = 0;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
|
|
166
|
+
indexInSection += 1;
|
|
167
|
+
indexInLesson += 1;
|
|
168
|
+
const idAttribute = (node.attributes ?? []).find(
|
|
169
|
+
(attribute) =>
|
|
170
|
+
attribute?.type === 'mdxJsxAttribute' && attribute.name === 'id',
|
|
171
|
+
);
|
|
172
|
+
const key =
|
|
173
|
+
typeof idAttribute?.value === 'string' ? idAttribute.value : undefined;
|
|
174
|
+
floats.push({
|
|
175
|
+
key: key ?? null,
|
|
176
|
+
number: keyedFloatNumber({
|
|
177
|
+
prefix,
|
|
178
|
+
offset,
|
|
179
|
+
section,
|
|
180
|
+
indexInSection,
|
|
181
|
+
indexInLesson,
|
|
182
|
+
}),
|
|
183
|
+
section,
|
|
184
|
+
indexInSection,
|
|
185
|
+
node,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return floats;
|
|
190
|
+
}
|
|
191
|
+
|