explico 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -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,16 @@ interface EquationLessonInput {
44
44
  readonly slug: string;
45
45
  readonly file: string;
46
46
  readonly body: string;
47
+ /**
48
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
49
+ * and how many display equations earlier lessons of that part already used.
50
+ * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
51
+ * legacy `##`-section scheme. See `docs/authoring/structure.md`.
52
+ */
53
+ readonly numbering?: {
54
+ readonly prefix: number;
55
+ readonly offset: number;
56
+ };
47
57
  }
48
58
  declare function validateEquations(lessons: readonly EquationLessonInput[]): EquationValidation;
49
59
 
@@ -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.numbering)) {
29
29
  if (seen.has(raw.key)) {
30
30
  diagnostics.push({
31
31
  code: "eq-duplicate-key",
@@ -8,31 +8,89 @@ 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
+ * Normalise the numbering input to `{ prefix, offset }`.
13
+ *
14
+ * `prefix` is the number before the dot — the **part**'s ordinal (its position
15
+ * among the sidebar groups). `offset` is how many display equations already
16
+ * appeared in earlier lessons of that same part, so `n` runs continuously
17
+ * across a part instead of restarting on every page. A bare number is the
18
+ * legacy per-lesson form (`prefix = n`, `offset = 0`) and is still accepted so
19
+ * hand-built trees and unit tests keep working. `undefined`/`null`/`0` ⇒ the
20
+ * `##`-section fallback for non-lesson pages.
21
+ */
22
+ export function resolveNumbering(input: any): {
23
+ prefix: any;
24
+ offset: number;
25
+ };
26
+ /**
27
+ * The visible number for one display equation. With a `prefix` (the part's
28
+ * ordinal) the scheme is `‹part›.‹offset + indexInLesson›` and `##` headings
29
+ * are ignored; without it, the legacy `##`-section scheme.
30
+ */
31
+ export function displayEquationNumber({ prefix, offset, section, indexInSection, indexInLesson, }: {
32
+ prefix: any;
33
+ offset?: number | undefined;
34
+ section: any;
35
+ indexInSection: any;
36
+ indexInLesson: any;
37
+ }): string;
11
38
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
12
39
  export function equationAnchorId({ key, number }: {
13
40
  key: any;
14
41
  number: any;
15
42
  }): string;
43
+ export function stripFencedCode(markdown: any): string;
16
44
  /**
17
- * 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.
45
+ * Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
46
+ * so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
47
+ * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
48
+ * outermost match wins, so nested components are blanked in one pass;
49
+ * newlines are kept so line/section structure is unchanged.
50
+ *
51
+ * `keep`, when given, leaves a component of that name untouched instead of
52
+ * blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
53
+ * component while still finding `<Table>`/`<Figure>` tags at the top level.
22
54
  */
23
- export function scanEquationLabels(markdown: any): {
24
- key: string;
55
+ export function stripJsxComponents(markdown: any, keep: any): string;
56
+ /**
57
+ * Every display equation in a Markdown body, keyed or not, in appearance order
58
+ * with its assigned number. `$$…$$` blocks only. With `numbering`
59
+ * (`{ prefix, offset }`, the part's ordinal and the count of display equations
60
+ * in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
61
+ * sequential over every display block in the lesson; without it, `##` headings
62
+ * advance a section counter and the number is `‹section›.‹indexInSection›`.
63
+ * Either way every display block advances the index, so a keyed equation's
64
+ * number reflects its position among *all* display equations, not only the
65
+ * keyed ones.
66
+ */
67
+ export function scanDisplayEquations(markdown: any, numbering: any): {
68
+ key: string | null | undefined;
69
+ number: string;
70
+ section: number;
71
+ indexInSection: number;
72
+ }[];
73
+ /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
74
+ export function scanEquationLabels(markdown: any, numbering: any): {
75
+ key: string | null | undefined;
25
76
  number: string;
26
77
  section: number;
27
78
  indexInSection: number;
28
79
  }[];
80
+ /**
81
+ * How many display equations a lesson body holds. This is what makes `n` run
82
+ * continuously across a part: a lesson's `offset` is the sum of this over every
83
+ * earlier lesson in the same part.
84
+ */
85
+ export function countDisplayEquations(markdown: any): number;
29
86
  /**
30
87
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
31
88
  * walk the tree in document order, advancing the section counter on every
32
89
  * depth-2 heading and emitting a record for every `math` node. `key` is the
33
- * `\label{eq:…}` payload when present, else `null`.
90
+ * `\label{eq:…}` payload when present, else `null`. `numbering` is threaded the
91
+ * same way as in `scanEquationLabels`.
34
92
  */
35
- export function collectDisplayEquations(tree: any): {
93
+ export function collectDisplayEquations(tree: any, numbering: any): {
36
94
  key: string | null;
37
95
  number: string;
38
96
  section: number;
@@ -43,7 +101,7 @@ export function collectDisplayEquations(tree: any): {
43
101
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
44
102
  * `scanEquationLabels`. Numbers reflect position among all display equations.
45
103
  */
46
- export function collectEquationLabels(tree: any): {
104
+ export function collectEquationLabels(tree: any, numbering: any): {
47
105
  key: string | null;
48
106
  number: string;
49
107
  section: number;
@@ -53,9 +111,18 @@ export function collectEquationLabels(tree: any): {
53
111
  /**
54
112
  * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
55
113
  *
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
114
+ * Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
115
+ * from appearance order. When the caller supplies `numbering`
116
+ * (`{ prefix, offset }` — see `resolveNumbering`), the number is
117
+ * `‹part›.‹offset + n›`: the prefix is the **part**'s ordinal among the sidebar
118
+ * groups, `n` counts every display equation in the lesson, and `offset` carries
119
+ * the count from earlier lessons of the same part so the sequence runs
120
+ * continuously across a part rather than restarting on every page. `##` and
121
+ * `###` headings never affect it — `(2.4)` is the fourth display equation of
122
+ * part 2, wherever in that part it sits. See `docs/authoring/structure.md` for
123
+ * the level names. With no `numbering` the number falls back to the
124
+ * `##`-section scheme `‹section›.‹indexInSection›` (used by hand-built test
125
+ * trees and non-lesson pages). An author additionally
59
126
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
60
127
  * block (inside math, so the `:` is safe and the form matches amsmath) when
61
128
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -1,9 +1,18 @@
1
1
  /**
2
2
  * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
3
3
  *
4
- * Every display equation (`$$…$$`) in a lesson is numbered and given an
5
- * `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 `numbering`
6
+ * (`{ prefix, offset }` — see `resolveNumbering`), the number is
7
+ * `‹part›.‹offset + n›`: the prefix is the **part**'s ordinal among the sidebar
8
+ * groups, `n` counts every display equation in the lesson, and `offset` carries
9
+ * the count from earlier lessons of the same part so the sequence runs
10
+ * continuously across a part rather than restarting on every page. `##` and
11
+ * `###` headings never affect it — `(2.4)` is the fourth display equation of
12
+ * part 2, wherever in that part it sits. See `docs/authoring/structure.md` for
13
+ * the level names. With no `numbering` the number falls back to the
14
+ * `##`-section scheme `‹section›.‹indexInSection›` (used by hand-built test
15
+ * trees and non-lesson pages). An author additionally
7
16
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
8
17
  * block (inside math, so the `:` is safe and the form matches amsmath) when
9
18
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -58,12 +67,51 @@ function equationNumber(section, index) {
58
67
  return section > 0 ? `${section}.${index}` : String(index);
59
68
  }
60
69
 
70
+ /**
71
+ * Normalise the numbering input to `{ prefix, offset }`.
72
+ *
73
+ * `prefix` is the number before the dot — the **part**'s ordinal (its position
74
+ * among the sidebar groups). `offset` is how many display equations already
75
+ * appeared in earlier lessons of that same part, so `n` runs continuously
76
+ * across a part instead of restarting on every page. A bare number is the
77
+ * legacy per-lesson form (`prefix = n`, `offset = 0`) and is still accepted so
78
+ * hand-built trees and unit tests keep working. `undefined`/`null`/`0` ⇒ the
79
+ * `##`-section fallback for non-lesson pages.
80
+ */
81
+ export function resolveNumbering(input) {
82
+ if (input == null) return { prefix: undefined, offset: 0 };
83
+ if (typeof input === 'number') {
84
+ return { prefix: input || undefined, offset: 0 };
85
+ }
86
+ return {
87
+ prefix: input.prefix || undefined,
88
+ offset: Number(input.offset) || 0,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The visible number for one display equation. With a `prefix` (the part's
94
+ * ordinal) the scheme is `‹part›.‹offset + indexInLesson›` and `##` headings
95
+ * are ignored; without it, the legacy `##`-section scheme.
96
+ */
97
+ export function displayEquationNumber({
98
+ prefix,
99
+ offset = 0,
100
+ section,
101
+ indexInSection,
102
+ indexInLesson,
103
+ }) {
104
+ return prefix == null
105
+ ? equationNumber(section, indexInSection)
106
+ : `${prefix}.${offset + indexInLesson}`;
107
+ }
108
+
61
109
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
62
110
  export function equationAnchorId({ key, number }) {
63
111
  return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
64
112
  }
65
113
 
66
- function stripFencedCode(markdown) {
114
+ export function stripFencedCode(markdown) {
67
115
  return String(markdown)
68
116
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
69
117
  .replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
@@ -76,22 +124,31 @@ function stripFencedCode(markdown) {
76
124
  * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
77
125
  * outermost match wins, so nested components are blanked in one pass;
78
126
  * newlines are kept so line/section structure is unchanged.
127
+ *
128
+ * `keep`, when given, leaves a component of that name untouched instead of
129
+ * blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
130
+ * component while still finding `<Table>`/`<Figure>` tags at the top level.
79
131
  */
80
- function stripJsxComponents(markdown) {
132
+ export function stripJsxComponents(markdown, keep) {
81
133
  return String(markdown).replace(
82
134
  /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
83
- (block) => block.replace(/[^\n]/g, ' '),
135
+ (block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
84
136
  );
85
137
  }
86
138
 
87
139
  /**
88
- * Every 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.
140
+ * Every display equation in a Markdown body, keyed or not, in appearance order
141
+ * with its assigned number. `$$…$$` blocks only. With `numbering`
142
+ * (`{ prefix, offset }`, the part's ordinal and the count of display equations
143
+ * in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
144
+ * sequential over every display block in the lesson; without it, `##` headings
145
+ * advance a section counter and the number is `‹section›.‹indexInSection›`.
146
+ * Either way every display block advances the index, so a keyed equation's
147
+ * number reflects its position among *all* display equations, not only the
148
+ * keyed ones.
93
149
  */
94
- export function scanEquationLabels(markdown) {
150
+ export function scanDisplayEquations(markdown, numbering) {
151
+ const { prefix, offset } = resolveNumbering(numbering);
95
152
  const text = stripJsxComponents(stripFencedCode(markdown));
96
153
  const tokens = [];
97
154
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
@@ -113,6 +170,7 @@ export function scanEquationLabels(markdown) {
113
170
  const labels = [];
114
171
  let section = 0;
115
172
  let indexInSection = 0;
173
+ let indexInLesson = 0;
116
174
  for (const token of tokens) {
117
175
  if (token.kind === 'section') {
118
176
  section += 1;
@@ -120,10 +178,16 @@ export function scanEquationLabels(markdown) {
120
178
  continue;
121
179
  }
122
180
  indexInSection += 1;
123
- if (token.key == null) continue;
181
+ indexInLesson += 1;
124
182
  labels.push({
125
183
  key: token.key,
126
- number: equationNumber(section, indexInSection),
184
+ number: displayEquationNumber({
185
+ prefix,
186
+ offset,
187
+ section,
188
+ indexInSection,
189
+ indexInLesson,
190
+ }),
127
191
  section,
128
192
  indexInSection,
129
193
  });
@@ -131,16 +195,35 @@ export function scanEquationLabels(markdown) {
131
195
  return labels;
132
196
  }
133
197
 
198
+ /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
199
+ export function scanEquationLabels(markdown, numbering) {
200
+ return scanDisplayEquations(markdown, numbering).filter(
201
+ (equation) => equation.key != null,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * How many display equations a lesson body holds. This is what makes `n` run
207
+ * continuously across a part: a lesson's `offset` is the sum of this over every
208
+ * earlier lesson in the same part.
209
+ */
210
+ export function countDisplayEquations(markdown) {
211
+ return scanDisplayEquations(markdown).length;
212
+ }
213
+
134
214
  /**
135
215
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
136
216
  * walk the tree in document order, advancing the section counter on every
137
217
  * depth-2 heading and emitting a record for every `math` node. `key` is the
138
- * `\label{eq:…}` payload when present, else `null`.
218
+ * `\label{eq:…}` payload when present, else `null`. `numbering` is threaded the
219
+ * same way as in `scanEquationLabels`.
139
220
  */
140
- export function collectDisplayEquations(tree) {
221
+ export function collectDisplayEquations(tree, numbering) {
222
+ const { prefix, offset } = resolveNumbering(numbering);
141
223
  const equations = [];
142
224
  let section = 0;
143
225
  let indexInSection = 0;
226
+ let indexInLesson = 0;
144
227
 
145
228
  // Only top-level blocks: a `$$…$$` nested in an MDX component (a
146
229
  // `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
@@ -155,11 +238,18 @@ export function collectDisplayEquations(tree) {
155
238
  }
156
239
  if (node.type === 'math') {
157
240
  indexInSection += 1;
241
+ indexInLesson += 1;
158
242
  EQ_LABEL_PATTERN.lastIndex = 0;
159
243
  const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
160
244
  equations.push({
161
245
  key: label ? label[1] : null,
162
- number: equationNumber(section, indexInSection),
246
+ number: displayEquationNumber({
247
+ prefix,
248
+ offset,
249
+ section,
250
+ indexInSection,
251
+ indexInLesson,
252
+ }),
163
253
  section,
164
254
  indexInSection,
165
255
  node,
@@ -173,8 +263,8 @@ export function collectDisplayEquations(tree) {
173
263
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
174
264
  * `scanEquationLabels`. Numbers reflect position among all display equations.
175
265
  */
176
- export function collectEquationLabels(tree) {
177
- return collectDisplayEquations(tree).filter(
266
+ export function collectEquationLabels(tree, numbering) {
267
+ return collectDisplayEquations(tree, numbering).filter(
178
268
  (equation) => equation.key != null,
179
269
  );
180
270
  }
@@ -0,0 +1,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,73 @@
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
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
61
+ * and how many floats of this kind earlier lessons of that part already used.
62
+ * When set, floats are numbered `‹part›.‹offset + n›`; when omitted, the
63
+ * legacy `##`-section scheme. Each kind counts on its own, so the caller
64
+ * passes a different `offset` for `<Table>` than for `<Figure>`.
65
+ */
66
+ readonly numbering?: {
67
+ readonly prefix: number;
68
+ readonly offset: number;
69
+ };
70
+ }
71
+ declare function validateKeyedFloats(lessons: readonly KeyedFloatLessonInput[], kind: KeyedFloatKind): KeyedFloatValidation;
72
+
73
+ export { type KeyedFloatDiagnostic, type KeyedFloatKind, type KeyedFloatLabelRecord, type KeyedFloatLessonInput, type KeyedFloatRefRecord, type KeyedFloatValidation, validateKeyedFloats };