explico 0.1.1 → 0.2.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 (42) hide show
  1. package/dist/compiler/cli.d.ts +1 -0
  2. package/dist/compiler/collections.js +27 -0
  3. package/dist/compiler/manifest.d.ts +10 -1
  4. package/dist/compiler/manifest.js +18 -2
  5. package/dist/compiler/resolution-report.d.ts +1 -0
  6. package/dist/reference/consistency.d.ts +1 -1
  7. package/dist/reference/consistency.js +93 -2
  8. package/dist/reference/equations.d.mts +43 -16
  9. package/dist/reference/equations.mjs +75 -39
  10. package/dist/reference/gate-math.d.ts +5 -4
  11. package/dist/reference/gate-math.js +73 -2
  12. package/dist/reference/gloss.d.ts +87 -0
  13. package/dist/reference/gloss.js +59 -0
  14. package/dist/reference/registry.js +25 -0
  15. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  16. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  17. package/dist/reference/remark-notation.mjs +30 -24
  18. package/dist/reference/types.d.ts +25 -2
  19. package/package.json +1 -1
  20. package/src/compiler/collections.ts +40 -0
  21. package/src/compiler/manifest.ts +31 -0
  22. package/src/components/HoverPanel.astro +42 -0
  23. package/src/components/citation/CitationLayer.astro +1 -0
  24. package/src/components/hover-panel.ts +71 -5
  25. package/src/components/notation/NotationFormula.astro +154 -0
  26. package/src/components/notation/NotationGlossary.astro +60 -0
  27. package/src/components/notation/NotationLayer.astro +248 -103
  28. package/src/components/notation/render-notation-math.ts +55 -0
  29. package/src/components/popup-stack.ts +345 -0
  30. package/src/components/starlight/EquationEnhancer.astro +20 -5
  31. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  32. package/src/components/starlight/LessonFooter.astro +4 -0
  33. package/src/content-config.ts +38 -0
  34. package/src/reference/consistency.ts +154 -1
  35. package/src/reference/equations.mjs +75 -39
  36. package/src/reference/gate-math.ts +104 -7
  37. package/src/reference/gloss.ts +153 -0
  38. package/src/reference/registry.ts +43 -0
  39. package/src/reference/rehype-heading-anchors.mjs +144 -0
  40. package/src/reference/remark-notation.mjs +30 -24
  41. package/src/reference/types.ts +26 -1
  42. package/src/styles/global.css +84 -2
@@ -7,6 +7,7 @@ import '../reference/types.js';
7
7
  import '../reference/lint-ignore.js';
8
8
  import '../reference/equations-validate.js';
9
9
  import '../reference/gate-math.js';
10
+ import '../reference/gloss.js';
10
11
  import './lesson-derivation.js';
11
12
 
