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
@@ -0,0 +1,63 @@
1
+ interface DiagramNodeInput {
2
+ readonly id: string;
3
+ readonly width: number;
4
+ readonly height: number;
5
+ }
6
+ interface DiagramEdgeInput {
7
+ readonly from: string;
8
+ readonly to: string;
9
+ }
10
+ interface DiagramLayoutOptions {
11
+ /** Top-to-bottom (default) or left-to-right. */
12
+ readonly direction?: 'TB' | 'LR';
13
+ readonly nodeSeparation?: number;
14
+ readonly rankSeparation?: number;
15
+ /** Uniform padding added around the computed bounding box. */
16
+ readonly padding?: number;
17
+ }
18
+ interface Point {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ }
22
+ interface LaidOutNode {
23
+ readonly id: string;
24
+ /** Top-left corner, not the center dagre itself works in. */
25
+ readonly x: number;
26
+ readonly y: number;
27
+ readonly width: number;
28
+ readonly height: number;
29
+ }
30
+ interface LaidOutEdge {
31
+ readonly from: string;
32
+ readonly to: string;
33
+ readonly points: readonly Point[];
34
+ }
35
+ interface DiagramLayout {
36
+ readonly nodes: readonly LaidOutNode[];
37
+ readonly edges: readonly LaidOutEdge[];
38
+ /** The SVG canvas size a caller should use as the `viewBox`. */
39
+ readonly width: number;
40
+ readonly height: number;
41
+ }
42
+ /**
43
+ * Lay out a small directed graph. Node ids must be unique; an edge naming an
44
+ * undeclared node fails loudly rather than silently dropping it (dagre itself
45
+ * would otherwise create a phantom zero-size node).
46
+ */
47
+ declare function layoutDiagram(nodes: readonly DiagramNodeInput[], edges: readonly DiagramEdgeInput[], options?: DiagramLayoutOptions): DiagramLayout;
48
+ interface BoxSizeEstimate {
49
+ readonly width: number;
50
+ readonly height: number;
51
+ }
52
+ /**
53
+ * Estimate a diagram node's box from its label text (or raw, pre-resolution
54
+ * LaTeX for a formula node). Deliberately generous: it is meant to avoid
55
+ * clipping, not to match a real render pixel-for-pixel. Pass an explicit
56
+ * `width`/`height` on the node to override for a label this estimate gets
57
+ * badly wrong.
58
+ */
59
+ declare function estimateBoxSize(content: string, kind: 'text' | 'formula', options?: {
60
+ readonly maxWidth?: number;
61
+ }): BoxSizeEstimate;
62
+
63
+ export { type BoxSizeEstimate, type DiagramEdgeInput, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodeInput, type LaidOutEdge, type LaidOutNode, type Point, estimateBoxSize, layoutDiagram };
@@ -0,0 +1,122 @@
1
+ import dagre from "@dagrejs/dagre";
2
+ function layoutDiagram(nodes, edges, options = {}) {
3
+ const ids = /* @__PURE__ */ new Set();
4
+ for (const node of nodes) {
5
+ if (ids.has(node.id)) {
6
+ throw new Error(`Diagram declares node "${node.id}" more than once.`);
7
+ }
8
+ if (!(node.width > 0) || !(node.height > 0)) {
9
+ throw new Error(
10
+ `Diagram node "${node.id}" must have a positive width and height.`
11
+ );
12
+ }
13
+ ids.add(node.id);
14
+ }
15
+ for (const edge of edges) {
16
+ if (!ids.has(edge.from)) {
17
+ throw new Error(`Diagram edge references unknown node "${edge.from}".`);
18
+ }
19
+ if (!ids.has(edge.to)) {
20
+ throw new Error(`Diagram edge references unknown node "${edge.to}".`);
21
+ }
22
+ }
23
+ const graph = new dagre.graphlib.Graph();
24
+ graph.setGraph({
25
+ rankdir: options.direction ?? "TB",
26
+ nodesep: options.nodeSeparation ?? 24,
27
+ ranksep: options.rankSeparation ?? 48,
28
+ marginx: 0,
29
+ marginy: 0
30
+ });
31
+ graph.setDefaultEdgeLabel(() => ({}));
32
+ for (const node of nodes) {
33
+ graph.setNode(node.id, { width: node.width, height: node.height });
34
+ }
35
+ for (const edge of edges) {
36
+ graph.setEdge(edge.from, edge.to);
37
+ }
38
+ dagre.layout(graph);
39
+ const centeredNodes = nodes.map((node) => {
40
+ const label = graph.node(node.id);
41
+ const cx = label.x ?? 0;
42
+ const cy = label.y ?? 0;
43
+ return {
44
+ id: node.id,
45
+ x: cx - node.width / 2,
46
+ y: cy - node.height / 2,
47
+ width: node.width,
48
+ height: node.height
49
+ };
50
+ });
51
+ const rawEdges = edges.map((edge) => {
52
+ const label = graph.edge(edge.from, edge.to) ?? {};
53
+ return {
54
+ from: edge.from,
55
+ to: edge.to,
56
+ points: (label.points ?? []).map((point) => ({
57
+ x: point.x,
58
+ y: point.y
59
+ }))
60
+ };
61
+ });
62
+ const padding = options.padding ?? 12;
63
+ const xs = [
64
+ ...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
65
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.x))
66
+ ];
67
+ const ys = [
68
+ ...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
69
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.y))
70
+ ];
71
+ const minX = xs.length > 0 ? Math.min(...xs) : 0;
72
+ const minY = ys.length > 0 ? Math.min(...ys) : 0;
73
+ const maxX = xs.length > 0 ? Math.max(...xs) : 0;
74
+ const maxY = ys.length > 0 ? Math.max(...ys) : 0;
75
+ const shiftX = padding - minX;
76
+ const shiftY = padding - minY;
77
+ return {
78
+ nodes: centeredNodes.map((node) => ({
79
+ ...node,
80
+ x: node.x + shiftX,
81
+ y: node.y + shiftY
82
+ })),
83
+ edges: rawEdges.map((edge) => ({
84
+ ...edge,
85
+ points: edge.points.map((point) => ({
86
+ x: point.x + shiftX,
87
+ y: point.y + shiftY
88
+ }))
89
+ })),
90
+ width: maxX - minX + padding * 2,
91
+ height: maxY - minY + padding * 2
92
+ };
93
+ }
94
+ const DEFAULT_MAX_WIDTH = 220;
95
+ const MIN_WIDTH = 90;
96
+ const PADDING_X = 20;
97
+ const PADDING_Y = 16;
98
+ const LINE_HEIGHT = 22;
99
+ const CHAR_WIDTH = {
100
+ text: 7.2,
101
+ formula: 11
102
+ };
103
+ function formulaVisibleLength(latex) {
104
+ const collapsed = latex.replace(/\\[a-zA-Z]+/g, "x").replace(/[{}$]/g, "").trim();
105
+ return Math.max(1, collapsed.length);
106
+ }
107
+ function estimateBoxSize(content, kind, options = {}) {
108
+ const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
109
+ const visibleLength = kind === "formula" ? formulaVisibleLength(content) : content.trim().length || 1;
110
+ const charWidth = CHAR_WIDTH[kind];
111
+ const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
112
+ const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
113
+ const usableWidth = width - PADDING_X * 2;
114
+ const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
115
+ const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
116
+ const height = lines * LINE_HEIGHT + PADDING_Y * 2;
117
+ return { width: Math.round(width), height: Math.round(height) };
118
+ }
119
+ export {
120
+ estimateBoxSize,
121
+ layoutDiagram
122
+ };
@@ -44,6 +44,11 @@ interface EquationLessonInput {
44
44
  readonly slug: string;
45
45
  readonly file: string;
46
46
  readonly body: string;
47
+ /**
48
+ * The lesson's 1-based ordinal in the course. When set, equations are
49
+ * numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
50
+ */
51
+ readonly chapter?: number;
47
52
  }
