explico 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +108 -0
  2. package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
  3. package/dist/analytics/AnalyticsEmitter.js +5 -0
  4. package/dist/compiler/cli.d.ts +189 -0
  5. package/dist/compiler/cli.js +552 -0
  6. package/dist/compiler/collections.d.ts +70 -0
  7. package/dist/compiler/collections.js +383 -0
  8. package/dist/compiler/course-config.d.ts +31 -0
  9. package/dist/compiler/course-config.js +0 -0
  10. package/dist/compiler/lesson-derivation.d.ts +89 -0
  11. package/dist/compiler/lesson-derivation.js +91 -0
  12. package/dist/compiler/manifest.d.ts +178 -0
  13. package/dist/compiler/manifest.js +345 -0
  14. package/dist/compiler/resolution-report.d.ts +45 -0
  15. package/dist/compiler/resolution-report.js +97 -0
  16. package/dist/curriculum/validation.d.ts +76 -0
  17. package/dist/curriculum/validation.js +320 -0
  18. package/dist/progress/ProgressRepository.d.ts +53 -0
  19. package/dist/progress/ProgressRepository.js +12 -0
  20. package/dist/reference/base-library.mjs +27 -0
  21. package/dist/reference/citation-format.mjs +133 -0
  22. package/dist/reference/consistency.d.ts +64 -0
  23. package/dist/reference/consistency.js +234 -0
  24. package/dist/reference/curriculum-alignment.d.ts +7 -0
  25. package/dist/reference/curriculum-alignment.js +166 -0
  26. package/dist/reference/equations-validate.d.ts +50 -0
  27. package/dist/reference/equations-validate.js +90 -0
  28. package/dist/reference/equations.mjs +144 -0
  29. package/dist/reference/gate-math.d.ts +41 -0
  30. package/dist/reference/gate-math.js +151 -0
  31. package/dist/reference/katex-options.mjs +91 -0
  32. package/dist/reference/lab-math-scope.d.ts +21 -0
  33. package/dist/reference/lab-math-scope.js +28 -0
  34. package/dist/reference/label.d.ts +11 -0
  35. package/dist/reference/label.js +38 -0
  36. package/dist/reference/lint-ignore.d.ts +32 -0
  37. package/dist/reference/lint-ignore.js +39 -0
  38. package/dist/reference/math-glyphs.mjs +830 -0
  39. package/dist/reference/prose.d.ts +15 -0
  40. package/dist/reference/prose.js +12 -0
  41. package/dist/reference/references.d.ts +17 -0
  42. package/dist/reference/references.js +42 -0
  43. package/dist/reference/registry.d.ts +6 -0
  44. package/dist/reference/registry.js +548 -0
  45. package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
  46. package/dist/reference/remark-citation.mjs +0 -0
  47. package/dist/reference/remark-notation.mjs +753 -0
  48. package/dist/reference/render-lab-math.d.ts +21 -0
  49. package/dist/reference/render-lab-math.js +58 -0
  50. package/dist/reference/types.d.ts +151 -0
  51. package/dist/reference/types.js +0 -0
  52. package/dist/reference/units-vocab.d.ts +26 -0
  53. package/dist/reference/units-vocab.js +119 -0
  54. package/dist/session/PreferenceStore.d.ts +28 -0
  55. package/dist/session/PreferenceStore.js +30 -0
  56. package/dist/session/user-context.d.ts +7 -0
  57. package/dist/session/user-context.js +10 -0
  58. package/dist/session/user.d.ts +21 -0
  59. package/dist/session/user.js +8 -0
  60. package/package.json +53 -0
  61. package/src/analytics/AnalyticsEmitter.ts +23 -0
  62. package/src/compiler/cli.ts +866 -0
  63. package/src/compiler/collections.ts +554 -0
  64. package/src/compiler/course-config.ts +32 -0
  65. package/src/compiler/lesson-derivation.ts +186 -0
  66. package/src/compiler/manifest.ts +601 -0
  67. package/src/compiler/resolution-report.ts +150 -0
  68. package/src/components/CurriculumMap.astro +208 -0
  69. package/src/components/HoverPanel.astro +67 -0
  70. package/src/components/assessments/AssessmentRunner.tsx +320 -0
  71. package/src/components/assessments/AssessmentSet.astro +54 -0
  72. package/src/components/citation/CitationLayer.astro +173 -0
  73. package/src/components/examples/CompactExample.astro +34 -0
  74. package/src/components/examples/CompactExamples.astro +253 -0
  75. package/src/components/hover-panel.ts +273 -0
  76. package/src/components/notation/NotationGlossary.astro +412 -0
  77. package/src/components/notation/NotationLayer.astro +594 -0
  78. package/src/components/notation/NotationMath.astro +24 -0
  79. package/src/components/notation/SafeMathNode.astro +23 -0
  80. package/src/components/notation/render-notation-math.ts +223 -0
  81. package/src/components/starlight/EquationEnhancer.astro +72 -0
  82. package/src/components/starlight/LayoutHeader.astro +102 -0
  83. package/src/components/starlight/LayoutPageSidebar.astro +33 -0
  84. package/src/components/starlight/LayoutSidebar.astro +29 -0
  85. package/src/components/starlight/LessonFooter.astro +58 -0
  86. package/src/content-config.ts +259 -0
  87. package/src/curriculum/validation.ts +481 -0
  88. package/src/progress/ProgressRepository.ts +64 -0
  89. package/src/reference/base-library.mjs +27 -0
  90. package/src/reference/citation-format.mjs +133 -0
  91. package/src/reference/consistency.ts +375 -0
  92. package/src/reference/curriculum-alignment.ts +228 -0
  93. package/src/reference/equations-validate.ts +167 -0
  94. package/src/reference/equations.mjs +144 -0
  95. package/src/reference/gate-math.ts +250 -0
  96. package/src/reference/katex-options.mjs +91 -0
  97. package/src/reference/lab-math-scope.ts +54 -0
  98. package/src/reference/label.ts +47 -0
  99. package/src/reference/lint-ignore.ts +75 -0
  100. package/src/reference/math-glyphs.mjs +830 -0
  101. package/src/reference/prose.ts +21 -0
  102. package/src/reference/references.ts +82 -0
  103. package/src/reference/registry.ts +723 -0
  104. package/src/reference/rehype-fail-katex-errors.mjs +55 -0
  105. package/src/reference/remark-citation.mjs +0 -0
  106. package/src/reference/remark-notation.mjs +753 -0
  107. package/src/reference/render-lab-math.ts +99 -0
  108. package/src/reference/types.ts +182 -0
  109. package/src/reference/units-vocab.ts +143 -0
  110. package/src/session/PreferenceStore.ts +54 -0
  111. package/src/session/user-context.ts +18 -0
  112. package/src/session/user.ts +24 -0
  113. package/src/styles/global.css +763 -0
