explico 0.2.0 → 0.3.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 (73) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +16 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +4 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +77 -2
  10. package/dist/compiler/manifest.js +115 -18
  11. package/dist/compiler/resolution-report.d.ts +4 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +51 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +42 -11
  21. package/dist/reference/equations.mjs +58 -17
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/keyed-float-validate.d.ts +67 -0
  25. package/dist/reference/keyed-float-validate.js +110 -0
  26. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  27. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  28. package/dist/reference/math-glyphs.d.mts +18 -0
  29. package/dist/reference/math-glyphs.mjs +92 -0
  30. package/dist/reference/remark-notation.d.mts +12 -0
  31. package/dist/reference/remark-notation.mjs +199 -9
  32. package/dist/reference/tables-validate.d.ts +21 -0
  33. package/dist/reference/tables-validate.js +13 -0
  34. package/dist/reference/units-vocab.js +19 -1
  35. package/docs/VERSIONING.md +66 -0
  36. package/docs/authoring/README.md +96 -0
  37. package/docs/authoring/agents.md +63 -0
  38. package/docs/authoring/migrating.md +107 -0
  39. package/docs/authoring/notation.md +296 -0
  40. package/docs/authoring/versioning.md +116 -0
  41. package/docs/authoring/visualizations.md +195 -0
  42. package/package.json +20 -5
  43. package/src/compiler/cli.ts +49 -0
  44. package/src/compiler/collections.ts +12 -3
  45. package/src/compiler/course-config.ts +54 -0
  46. package/src/compiler/knowledge-version.ts +157 -0
  47. package/src/compiler/manifest.ts +239 -17
  48. package/src/components/CurriculumMap.astro +27 -0
  49. package/src/components/diagram/Diagram.astro +317 -0
  50. package/src/components/examples/CompactExamples.astro +1 -1
  51. package/src/components/figure/Figure.astro +104 -0
  52. package/src/components/notation/render-notation-math.ts +13 -2
  53. package/src/components/reference/FloatRefLayer.astro +671 -0
  54. package/src/components/starlight/AboutPanel.astro +255 -0
  55. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  56. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  57. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  58. package/src/components/starlight/LessonFooter.astro +4 -2
  59. package/src/components/table/Table.astro +104 -0
  60. package/src/reference/base-operators.mjs +103 -0
  61. package/src/reference/consistency.ts +83 -2
  62. package/src/reference/diagram-layout.ts +246 -0
  63. package/src/reference/equations-validate.ts +6 -1
  64. package/src/reference/equations.mjs +58 -17
  65. package/src/reference/figures-validate.ts +40 -0
  66. package/src/reference/keyed-float-validate.ts +207 -0
  67. package/src/reference/keyed-jsx-floats.mjs +167 -0
  68. package/src/reference/math-glyphs.mjs +92 -0
  69. package/src/reference/remark-notation.mjs +199 -9
  70. package/src/reference/tables-validate.ts +40 -0
  71. package/src/reference/units-vocab.ts +18 -0
  72. package/src/styles/global.css +115 -7
  73. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -19,15 +19,23 @@
19
19
  * (f) weak-local a `notation.local` symbol used only in its
20
20
  * own defining equation and prose
21
21
  * (g) notation-source-locator every notation `sources` entry has a locator
22
+ * (h) operator-explained a named operator a lesson uses is on the
23
+ * shipped/course operator allowlist or
24
+ * carries a notation entry (rule N8)
22
25
  *
23
26
  * Check (d) — the committed per-lesson resolution report — lives in
24
27
  * `src/content/resolution-report.ts`.
25
28
  */
26
29
 
27
- import type { CourseConventions } from '../compiler/course-config';
30
+ import type {
31
+ CourseConventions,
32
+ CourseOperators,
33
+ } from '../compiler/course-config';
28
34
  import type { CurriculumCatalog } from '../curriculum/validation';
29
35
  import type { NotationSpec } from '../compiler/collections';
30
36
  import type { LintIgnore } from './lint-ignore';
37
+ import { effectiveOperatorAllowlist } from './base-operators.mjs';
38
+ import { collectOperators } from './math-glyphs.mjs';
31
39
  import { offVocabularyTokens } from './units-vocab';