12
13
  /**
@@ -4,6 +4,7 @@ import { parseFrontmatter } from "@astrojs/markdown-remark";
4
4
  import { isSubstantiveMeaning } from "../reference/prose.js";
5
5
  import { parseEquationRef } from "../reference/equations.mjs";
6
6
  import { resolveLabel } from "../reference/label.js";
7
+ import { resolveGlosses } from "../reference/gloss.js";
7
8
  import {
8
9
  deriveRequires,
9
10
  deriveSources,
@@ -206,6 +207,30 @@ async function loadCurriculumCatalog() {
206
207
  tracks: jsonEntries(join(CONTENT_ROOT, "tracks"))
207
208
  };
208
209
  }
210
+ function glossEntries(raw, ownerKey, at) {
211
+ if (raw === void 0) return [];
212
+ if (!Array.isArray(raw)) throw new Error(`${at} must be an array`);
213
+ const authored = raw.map((value, index) => {
214
+ const data = record(value, `${at}[${index}]`);
215
+ const units = optionalString(data.units, `${at}[${index}].units`);
216
+ return {
217
+ latex: string(data.latex, `${at}[${index}].latex`),
218
+ name: string(data.name, `${at}[${index}].name`),
219
+ ...units === void 0 ? {} : { units }
220
+ };
221
+ });
222
+ const glosses = resolveGlosses(ownerKey, authored);
223
+ const seen = /* @__PURE__ */ new Set();
224
+ for (const gloss of glosses) {
225
+ if (seen.has(gloss.key)) {
226
+ throw new Error(
227
+ `${at} declares two glosses named ${JSON.stringify(gloss.label)}`
228
+ );
229
+ }
230
+ seen.add(gloss.key);
231
+ }
232
+ return glosses;
233
+ }
209
234
  function localDefinition(raw, file, index) {
210
235
  const at = `${file} notation.local[${index}]`;
211
236
  const data = record(raw, at);
@@ -224,6 +249,7 @@ function localDefinition(raw, file, index) {
224
249
  seeAlso: strings(data.seeAlso, `${at}.seeAlso`),
225
250
  alignment: data.alignment === void 0 ? { kind: "general", rationale: "Lesson-local symbol." } : alignment(data.alignment, `${at}.alignment`),
226
251
  references: extractNotationReferences(definitionText, file, "definition"),
252
+ glosses: glossEntries(data.glosses, key, `${at}.glosses`),
227
253
  source: { file },
228
254
  ...formula === void 0 ? {} : { formula },
229
255
  ...units === void 0 ? {} : { units }
@@ -255,6 +281,7 @@ function sharedNotationEntries() {
255
281
  aiAssisted: boolean(data.aiAssisted, `${file} aiAssisted`),
256
282
  body,
257
283
  references: extractNotationReferences(body, file, void 0),
284
+ glosses: glossEntries(data.glosses, key, `${file} glosses`),
258
285
  source: { file },
259
286
  ...formula === void 0 ? {} : { formula },
260
287
  ...units === void 0 ? {} : { units }
@@ -2,7 +2,8 @@ import { CurriculumIssue } from '../curriculum/validation.js';
2
2
  import { ConsistencyDiagnostic } from '../reference/consistency.js';
3
3
  import { EquationLabelRecord, EquationDiagnostic } from '../reference/equations-validate.js';
4
4
  import { MathGateDiagnostic } from '../reference/gate-math.js';
5
- import { NotationDefinitionRecord, NotationPageBundle, NotationBacklink, NotationDiagnostic, EditorialStatus } from '../reference/types.js';
5
+ import { GlyphScopeEntry } from '../reference/gloss.js';
6
+ import { NotationDefinitionRecord, NotationPageBundle, NotationBacklink, NotationDiagnostic, EditorialStatus, NotationGlossEntry } from '../reference/types.js';
6
7
  import { CourseConfig } from './course-config.js';
7
8
  import { SidebarGroup } from './lesson-derivation.js';
8
9
  import './collections.js';
@@ -81,6 +82,14 @@ interface ManifestLessonNotation {
81
82
  readonly seeAlso: readonly string[];
82
83
  readonly units?: string;
83
84
  readonly formula?: string;
85
+ /** Symbols the formula names that have no card of their own (D15). */
86
+ readonly glosses: readonly NotationGlossEntry[];
87
+ /**
88
+ * Entry-scoped glyph table for `formula` — the entry, its glosses, and any
89
+ * card the formula names. Precomputed here because the whole card set lives
90
+ * in the compiler, not in the page component.
91
+ */
92
+ readonly formulaScope: readonly GlyphScopeEntry[];
84
93
  }
85
94
  interface ManifestLesson {
86
95
  readonly id: string;
@@ -23,6 +23,7 @@ import {
23
23
  } from "../reference/gate-math.js";
24
24
  import { loadLintIgnore } from "../reference/lint-ignore.js";
25
25
  import { buildNotationRegistry } from "../reference/registry.js";
26
+ import { formulaGlyphScope } from "../reference/gloss.js";
26
27
  import { resolveReferenceId } from "../reference/references.js";
27
28
  import {
28
29
  loadCurriculumCatalog,
@@ -97,7 +98,7 @@ function lessonNotationResolution(lesson, definitionsById, availableDefinitionId
97
98
  };
98
99
  });
99
100
  }
100
- function lessonNotationBundle(bundle, definitionsById, resolution) {
101
+ function lessonNotationBundle(bundle, definitionsById, resolution, cardsByKey) {
101
102
  const definitions = [];
102
103
  for (const definitionId of bundle?.definitionIds ?? []) {
103
104
  const definition = definitionsById.get(definitionId);
@@ -111,6 +112,14 @@ function lessonNotationBundle(bundle, definitionsById, resolution) {
111
112
  summary: definition.summary,
112
113
  editorialStatus: definition.status,
113
114
  seeAlso: [...definition.seeAlso],
115
+ glosses: [...definition.glosses],
116
+ formulaScope: formulaGlyphScope({
117
+ key: definition.key,
118
+ notation: definition.notation,
119
+ glosses: definition.glosses,
120
+ cardsByKey,
121
+ ...definition.formula === void 0 ? {} : { formula: definition.formula }
122
+ }),
114
123
  ...definition.units === void 0 ? {} : { units: definition.units },
115
124
  ...definition.formula === void 0 ? {} : { formula: definition.formula }
116
125
  });
@@ -152,6 +161,12 @@ async function compileManifest(courseConfig) {
152
161
  loadNotationRegistryInput()
153
162
  ]);
154
163
  const registry = buildNotationRegistry(notationInput);
164
+ const cardsByKey = new Map(
165
+ notationInput.sharedDefinitions.map((definition) => [
166
+ definition.key,
167
+ { key: definition.key, notation: definition.notation }
168
+ ])
169
+ );
155
170
  const definitionsById = new Map(
156
171
  registry.definitions.map((definition) => [definition.id, definition])
157
172
  );
@@ -237,7 +252,8 @@ async function compileManifest(courseConfig) {
237
252
  notationLesson,
238
253
  definitionsById,
239
254
  availableDefinitionIds
240
- )
255
+ ),
256
+ cardsByKey
241
257
  )
242
258
  });