@@ -0,0 +1,830 @@
1
+ import katex from 'katex';
2
+
3
+ import { BASE_LIBRARY } from './base-library.mjs';
4
+
5
+ /**
6
+ * Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
7
+ *
8
+ * A page declares its glyphs once — `notation.local` plus the shared entries it
9
+ * introduces in prose with `[[key]]` — so the glyph table handed in here is a
10
+ * flat list with **one meaning per glyph**. This module parses one `$…$` /
11
+ * `$$…$$` expression, matches each declared symbol against it (whole canonical
12
+ * form first, then a unique base glyph for a shorter or instantiated form),
13
+ * marks every match with a validated `\explain` marker, and reports any
14
+ * remaining identifier atom so the caller can fail at `file:line:token`.
15
+ * `\explain{key}{latex}` in the source stays as the escape hatch for a
16
+ * compound or glyph-colliding symbol.
17
+ *
18
+ * Because the table is unambiguous, matching is a lookup, not a search: no
19
+ * cross-definition candidate solving, no ambiguity permutation. What remains is
20
+ * the tokenizer the page-glyph-map syntax requires — a KaTeX parse plus the
21
+ * structural signatures that let `CF_k` be found as a three-node run and `D` be
22
+ * found as the head of `D(0,2)`. `katex.__parse` is that tokenizer; the version
23
+ * guard below keeps a dependency bump from silently changing its tree.
24
+ */
25
+ export const SUPPORTED_KATEX_PARSE_VERSION = '0.16.47';
26
+
27
+ const KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
28
+ const EXPLAIN_COMMAND = String.raw`\explain`;
29
+
30
+ // `\mathrm{…}` always wraps an upright roman label (`\mathrm{opt}`,
31
+ // `\mathrm{PV}`), never a bindable identifier atom, so its body is skipped whole.
32
+ const ROMAN_LABEL_FONT = 'mathrm';
33
+
34
+ // The only blackboard-bold / script tokens treated like the base library: the
35
+ // universal number systems. This is an explicit token list, not a font class —
36
+ // `\mathbb{E}`, `\mathbb{P}`, and `\mathbb{Q}` are ordinary identifiers that
37
+ // must resolve to a semantic key. (`\mathbb{Q}` is the risk-neutral measure in
38
+ // this corpus, not the rationals; a lesson that needs the rationals reaches for
39
+ // `\explain`.)
40
+ const IGNORED_MATH_TOKENS = new Set([
41
+ '\\mathbb{N}',
42
+ '\\mathbb{Z}',
43
+ '\\mathbb{R}',
44
+ '\\mathbb{C}',
45
+ ]);
46
+
47
+ // KaTeX font ids mapped back to their LaTeX command, so a single-letter font
48
+ // node can be reconstructed as its source token (`\mathbb{N}`).
49
+ const FONT_COMMANDS = new Map([
50
+ ['mathbb', '\\mathbb'],
51
+ ['mathcal', '\\mathcal'],
52
+ ['mathfrak', '\\mathfrak'],
53
+ ['mathscr', '\\mathscr'],
54
+ ['mathrm', '\\mathrm'],
55
+ ['mathbf', '\\mathbf'],
56
+ ['mathit', '\\mathit'],
57
+ ]);
58
+
59
+ function fontBodyText(value) {
60
+ if (Array.isArray(value)) {
61
+ const parts = value.map(fontBodyText);
62
+ return parts.every((part) => part !== undefined)
63
+ ? parts.join('')
64
+ : undefined;
65
+ }
66
+ if (!isNode(value)) return undefined;
67
+ if (value.type === 'ordgroup') return fontBodyText(value.body);
68
+ if (value.type === 'mathord' || value.type === 'textord') return value.text;
69
+ return undefined;
70
+ }
71
+
72
+ /** Reconstruct the source token of a font node, e.g. `\mathbb{N}`. */
73
+ function fontTokenText(node) {
74
+ if (!isNode(node) || node.type !== 'font') return undefined;
75
+ const command = FONT_COMMANDS.get(node.font);
76
+ if (command === undefined) return undefined;
77
+ const letters = fontBodyText(node.body);
78
+ return letters === undefined ? undefined : `${command}{${letters}}`;
79
+ }
80
+ const SOURCE_CHILD_KEYS = new Set([
81
+ 'above',
82
+ 'base',
83
+ 'below',
84
+ 'body',
85
+ 'denom',
86
+ 'display',
87
+ 'html',
88
+ 'index',
89
+ 'mathml',
90
+ 'nameGroup',
91
+ 'numer',
92
+ 'sub',
93
+ 'sup',
94
+ 'tag',
95
+ 'text',
96
+ ]);
97
+
98
+ export class GlyphResolutionError extends Error {
99
+ constructor(code, message, details = undefined) {
100
+ super(message);
101
+ this.name = 'GlyphResolutionError';
102
+ this.code = code;
103
+ this.details = details;
104
+ }
105
+ }
106
+
107
+ function fail(code, message, details) {
108
+ throw new GlyphResolutionError(code, message, details);
109
+ }
110
+
111
+ function assertPinnedKatex() {
112
+ if (katex.version !== SUPPORTED_KATEX_PARSE_VERSION) {
113
+ fail(
114
+ 'unsupported-katex-version',
115
+ `Glyph resolution expects KaTeX ${SUPPORTED_KATEX_PARSE_VERSION}, but ${katex.version} is installed. Review the internal parse-tree adapter before changing the pin.`,
116
+ { expected: SUPPORTED_KATEX_PARSE_VERSION, actual: katex.version },
117
+ );
118
+ }
119
+ }
120
+
121
+ function parseMath(latex, label) {
122
+ assertPinnedKatex();
123
+ try {
124
+ return katex.__parse(latex, { throwOnError: true, strict: 'error' });
125
+ } catch (error) {
126
+ fail(
127
+ 'invalid-latex',
128
+ `KaTeX could not parse ${label}: ${error instanceof Error ? error.message : String(error)}`,
129
+ { label, latex },
130
+ );
131
+ }
132
+ }
133
+
134
+ // --- low-level source scanning -------------------------------------------
135
+
136
+ function isNode(value) {
137
+ return Boolean(
138
+ value &&
139
+ typeof value === 'object' &&
140
+ !Array.isArray(value) &&
141
+ typeof value.type === 'string',
142
+ );
143
+ }
144
+
145
+ function isEscaped(value, index) {
146
+ let slashes = 0;
147
+ for (
148
+ let cursor = index - 1;
149
+ cursor >= 0 && value[cursor] === '\\';
150
+ cursor--
151
+ ) {
152
+ slashes += 1;
153
+ }
154
+ return slashes % 2 === 1;
155
+ }
156
+
157
+ function skipWhitespace(value, index) {
158
+ let cursor = index;
159
+ while (/\s/.test(value[cursor] ?? '')) cursor += 1;
160
+ return cursor;
161
+ }
162
+
163
+ function readBracedGroup(value, start) {
164
+ if (value[start] !== '{') return undefined;
165
+ let depth = 0;
166
+ for (let cursor = start; cursor < value.length; cursor++) {
167
+ if (isEscaped(value, cursor)) continue;
168
+ if (value[cursor] === '{') depth += 1;
169
+ if (value[cursor] === '}') {
170
+ depth -= 1;
171
+ if (depth === 0) {
172
+ return {
173
+ content: value.slice(start + 1, cursor),
174
+ contentStart: start + 1,
175
+ contentEnd: cursor,
176
+ end: cursor + 1,
177
+ };
178
+ }
179
+ }
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ /**
185
+ * Every `\explain{key}{latex}` in the source (the escape hatch). Each key must
186
+ * already be in the page glyph table. The returned span points at the wrapped
187
+ * LaTeX so it can be re-marked without disturbing the author's own braces.
188
+ */
189
+ function findExplainCalls(latex, definitionsByKey) {
190
+ const calls = [];
191
+ let cursor = 0;
192
+
193
+ while (cursor < latex.length) {
194
+ const start = latex.indexOf(EXPLAIN_COMMAND, cursor);
195
+ if (start === -1) break;
196
+ cursor = start + EXPLAIN_COMMAND.length;
197
+
198
+ if (isEscaped(latex, start)) continue;
199
+ if (latex[cursor] && /[A-Za-z@]/.test(latex[cursor])) continue;
200
+
201
+ const keyStart = skipWhitespace(latex, cursor);
202
+ const keyGroup = readBracedGroup(latex, keyStart);
203
+ if (!keyGroup) {
204
+ fail(
205
+ 'malformed-explicit-binding',
206
+ `${EXPLAIN_COMMAND} at offset ${start} is missing {semantic.key}.`,
207
+ { start },
208
+ );
209
+ }
210
+ const key = keyGroup.content.trim();
211
+ if (!KEY_PATTERN.test(key)) {
212
+ fail(
213
+ 'invalid-key',
214
+ `Invalid semantic notation key "${key}" in ${EXPLAIN_COMMAND}.`,
215
+ {
216
+ key,
217
+ start,
218
+ },
219
+ );
220
+ }
221
+ if (!definitionsByKey.has(key)) {
222
+ fail(
223
+ 'unknown-explicit-key',
224
+ `Explicit math binding at offset ${start} refers to unknown lesson-scoped key "${key}".`,
225
+ { key, start },
226
+ );
227
+ }
228
+
229
+ const latexStart = skipWhitespace(latex, keyGroup.end);
230
+ const latexGroup = readBracedGroup(latex, latexStart);
231
+ if (!latexGroup) {
232
+ fail(
233
+ 'malformed-explicit-binding',
234
+ `${EXPLAIN_COMMAND}{${key}} at offset ${start} is missing {latex}.`,
235
+ { key, start },
236
+ );
237
+ }
238
+ if (latexGroup.content.trim().length === 0) {
239
+ fail(
240
+ 'malformed-explicit-binding',
241
+ `${EXPLAIN_COMMAND}{${key}} at offset ${start} has empty {latex}.`,
242
+ { key, start },
243
+ );
244
+ }
245
+
246
+ calls.push({
247
+ key,
248
+ prefixEnd: latexStart,
249
+ callStart: start,
250
+ start: latexGroup.contentStart,
251
+ end: latexGroup.contentEnd,
252
+ token: latexGroup.content,
253
+ level: 'explicit',
254
+ match: 'explicit',
255
+ });
256
+ }
257
+
258
+ return calls;
259
+ }
260
+
261
+ /** Blank out the `\explain{key}{` prefixes so KaTeX can parse what they wrap. */
262
+ function sourceWithoutExplainPrefixes(latex, calls) {
263
+ if (calls.length === 0) return latex;
264
+ const characters = [...latex];
265
+ for (const call of calls) {
266
+ for (let index = call.callStart; index < call.prefixEnd; index++) {
267
+ if (!/\s/.test(characters[index])) characters[index] = ' ';
268
+ }
269
+ }
270
+ return characters.join('');
271
+ }
272
+
273
+ // --- structural signatures --------------------------------------------
274
+
275
+ function signatureParts(node) {
276
+ if (!isNode(node)) return [];
277
+
278
+ if (node.type === 'ordgroup') return signatureValue(node.body);
279
+ if (node.type === 'font' && node.font === 'mathrm') {
280
+ return signatureValue(node.body);
281
+ }
282
+ if (node.type === 'mathord' || node.type === 'textord') {
283
+ return [`ord:${node.text}`];
284
+ }
285
+ if (node.type === 'atom')
286
+ return [`atom:${node.family ?? ''}:${node.text ?? ''}`];
287
+ if (node.type === 'spacing') return [`space:${node.text ?? ''}`];
288
+ if (node.type === 'op' || node.type === 'operatorname') {
289
+ return [`operator:${node.name ?? node.type}`];
290
+ }
291
+ if (node.type === 'text')
292
+ return [`text:${signatureValue(node.body).join('|')}`];
293
+ if (node.type === 'supsub') {
294
+ return [
295
+ `supsub(${signatureValue(node.base).join('|')})` +
296
+ `_(${signatureValue(node.sub).join('|')})` +
297
+ `^(${signatureValue(node.sup).join('|')})`,
298
+ ];
299
+ }
300
+
301
+ const primitive = [];
302
+ for (const key of ['family', 'font', 'label', 'name', 'text']) {
303
+ if (typeof node[key] === 'string') primitive.push(`${key}=${node[key]}`);
304
+ }
305
+ const children = [];
306
+ for (const key of SOURCE_CHILD_KEYS) {
307
+ if (node[key] !== undefined && node[key] !== null) {
308
+ children.push(`${key}=[${signatureValue(node[key]).join('|')}]`);
309
+ }
310
+ }
311
+ return [`${node.type}{${primitive.join(',')}}(${children.join(',')})`];
312
+ }
313
+
314
+ function signatureValue(value) {
315
+ if (Array.isArray(value)) return value.flatMap(signatureValue);
316
+ return signatureParts(value);
317
+ }
318
+
319
+ function sequenceSignature(nodes) {
320
+ return nodes.flatMap(signatureParts).join('|');
321
+ }
322
+
323
+ // --- source spans -----------------------------------------------------
324
+
325
+ function nodeSpan(node) {
326
+ let start = Number.POSITIVE_INFINITY;
327
+ let end = Number.NEGATIVE_INFINITY;
328
+ const visited = new Set();
329
+
330
+ function visit(value) {
331
+ if (!value || typeof value !== 'object' || visited.has(value)) return;
332
+ visited.add(value);
333
+ if (
334
+ value.loc &&
335
+ Number.isInteger(value.loc.start) &&
336
+ Number.isInteger(value.loc.end)
337
+ ) {
338
+ start = Math.min(start, value.loc.start);
339
+ end = Math.max(end, value.loc.end);
340
+ }
341
+ if (Array.isArray(value)) {
342
+ for (const child of value) visit(child);
343
+ return;
344
+ }
345
+ if (!isNode(value)) return;
346
+ for (const key of SOURCE_CHILD_KEYS) visit(value[key]);
347
+ }
348
+
349
+ visit(node);
350
+ return Number.isFinite(start) && Number.isFinite(end)
351
+ ? { start, end }
352
+ : undefined;
353
+ }
354
+
355
+ function sequenceSpan(nodes) {
356
+ const spans = nodes.map(nodeSpan).filter(Boolean);
357
+ if (spans.length === 0) return undefined;
358
+ return {
359
+ start: Math.min(...spans.map((span) => span.start)),
360
+ end: Math.max(...spans.map((span) => span.end)),
361
+ };
362
+ }
363
+
364
+ function childValues(node) {
365
+ const children = [];
366
+ for (const key of SOURCE_CHILD_KEYS) {
367
+ const value = node[key];
368
+ if (value !== undefined && value !== null) children.push(value);
369
+ }
370
+ return children;
371
+ }
372
+
373
+ function isIdentifierNode(node) {
374
+ if (node.type !== 'mathord' && node.type !== 'textord') return false;
375
+ if (typeof node.text !== 'string') return false;
376
+ if (/^[0-9.,]+$/u.test(node.text)) return false;
377
+ return /^(?:\p{L}|\\[A-Za-z]+)$/u.test(node.text);
378
+ }
379
+
380
+ const contains = (outer, inner) =>
381
+ outer.start <= inner.start && inner.end <= outer.end;
382
+ const sameSpan = (left, right) =>
383
+ left.start === right.start && left.end === right.end;
384
+
385
+ /**
386
+ * Walk the parse tree once, collecting: every maximal run of sibling nodes
387
+ * (`containers`, where a canonical form is matched), every identifier atom with
388
+ * its source span, and the spans of every sub/superscript (so a symbol nested
389
+ * in a script can still be marked).
390
+ */
391
+ function collectSourceStructure(tree) {
392
+ const containers = [];
393
+ const identifiers = [];
394
+ const scriptSpans = [];
395
+ const seenContainers = new Set();
396
+ const seenIdentifiers = new Set();
397
+
398
+ function addContainer(nodes) {
399
+ const usable = nodes.filter(isNode);
400
+ if (usable.length === 0) return;
401
+ const span = sequenceSpan(usable);
402
+ if (!span) return;
403
+ const identity = `${span.start}:${span.end}:${usable.length}:${sequenceSignature(usable)}`;
404
+ if (seenContainers.has(identity)) return;
405
+ seenContainers.add(identity);
406
+ containers.push(usable);
407
+ }
408
+
409
+ function visitValue(value, ignored = false) {
410
+ if (Array.isArray(value)) {
411
+ if (!ignored && value.every(isNode)) addContainer(value);
412
+ for (const child of value) visitValue(child, ignored);
413
+ return;
414
+ }
415
+ if (!isNode(value)) return;
416
+
417
+ const ignoredBody =
418
+ ignored ||
419
+ value.type === 'text' ||
420
+ value.type === 'op' ||
421
+ value.type === 'operatorname' ||
422
+ (value.type === 'font' && value.font === ROMAN_LABEL_FONT) ||
423
+ IGNORED_MATH_TOKENS.has(fontTokenText(value));
424
+
425
+ if (!ignored && isIdentifierNode(value)) {
426
+ const span = nodeSpan(value);
427
+ if (span && !seenIdentifiers.has(`${span.start}:${span.end}`)) {
428
+ seenIdentifiers.add(`${span.start}:${span.end}`);
429
+ identifiers.push({
430
+ node: value,
431
+ signature: sequenceSignature([value]),
432
+ ...span,
433
+ });
434
+ }
435
+ }
436
+
437
+ if (value.type === 'supsub') {
438
+ for (const script of [value.sub, value.sup]) {
439
+ const span = nodeSpan(script);
440
+ if (span) scriptSpans.push(span);
441
+ }
442
+ }
443
+
444
+ for (const child of childValues(value)) {
445
+ if (Array.isArray(child)) {
446
+ if (!ignoredBody && child.every(isNode)) addContainer(child);
447
+ for (const nested of child) visitValue(nested, ignoredBody);
448
+ } else if (isNode(child)) {
449
+ if (!ignoredBody) addContainer([child]);
450
+ visitValue(child, ignoredBody);
451
+ }
452
+ }
453
+ }
454
+
455
+ addContainer(tree);
456
+ visitValue(tree);
457
+ return { containers, identifiers, scriptSpans };
458
+ }
459
+
460
+ // --- base-glyph signatures ------------------------------------------
461
+
462
+ function identifierBaseSignature(node) {
463
+ if (isIdentifierNode(node)) return sequenceSignature([node]);
464
+ if (node?.type === 'supsub' && isIdentifierNode(node.base)) {
465
+ return sequenceSignature([node.base]);
466
+ }
467
+ return undefined;
468
+ }
469
+
470
+ /**
471
+ * The shorter page form a parameterized symbol also permits: the bare base
472
+ * glyph of `t_k` / `r_m` / `j^{(m)}` (one significant node) or the head of a
473
+ * function form `D(0,t)` / `V_0(1_t)` (head followed by `(`).
474
+ */
475
+ function canonicalBaseSignature(tree) {
476
+ const significant = tree.filter(
477
+ (node) => node.type !== 'spacing' && node.type !== 'ordgroup',
478
+ );
479
+ if (significant.length === 1) return identifierBaseSignature(significant[0]);
480
+ if (significant.length < 2) return undefined;
481
+ const [head, open] = significant;
482
+ if (open.type !== 'atom' || open.family !== 'open' || open.text !== '(') {
483
+ return undefined;
484
+ }
485
+ return identifierBaseSignature(head);
486
+ }
487
+
488
+ /**
489
+ * KaTeX puts source braces inside an ordgroup node's `loc`, but signatures
490
+ * treat ordgroups as transparent. Peel a fully-enclosing `{…}` off a matched
491
+ * span so `\frac{j}{m}` marks as `\frac{\explain{…}{j}}{…}`, not
492
+ * `\frac\explain{…}{{j}}…`.
493
+ */
494
+ function unwrapEnclosingGroupSpan(latex, span) {
495
+ let { start, end } = span;
496
+ while (latex[start] === '{') {
497
+ const group = readBracedGroup(latex, start);
498
+ if (!group || group.end !== end) break;
499
+ start = group.contentStart;
500
+ end = group.contentEnd;
501
+ }
502
+ return { start, end };
503
+ }
504
+
505
+ const FONT_WRAPPER_OPEN =
506
+ /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
507
+
508
+ /**
509
+ * KaTeX puts no `loc` on a `\mathbb{…}` font node, so a span derived from one
510
+ * begins at the inner letter. When a matched span sits exactly inside a
511
+ * single-letter font command, widen it to wrap the whole `\mathbb{E}` token so
512
+ * the injected `\explain{…}{…}` stays brace-balanced.
513
+ */
514
+ function expandFontWrapperSpan(latex, span) {
515
+ const open = latex.slice(0, span.start).match(FONT_WRAPPER_OPEN);
516
+ if (!open) return span;
517
+ const group = readBracedGroup(latex, span.start - 1);
518
+ if (!group || group.contentEnd - group.contentStart !== 1) return span;
519
+ return {
520
+ start: span.start - open[0].length,
521
+ end: Math.max(span.end, group.end),
522
+ };
523
+ }
524
+
525
+ /** A nested symbol may still be marked when it sits inside a script group. */
526
+ function isSafeScriptNesting(inner, outer, scriptSpans) {
527
+ if (inner.start === outer.start) return false;
528
+ return scriptSpans.some(
529
+ (script) => contains(outer, script) && contains(script, inner),
530
+ );
531
+ }
532
+
533
+ // --- glyph table + matching -----------------------------------------
534
+
535
+ /**
536
+ * Index the page glyph table by structural signature. Because one glyph carries
537
+ * one meaning per page, each signature maps to exactly one key; a genuine
538
+ * structural clash between two keys is a page authoring error and fails here.
539
+ */
540
+ function buildGlyphIndex(definitions) {
541
+ if (!Array.isArray(definitions)) {
542
+ fail(
543
+ 'invalid-definitions',
544
+ 'The page glyph table must be an array of {key, notation}.',
545
+ );
546
+ }
547
+
548
+ const byKey = new Map();
549
+ const bySignature = new Map();
550
+ const byBase = new Map();
551
+ const ambiguousBase = new Set();
552
+ let maxNodeLength = 0;
553
+
554
+ for (const [index, value] of definitions.entries()) {
555
+ const key = value?.key;
556
+ const notation = value?.notation;
557
+ if (!KEY_PATTERN.test(key ?? '')) {
558
+ fail(
559
+ 'invalid-key',
560
+ `Invalid notation key at glyph-table index ${index}.`,
561
+ {
562
+ index,
563
+ key,
564
+ },
565
+ );
566
+ }
567
+ if (typeof notation !== 'string' || notation.trim().length === 0) {
568
+ fail('invalid-notation', `Glyph "${key}" must have non-empty LaTeX.`, {
569
+ index,
570
+ key,
571
+ notation,
572
+ });
573
+ }
574
+ if (byKey.has(key)) {
575
+ fail(
576
+ 'duplicate-key',
577
+ `Page glyph key "${key}" is defined more than once.`,
578
+ {
579
+ key,
580
+ },
581
+ );
582
+ }
583
+
584
+ const tree = parseMath(notation, `notation "${key}"`);
585
+ const entry = { key, notation };
586
+ byKey.set(key, entry);
587
+
588
+ const signature = sequenceSignature(tree);
589
+ const hasIdentifier = collectSourceStructure(tree).identifiers.length > 0;
590
+ if (hasIdentifier && tree.length > 0) {
591
+ const clash = bySignature.get(signature);
592
+ if (clash && clash !== key) {
593
+ fail(
594
+ 'ambiguous-canonical-notation',
595
+ `Page glyphs "${clash}" and "${key}" have structurally identical LaTeX ("${notation}").`,
596
+ { left: clash, right: key },
597
+ );
598
+ }
599
+ bySignature.set(signature, key);
600
+ maxNodeLength = Math.max(maxNodeLength, tree.length);
601
+ }
602
+
603
+ const baseSignature = canonicalBaseSignature(tree);
604
+ if (baseSignature) {
605
+ if (byBase.has(baseSignature) && byBase.get(baseSignature) !== key) {
606
+ ambiguousBase.add(baseSignature);
607
+ } else {
608
+ byBase.set(baseSignature, key);
609
+ }
610
+ }
611
+ }
612
+
613
+ return { byKey, bySignature, byBase, ambiguousBase, maxNodeLength };
614
+ }
615
+
616
+ /** Bind whole canonical forms: greedy longest match, left to right, per run. */
617
+ function matchCanonicalForms(latex, containers, glyphs) {
618
+ const bindings = [];
619
+ const claimed = [];
620
+
621
+ for (const container of containers) {
622
+ let cursor = 0;
623
+ while (cursor < container.length) {
624
+ let step = 1;
625
+ const maxLength = Math.min(
626
+ glyphs.maxNodeLength,
627
+ container.length - cursor,
628
+ );
629
+ for (let length = maxLength; length >= 1; length--) {
630
+ const nodes = container.slice(cursor, cursor + length);
631
+ const key = glyphs.bySignature.get(sequenceSignature(nodes));
632
+ if (key === undefined) continue;
633
+ const rawSpan = sequenceSpan(nodes);
634
+ if (!rawSpan) continue;
635
+ const span = expandFontWrapperSpan(
636
+ latex,
637
+ unwrapEnclosingGroupSpan(latex, rawSpan),
638
+ );
639
+ step = length;
640
+ if (
641
+ claimed.some(
642
+ (existing) => sameSpan(existing, span) || contains(existing, span),
643
+ )
644
+ ) {
645
+ break;
646
+ }
647
+ bindings.push({
648
+ key,
649
+ ...span,
650
+ token: latex.slice(span.start, span.end),
651
+ level: 'scope',
652
+ match: 'canonical',
653
+ });
654
+ claimed.push(span);
655
+ break;
656
+ }
657
+ cursor += step;
658
+ }
659
+ }
660
+
661
+ return bindings;
662
+ }
663
+
664
+ /**
665
+ * Bind the remaining identifier atoms to a unique base glyph. An atom already
666
+ * inside a canonical match is skipped unless it sits in a script group; an atom
667
+ * whose base glyph names two keys fails (the author must reach for `\explain`).
668
+ */
669
+ function matchBaseGlyphs(latex, identifiers, canonical, glyphs, scriptSpans) {
670
+ const bindings = [];
671
+ for (const identifier of identifiers) {
672
+ // An atom inside a region the author wrapped in an explicit `\explain{key}{…}`
673
+ // is already accounted for by that call; never re-flag or re-bind it, even
674
+ // when it also sits in a script inside a larger canonical match. (To bind a
675
+ // nested glyph separately, wrap it in its own nested `\explain`.)
676
+ if (
677
+ canonical.some(
678
+ (binding) =>
679
+ binding.level === 'explicit' && contains(binding, identifier),
680
+ )
681
+ ) {
682
+ continue;
683
+ }
684
+
685
+ const cover = canonical.find((binding) => contains(binding, identifier));
686
+ if (cover && !isSafeScriptNesting(identifier, cover, scriptSpans)) continue;
687
+
688
+ const baseSignature = identifier.signature;
689
+ if (glyphs.ambiguousBase.has(baseSignature)) {
690
+ const token = latex.slice(identifier.start, identifier.end);
691
+ fail(
692
+ 'ambiguous-base-fallback',
693
+ `Identifier "${token}" at ${identifier.start}:${identifier.end} is the base glyph of more than one page symbol; wrap it in \\explain{key}{${token}}.`,
694
+ { token, start: identifier.start, end: identifier.end },
695
+ );
696
+ }
697
+ const key = glyphs.byBase.get(baseSignature);
698
+ if (key === undefined) continue;
699
+ bindings.push({
700
+ key,
701
+ start: identifier.start,
702
+ end: identifier.end,
703
+ token: latex.slice(identifier.start, identifier.end),
704
+ level: 'scope',
705
+ match: 'base',
706
+ });
707
+ }
708
+ return bindings;
709
+ }
710
+
711
+ /** Wrap every `level: 'scope'` binding in a `\explain{key}{…}` marker. */
712
+ function injectScopeBindings(latex, bindings) {
713
+ const scopeBindings = bindings.filter(({ level }) => level === 'scope');
714
+ const openings = new Map();
715
+ const closings = new Map();
716
+
717
+ const at = (map, index) => {
718
+ if (!map.has(index)) map.set(index, []);
719
+ return map.get(index);
720
+ };
721
+ for (const binding of scopeBindings) {
722
+ let preceding = binding.start - 1;
723
+ while (preceding >= 0 && /\s/.test(latex[preceding])) preceding -= 1;
724
+ const insertion = {
725
+ ...binding,
726
+ scriptGroup: latex[preceding] === '_' || latex[preceding] === '^',
727
+ };
728
+ at(openings, binding.start).push(insertion);
729
+ at(closings, binding.end).push(insertion);
730
+ }
731
+
732
+ let output = '';
733
+ for (let index = 0; index <= latex.length; index++) {
734
+ for (const binding of (closings.get(index) ?? []).sort(
735
+ (left, right) => right.start - left.start,
736
+ )) {
737
+ output += binding.scriptGroup ? '}}' : '}';
738
+ }
739
+ for (const binding of (openings.get(index) ?? []).sort(
740
+ (left, right) => right.end - left.end,
741
+ )) {
742
+ output += `${binding.scriptGroup ? '{' : ''}${EXPLAIN_COMMAND}{${binding.key}}{`;
743
+ }
744
+ if (index < latex.length) output += latex[index];
745
+ }
746
+ return output;
747
+ }
748
+
749
+ function unresolvedIdentifiers(latex, identifiers, bindings) {
750
+ return identifiers
751
+ .filter((identifier) => {
752
+ const token = latex.slice(identifier.start, identifier.end);
753
+ return (
754
+ !BASE_LIBRARY.has(token) &&
755
+ !bindings.some((binding) => contains(binding, identifier))
756
+ );
757
+ })
758
+ .map(({ start, end }) => {
759
+ // Report `\mathbb{E}`, not a bare `E`, so the fix hint points at the
760
+ // whole token the author has to wrap in `\explain`.
761
+ const wrapped = expandFontWrapperSpan(latex, { start, end });
762
+ return {
763
+ token: latex.slice(wrapped.start, wrapped.end),
764
+ start: wrapped.start,
765
+ end: wrapped.end,
766
+ };
767
+ })
768
+ .sort((left, right) => left.start - right.start || left.end - right.end);
769
+ }
770
+
771
+ /**
772
+ * Resolve and annotate one KaTeX expression against a page's flat glyph table.
773
+ * Source offsets in the result always refer to the original, unmodified LaTeX.
774
+ *
775
+ * @param {string} latex
776
+ * @param {Array<{key: string, notation: string}>} definitions
777
+ */
778
+ export function resolveMathGlyphs(latex, definitions) {
779
+ if (typeof latex !== 'string') {
780
+ fail('invalid-latex-input', 'Math source must be a string.');
781
+ }
782
+
783
+ const glyphs = buildGlyphIndex(definitions);
784
+ const explicitCalls = findExplainCalls(latex, glyphs.byKey);
785
+ const tree = parseMath(
786
+ sourceWithoutExplainPrefixes(latex, explicitCalls),
787
+ 'lesson math',
788
+ );
789
+ const { containers, identifiers, scriptSpans } = collectSourceStructure(tree);
790
+
791
+ const canonical = matchCanonicalForms(latex, containers, glyphs);
792
+ const covered = [...canonical, ...explicitCalls];
793
+ const base = matchBaseGlyphs(
794
+ latex,
795
+ identifiers,
796
+ covered,
797
+ glyphs,
798
+ scriptSpans,
799
+ );
800
+
801
+ const bindings = [...canonical, ...base, ...explicitCalls].sort(
802
+ (left, right) =>
803
+ left.start - right.start ||
804
+ right.end - left.end ||
805
+ (left.level === 'explicit' ? -1 : 1),
806
+ );
807
+
808
+ for (const [i, left] of bindings.entries()) {
809
+ for (const right of bindings.slice(i + 1)) {
810
+ if (sameSpan(left, right) && left.key !== right.key) {
811
+ fail(
812
+ 'ambiguous-span',
813
+ `Math span ${left.start}:${left.end} resolves to both "${left.key}" and "${right.key}".`,
814
+ { left, right },
815
+ );
816
+ }
817
+ }
818
+ }
819
+
820
+ return {
821
+ latex: injectScopeBindings(latex, bindings),
822
+ bindings: bindings.map(({ key, level, match, token }) => ({
823
+ key,
824
+ level,
825
+ match,
826
+ token,
827
+ })),
828
+ unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
829
+ };
830
+ }