48
53
  declare function validateEquations(lessons: readonly EquationLessonInput[]): EquationValidation;
49
54
 
@@ -25,7 +25,7 @@ function validateEquations(lessons) {
25
25
  )) {
26
26
  const seen = /* @__PURE__ */ new Set();
27
27
  const table = {};
28
- for (const raw of scanEquationLabels(lesson.body)) {
28
+ for (const raw of scanEquationLabels(lesson.body, lesson.chapter)) {
29
29
  if (seen.has(raw.key)) {
30
30
  diagnostics.push({
31
31
  code: "eq-duplicate-key",
@@ -8,19 +8,45 @@ export function parseEquationRef(raw: any): {
8
8
  } | undefined;
9
9
  /** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
10
10
  export function stripEquationLabels(latex: any): string;
11
+ /**
12
+ * The visible number for one display equation. With `chapter` (the lesson's
13
+ * course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
14
+ * are ignored; without it, the legacy `##`-section scheme.
15
+ */
16
+ export function displayEquationNumber({ chapter, section, indexInSection, indexInLesson, }: {
17
+ chapter: any;
18
+ section: any;
19
+ indexInSection: any;
20
+ indexInLesson: any;
21
+ }): string;
11
22
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
12
23
  export function equationAnchorId({ key, number }: {
13
24
  key: any;
14
25
  number: any;
15
26
  }): string;
27
+ export function stripFencedCode(markdown: any): string;
28
+ /**
29
+ * Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
30
+ * so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
31
+ * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
32
+ * outermost match wins, so nested components are blanked in one pass;
33
+ * newlines are kept so line/section structure is unchanged.
34
+ *
35
+ * `keep`, when given, leaves a component of that name untouched instead of
36
+ * blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
37
+ * component while still finding `<Table>`/`<Figure>` tags at the top level.
38
+ */
39
+ export function stripJsxComponents(markdown: any, keep: any): string;
16
40
  /**
17
41
  * Every keyed display equation in a Markdown body, in appearance order, with
18
- * its assigned number. `$$…$$` blocks only; `##` headings between them advance
19
- * the section counter. Every display block advances the in-section index, so a
20
- * keyed equation's number reflects its position among *all* display equations,
21
- * not only the keyed ones.
42
+ * its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
43
+ * course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
44
+ * display block in the lesson; without it, `##` headings advance a section
45
+ * counter and the number is `‹section›.‹indexInSection›`. Either way every
46
+ * display block advances the index, so a keyed equation's number reflects its
47
+ * position among *all* display equations, not only the keyed ones.
22
48
  */
23
- export function scanEquationLabels(markdown: any): {
49
+ export function scanEquationLabels(markdown: any, chapter: any): {
24
50
  key: string;
25
51
  number: string;
26
52
  section: number;
@@ -30,9 +56,10 @@ export function scanEquationLabels(markdown: any): {
30
56
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
31
57
  * walk the tree in document order, advancing the section counter on every
32
58
  * depth-2 heading and emitting a record for every `math` node. `key` is the
33
- * `\label{eq:…}` payload when present, else `null`.
59
+ * `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
60
+ * same way as in `scanEquationLabels`.
34
61
  */
35
- export function collectDisplayEquations(tree: any): {
62
+ export function collectDisplayEquations(tree: any, chapter: any): {
36
63
  key: string | null;
37
64
  number: string;
38
65
  section: number;
@@ -43,7 +70,7 @@ export function collectDisplayEquations(tree: any): {
43
70
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
44
71
  * `scanEquationLabels`. Numbers reflect position among all display equations.
45
72
  */
46
- export function collectEquationLabels(tree: any): {
73
+ export function collectEquationLabels(tree: any, chapter: any): {
47
74
  key: string | null;
48
75
  number: string;
49
76
  section: number;
@@ -53,9 +80,13 @@ export function collectEquationLabels(tree: any): {
53
80
  /**
54
81
  * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
55
82
  *
56
- * Every display equation (`$$…$$`) in a lesson is numbered and given an
57
- * `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
58
- * fourth display equation under the second section). An author additionally
83
+ * Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
84
+ * from appearance order. When the caller supplies a `chapter` (the lesson's
85
+ * ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
86
+ * every display equation in the lesson and `##` headings do not affect it
87
+ * (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
88
+ * the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
89
+ * (used by hand-built test trees and non-lesson pages). An author additionally
59
90
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
60
91
  * block (inside math, so the `:` is safe and the form matches amsmath) when
61
92
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -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,21 @@
1
+ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } from './keyed-float-validate.js';
2
+
3
+ /** `validateKeyedFloats` for `<Figure>` — see `keyed-float-validate.ts`. */
4
+
5
+ type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused';
6
+ interface FigureDiagnostic {
7
+ readonly code: FigureDiagnosticCode;
8
+ readonly severity: 'error' | 'warning';
9
+ readonly message: string;
10
+ readonly file?: string;
11
+ readonly lessonId?: string;
12
+ readonly key?: string;
13
+ }
14
+ type FigureLabelRecord = KeyedFloatLabelRecord;
15
+ type FigureLessonInput = KeyedFloatLessonInput;
16
+ interface FigureValidation extends KeyedFloatValidation {
17
+ readonly diagnostics: readonly FigureDiagnostic[];
18
+ }
19
+ declare function validateFigures(lessons: readonly FigureLessonInput[]): FigureValidation;
20
+
21
+ export { type FigureDiagnostic, type FigureDiagnosticCode, type FigureLabelRecord, type FigureLessonInput, type FigureValidation, validateFigures };
@@ -0,0 +1,13 @@
1
+ import {
2
+ validateKeyedFloats
3
+ } from "./keyed-float-validate.js";
4
+ function validateFigures(lessons) {
5
+ return validateKeyedFloats(lessons, {
6
+ tagName: "Figure",
7
+ prefix: "fig",
8
+ noun: "figure"
9
+ });
10
+ }
11
+ export {
12
+ validateFigures
13
+ };
@@ -0,0 +1,67 @@
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
+ interface KeyedFloatKind {
18
+ /** The MDX component tag, e.g. `Table` or `Figure`. */
19
+ readonly tagName: string;
20
+ /** The `[[<prefix>-key]]` reference prefix, e.g. `tab` or `fig`. */
21
+ readonly prefix: string;
22
+ /** Lowercase noun used in diagnostic messages, e.g. `table` or `figure`. */
23
+ readonly noun: string;
24
+ }
25
+ interface KeyedFloatLabelRecord {
26
+ readonly key: string;
27
+ readonly number: string;
28
+ readonly lessonId: string;
29
+ readonly slug: string;
30
+ readonly section: number;
31
+ readonly indexInSection: number;
32
+ }
33
+ interface KeyedFloatRefRecord {
34
+ readonly key: string;
35
+ readonly lessonId: string;
36
+ /** Target lesson slug for a cross-page reference; `undefined` = same page. */
37
+ readonly targetSlug?: string;
38
+ }
39
+ interface KeyedFloatDiagnostic {
40
+ readonly code: string;
41
+ readonly severity: 'error' | 'warning';
42
+ readonly message: string;
43
+ readonly file?: string;
44
+ readonly lessonId?: string;
45
+ readonly key?: string;
46
+ }
47
+ interface KeyedFloatValidation {
48
+ /** Every keyed float, sorted by lesson then appearance. */
49
+ readonly labels: readonly KeyedFloatLabelRecord[];
50
+ /** `slug -> { key -> number }` — the render path's cross-page lookup table. */
51
+ readonly numbersBySlug: Record<string, Record<string, string>>;
52
+ readonly diagnostics: readonly KeyedFloatDiagnostic[];
53
+ }
54
+ interface KeyedFloatLessonInput {
55
+ readonly lessonId: string;
56
+ readonly slug: string;
57
+ readonly file: string;
58
+ readonly body: string;
59
+ /**
60
+ * The lesson's 1-based ordinal in the course. When set, floats are numbered
61
+ * `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
62
+ */
63
+ readonly chapter?: number;
64
+ }
65
+ declare function validateKeyedFloats(lessons: readonly KeyedFloatLessonInput[], kind: KeyedFloatKind): KeyedFloatValidation;
66
+
67
+ export { type KeyedFloatDiagnostic, type KeyedFloatKind, type KeyedFloatLabelRecord, type KeyedFloatLessonInput, type KeyedFloatRefRecord, type KeyedFloatValidation, validateKeyedFloats };
@@ -0,0 +1,110 @@
1
+ import {
2
+ KEYED_FLOAT_KEY_PATTERN,
3
+ parseKeyedFloatRef,
4
+ scanKeyedJsxFloats
5
+ } from "./keyed-jsx-floats.mjs";
6
+ const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
7
+ function stripCode(text) {
8
+ return text.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
9
+ }
10
+ function bodyFloatRefs(lesson, prefix) {
11
+ const refs = [];
12
+ for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
13
+ const parsed = parseKeyedFloatRef((match[1] ?? "").trim(), { prefix });
14
+ if (!parsed) continue;
15
+ refs.push({
16
+ key: parsed.key,
17
+ lessonId: lesson.lessonId,
18
+ ...parsed.lesson ? { targetSlug: parsed.lesson } : {}
19
+ });
20
+ }
21
+ return refs;
22
+ }
23
+ function validateKeyedFloats(lessons, kind) {
24
+ const { tagName, prefix, noun } = kind;
25
+ const labels = [];
26
+ const diagnostics = [];
27
+ const numbersBySlug = {};
28
+ for (const lesson of [...lessons].sort(
29
+ (a, b) => a.lessonId.localeCompare(b.lessonId)
30
+ )) {
31
+ const seen = /* @__PURE__ */ new Set();
32
+ const table = {};
33
+ for (const raw of scanKeyedJsxFloats(lesson.body, {
34
+ tagName,
35
+ chapter: lesson.chapter
36
+ })) {
37
+ if (raw.key == null) continue;
38
+ if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
39
+ diagnostics.push({
40
+ code: `${prefix}-invalid-key`,
41
+ severity: "error",
42
+ message: `${lesson.lessonId} has a <${tagName} id="${raw.key}"> whose id is not a valid key \u2014 expected lowercase letters, digits, and hyphens only.`,
43
+ file: lesson.file,
44
+ lessonId: lesson.lessonId,
45
+ key: raw.key
46
+ });
47
+ continue;
48
+ }
49
+ if (seen.has(raw.key)) {
50
+ diagnostics.push({
51
+ code: `${prefix}-duplicate-key`,
52
+ severity: "error",
53
+ message: `${lesson.lessonId} has two <${tagName}> elements with id="${raw.key}"; an id is unique per lesson.`,
54
+ file: lesson.file,
55
+ lessonId: lesson.lessonId,
56
+ key: raw.key
57
+ });
58
+ continue;
59
+ }
60
+ seen.add(raw.key);
61
+ table[raw.key] = raw.number;
62
+ labels.push({
63
+ key: raw.key,
64
+ number: raw.number,
65
+ lessonId: lesson.lessonId,
66
+ slug: lesson.slug,
67
+ section: raw.section,
68
+ indexInSection: raw.indexInSection
69
+ });
70
+ }
71
+ numbersBySlug[lesson.slug] = table;
72
+ }
73
+ const referenced = /* @__PURE__ */ new Set();
74
+ for (const lesson of lessons) {
75
+ const slug = lesson.slug;
76
+ for (const ref of bodyFloatRefs(lesson, prefix)) {
77
+ const targetSlug = ref.targetSlug ?? slug;
78
+ const number = numbersBySlug[targetSlug]?.[ref.key];
79
+ referenced.add(`${targetSlug}#${ref.key}`);
80
+ if (number === void 0) {
81
+ diagnostics.push({
82
+ code: `${prefix}-ref-resolves`,
83
+ severity: "error",
84
+ message: ref.targetSlug ? `${lesson.lessonId} references [[${ref.targetSlug}#${prefix}-${ref.key}]] but that lesson has no <${tagName} id="${ref.key}">` : `${lesson.lessonId} references [[${prefix}-${ref.key}]] but no <${tagName}> on the page carries id="${ref.key}"`,
85
+ file: lesson.file,
86
+ lessonId: lesson.lessonId,
87
+ key: ref.key
88
+ });
89
+ }
90
+ }
91
+ }
92
+ for (const label of labels) {
93
+ if (referenced.has(`${label.slug}#${label.key}`)) continue;
94
+ diagnostics.push({
95
+ code: `${prefix}-key-unused`,
96
+ severity: "warning",
97
+ message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
98
+ file: label.slug,
99
+ lessonId: label.lessonId,
100
+ key: label.key
101
+ });
102
+ }
103
+ diagnostics.sort(
104
+ (a, b) => a.code.localeCompare(b.code) || (a.lessonId ?? "").localeCompare(b.lessonId ?? "") || (a.key ?? "").localeCompare(b.key ?? "") || a.message.localeCompare(b.message)
105
+ );
106
+ return { labels, numbersBySlug, diagnostics };
107
+ }
108
+ export {
109
+ validateKeyedFloats
110
+ };