explico 0.1.1 → 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 (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -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 +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -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 +142 -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 +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -1,22 +1,28 @@
1
1
  /**
2
- * Equation identity (Step D7) — shared, framework-free helpers.
2
+ * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
3
3
  *
4
- * An author marks a display equation by putting a trailing `\label{eq:<key>}`
5
- * inside its `$$…$$` block (inside math, so the `:` is safe and the form
6
- * matches amsmath). The compiler assigns the *visible* number from appearance
7
- * order, scoped to the enclosing `##` section (`(2.4)` = fourth keyed equation
8
- * under the second section). The number is never authored; unkeyed display
9
- * equations render clean and stay un-referenceable. Prose refers to an
10
- * equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
11
- * be eaten by `remark-directive`):
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
11
+ * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
12
+ * block (inside math, so the `:` is safe and the form matches amsmath) when
13
+ * they want a *stable* anchor that survives edits and cross-page references:
12
14
  *
13
15
  * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
14
16
  * [[foundations/present-value#eq-pv]] cross page → number from the manifest
15
17
  *
18
+ * An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
19
+ * deep-linked, but only a keyed equation is referenceable by name.
20
+ *
16
21
  * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
17
22
  * MUST agree — `tests/notation/equations.test.ts` checks that on the real
18
23
  * corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
19
- * numbering algorithm.
24
+ * numbering algorithm. `collectDisplayEquations` is the superset the render
25
+ * path uses to number and anchor every display equation, keyed or not.
20
26
  */
21
27
 
22
28
  /** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
@@ -51,25 +57,68 @@ export function stripEquationLabels(latex) {
51
57
  .trim();
52
58
  }
53
59
 
54
- /** The visible number for the `index`-th keyed equation under `section`. */
60
+ /** The visible number for the `index`-th display equation under `section`. */
55
61
  function equationNumber(section, index) {
56
62
  return section > 0 ? `${section}.${index}` : String(index);
57
63
  }
58
64
 
59
- function stripFencedCode(markdown) {
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
+
81
+ /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
82
+ export function equationAnchorId({ key, number }) {
83
+ return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
84
+ }
85
+
86
+ export function stripFencedCode(markdown) {
60
87
  return String(markdown)
61
88
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
62
89
  .replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
63
90
  .replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
64
91
  }
65
92
 
93
+ /**
94
+ * Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
95
+ * so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
96
+ * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
97
+ * outermost match wins, so nested components are blanked in one pass;
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.
103
+ */
104
+ export function stripJsxComponents(markdown, keep) {
105
+ return String(markdown).replace(
106
+ /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
107
+ (block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
108
+ );
109
+ }
110
+
66
111
  /**
67
112
  * Every keyed display equation in a Markdown body, in appearance order, with
68
- * its assigned number. `$$…$$` blocks only; `##` headings between them advance
69
- * the section counter.
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.
70
119
  */
71
- export function scanEquationLabels(markdown) {
72
- const text = stripFencedCode(markdown);
120
+ export function scanEquationLabels(markdown, chapter) {
121
+ const text = stripJsxComponents(stripFencedCode(markdown));
73
122
  const tokens = [];
74
123
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
75
124
  const display = /\$\$([\s\S]+?)\$\$/g;
@@ -79,15 +128,18 @@ export function scanEquationLabels(markdown) {
79
128
  for (const match of text.matchAll(display)) {
80
129
  EQ_LABEL_PATTERN.lastIndex = 0;
81
130
  const label = EQ_LABEL_PATTERN.exec(match[1]);
82
- if (label) {
83
- tokens.push({ kind: 'equation', index: match.index, key: label[1] });
84
- }
131
+ tokens.push({
132
+ kind: 'equation',
133
+ index: match.index,
134
+ key: label ? label[1] : null,
135
+ });
85
136
  }
86
137
  tokens.sort((a, b) => a.index - b.index);
87
138
 
88
139
  const labels = [];
89
140
  let section = 0;
90
141
  let indexInSection = 0;
142
+ let indexInLesson = 0;
91
143
  for (const token of tokens) {
92
144
  if (token.kind === 'section') {
93
145
  section += 1;
@@ -95,9 +147,16 @@ export function scanEquationLabels(markdown) {
95
147
  continue;
96
148
  }
97
149
  indexInSection += 1;
150
+ indexInLesson += 1;
151
+ if (token.key == null) continue;
98
152
  labels.push({
99
153
  key: token.key,
100
- number: equationNumber(section, indexInSection),
154
+ number: displayEquationNumber({
155
+ chapter,
156
+ section,
157
+ indexInSection,
158
+ indexInLesson,
159
+ }),
101
160
  section,
102
161
  indexInSection,
103
162
  });
@@ -106,39 +165,57 @@ export function scanEquationLabels(markdown) {
106
165
  }
107
166
 
108
167
  /**
109
- * The mdast equivalent of `scanEquationLabels`: walk the tree in document
110
- * order, advancing the section counter on every depth-2 heading and emitting a
111
- * label for every `math` node whose source carries `\label{eq:…}`.
168
+ * The mdast equivalent of `scanEquationLabels` for *every* display equation:
169
+ * walk the tree in document order, advancing the section counter on every
170
+ * depth-2 heading and emitting a record for every `math` node. `key` is the
171
+ * `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
172
+ * same way as in `scanEquationLabels`.
112
173
  */
113
- export function collectEquationLabels(tree) {
114
- const labels = [];
174
+ export function collectDisplayEquations(tree, chapter) {
175
+ const equations = [];
115
176
  let section = 0;
116
177
  let indexInSection = 0;
178
+ let indexInLesson = 0;
117
179
 
118
- const walk = (node) => {
119
- if (!node || typeof node !== 'object') return;
180
+ // Only top-level blocks: a `$$…$$` nested in an MDX component (a
181
+ // `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
182
+ // of the lesson's equation sequence, and its container may be collapsed.
183
+ const children = Array.isArray(tree?.children) ? tree.children : [];
184
+ for (const node of children) {
185
+ if (!node || typeof node !== 'object') continue;
120
186
  if (node.type === 'heading' && node.depth === 2) {
121
187
  section += 1;
122
188
  indexInSection = 0;
123
- return;
189
+ continue;
124
190
  }
125
191
  if (node.type === 'math') {
192
+ indexInSection += 1;
193
+ indexInLesson += 1;
126
194
  EQ_LABEL_PATTERN.lastIndex = 0;
127
195
  const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
128
- if (label) {
129
- indexInSection += 1;
130
- labels.push({
131
- key: label[1],
132
- number: equationNumber(section, indexInSection),
196
+ equations.push({
197
+ key: label ? label[1] : null,
198
+ number: displayEquationNumber({
199
+ chapter,
133
200
  section,
134
201
  indexInSection,
135
- node,
136
- });
137
- }
138
- return;
202
+ indexInLesson,
203
+ }),
204
+ section,
205
+ indexInSection,
206
+ node,
207
+ });
139
208
  }
140
- if (Array.isArray(node.children)) node.children.forEach(walk);
141
- };
142
- walk(tree);
143
- return labels;
209
+ }
210
+ return equations;
211
+ }
212
+
213
+ /**
214
+ * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
215
+ * `scanEquationLabels`. Numbers reflect position among all display equations.
216
+ */
217
+ export function collectEquationLabels(tree, chapter) {
218
+ return collectDisplayEquations(tree, chapter).filter(
219
+ (equation) => equation.key != null,
220
+ );
144
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
+ };
@@ -4,10 +4,11 @@ interface MathGateDiagnostic {
4
4
  readonly context: 'body' | 'formula' | 'assessment';
5
5
  /**
6
6
  * Body / component-slot math blocks the build (the render path needs every
7
- * glyph resolved for the KaTeX trust callback). `formula` and `assessment`
8
- * math enter as warnings (Tier 3) and are promoted once existing content is
9
- * brought into line — they surface pre-existing ambiguities that are a
10
- * separate reviewed content pass (D6/D13/D14), not a D3 codemod.
7
+ * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
8
+ * a formula's glyphs are now rendered live and explorable, so an unresolved
9
+ * one is a hole the reader can see, and the corpus was brought to zero when
10
+ * the tier was promoted. `assessment` math stays a Tier-3 warning until its
11
+ * own reviewed content pass (D6/D13/D14).
11
12
  */
12
13
  readonly severity: 'error' | 'warning';
13
14
  readonly file: string;
@@ -1,8 +1,9 @@
1
1
  import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
2
2
  import { stripEquationLabels } from "./equations.mjs";
3
+ import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
3
4
  const SEVERITY = {
4
5
  body: "error",
5
- formula: "warning",
6
+ formula: "error",
6
7
  assessment: "warning"
7
8
  };
8
9
  const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
@@ -75,6 +76,33 @@ function resolveInto(latex, scope, context, file, line, fix, out) {
75
76
  }
76
77
  }
77
78
  const BODY_FIX = "Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.";
79
+ function dedupeScope(scope) {
80
+ const seen = /* @__PURE__ */ new Set();
81
+ const out = [];
82
+ for (const entry of scope) {
83
+ if (seen.has(entry.key)) continue;
84
+ seen.add(entry.key);
85
+ out.push(entry);
86
+ }
87
+ return out;
88
+ }
89
+ function reportUnknownScopeKeys(definition, cardKeys, file, out) {
90
+ const glossKeys = new Set(definition.glosses.map((gloss) => gloss.key));
91
+ for (const key of unknownFormulaScopeKeys(
92
+ definition.formula,
93
+ glossKeys,
94
+ cardKeys
95
+ )) {
96
+ out.push({
97
+ context: "formula",
98
+ severity: SEVERITY.formula,
99
+ file,
100
+ token: key,
101
+ message: `${definition.key} formula names unknown notation key ${JSON.stringify(key)}. Name an existing card, or declare the symbol under glosses: on this entry.`
102
+ });
103
+ }
104
+ }
105
+ const SHARED_FORMULA_FIX = "Every symbol in a notation entry formula must resolve to the entry itself, one of its glosses:, a card the formula names with \\explain{key}{latex}, or the base library.";
78
106
  const FORMULA_FIX = "Every right-hand-side variable of a notation.local formula must resolve to the entry key, another page symbol, or the base library.";
79
107
  const ASSESSMENT_FIX = "Introduce the symbol in the owning lesson's prose with [[key]], or wrap it in \\explain{key}{latex}.";
80
108
  function gateContentMath(input) {
@@ -85,6 +113,18 @@ function gateContentMath(input) {
85
113
  definition
86
114
  ])
87
115
  );
116
+ const sharedCards = new Map(
117
+ notationInput.sharedDefinitions.map((definition) => [
118
+ definition.key,
119
+ { key: definition.key, notation: definition.notation }
120
+ ])
121
+ );
122
+ const cardKeys = /* @__PURE__ */ new Set([
123
+ ...sharedCards.keys(),
124
+ ...notationInput.lessons.flatMap(
125
+ (lesson) => lesson.localDefinitions.map((definition) => definition.key)
126
+ )
127
+ ]);
88
128
  const lessonById = new Map(
89
129
  notationInput.lessons.map((lesson) => [lesson.lessonId, lesson])
90
130
  );
@@ -110,15 +150,46 @@ function gateContentMath(input) {
110
150
  if (!definition.formula) continue;
111
151
  resolveInto(
112
152
  definition.formula,
113
- scope,
153
+ dedupeScope([
154
+ ...scope,
155
+ ...definition.glosses.map((gloss) => ({
156
+ key: gloss.key,
157
+ notation: gloss.notation
158
+ }))
159
+ ]),
114
160
  "formula",
115
161
  file,
116
162
  void 0,
117
163
  FORMULA_FIX,
118
164
  diagnostics
119
165
  );
166
+ reportUnknownScopeKeys(definition, cardKeys, file, diagnostics);
120
167
  }
121
168
  }
169
+ for (const definition of notationInput.sharedDefinitions) {
170
+ if (!definition.formula) continue;
171
+ resolveInto(
172
+ definition.formula,
173
+ formulaGlyphScope({
174
+ key: definition.key,
175
+ notation: definition.notation,
176
+ formula: definition.formula,
177
+ glosses: definition.glosses,
178
+ cardsByKey: sharedCards
179
+ }),
180
+ "formula",
181
+ definition.source.file,
182
+ void 0,
183
+ SHARED_FORMULA_FIX,
184
+ diagnostics
185
+ );
186
+ reportUnknownScopeKeys(
187
+ definition,
188
+ cardKeys,
189
+ definition.source.file,
190
+ diagnostics
191
+ );
192
+ }
122
193
  for (const link of lessonAssessments) {
123
194
  const lesson = lessonById.get(link.lessonId);
124
195
  if (!lesson) continue;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Glosses and formula scope (D15) — the second tier of the notation
3
+ * vocabulary.
4
+ *
5
+ * A **card** is a `content/notation/*.md` entry (or a `notation.local` entry):
6
+ * symbol, name, description, units, sources, curriculum alignment, review
7
+ * state. It gets a glossary card and is a node in the curriculum graph.
8
+ *
9
+ * A **gloss** is declared on a card, in that card's `glosses:` frontmatter,
10
+ * and carries a symbol, a name, and optionally a bare units string. It exists
11
+ * for one reason: so a card's rigorous `formula` can name its own letters
12
+ * without each letter having to become a card. A gloss has no description, no
13
+ * sources, no alignment, no review state, and no glossary card — and, the
14
+ * point of the whole tier, **it creates no curriculum edge**. A card that
15
+ * dragged its formula's letters into the registry would drag them into the
16
+ * lesson bundle of every lesson that uses it, and a foundations lesson would
17
+ * fail `alignment-introduction` on a symbol introduced six lessons later.
18
+ *
19
+ * The editorial convention (not enforced here): a card's formula may introduce
20
+ * glosses; glosses terminate — they have no formula of their own. A symbol
21
+ * that later needs a real definition is **promoted**: delete the gloss line,
22
+ * add `content/notation/<key>.md`. Because a formula spells LaTeX and never a
23
+ * key, nothing else in the corpus changes.
24
+ */
25
+ interface NotationGlossInput {
26
+ readonly latex: string;
27
+ readonly name: string;
28
+ readonly units?: string;
29
+ }
30
+ /** A gloss with its derived key and display label. */
31
+ interface ResolvedGloss {
32
+ /** `<card key>.<slug(name)>` — derived, never authored. */
33
+ readonly key: string;
34
+ readonly notation: string;
35
+ readonly label: string;
36
+ readonly units?: string;
37
+ /** The card that declares it. */
38
+ readonly ownerKey: string;
39
+ }
40
+ /** One `{key, notation}` row of the flat table `resolveMathGlyphs` consumes. */
41
+ interface GlyphScopeEntry {
42
+ readonly key: string;
43
+ readonly notation: string;
44
+ }
45
+ /**
46
+ * The key segment derived from a gloss name: lower-cased, runs of anything
47
+ * that is not a letter or digit collapsed to a single `-`. Returns `undefined`
48
+ * when the name has no usable characters, so the schema can reject it rather
49
+ * than the loader minting an invalid key.
50
+ */
51
+ declare function glossKeySegment(name: string): string | undefined;
52
+ /**
53
+ * Derive a gloss's key. Dotted onto the owning card's key, so a gloss is
54
+ * entry-scoped by construction: two cards may each gloss `T`, and a gloss can
55
+ * never be confused with a card in the flat registry.
56
+ */
57
+ declare function glossKey(ownerKey: string, name: string): string;
58
+ /** Resolve a card's authored `glosses:` list into keyed, labelled entries. */
59
+ declare function resolveGlosses(ownerKey: string, glosses: readonly NotationGlossInput[]): ResolvedGloss[];
60
+ declare function formulaExplainKeys(formula: string): string[];
61
+ /**
62
+ * The glyph table a card's `formula` resolves against:
63
+ *
64
+ * {the card itself} ∪ {its glosses} ∪ {cards its formula names} ∪ base library
65
+ *
66
+ * Narrower than the registry-wide table a card's *body* math still uses, and
67
+ * narrower than a lesson page's scope. A card named with `\explain{key}` in
68
+ * the formula joins the table for glyph resolution **only**: its existence is
69
+ * checked, but it deliberately becomes no reference edge, because a
70
+ * definitional letter is not a curriculum prerequisite. See the module note.
71
+ */
72
+ declare function formulaGlyphScope(input: {
73
+ readonly key: string;
74
+ readonly notation: string;
75
+ readonly formula?: string;
76
+ readonly glosses: readonly ResolvedGloss[];
77
+ /** Every card by key — shared entries, plus page-local ones for a lesson. */
78
+ readonly cardsByKey: ReadonlyMap<string, GlyphScopeEntry>;
79
+ }): GlyphScopeEntry[];
80
+ /**
81
+ * Keys named by a formula that no card defines. The build reports these — an
82
+ * `\explain{…}` pointing at nothing is an authoring mistake, not a silent
83
+ * fallback to an unresolved glyph.
84
+ */
85
+ declare function unknownFormulaScopeKeys(formula: string | undefined, glossKeys: ReadonlySet<string>, cardKeys: ReadonlySet<string>): string[];
86
+
87
+ export { type GlyphScopeEntry, type NotationGlossInput, type ResolvedGloss, formulaExplainKeys, formulaGlyphScope, glossKey, glossKeySegment, resolveGlosses, unknownFormulaScopeKeys };
@@ -0,0 +1,59 @@
1
+ function glossKeySegment(name) {
2
+ const segment = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3
+ return segment.length > 0 ? segment : void 0;
4
+ }
5
+ function glossKey(ownerKey, name) {
6
+ const segment = glossKeySegment(name);
7
+ if (segment === void 0) {
8
+ throw new Error(
9
+ `Gloss name ${JSON.stringify(name)} on ${ownerKey} yields no key segment.`
10
+ );
11
+ }
12
+ return `${ownerKey}.${segment}`;
13
+ }
14
+ function resolveGlosses(ownerKey, glosses) {
15
+ return glosses.map((gloss) => ({
16
+ key: glossKey(ownerKey, gloss.name),
17
+ notation: gloss.latex,
18
+ label: gloss.name,
19
+ ownerKey,
20
+ ...gloss.units === void 0 ? {} : { units: gloss.units }
21
+ }));
22
+ }
23
+ const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
24
+ function formulaExplainKeys(formula) {
25
+ EXPLAIN_KEY.lastIndex = 0;
26
+ return [...formula.matchAll(EXPLAIN_KEY)].map((match) => match[1] ?? "");
27
+ }
28
+ function formulaGlyphScope(input) {
29
+ const scope = [
30
+ { key: input.key, notation: input.notation }
31
+ ];
32
+ const seen = /* @__PURE__ */ new Set([input.key]);
33
+ for (const gloss of input.glosses) {
34
+ if (seen.has(gloss.key)) continue;
35
+ seen.add(gloss.key);
36
+ scope.push({ key: gloss.key, notation: gloss.notation });
37
+ }
38
+ for (const key of formulaExplainKeys(input.formula ?? "")) {
39
+ if (seen.has(key)) continue;
40
+ const card = input.cardsByKey.get(key);
41
+ if (!card) continue;
42
+ seen.add(key);
43
+ scope.push({ key: card.key, notation: card.notation });
44
+ }
45
+ return scope;
46
+ }
47
+ function unknownFormulaScopeKeys(formula, glossKeys, cardKeys) {
48
+ return formulaExplainKeys(formula ?? "").filter(
49
+ (key) => !cardKeys.has(key) && !glossKeys.has(key)
50
+ );
51
+ }
52
+ export {
53
+ formulaExplainKeys,
54
+ formulaGlyphScope,
55
+ glossKey,
56
+ glossKeySegment,
57
+ resolveGlosses,
58
+ unknownFormulaScopeKeys
59
+ };
@@ -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 };