243
259
  }
@@ -7,6 +7,7 @@ import '../reference/types.js';
7
7
  import '../reference/lint-ignore.js';
8
8
  import '../reference/equations-validate.js';
9
9
  import '../reference/gate-math.js';
10
+ import '../reference/gloss.js';
10
11
  import './lesson-derivation.js';
11
12
 
12
13
  /**
@@ -30,7 +30,7 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
30
30
  * `src/content/resolution-report.ts`.
31
31
  */
32
32
 
33
- type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator';
33
+ type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape';
34
34
  interface ConsistencyDiagnostic {
35
35
  readonly code: ConsistencyCode;
36
36
  /** Tier 3 — every finding here is a warning until content is brought clean. */
@@ -200,13 +200,101 @@ function checkNotationSourceLocator(input, out) {
200
200
  }
201
201
  }
202
202
  }
203
+ const GLOSS_PROMOTION_LIMIT = 3;
204
+ function checkGlossWantsPromoting(input, out) {
205
+ const owners = /* @__PURE__ */ new Map();
206
+ const record = (ownerKey, gloss) => {
207
+ const id = `${gloss.notation}\0${gloss.label.toLowerCase()}`;
208
+ const current = owners.get(id) ?? { label: gloss.label, keys: /* @__PURE__ */ new Set() };
209
+ current.keys.add(ownerKey);
210
+ owners.set(id, current);
211
+ };
212
+ for (const definition of input.notationInput.sharedDefinitions) {
213
+ for (const gloss of definition.glosses) record(definition.key, gloss);
214
+ }
215
+ for (const lesson of input.notationInput.lessons) {
216
+ for (const definition of lesson.localDefinitions) {
217
+ for (const gloss of definition.glosses) record(definition.key, gloss);
218
+ }
219
+ }
220
+ for (const [id, { label, keys }] of [...owners].sort(
221
+ ([a], [b]) => a.localeCompare(b)
222
+ )) {
223
+ if (keys.size < GLOSS_PROMOTION_LIMIT) continue;
224
+ const glyph = id.split("\0")[0] ?? "";
225
+ if (input.lintIgnore.matches("gloss-wants-promoting", glyph)) continue;
226
+ out.push({
227
+ code: "gloss-wants-promoting",
228
+ severity: "warning",
229
+ glyph,
230
+ message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(", ")}); a meaning reused this widely wants promoting to a card`
231
+ });
232
+ }
233
+ }
234
+ const CARD_BODY_MINIMUM_WORDS = 25;
235
+ function checkCardWantsDemoting(input, out) {
236
+ const used = new Set(
237
+ input.registry.backlinks.map((backlink) => backlink.key)
238
+ );
239
+ for (const definition of input.notationInput.sharedDefinitions) {
240
+ if (definition.formula !== void 0) continue;
241
+ if (definition.sources.length > 0) continue;
242
+ if (used.has(definition.key)) continue;
243
+ const words = stripCode(definition.body).split(/\s+/).filter((word) => word.length > 0).length;
244
+ if (words >= CARD_BODY_MINIMUM_WORDS) continue;
245
+ if (input.lintIgnore.matches("card-wants-demoting", definition.key)) {
246
+ continue;
247
+ }
248
+ out.push({
249
+ code: "card-wants-demoting",
250
+ severity: "warning",
251
+ key: definition.key,
252
+ file: definition.source.file,
253
+ count: words,
254
+ message: `notation card ${definition.key} has no formula, no sources, a ${words}-word body, and no lesson uses it; it carries no more than a name \u2014 a gloss on the entry that needs it may be the right home`
255
+ });
256
+ }
257
+ }
258
+ const GLOSS_NAME_WORD_LIMIT = 6;
259
+ const GLOSS_NAME_PROSE = /[.;]|\b(?:which|whose|that is|such that)\b/i;
260
+ function checkGlossNameShape(input, out) {
261
+ const check = (gloss, file, lessonId) => {
262
+ const words = gloss.label.trim().split(/\s+/).filter(Boolean);
263
+ const reason = words.length > GLOSS_NAME_WORD_LIMIT ? `${words.length} words` : GLOSS_NAME_PROSE.test(gloss.label) ? "sentence punctuation or a relative clause" : void 0;
264
+ if (reason === void 0) return;
265
+ if (input.lintIgnore.matches("gloss-name-shape", gloss.key)) return;
266
+ out.push({
267
+ code: "gloss-name-shape",
268
+ severity: "warning",
269
+ key: gloss.key,
270
+ file,
271
+ ...lessonId ? { lessonId } : {},
272
+ message: `gloss ${gloss.key} is named ${JSON.stringify(gloss.label)} (${reason}); a gloss name is a short noun phrase naming the symbol, not a description of it`
273
+ });
274
+ };
275
+ for (const definition of input.notationInput.sharedDefinitions) {
276
+ for (const gloss of definition.glosses) {
277
+ check(gloss, definition.source.file);
278
+ }
279
+ }
280
+ for (const lesson of input.notationInput.lessons) {
281
+ for (const definition of lesson.localDefinitions) {
282
+ for (const gloss of definition.glosses) {
283
+ check(gloss, definition.source.file, lesson.lessonId);
284
+ }
285
+ }
286
+ }
287
+ }
203
288
  const CHECKS = [
204
289
  checkGlyphUniqueness,
205
290
  checkNotationUnits,
206
291
  checkConventionSingleDefinition,
207
292
  checkNumeralsTagged,
208
293
  checkWeakLocal,
209
- checkNotationSourceLocator
294
+ checkNotationSourceLocator,
295
+ checkGlossWantsPromoting,
296
+ checkCardWantsDemoting,
297
+ checkGlossNameShape
210
298
  ];
211
299
  function runConsistencyChecks(input) {
212
300
  const diagnostics = [];
@@ -223,7 +311,10 @@ function consistencyCounts(diagnostics) {
223
311
  "convention-single-definition": 0,
224
312
  "numerals-tagged": 0,
225
313
  "weak-local": 0,
226
- "notation-source-locator": 0
314
+ "notation-source-locator": 0,
315
+ "gloss-wants-promoting": 0,
316
+ "card-wants-demoting": 0,
317
+ "gloss-name-shape": 0
227
318
  };
228
319
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
229
320
  return counts;
@@ -8,42 +8,69 @@ 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
+ /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
12
+ export function equationAnchorId({ key, number }: {
13
+ key: any;
14
+ number: any;
15
+ }): string;
11
16
  /**
12
17
  * Every keyed display equation in a Markdown body, in appearance order, with
13
18
  * its assigned number. `$$…$$` blocks only; `##` headings between them advance
14
- * the section counter.
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.
15
22
  */
16
23
  export function scanEquationLabels(markdown: any): {
17
- key: string | undefined;
24
+ key: string;
25
+ number: string;
26
+ section: number;
27
+ indexInSection: number;
28
+ }[];
29
+ /**
30
+ * The mdast equivalent of `scanEquationLabels` for *every* display equation:
31
+ * walk the tree in document order, advancing the section counter on every
32
+ * depth-2 heading and emitting a record for every `math` node. `key` is the
33
+ * `\label{eq:…}` payload when present, else `null`.
34
+ */
35
+ export function collectDisplayEquations(tree: any): {
36
+ key: string | null;
18
37
  number: string;
19
38
  section: number;
20
39
  indexInSection: number;
40
+ node: any;
21
41
  }[];
22
42
  /**
23
- * The mdast equivalent of `scanEquationLabels`: walk the tree in document
24
- * order, advancing the section counter on every depth-2 heading and emitting a
25
- * label for every `math` node whose source carries `\label{eq:…}`.
43
+ * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
44
+ * `scanEquationLabels`. Numbers reflect position among all display equations.
26
45
  */
27
- export function collectEquationLabels(tree: any): any[];
46
+ export function collectEquationLabels(tree: any): {
47
+ key: string | null;
48
+ number: string;
49
+ section: number;
50
+ indexInSection: number;
51
+ node: any;
52
+ }[];
28
53
  /**
29
- * Equation identity (Step D7) — shared, framework-free helpers.
54
+ * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
30
55
  *
31
- * An author marks a display equation by putting a trailing `\label{eq:<key>}`
32
- * inside its `$$…$$` block (inside math, so the `:` is safe and the form
33
- * matches amsmath). The compiler assigns the *visible* number from appearance
34
- * order, scoped to the enclosing `##` section (`(2.4)` = fourth keyed equation
35
- * under the second section). The number is never authored; unkeyed display
36
- * equations render clean and stay un-referenceable. Prose refers to an
37
- * equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
38
- * be eaten by `remark-directive`):
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
59
+ * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
60
+ * block (inside math, so the `:` is safe and the form matches amsmath) when
61
+ * they want a *stable* anchor that survives edits and cross-page references:
39
62
  *
40
63
  * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
41
64
  * [[foundations/present-value#eq-pv]] cross page → number from the manifest
42
65
  *
66
+ * An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
67
+ * deep-linked, but only a keyed equation is referenceable by name.
68
+ *
43
69
  * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
44
70
  * MUST agree — `tests/notation/equations.test.ts` checks that on the real
45
71
  * corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
46
- * numbering algorithm.
72
+ * numbering algorithm. `collectDisplayEquations` is the superset the render
73
+ * path uses to number and anchor every display equation, keyed or not.
47
74
  */
48
75
  /** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
49
76
  export const EQ_KEY_PATTERN: RegExp;
@@ -1,22 +1,24 @@
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
5
+ * `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
6
+ * fourth display equation under the second section). An author additionally
7
+ * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
8
+ * block (inside math, so the `:` is safe and the form matches amsmath) when
9
+ * they want a *stable* anchor that survives edits and cross-page references:
12
10
  *
13
11
  * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
14
12
  * [[foundations/present-value#eq-pv]] cross page → number from the manifest
15
13
  *
14
+ * An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
15
+ * deep-linked, but only a keyed equation is referenceable by name.
16
+ *
16
17
  * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
17
18
  * MUST agree — `tests/notation/equations.test.ts` checks that on the real
18
19
  * corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
19
- * numbering algorithm.
20
+ * numbering algorithm. `collectDisplayEquations` is the superset the render
21
+ * path uses to number and anchor every display equation, keyed or not.
20
22
  */
21
23
 
22
24
  /** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
@@ -51,11 +53,16 @@ export function stripEquationLabels(latex) {
51
53
  .trim();
52
54
  }
53
55
 
54
- /** The visible number for the `index`-th keyed equation under `section`. */
56
+ /** The visible number for the `index`-th display equation under `section`. */
55
57
  function equationNumber(section, index) {
56
58
  return section > 0 ? `${section}.${index}` : String(index);
57
59
  }
58
60
 
61
+ /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
62
+ export function equationAnchorId({ key, number }) {
63
+ return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
64
+ }
65
+
59
66
  function stripFencedCode(markdown) {
60
67
  return String(markdown)
61
68
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
@@ -63,13 +70,29 @@ function stripFencedCode(markdown) {
63
70
  .replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
64
71
  }
65
72
 
73
+ /**
74
+ * Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
75
+ * so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
76
+ * (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
77
+ * outermost match wins, so nested components are blanked in one pass;
78
+ * newlines are kept so line/section structure is unchanged.
79
+ */
80
+ function stripJsxComponents(markdown) {
81
+ return String(markdown).replace(
82
+ /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
83
+ (block) => block.replace(/[^\n]/g, ' '),
84
+ );
85
+ }
86
+
66
87
  /**
67
88
  * Every keyed display equation in a Markdown body, in appearance order, with
68
89
  * its assigned number. `$$…$$` blocks only; `##` headings between them advance
69
- * the section counter.
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.
70
93
  */
71
94
  export function scanEquationLabels(markdown) {
72
- const text = stripFencedCode(markdown);
95
+ const text = stripJsxComponents(stripFencedCode(markdown));
73
96
  const tokens = [];
74
97
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
75
98
  const display = /\$\$([\s\S]+?)\$\$/g;
@@ -79,9 +102,11 @@ export function scanEquationLabels(markdown) {
79
102
  for (const match of text.matchAll(display)) {
80
103
  EQ_LABEL_PATTERN.lastIndex = 0;
81
104
  const label = EQ_LABEL_PATTERN.exec(match[1]);
82
- if (label) {
83
- tokens.push({ kind: 'equation', index: match.index, key: label[1] });
84
- }
105
+ tokens.push({
106
+ kind: 'equation',
107
+ index: match.index,
108
+ key: label ? label[1] : null,
109
+ });
85
110
  }
86
111
  tokens.sort((a, b) => a.index - b.index);
87
112
 
@@ -95,6 +120,7 @@ export function scanEquationLabels(markdown) {
95
120
  continue;
96
121
  }
97
122
  indexInSection += 1;
123
+ if (token.key == null) continue;
98
124
  labels.push({
99
125
  key: token.key,
100
126
  number: equationNumber(section, indexInSection),
@@ -106,39 +132,49 @@ export function scanEquationLabels(markdown) {
106
132
  }
107
133
 
108
134
  /**
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:…}`.
135
+ * The mdast equivalent of `scanEquationLabels` for *every* display equation:
136
+ * walk the tree in document order, advancing the section counter on every
137
+ * depth-2 heading and emitting a record for every `math` node. `key` is the
138
+ * `\label{eq:…}` payload when present, else `null`.
112
139
  */
113
- export function collectEquationLabels(tree) {
114
- const labels = [];
140
+ export function collectDisplayEquations(tree) {
141
+ const equations = [];
115
142
  let section = 0;
116
143
  let indexInSection = 0;
117
144
 
118
- const walk = (node) => {
119
- if (!node || typeof node !== 'object') return;
145
+ // Only top-level blocks: a `$$…$$` nested in an MDX component (a
146
+ // `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
147
+ // of the lesson's equation sequence, and its container may be collapsed.
148
+ const children = Array.isArray(tree?.children) ? tree.children : [];
149
+ for (const node of children) {
150
+ if (!node || typeof node !== 'object') continue;
120
151
  if (node.type === 'heading' && node.depth === 2) {
121
152
  section += 1;
122
153
  indexInSection = 0;
123
- return;
154
+ continue;
124
155
  }
125
156
  if (node.type === 'math') {
157
+ indexInSection += 1;
126
158
  EQ_LABEL_PATTERN.lastIndex = 0;
127
159
  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),
133
- section,
134
- indexInSection,
135
- node,
136
- });
137
- }
138
- return;
160
+ equations.push({
161
+ key: label ? label[1] : null,
162
+ number: equationNumber(section, indexInSection),
163
+ section,
164
+ indexInSection,
165
+ node,
166
+ });
139
167
  }
140
- if (Array.isArray(node.children)) node.children.forEach(walk);
141
- };
142
- walk(tree);
143
- return labels;
168
+ }
169
+ return equations;
170
+ }
171
+
172
+ /**
173
+ * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
174
+ * `scanEquationLabels`. Numbers reflect position among all display equations.
175
+ */
176
+ export function collectEquationLabels(tree) {
177
+ return collectDisplayEquations(tree).filter(
178
+ (equation) => equation.key != null,
179
+ );
144
180
  }
@@ -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;