explico 0.3.0 → 0.5.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 (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -22,6 +22,10 @@
22
22
  * (h) operator-explained a named operator a lesson uses is on the
23
23
  * shipped/course operator allowlist or
24
24
  * carries a notation entry (rule N8)
25
+ * (i) roman-label-explained a `\mathrm{…}` multi-letter label a lesson
26
+ * uses is on the shipped/course roman-label
27
+ * allowlist or carries a notation entry
28
+ * (rule N10)
25
29
  *
26
30
  * Check (d) — the committed per-lesson resolution report — lives in
27
31
  * `src/content/resolution-report.ts`.
@@ -30,12 +34,14 @@
30
34
  import type {
31
35
  CourseConventions,
32
36
  CourseOperators,
37
+ CourseRomanLabels,
33
38
  } from '../compiler/course-config';
34
39
  import type { CurriculumCatalog } from '../curriculum/validation';
35
40
  import type { NotationSpec } from '../compiler/collections';
36
41
  import type { LintIgnore } from './lint-ignore';
37
42
  import { effectiveOperatorAllowlist } from './base-operators.mjs';
38
- import { collectOperators } from './math-glyphs.mjs';
43
+ import { effectiveRomanLabelAllowlist } from './base-roman-labels.mjs';
44
+ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
39
45
  import { offVocabularyTokens } from './units-vocab';
40
46
  import type {
41
47
  NotationDefinitionRecord,
@@ -54,7 +60,8 @@ export type ConsistencyCode =
54
60
  | 'gloss-wants-promoting'
55
61
  | 'card-wants-demoting'
56
62
  | 'gloss-name-shape'
57
- | 'operator-explained';
63
+ | 'operator-explained'
64
+ | 'roman-label-explained';
58
65
 
59
66
  export interface ConsistencyDiagnostic {
60
67
  readonly code: ConsistencyCode;
@@ -79,6 +86,8 @@ export interface ConsistencyInput {
79
86
  readonly conventions: CourseConventions;
80
87
  /** This course's edits to the named-operator baseline (rule N8). */
81
88
  readonly operators?: CourseOperators;
89
+ /** This course's edits to the roman-label baseline (rule N10). */
90
+ readonly romanLabels?: CourseRomanLabels;
82
91
  }
83
92
 
84
93
  const CONVENTION_RE =
@@ -551,6 +560,66 @@ function checkOperatorExplained(
551
560
  }
552
561
  }
553
562
 
563
+ /**
564
+ * (i) roman-label-explained (rule N10) — the `\mathrm{…}` twin of (h)
565
+ * above: N3 requires the *format* (`\mathrm{KE}`, never bare `KE`) but has
566
+ * no opinion on whether the label is ever explained, because the glyph
567
+ * resolver skips a `\mathrm{…}` body whole. This is the check that looks at
568
+ * them; it reads `collectRomanLabels`, which parses with the same pinned
569
+ * KaTeX as the completeness gate.
570
+ */
571
+ function checkRomanLabelExplained(
572
+ input: ConsistencyInput,
573
+ out: ConsistencyDiagnostic[],
574
+ ): void {
575
+ const allowlist = effectiveRomanLabelAllowlist(input.romanLabels);
576
+ const byId = new Map<string, NotationDefinitionRecord>(
577
+ input.registry.definitions.map((definition) => [definition.id, definition]),
578
+ );
579
+ const bindingsByLesson = new Map(
580
+ input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
581
+ );
582
+
583
+ for (const lesson of input.notationInput.lessons) {
584
+ // Labels the reader is shown on this page: the lesson's own
585
+ // `notation.local` entries, plus every shared card it binds with `[[key]]`.
586
+ const explained = new Set<string>();
587
+ const noteEntryLabels = (latex: string): void => {
588
+ for (const label of collectRomanLabels(latex)) {
589
+ explained.add(label.name);
590
+ }
591
+ };
592
+ for (const definition of lesson.localDefinitions) {
593
+ noteEntryLabels(definition.notation);
594
+ }
595
+ for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
596
+ const definition = byId.get(binding.definitionId);
597
+ if (definition) noteEntryLabels(definition.notation);
598
+ }
599
+
600
+ const reported = new Set<string>();
601
+ for (const span of mathSpans(lesson.body)) {
602
+ for (const label of collectRomanLabels(span.latex)) {
603
+ if (allowlist.has(label.name)) continue;
604
+ if (explained.has(label.name)) continue;
605
+ if (reported.has(label.name)) continue;
606
+ reported.add(label.name);
607
+ if (input.lintIgnore.matches('roman-label-explained', label.name)) {
608
+ continue;
609
+ }
610
+ out.push({
611
+ code: 'roman-label-explained',
612
+ severity: 'warning',
613
+ lessonId: lesson.lessonId,
614
+ file: lesson.source.file,
615
+ glyph: label.name,
616
+ message: `${lesson.lessonId}: roman label ${JSON.stringify(`\\mathrm{${label.name}}`)} is neither on the course roman-label allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(`\\mathrm{${label.name}}`)}) introduced with [[key]], or add ${JSON.stringify(label.name)} to romanLabels.allow in course.config.ts`,
617
+ });
618
+ }
619
+ }
620
+ }
621
+ }
622
+
554
623
  const CHECKS: ((
555
624
  input: ConsistencyInput,
556
625
  out: ConsistencyDiagnostic[],
@@ -565,6 +634,7 @@ const CHECKS: ((
565
634
  checkCardWantsDemoting,
566
635
  checkGlossNameShape,
567
636
  checkOperatorExplained,
637
+ checkRomanLabelExplained,
568
638
  ];
569
639
 
570
640
  /**
@@ -603,6 +673,7 @@ export function consistencyCounts(
603
673
  'card-wants-demoting': 0,
604
674
  'gloss-name-shape': 0,
605
675
  'operator-explained': 0,
676
+ 'roman-label-explained': 0,
606
677
  } satisfies Record<ConsistencyCode, number>;
607
678
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
608
679
  return counts;
@@ -0,0 +1,40 @@
1
+ /** `validateKeyedFloats` for `<Diagram>` — see `keyed-float-validate.ts`. */
2
+
3
+ import {
4
+ validateKeyedFloats,
5
+ type KeyedFloatLabelRecord,
6
+ type KeyedFloatLessonInput,
7
+ type KeyedFloatValidation,
8
+ } from './keyed-float-validate';
9
+
10
+ export type DiagramDiagnosticCode =
11
+ | 'dia-invalid-key'
12
+ | 'dia-duplicate-key'
13
+ | 'dia-ref-resolves'
14
+ | 'dia-key-unused';
15
+
16
+ export interface DiagramDiagnostic {
17
+ readonly code: DiagramDiagnosticCode;
18
+ readonly severity: 'error' | 'warning';
19
+ readonly message: string;
20
+ readonly file?: string;
21
+ readonly lessonId?: string;
22
+ readonly key?: string;
23
+ }
24
+
25
+ export type DiagramLabelRecord = KeyedFloatLabelRecord;
26
+ export type DiagramLessonInput = KeyedFloatLessonInput;
27
+
28
+ export interface DiagramValidation extends KeyedFloatValidation {
29
+ readonly diagnostics: readonly DiagramDiagnostic[];
30
+ }
31
+
32
+ export function validateDiagrams(
33
+ lessons: readonly DiagramLessonInput[],
34
+ ): DiagramValidation {
35
+ return validateKeyedFloats(lessons, {
36
+ tagName: 'Diagram',
37
+ prefix: 'dia',
38
+ noun: 'diagram',
39
+ }) as DiagramValidation;
40
+ }
@@ -54,10 +54,12 @@ export interface EquationLessonInput {
54
54
  readonly file: string;
55
55
  readonly body: string;
56
56
  /**
57
- * The lesson's 1-based ordinal in the course. When set, equations are
58
- * numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
57
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
58
+ * and how many display equations earlier lessons of that part already used.
59
+ * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
60
+ * legacy `##`-section scheme. See `docs/authoring/structure.md`.
59
61
  */
60
- readonly chapter?: number;
62
+ readonly numbering?: { readonly prefix: number; readonly offset: number };
61
63
  }
62
64
 
63
65
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -95,7 +97,7 @@ export function validateEquations(
95
97
  )) {
96
98
  const seen = new Set<string>();
97
99
  const table: Record<string, string> = {};
98
- for (const raw of scanEquationLabels(lesson.body, lesson.chapter) as {
100
+ for (const raw of scanEquationLabels(lesson.body, lesson.numbering) as {
99
101
  key: string;
100
102
  number: string;
101
103
  section: number;
@@ -2,12 +2,17 @@
2
2
  * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
3
3
  *
4
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
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
11
16
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
12
17
  * block (inside math, so the `:` is safe and the form matches amsmath) when
13
18
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -63,19 +68,42 @@ function equationNumber(section, index) {
63
68
  }
64
69
 
65
70
  /**
66
- * The visible number for one display equation. With `chapter` (the lesson's
67
- * course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
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
68
95
  * are ignored; without it, the legacy `##`-section scheme.
69
96
  */
70
97
  export function displayEquationNumber({
71
- chapter,
98
+ prefix,
99
+ offset = 0,
72
100
  section,
73
101
  indexInSection,
74
102
  indexInLesson,
75
103
  }) {
76
- return chapter == null
104
+ return prefix == null
77
105
  ? equationNumber(section, indexInSection)
78
- : `${chapter}.${indexInLesson}`;
106
+ : `${prefix}.${offset + indexInLesson}`;
79
107
  }
80
108
 
81
109
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
@@ -100,25 +128,58 @@ export function stripFencedCode(markdown) {
100
128
  * `keep`, when given, leaves a component of that name untouched instead of
101
129
  * blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
102
130
  * component while still finding `<Table>`/`<Figure>` tags at the top level.
131
+ *
132
+ * `transparent`, when given, removes only those components' opening/closing
133
+ * tags and exposes their body to the later blanking pass. `<Memo>` and
134
+ * `<ShortNote>` are intentional uses: unlike a worked-example/presentation
135
+ * wrapper, their bodies are details in the lesson's reading order, so numbered
136
+ * equations/floats in them stay in the official sequence. Section headings in
137
+ * a transparent body do not change the surrounding page's section counter.
103
138
  */
104
- export function stripJsxComponents(markdown, keep) {
105
- return String(markdown).replace(
139
+ export function stripJsxComponents(markdown, keep, transparent = []) {
140
+ let source = String(markdown);
141
+ const attributes = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'])*`;
142
+ for (const name of transparent) {
143
+ const component = new RegExp(
144
+ `<${name}\\b${attributes}>([\\s\\S]*?)<\\/${name}\\s*>`,
145
+ 'g',
146
+ );
147
+ source = source.replace(component, (block, body) => {
148
+ const bodyStart = block.indexOf(body);
149
+ const visibleBody = body.replace(
150
+ /(^|\n)([ \t]{0,3})##(?=[ \t]+\S)/g,
151
+ (match) => match.replace(/#/g, ' '),
152
+ );
153
+ return (
154
+ block.slice(0, bodyStart).replace(/[^\n]/g, ' ') +
155
+ visibleBody +
156
+ block.slice(bodyStart + body.length).replace(/[^\n]/g, ' ')
157
+ );
158
+ });
159
+ }
160
+ return source.replace(
106
161
  /<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
107
162
  (block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
108
163
  );
109
164
  }
110
165
 
111
166
  /**
112
- * Every keyed display equation in a Markdown body, in appearance order, with
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.
167
+ * Every display equation in a Markdown body, keyed or not, in appearance order
168
+ * with its assigned number. `$$…$$` blocks only. With `numbering`
169
+ * (`{ prefix, offset }`, the part's ordinal and the count of display equations
170
+ * in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
171
+ * sequential over every display block in the lesson; without it, `##` headings
172
+ * advance a section counter and the number is `‹section›.‹indexInSection›`.
173
+ * Either way every display block advances the index, so a keyed equation's
174
+ * number reflects its position among *all* display equations, not only the
175
+ * keyed ones.
119
176
  */
120
- export function scanEquationLabels(markdown, chapter) {
121
- const text = stripJsxComponents(stripFencedCode(markdown));
177
+ export function scanDisplayEquations(markdown, numbering) {
178
+ const { prefix, offset } = resolveNumbering(numbering);
179
+ const text = stripJsxComponents(stripFencedCode(markdown), undefined, [
180
+ 'Memo',
181
+ 'ShortNote',
182
+ ]);
122
183
  const tokens = [];
123
184
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
124
185
  const display = /\$\$([\s\S]+?)\$\$/g;
@@ -148,11 +209,11 @@ export function scanEquationLabels(markdown, chapter) {
148
209
  }
149
210
  indexInSection += 1;
150
211
  indexInLesson += 1;
151
- if (token.key == null) continue;
152
212
  labels.push({
153
213
  key: token.key,
154
214
  number: displayEquationNumber({
155
- chapter,
215
+ prefix,
216
+ offset,
156
217
  section,
157
218
  indexInSection,
158
219
  indexInLesson,
@@ -164,30 +225,43 @@ export function scanEquationLabels(markdown, chapter) {
164
225
  return labels;
165
226
  }
166
227
 
228
+ /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
229
+ export function scanEquationLabels(markdown, numbering) {
230
+ return scanDisplayEquations(markdown, numbering).filter(
231
+ (equation) => equation.key != null,
232
+ );
233
+ }
234
+
235
+ /**
236
+ * How many display equations a lesson body holds. This is what makes `n` run
237
+ * continuously across a part: a lesson's `offset` is the sum of this over every
238
+ * earlier lesson in the same part.
239
+ */
240
+ export function countDisplayEquations(markdown) {
241
+ return scanDisplayEquations(markdown).length;
242
+ }
243
+
167
244
  /**
168
245
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
169
246
  * walk the tree in document order, advancing the section counter on every
170
247
  * 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
248
+ * `\label{eq:…}` payload when present, else `null`. `numbering` is threaded the
172
249
  * same way as in `scanEquationLabels`.
173
250
  */
174
- export function collectDisplayEquations(tree, chapter) {
251
+ export function collectDisplayEquations(tree, numbering) {
252
+ const { prefix, offset } = resolveNumbering(numbering);
175
253
  const equations = [];
176
254
  let section = 0;
177
255
  let indexInSection = 0;
178
256
  let indexInLesson = 0;
179
257
 
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;
186
- if (node.type === 'heading' && node.depth === 2) {
187
- section += 1;
188
- indexInSection = 0;
189
- continue;
190
- }
258
+ // Top-level blocks plus the bodies of `<Memo>` and `<ShortNote>`: other MDX
259
+ // components (a `<CompactExample>` worked example, an `<Aside>`) remain
260
+ // illustrative and outside the lesson's official equation sequence. These
261
+ // two detail components are deliberately transparent because they occupy an
262
+ // exact point in the reading order, even while visually collapsed.
263
+ const collectFromDetail = (node) => {
264
+ if (!node || typeof node !== 'object') return;
191
265
  if (node.type === 'math') {
192
266
  indexInSection += 1;
193
267
  indexInLesson += 1;
@@ -196,7 +270,8 @@ export function collectDisplayEquations(tree, chapter) {
196
270
  equations.push({
197
271
  key: label ? label[1] : null,
198
272
  number: displayEquationNumber({
199
- chapter,
273
+ prefix,
274
+ offset,
200
275
  section,
201
276
  indexInSection,
202
277
  indexInLesson,
@@ -205,6 +280,33 @@ export function collectDisplayEquations(tree, chapter) {
205
280
  indexInSection,
206
281
  node,
207
282
  });
283
+ return;
284
+ }
285
+ if (
286
+ node.type === 'mdxJsxFlowElement' &&
287
+ node.name !== 'Memo' &&
288
+ node.name !== 'ShortNote'
289
+ ) {
290
+ return;
291
+ }
292
+ if (!Array.isArray(node.children)) return;
293
+ for (const child of node.children) collectFromDetail(child);
294
+ };
295
+
296
+ const children = Array.isArray(tree?.children) ? tree.children : [];
297
+ for (const node of children) {
298
+ if (!node || typeof node !== 'object') continue;
299
+ if (node.type === 'heading' && node.depth === 2) {
300
+ section += 1;
301
+ indexInSection = 0;
302
+ continue;
303
+ }
304
+ if (node.type === 'math') collectFromDetail(node);
305
+ else if (
306
+ node.type === 'mdxJsxFlowElement' &&
307
+ (node.name === 'Memo' || node.name === 'ShortNote')
308
+ ) {
309
+ collectFromDetail(node);
208
310
  }
209
311
  }
210
312
  return equations;
@@ -214,8 +316,8 @@ export function collectDisplayEquations(tree, chapter) {
214
316
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
215
317
  * `scanEquationLabels`. Numbers reflect position among all display equations.
216
318
  */
217
- export function collectEquationLabels(tree, chapter) {
218
- return collectDisplayEquations(tree, chapter).filter(
319
+ export function collectEquationLabels(tree, numbering) {
320
+ return collectDisplayEquations(tree, numbering).filter(
219
321
  (equation) => equation.key != null,
220
322
  );
221
323
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The abbreviation-notation gate (rule N14): every acronym-shaped or
3
+ * dotted-abbreviation-shaped token sitting in plain lesson prose is either
4
+ * universal, explained by the abbreviations table, or already explained as a
5
+ * math quantity elsewhere on the same page.
6
+ *
7
+ * This is the prose-shaped twin of [N8](#n8)/[N10](#n10)
8
+ * (`checkOperatorExplained` / `checkRomanLabelExplained` in `consistency.ts`)
9
+ * rather than a rewrite of them: those two already require every
10
+ * `\operatorname{…}` / `\mathrm{…}` used *inside* `$…$` to be universal or
11
+ * explained, so an abbreviation that is also a math quantity — `MSE` written
12
+ * `\mathrm{MSE}` in an equation and bound to a notation entry — is already
13
+ * covered there. What N8/N10 cannot see is the same word sitting in ordinary
14
+ * prose ("the OLS estimator…") with no `$…$` around it at all; that is this
15
+ * gate's whole job, so it reuses `pageScope` plus `collectRomanLabels` /
16
+ * `collectOperators` (the exact functions N8/N10 already call) to recognize
17
+ * when a prose token is covered by that other mechanism instead of asking the
18
+ * author to explain the same term twice.
19
+ *
20
+ * Ships straight as an error, same as N9 (`gate-prose-notation.ts`): the
21
+ * corpus has no prior instances to absorb, so there is no Tier-3 warm-up
22
+ * period and no `lint-ignore.yml` entry.
23
+ */
24
+ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
25
+ import { isBaseAbbreviation } from './base-abbreviations.mjs';
26
+ import { pageScope } from './gate-math';
27
+ import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
28
+ import { lineOf, proseOnly } from './gate-prose-notation';
29
+ import type {
30
+ AbbreviationDefinitionInput,
31
+ NotationRegistryInput,
32
+ } from './types';
33
+ import type { CourseAbbreviations } from '../compiler/course-config';
34
+
35
+ // An all-caps run of 2+ letters, an optional trailing plural/possessive `s`
36
+ // or `'s` (`OLSs`, `SSR's`) stripped from the reported token so it matches
37
+ // the table/baseline entry for the singular form.
38
+ const CAPS_RUN = /\b([A-Z]{2,})(?:'s|s)?\b/g;
39
+ // A run of 2+ single-letter-then-period groups: `w.r.t.`, `i.i.d.`, `e.g.`.
40
+ const DOTTED_ABBR = /\b(?:[A-Za-z]\.){2,}/g;
41
+
42
+ interface AbbreviationMatch {
43
+ readonly token: string;
44
+ readonly index: number;
45
+ }
46
+
47
+ /** Every abbreviation-shaped token in an already-prose-only string, in source order. */
48
+ function findAbbreviationLikeTokens(text: string): AbbreviationMatch[] {
49
+ const out: AbbreviationMatch[] = [];
50
+ for (const match of text.matchAll(CAPS_RUN)) {
51
+ out.push({ token: match[1] ?? match[0], index: match.index ?? 0 });
52
+ }
53
+ for (const match of text.matchAll(DOTTED_ABBR)) {
54
+ out.push({ token: match[0], index: match.index ?? 0 });
55
+ }
56
+ return out.sort((a, b) => a.index - b.index);
57
+ }
58
+
59
+ const FIX =
60
+ 'Add it to an abbreviations-table entry (content/abbreviations/*.md), ' +
61
+ 'allowlist it in abbreviations.allow in course.config.ts if it is common, ' +
62
+ 'non-technical usage, or — if it names a math quantity — introduce it as ' +
63
+ '\\mathrm{…} (or \\operatorname{…}) bound to a notation entry the reader ' +
64
+ 'can hover. See docs/authoring/notation.md#n14.';
65
+
66
+ /**
67
+ * Scan every lesson body for an abbreviation-shaped token outside `$…$` that
68
+ * is neither universal nor explained. Returns one `MathGateDiagnostic`
69
+ * (context `'abbreviation'`, always an error) per unresolved token, deduped
70
+ * per lesson so a term used many times on one page is reported once.
71
+ */
72
+ export function gateAbbreviations(input: {
73
+ readonly notationInput: NotationRegistryInput;
74
+ readonly abbreviations: readonly AbbreviationDefinitionInput[];
75
+ readonly courseAbbreviations?: CourseAbbreviations;
76
+ }): MathGateDiagnostic[] {
77
+ const { notationInput, abbreviations, courseAbbreviations } = input;
78
+
79
+ const tableTokens = new Set<string>();
80
+ for (const entry of abbreviations) {
81
+ tableTokens.add(entry.abbr);
82
+ for (const alias of entry.aliases) tableTokens.add(alias);
83
+ }
84
+
85
+ const sharedByKey = new Map<string, ScopeEntry>(
86
+ notationInput.sharedDefinitions.map((definition) => [
87
+ definition.key,
88
+ { key: definition.key, notation: definition.notation },
89
+ ]),
90
+ );
91
+
92
+ const diagnostics: MathGateDiagnostic[] = [];
93
+
94
+ for (const lesson of notationInput.lessons) {
95
+ // Terms already explained as a math quantity on this page (N8/N10): every
96
+ // roman label / named operator carried by a bound local or `[[key]]`
97
+ // /`\explain`-referenced shared definition.
98
+ const mathQuantityNames = new Set<string>();
99
+ for (const entry of pageScope(lesson, sharedByKey)) {
100
+ for (const label of collectRomanLabels(entry.notation)) {
101
+ mathQuantityNames.add(label.name);
102
+ }
103
+ for (const operator of collectOperators(entry.notation)) {
104
+ mathQuantityNames.add(operator.name);
105
+ }
106
+ }
107
+
108
+ const text = proseOnly(lesson.body);
109
+ const reported = new Set<string>();
110
+ for (const { token, index } of findAbbreviationLikeTokens(text)) {
111
+ if (reported.has(token)) continue;
112
+ if (isBaseAbbreviation(token, courseAbbreviations)) continue;
113
+ if (tableTokens.has(token)) continue;
114
+ if (mathQuantityNames.has(token)) continue;
115
+ reported.add(token);
116
+ diagnostics.push({
117
+ context: 'abbreviation',
118
+ severity: 'error',
119
+ file: lesson.source.file,
120
+ line: lineOf(lesson.body, index),
121
+ token,
122
+ message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
123
+ });
124
+ }
125
+ }
126
+
127
+ return diagnostics;
128
+ }
@@ -20,14 +20,25 @@ import type {
20
20
  } from './types';
21
21
 
22
22
  export interface MathGateDiagnostic {
23
- readonly context: 'body' | 'formula' | 'assessment';
23
+ readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation';
24
24
  /**
25
25
  * Body / component-slot math blocks the build (the render path needs every
26
26
  * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
27
27
  * a formula's glyphs are now rendered live and explorable, so an unresolved
28
28
  * one is a hole the reader can see, and the corpus was brought to zero when
29
- * the tier was promoted. `assessment` math stays a Tier-3 warning until its
30
- * own reviewed content pass (D6/D13/D14).
29
+ * the tier was promoted. `assessment` joined the same way when
30
+ * `AssessmentRunner` started rendering `prompt` / `explanation` through
31
+ * KaTeX (`mixed-content.ts`) instead of showing them as raw text: an
32
+ * unresolved glyph is now a visible hole in the assessment UI, not an
33
+ * invisible warning, so the corpus was brought to zero and the tier
34
+ * promoted in the same change — see `CHANGELOG.md`. `prose` (N9,
35
+ * `gate-prose-notation.ts`) is the complementary direction — plain-text
36
+ * notation sitting *outside* `$…$` — and ships straight as an error: it has
37
+ * no partial corpus to absorb, since every prior instance was fixed in the
38
+ * same change that introduced the check. `abbreviation` (N14,
39
+ * `gate-abbreviations.ts`) is the same "ships straight as an error" story
40
+ * for a different shape of plain-text token — an unexplained acronym or
41
+ * dotted abbreviation, rather than a mis-typed quantity/unit.
31
42
  */
32
43
  readonly severity: 'error' | 'warning';
33
44
  readonly file: string;
@@ -39,7 +50,9 @@ export interface MathGateDiagnostic {
39
50
  const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
40
51
  body: 'error',
41
52
  formula: 'error',
42
- assessment: 'warning',
53
+ assessment: 'error',
54
+ prose: 'error',
55
+ abbreviation: 'error',
43
56
  };
44
57
 
45
58
  interface AssessmentItemLike {
@@ -93,11 +106,18 @@ function mathSpans(body: string): MathSpan[] {
93
106
  return spans;
94
107
  }
95
108
 
96
- type ScopeEntry = { key: string; notation: string };
109
+ export type ScopeEntry = { key: string; notation: string };
97
110
 
98
- function pageScope(
111
+ /**
112
+ * A lesson's flat glyph table: its own `notation.local` entries plus every
113
+ * shared card it introduces with `[[key]]` / `\explain{key}`. Exported so a
114
+ * caller that needs the *same* scope for a different purpose — rendering an
115
+ * assessment's math (`mixed-content.ts`), say — doesn't reconstruct it by
116
+ * hand and risk drifting from what the completeness gate actually checks.
117
+ */
118
+ export function pageScope(
99
119
  lesson: NotationLessonInput,
100
- sharedByKey: ReadonlyMap<string, SharedNotationDefinitionInput>,
120
+ sharedByKey: ReadonlyMap<string, ScopeEntry>,
101
121
  ): ScopeEntry[] {
102
122
  const local = lesson.localDefinitions.map((definition) => ({
103
123
  key: definition.key,