32
40
  import type {
33
41
  NotationDefinitionRecord,
@@ -45,7 +53,8 @@ export type ConsistencyCode =
45
53
  | 'notation-source-locator'
46
54
  | 'gloss-wants-promoting'
47
55
  | 'card-wants-demoting'
48
- | 'gloss-name-shape';
56
+ | 'gloss-name-shape'
57
+ | 'operator-explained';
49
58
 
50
59
  export interface ConsistencyDiagnostic {
51
60
  readonly code: ConsistencyCode;
@@ -68,6 +77,8 @@ export interface ConsistencyInput {
68
77
  readonly lintIgnore: LintIgnore;
69
78
  /** Convention keys this course anchors on (from `content/course.config.ts`). */
70
79
  readonly conventions: CourseConventions;
80
+ /** This course's edits to the named-operator baseline (rule N8). */
81
+ readonly operators?: CourseOperators;
71
82
  }
72
83
 
73
84
  const CONVENTION_RE =
@@ -472,6 +483,74 @@ function checkGlossNameShape(
472
483
  }
473
484
  }
474
485
 
486
+ /**
487
+ * A named operator a lesson uses is either universal — on the shipped
488
+ * `BASE_OPERATORS` baseline plus this course's `operators.allow`, minus its
489
+ * `operators.deny` — or it is explained: a notation card or `notation.local`
490
+ * entry the lesson binds with `[[key]]` whose `latex` is that operator, so the
491
+ * reader can hover it. Anything else (`\operatorname{Var}` with no entry, or a
492
+ * baseline operator the course chose to `deny`) is a finding. Rule N8.
493
+ *
494
+ * The glyph resolver skips operator bodies whole, so this is the only check
495
+ * that looks at them; it reads `collectOperators`, which parses with the same
496
+ * pinned KaTeX as the completeness gate.
497
+ */
498
+ function checkOperatorExplained(
499
+ input: ConsistencyInput,
500
+ out: ConsistencyDiagnostic[],
501
+ ): void {
502
+ const allowlist = effectiveOperatorAllowlist(input.operators);
503
+ const byId = new Map<string, NotationDefinitionRecord>(
504
+ input.registry.definitions.map((definition) => [definition.id, definition]),
505
+ );
506
+ const bindingsByLesson = new Map(
507
+ input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
508
+ );
509
+
510
+ for (const lesson of input.notationInput.lessons) {
511
+ // Operators the reader is shown on this page: the lesson's own
512
+ // `notation.local` entries, plus every shared card it binds with `[[key]]`.
513
+ const explained = new Set<string>();
514
+ const noteEntryOperators = (latex: string): void => {
515
+ for (const operator of collectOperators(latex)) {
516
+ explained.add(operator.name);
517
+ }
518
+ };
519
+ for (const definition of lesson.localDefinitions) {
520
+ noteEntryOperators(definition.notation);
521
+ }
522
+ for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
523
+ const definition = byId.get(binding.definitionId);
524
+ if (definition) noteEntryOperators(definition.notation);
525
+ }
526
+
527
+ const reported = new Set<string>();
528
+ for (const span of mathSpans(lesson.body)) {
529
+ for (const operator of collectOperators(span.latex)) {
530
+ if (allowlist.has(operator.name)) continue;
531
+ if (explained.has(operator.name)) continue;
532
+ if (reported.has(operator.name)) continue;
533
+ reported.add(operator.name);
534
+ if (input.lintIgnore.matches('operator-explained', operator.name)) {
535
+ continue;
536
+ }
537
+ const display =
538
+ operator.source === 'operatorname'
539
+ ? `\\operatorname{${operator.name}}`
540
+ : `\\${operator.name}`;
541
+ out.push({
542
+ code: 'operator-explained',
543
+ severity: 'warning',
544
+ lessonId: lesson.lessonId,
545
+ file: lesson.source.file,
546
+ glyph: operator.name,
547
+ message: `${lesson.lessonId}: operator ${JSON.stringify(display)} is neither on the course operator allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(display)}) introduced with [[key]], or add ${JSON.stringify(operator.name)} to operators.allow in course.config.ts`,
548
+ });
549
+ }
550
+ }
551
+ }
552
+ }
553
+
475
554
  const CHECKS: ((
476
555
  input: ConsistencyInput,
477
556
  out: ConsistencyDiagnostic[],
@@ -485,6 +564,7 @@ const CHECKS: ((
485
564
  checkGlossWantsPromoting,
486
565
  checkCardWantsDemoting,
487
566
  checkGlossNameShape,
567
+ checkOperatorExplained,
488
568
  ];
489
569
 
490
570
  /**
@@ -522,6 +602,7 @@ export function consistencyCounts(
522
602
  'gloss-wants-promoting': 0,
523
603
  'card-wants-demoting': 0,
524
604
  'gloss-name-shape': 0,
605
+ 'operator-explained': 0,
525
606
  } satisfies Record<ConsistencyCode, number>;
526
607
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
527
608
  return counts;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Build-time graph layout for the `Diagram` component.
3
+ *
4
+ * Wraps `@dagrejs/dagre` — a pure layout algorithm, no DOM — so node
5
+ * positions and edge routes are computed once at build time, the same way
6
+ * `renderLabMath` and the notation formulas are: static output, no runtime
7
+ * layout library shipped to the reader. `Diagram.astro` turns the result into
8
+ * SVG; this module only computes geometry and is deliberately framework-free
9
+ * so it is unit-testable without KaTeX, Astro, or a browser.
10
+ *
11
+ * Box sizes are not measured from a real render (there is no DOM at build
12
+ * time) — `estimateBoxSize` is a deliberately generous character-count
13
+ * heuristic. It is meant to avoid overlap and clipping in the common case,
14
+ * not to be pixel-accurate; a diagram author can always pass an explicit
15
+ * `width`/`height` on a node for a wide equation.
16
+ */
17
+ import dagre from '@dagrejs/dagre';
18
+ import type {
19
+ EdgeLabel as DagreEdgeLabel,
20
+ GraphLabel as DagreGraphLabel,
21
+ NodeLabel as DagreNodeLabel,
22
+ } from '@dagrejs/dagre';
23
+
24
+ export interface DiagramNodeInput {
25
+ readonly id: string;
26
+ readonly width: number;
27
+ readonly height: number;
28
+ }
29
+
30
+ export interface DiagramEdgeInput {
31
+ readonly from: string;
32
+ readonly to: string;
33
+ }
34
+
35
+ export interface DiagramLayoutOptions {
36
+ /** Top-to-bottom (default) or left-to-right. */
37
+ readonly direction?: 'TB' | 'LR';
38
+ readonly nodeSeparation?: number;
39
+ readonly rankSeparation?: number;
40
+ /** Uniform padding added around the computed bounding box. */
41
+ readonly padding?: number;
42
+ }
43
+
44
+ export interface Point {
45
+ readonly x: number;
46
+ readonly y: number;
47
+ }
48
+
49
+ export interface LaidOutNode {
50
+ readonly id: string;
51
+ /** Top-left corner, not the center dagre itself works in. */
52
+ readonly x: number;
53
+ readonly y: number;
54
+ readonly width: number;
55
+ readonly height: number;
56
+ }
57
+
58
+ export interface LaidOutEdge {
59
+ readonly from: string;
60
+ readonly to: string;
61
+ readonly points: readonly Point[];
62
+ }
63
+
64
+ export interface DiagramLayout {
65
+ readonly nodes: readonly LaidOutNode[];
66
+ readonly edges: readonly LaidOutEdge[];
67
+ /** The SVG canvas size a caller should use as the `viewBox`. */
68
+ readonly width: number;
69
+ readonly height: number;
70
+ }
71
+
72
+ /**
73
+ * Lay out a small directed graph. Node ids must be unique; an edge naming an
74
+ * undeclared node fails loudly rather than silently dropping it (dagre itself
75
+ * would otherwise create a phantom zero-size node).
76
+ */
77
+ export function layoutDiagram(
78
+ nodes: readonly DiagramNodeInput[],
79
+ edges: readonly DiagramEdgeInput[],
80
+ options: DiagramLayoutOptions = {},
81
+ ): DiagramLayout {
82
+ const ids = new Set<string>();
83
+ for (const node of nodes) {
84
+ if (ids.has(node.id)) {
85
+ throw new Error(`Diagram declares node "${node.id}" more than once.`);
86
+ }
87
+ if (!(node.width > 0) || !(node.height > 0)) {
88
+ throw new Error(
89
+ `Diagram node "${node.id}" must have a positive width and height.`,
90
+ );
91
+ }
92
+ ids.add(node.id);
93
+ }
94
+ for (const edge of edges) {
95
+ if (!ids.has(edge.from)) {
96
+ throw new Error(`Diagram edge references unknown node "${edge.from}".`);
97
+ }
98
+ if (!ids.has(edge.to)) {
99
+ throw new Error(`Diagram edge references unknown node "${edge.to}".`);
100
+ }
101
+ }
102
+
103
+ const graph = new dagre.graphlib.Graph<
104
+ DagreGraphLabel,
105
+ DagreNodeLabel,
106
+ DagreEdgeLabel
107
+ >();
108
+ graph.setGraph({
109
+ rankdir: options.direction ?? 'TB',
110
+ nodesep: options.nodeSeparation ?? 24,
111
+ ranksep: options.rankSeparation ?? 48,
112
+ marginx: 0,
113
+ marginy: 0,
114
+ });
115
+ graph.setDefaultEdgeLabel(() => ({}));
116
+
117
+ for (const node of nodes) {
118
+ graph.setNode(node.id, { width: node.width, height: node.height });
119
+ }
120
+ for (const edge of edges) {
121
+ graph.setEdge(edge.from, edge.to);
122
+ }
123
+
124
+ dagre.layout(graph);
125
+
126
+ const centeredNodes = nodes.map((node) => {
127
+ const label = graph.node(node.id);
128
+ const cx = label.x ?? 0;
129
+ const cy = label.y ?? 0;
130
+ return {
131
+ id: node.id,
132
+ x: cx - node.width / 2,
133
+ y: cy - node.height / 2,
134
+ width: node.width,
135
+ height: node.height,
136
+ };
137
+ });
138
+
139
+ const rawEdges = edges.map((edge) => {
140
+ const label = graph.edge(edge.from, edge.to) ?? {};
141
+ return {
142
+ from: edge.from,
143
+ to: edge.to,
144
+ points: (label.points ?? []).map((point) => ({
145
+ x: point.x,
146
+ y: point.y,
147
+ })),
148
+ };
149
+ });
150
+
151
+ // dagre does not guarantee the layout starts at (0, 0); compute the real
152
+ // bounding box from node boxes and edge routing points, then shift
153
+ // everything so the canvas starts at `padding`.
154
+ const padding = options.padding ?? 12;
155
+ const xs = [
156
+ ...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
157
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.x)),
158
+ ];
159
+ const ys = [
160
+ ...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
161
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.y)),
162
+ ];
163
+ const minX = xs.length > 0 ? Math.min(...xs) : 0;
164
+ const minY = ys.length > 0 ? Math.min(...ys) : 0;
165
+ const maxX = xs.length > 0 ? Math.max(...xs) : 0;
166
+ const maxY = ys.length > 0 ? Math.max(...ys) : 0;
167
+ const shiftX = padding - minX;
168
+ const shiftY = padding - minY;
169
+
170
+ return {
171
+ nodes: centeredNodes.map((node) => ({
172
+ ...node,
173
+ x: node.x + shiftX,
174
+ y: node.y + shiftY,
175
+ })),
176
+ edges: rawEdges.map((edge) => ({
177
+ ...edge,
178
+ points: edge.points.map((point) => ({
179
+ x: point.x + shiftX,
180
+ y: point.y + shiftY,
181
+ })),
182
+ })),
183
+ width: maxX - minX + padding * 2,
184
+ height: maxY - minY + padding * 2,
185
+ };
186
+ }
187
+
188
+ // --- box-size estimation (no DOM at build time) --------------------------
189
+
190
+ export interface BoxSizeEstimate {
191
+ readonly width: number;
192
+ readonly height: number;
193
+ }
194
+
195
+ const DEFAULT_MAX_WIDTH = 220;
196
+ const MIN_WIDTH = 90;
197
+ const PADDING_X = 20;
198
+ const PADDING_Y = 16;
199
+ const LINE_HEIGHT = 22;
200
+ /** Generous per-visible-character width estimates, in px, at diagram scale. */
201
+ const CHAR_WIDTH: Record<'text' | 'formula', number> = {
202
+ text: 7.2,
203
+ formula: 11,
204
+ };
205
+
206
+ /**
207
+ * A LaTeX command name (`\theta`, `\cdot`, …) renders as roughly one glyph,
208
+ * not one character per letter of its name; braces are structural, not
209
+ * visible. Collapsing both keeps the estimate from reading a formula as far
210
+ * wider than it will actually render.
211
+ */
212
+ function formulaVisibleLength(latex: string): number {
213
+ const collapsed = latex
214
+ .replace(/\\[a-zA-Z]+/g, 'x')
215
+ .replace(/[{}$]/g, '')
216
+ .trim();
217
+ return Math.max(1, collapsed.length);
218
+ }
219
+
220
+ /**
221
+ * Estimate a diagram node's box from its label text (or raw, pre-resolution
222
+ * LaTeX for a formula node). Deliberately generous: it is meant to avoid
223
+ * clipping, not to match a real render pixel-for-pixel. Pass an explicit
224
+ * `width`/`height` on the node to override for a label this estimate gets
225
+ * badly wrong.
226
+ */
227
+ export function estimateBoxSize(
228
+ content: string,
229
+ kind: 'text' | 'formula',
230
+ options: { readonly maxWidth?: number } = {},
231
+ ): BoxSizeEstimate {
232
+ const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
233
+ const visibleLength =
234
+ kind === 'formula' ? formulaVisibleLength(content) : content.trim().length || 1;
235
+ const charWidth = CHAR_WIDTH[kind];
236
+
237
+ const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
238
+ const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
239
+
240
+ const usableWidth = width - PADDING_X * 2;
241
+ const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
242
+ const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
243
+ const height = lines * LINE_HEIGHT + PADDING_Y * 2;
244
+
245
+ return { width: Math.round(width), height: Math.round(height) };
246
+ }
@@ -53,6 +53,11 @@ export interface EquationLessonInput {
53
53
  readonly slug: string;
54
54
  readonly file: string;
55
55
  readonly body: string;
56
+ /**
57
+ * The lesson's 1-based ordinal in the course. When set, equations are
58
+ * numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
59
+ */
60
+ readonly chapter?: number;
56
61
  }
57
62
 
58
63
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -90,7 +95,7 @@ export function validateEquations(
90
95
  )) {
91
96
  const seen = new Set<string>();
92
97
  const table: Record<string, string> = {};
93
- for (const raw of scanEquationLabels(lesson.body) as {
98
+ for (const raw of scanEquationLabels(lesson.body, lesson.chapter) as {
94
99
  key: string;
95
100
  number: string;
96
101
  section: number;
@@ -1,9 +1,13 @@
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
- * `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
6
- * fourth display equation under the second section). An author additionally
4
+ * Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
5
+ * from appearance order. When the caller supplies a `chapter` (the lesson's
6
+ * ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
7
+ * every display equation in the lesson and `##` headings do not affect it
8
+ * (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
9
+ * the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
10
+ * (used by hand-built test trees and non-lesson pages). An author additionally
7
11
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
8
12
  * block (inside math, so the `:` is safe and the form matches amsmath) when
9
13
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -58,12 +62,28 @@ function equationNumber(section, index) {
58
62
  return section > 0 ? `${section}.${index}` : String(index);
59
63
  }
60
64
 
65
+ /**
66
+ * The visible number for one display equation. With `chapter` (the lesson's
67
+ * course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
68
+ * are ignored; without it, the legacy `##`-section scheme.
69
+ */
70
+ export function displayEquationNumber({
71
+ chapter,
72
+ section,
73
+ indexInSection,
74
+ indexInLesson,
75
+ }) {
76
+ return chapter == null
77
+ ? equationNumber(section, indexInSection)
78
+ : `${chapter}.${indexInLesson}`;
79
+ }
80
+
61
81
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
62
82
  export function equationAnchorId({ key, number }) {
63
83
  return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
64
84
  }
65
85
 
66
- function stripFencedCode(markdown) {
86
+ export function stripFencedCode(markdown) {
67
87
  return String(markdown)
68
88
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
69
89
  .replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
@@ -76,22 +96,28 @@ function stripFencedCode(markdown) {
76
96
  * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
77
97
  * outermost match wins, so nested components are blanked in one pass;
78
98
  * newlines are kept so line/section structure is unchanged.
99
+ *
100
+ * `keep`, when given, leaves a component of that name untouched instead of
101
+ * blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
102
+ * component while still finding `<Table>`/`<Figure>` tags at the top level.
79
103
  */
80
- function stripJsxComponents(markdown) {
104
+ export function stripJsxComponents(markdown, keep) {
81
105
  return String(markdown).replace(
82
106
  /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
83
- (block) => block.replace(/[^\n]/g, ' '),
107
+ (block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
84
108
  );
85
109
  }
86
110
 
87
111
  /**
88
112
  * Every keyed display equation in a Markdown body, in appearance order, with
89
- * its assigned number. `$$…$$` blocks only; `##` headings between them advance
90
- * the section counter. Every display block advances the in-section index, so a
91
- * keyed equation's number reflects its position among *all* display equations,
92
- * not only the keyed ones.
113
+ * its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
114
+ * course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
115
+ * display block in the lesson; without it, `##` headings advance a section
116
+ * counter and the number is `‹section›.‹indexInSection›`. Either way every
117
+ * display block advances the index, so a keyed equation's number reflects its
118
+ * position among *all* display equations, not only the keyed ones.
93
119
  */
94
- export function scanEquationLabels(markdown) {
120
+ export function scanEquationLabels(markdown, chapter) {
95
121
  const text = stripJsxComponents(stripFencedCode(markdown));
96
122
  const tokens = [];
97
123
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
@@ -113,6 +139,7 @@ export function scanEquationLabels(markdown) {
113
139
  const labels = [];
114
140
  let section = 0;
115
141
  let indexInSection = 0;
142
+ let indexInLesson = 0;
116
143
  for (const token of tokens) {
117
144
  if (token.kind === 'section') {
118
145
  section += 1;
@@ -120,10 +147,16 @@ export function scanEquationLabels(markdown) {
120
147
  continue;
121
148
  }
122
149
  indexInSection += 1;
150
+ indexInLesson += 1;
123
151
  if (token.key == null) continue;
124
152
  labels.push({
125
153
  key: token.key,
126
- number: equationNumber(section, indexInSection),
154
+ number: displayEquationNumber({
155
+ chapter,
156
+ section,
157
+ indexInSection,
158
+ indexInLesson,
159
+ }),
127
160
  section,
128
161
  indexInSection,
129
162
  });
@@ -135,12 +168,14 @@ export function scanEquationLabels(markdown) {
135
168
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
136
169
  * walk the tree in document order, advancing the section counter on every
137
170
  * depth-2 heading and emitting a record for every `math` node. `key` is the
138
- * `\label{eq:…}` payload when present, else `null`.
171
+ * `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
172
+ * same way as in `scanEquationLabels`.
139
173
  */
140
- export function collectDisplayEquations(tree) {
174
+ export function collectDisplayEquations(tree, chapter) {
141
175
  const equations = [];
142
176
  let section = 0;
143
177
  let indexInSection = 0;
178
+ let indexInLesson = 0;
144
179
 
145
180
  // Only top-level blocks: a `$$…$$` nested in an MDX component (a
146
181
  // `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
@@ -155,11 +190,17 @@ export function collectDisplayEquations(tree) {
155
190
  }
156
191
  if (node.type === 'math') {
157
192
  indexInSection += 1;
193
+ indexInLesson += 1;
158
194
  EQ_LABEL_PATTERN.lastIndex = 0;
159
195
  const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
160
196
  equations.push({
161
197
  key: label ? label[1] : null,
162
- number: equationNumber(section, indexInSection),
198
+ number: displayEquationNumber({
199
+ chapter,
200
+ section,
201
+ indexInSection,
202
+ indexInLesson,
203
+ }),
163
204
  section,
164
205
  indexInSection,
165
206
  node,
@@ -173,8 +214,8 @@ export function collectDisplayEquations(tree) {
173
214
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
174
215
  * `scanEquationLabels`. Numbers reflect position among all display equations.
175
216
  */
176
- export function collectEquationLabels(tree) {
177
- return collectDisplayEquations(tree).filter(
217
+ export function collectEquationLabels(tree, chapter) {
218
+ return collectDisplayEquations(tree, chapter).filter(
178
219
  (equation) => equation.key != null,
179
220
  );
180
221
  }
@@ -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
+ }