explico 0.7.0 → 0.11.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +14 -1
  15. package/dist/compiler/logging.js +6 -2
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -49,8 +49,16 @@ import remarkNotation, {
49
49
  } from '../reference/remark-notation.mjs';
50
50
  import remarkShortNote from '../reference/remark-short-note.mjs';
51
51
  import type { DiagnosticSeverity } from '../reference/types';
52
+ import type { EquationLink } from '../reference/gate-math';
52
53
  import type { Manifest, ManifestLesson } from './manifest';
53
54
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
55
+ import { isCourseRuleId, ownerOf, ELEMENTS, type Element, type Enforcement } from '../rules/catalog';
56
+ import {
57
+ renderRulesInEffect,
58
+ rulesInEffect,
59
+ type RuleInEffect,
60
+ type RulesFilter,
61
+ } from '../rules/in-effect';
54
62
 
55
63
  const REPOSITORY_ROOT = process.cwd();
56
64
  // Content and its course math are the top-level `content/` folder (Phase F).
@@ -85,17 +93,45 @@ export class ContentCliError extends Error {
85
93
  /** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
86
94
  export interface CliDiagnostic {
87
95
  readonly code: string;
96
+ /**
97
+ * The catalog entry that owns the code — a rule ID (`N9`) or an invariant
98
+ * slug (`keyed-floats`). Absent only for a code the catalog does not know,
99
+ * which `tests/rules/catalog.test.ts` treats as a bug.
100
+ */
101
+ readonly rule?: string;
88
102
  readonly severity: DiagnosticSeverity;
89
103
  readonly message: string;
90
104
  readonly file?: string;
91
105
  readonly line?: number;
92
106
  readonly column?: number;
107
+ /**
108
+ * The keyed equation the finding sits in, when the math carries a
109
+ * `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
110
+ * rendered page. A size-only finding inside a long `aligned` block is
111
+ * otherwise anonymous.
112
+ */
113
+ readonly equation?: EquationLink;
93
114
  }
94
115
 
95
116
  function diagnosticSeverity(value: unknown): DiagnosticSeverity {
96
117
  return value === 'info' || value === 'warning' ? value : 'error';
97
118
  }
98
119
 
120
+ /**
121
+ * `{ rule }` for a `<source>:<code>` (or bare) code the catalog owns — or for a
122
+ * course rule, whose code is its own ID (`X1`).
123
+ */
124
+ function ownerFields(code: string): { readonly rule?: string } {
125
+ const bare = code.slice(code.indexOf(':') + 1);
126
+ const owner = ownerOf(bare);
127
+ if (owner !== undefined) return { rule: owner.id };
128
+ return isCourseRuleId(bare) ? { rule: bare } : {};
129
+ }
130
+
131
+ function withOwner(diagnostic: CliDiagnostic): CliDiagnostic {
132
+ return { ...diagnostic, ...ownerFields(diagnostic.code) };
133
+ }
134
+
99
135
  function mentions(message: string, lessonId: string, file: string): boolean {
100
136
  return (
101
137
  message.includes(lessonId) ||
@@ -159,12 +195,15 @@ export function lessonDiagnostics(
159
195
  ));
160
196
  if (!forLesson) continue;
161
197
  out.push({
162
- code: `math:${diagnostic.code ?? diagnostic.context}`,
198
+ code: `math:${diagnostic.code}`,
163
199
  severity: diagnostic.severity,
164
200
  message: diagnostic.message,
165
201
  file: diagnostic.file,
166
202
  ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
167
203
  ...(diagnostic.column === undefined ? {} : { column: diagnostic.column }),
204
+ ...(diagnostic.equation === undefined
205
+ ? {}
206
+ : { equation: diagnostic.equation }),
168
207
  });
169
208
  }
170
209
 
@@ -189,6 +228,8 @@ export function lessonDiagnostics(
189
228
  ...manifest.diagnostics.figures,
190
229
  ...manifest.diagnostics.diagrams,
191
230
  ...manifest.diagnostics.checks,
231
+ ...manifest.diagnostics.examples,
232
+ ...manifest.diagnostics.folds,
192
233
  ...manifest.diagnostics.rawFloats,
193
234
  ]) {
194
235
  if (
@@ -216,10 +257,12 @@ export function lessonDiagnostics(
216
257
  });
217
258
  }
218
259
 
219
- return out.sort(
220
- (a, b) =>
221
- a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
222
- );
260
+ return out
261
+ .map(withOwner)
262
+ .sort(
263
+ (a, b) =>
264
+ a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
265
+ );
223
266
  }
224
267
 
225
268
  // --- renderability -------------------------------------------------------
@@ -501,6 +544,7 @@ export interface StatusReport {
501
544
  readonly keyedTables: number;
502
545
  readonly keyedFigures: number;
503
546
  readonly keyedDiagrams: number;
547
+ readonly keyedExamples: number;
504
548
  };
505
549
  readonly draftDebt: {
506
550
  readonly lessons: readonly string[];
@@ -528,6 +572,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
528
572
  .filter((diagnostic) => diagnostic.code === 'unused-definition')
529
573
  .map((diagnostic) => ({
530
574
  code: `notation:${diagnostic.code}`,
575
+ ...ownerFields(diagnostic.code),
531
576
  severity: diagnosticSeverity(diagnostic.severity),
532
577
  message: diagnostic.message,
533
578
  ...(diagnostic.source?.file === undefined
@@ -546,9 +591,13 @@ export function contentStatus(manifest: Manifest): StatusReport {
546
591
  ...manifest.diagnostics.figures.map((d) => d.severity),
547
592
  ...manifest.diagnostics.diagrams.map((d) => d.severity),
548
593
  ...manifest.diagnostics.checks.map((d) => d.severity),
594
+ ...manifest.diagnostics.examples.map((d) => d.severity),
595
+ ...manifest.diagnostics.folds.map((d) => d.severity),
549
596
  ...manifest.diagnostics.rawFloats.map((d) => d.severity),
550
597
  ...manifest.diagnostics.consistency.map((d) => d.severity),
551
598
  ...manifest.diagnostics.knowledge.map((d) => d.severity),
599
+ ...manifest.diagnostics.config.map((d) => d.severity),
600
+ ...manifest.diagnostics.waivers.map((d) => d.severity),
552
601
  ];
553
602
 
554
603
  const keyedEquations = manifest.lessons.reduce(
@@ -567,6 +616,10 @@ export function contentStatus(manifest: Manifest): StatusReport {
567
616
  (count, lesson) => count + lesson.diagrams.length,
568
617
  0,
569
618
  );
619
+ const keyedExamples = manifest.lessons.reduce(
620
+ (count, lesson) => count + lesson.examples.length,
621
+ 0,
622
+ );
570
623
  const assessments = new Set(
571
624
  manifest.lessons.flatMap((lesson) => lesson.assessments),
572
625
  ).size;
@@ -584,6 +637,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
584
637
  keyedTables,
585
638
  keyedFigures,
586
639
  keyedDiagrams,
640
+ keyedExamples,
587
641
  },
588
642
  draftDebt: { lessons: draftLessons, notation: draftNotation },
589
643
  orphans,
@@ -621,8 +675,18 @@ type DiagnosticRecord = Record<string, unknown> & {
621
675
  readonly line?: unknown;
622
676
  readonly column?: unknown;
623
677
  readonly source?: unknown;
678
+ readonly equation?: unknown;
624
679
  };
625
680
 
681
+ /** `{ key, href }` as it survives the untyped manifest walk above. */
682
+ function equationLink(value: unknown): EquationLink | undefined {
683
+ if (value === null || typeof value !== 'object') return undefined;
684
+ const { key, href } = value as Record<string, unknown>;
685
+ return typeof key === 'string' && typeof href === 'string'
686
+ ? { key, href }
687
+ : undefined;
688
+ }
689
+
626
690
  function numericLocation(value: unknown): number | undefined {
627
691
  return typeof value === 'number' && Number.isInteger(value) && value > 0
628
692
  ? value
@@ -685,13 +749,16 @@ export function contentDiagnostics(
685
749
  : typeof diagnostic.kind === 'string'
686
750
  ? diagnostic.kind
687
751
  : 'diagnostic';
752
+ const equation = equationLink(diagnostic.equation);
688
753
  diagnostics.push({
689
754
  code: `${group}:${detail}`,
755
+ ...ownerFields(detail),
690
756
  severity,
691
757
  message: String(diagnostic.message ?? detail),
692
758
  ...(file === undefined ? {} : { file }),
693
759
  ...(line === undefined ? {} : { line }),
694
760
  ...(column === undefined ? {} : { column }),
761
+ ...(equation === undefined ? {} : { equation }),
695
762
  });
696
763
  }
697
764
  }
@@ -719,6 +786,61 @@ export function contentWarnings(manifest: Manifest): WarningsReport {
719
786
  return { count: warnings.length, warnings };
720
787
  }
721
788
 
789
+ // --- content rules -------------------------------------------------------
790
+
791
+ export interface RulesReport {
792
+ readonly filter: RulesFilter;
793
+ readonly count: number;
794
+ readonly rules: readonly RuleInEffect[];
795
+ /** The same selection as the page `validate:content` writes. */
796
+ readonly markdown: string;
797
+ }
798
+
799
+ /**
800
+ * Parse `--element a,b` / `--enforcement script` into a filter, throwing a
801
+ * usage error on a value outside the vocabulary.
802
+ */
803
+ export function parseRulesFilter(input: {
804
+ readonly element?: string;
805
+ readonly enforcement?: string;
806
+ }): RulesFilter {
807
+ const filter: { elements?: Element[]; enforcement?: Enforcement | 'locked' } = {};
808
+ if (input.element !== undefined) {
809
+ const elements = input.element.split(',').map((e) => e.trim()).filter(Boolean);
810
+ for (const element of elements) {
811
+ if (!(ELEMENTS as readonly string[]).includes(element)) {
812
+ throw new ContentCliError(
813
+ 'usage',
814
+ `--element ${element}: not an element. One of: ${ELEMENTS.join(', ')}.`,
815
+ );
816
+ }
817
+ }
818
+ filter.elements = elements as Element[];
819
+ }
820
+ if (input.enforcement !== undefined) {
821
+ const kinds = ['script', 'partial', 'guide', 'locked'];
822
+ if (!kinds.includes(input.enforcement)) {
823
+ throw new ContentCliError(
824
+ 'usage',
825
+ `--enforcement ${input.enforcement}: one of ${kinds.join(', ')}.`,
826
+ );
827
+ }
828
+ filter.enforcement = input.enforcement as Enforcement | 'locked';
829
+ }
830
+ return filter;
831
+ }
832
+
833
+ /** The rules this course has in effect, optionally narrowed. */
834
+ export function contentRules(manifest: Manifest, filter: RulesFilter = {}): RulesReport {
835
+ const rules = rulesInEffect(manifest, filter);
836
+ return {
837
+ filter,
838
+ count: rules.length,
839
+ rules,
840
+ markdown: renderRulesInEffect(manifest, filter),
841
+ };
842
+ }
843
+
722
844
  // --- content context ---------------------------------------------------
723
845
 
724
846
  export interface ContextReport {
@@ -749,7 +871,16 @@ export interface ContextReport {
749
871
  readonly tables: readonly { key: string; number: string }[];
750
872
  readonly figures: readonly { key: string; number: string }[];
751
873
  readonly diagrams: readonly { key: string; number: string }[];
752
- readonly knowledgeChecks: readonly { key: string; number: string }[];
874
+ /** Worked-example sets: `short` is the title `[[ex-key]]` names. */
875
+ readonly examples: readonly { key: string; number: string; short?: string }[];
876
+ readonly knowledgeChecks: readonly { key: string; number: string; title?: string }[];
877
+ /** The top-level headings: `slug` is what `[[sec-slug]]` names. */
878
+ readonly sections: readonly {
879
+ slug: string;
880
+ text: string;
881
+ level: number;
882
+ number?: string;
883
+ }[];
753
884
  readonly sources: readonly {
754
885
  id: string;
755
886
  title: string;
@@ -821,7 +952,9 @@ export function contentContext(
821
952
  tables: lesson.tables.map((table) => ({ ...table })),
822
953
  figures: lesson.figures.map((figure) => ({ ...figure })),
823
954
  diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
955
+ examples: lesson.examples.map((example) => ({ ...example })),
824
956
  knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
957
+ sections: lesson.headings.map((heading) => ({ ...heading })),
825
958
  sources: lesson.sources.map((id) => {
826
959
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
827
960
  return {
@@ -31,15 +31,21 @@ import type {
31
31
  NotationLessonInput,
32
32
  NotationReferenceInput,
33
33
  NotationRegistryInput,
34
+ NotationSuspension,
34
35
  SharedNotationDefinitionInput,
35
36
  SourceSpan,
36
37
  NotationGlossEntry,
37
38
  } from '../reference/types';
38
39
  import { isSubstantiveMeaning } from '../reference/prose';
39
40
  import { parseEquationRef } from '../reference/equations.mjs';
41
+ import {
42
+ DECORATION_NAMES,
43
+ decorationOfEntry,
44
+ } from '../reference/math-glyphs.mjs';
40
45
  import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
41
46
  import { parseKnowledgeCheckRef } from '../reference/knowledge-checks.mjs';
42
47
  import { parseMemoRef } from '../reference/remark-memo.mjs';
48
+ import { parseSectionRef } from '../reference/sections.mjs';
43
49
  import { resolveLabel } from '../reference/label';
44
50
  import { resolveGlosses } from '../reference/gloss';
45
51
  import {
@@ -80,6 +86,32 @@ interface ParsedDocument {
80
86
  readonly data: Record<string, unknown>;
81
87
  /** Markdown body, byte-identical to the previous `gray-matter` output. */
82
88
  readonly body: string;
89
+ /**
90
+ * 1-based line *in the file* where {@link body} starts — the frontmatter
91
+ * block's height plus one. Every gate scans the body, so a finding's index
92
+ * is body-relative while the `file` it is reported against is not; without
93
+ * this offset a `file:line:column` diagnostic points at the wrong line (by
94
+ * the height of the frontmatter) and no editor can jump to it.
95
+ */
96
+ readonly bodyLine: number;
97
+ }
98
+
99
+ /**
100
+ * The line `body` starts on in `raw`. `parseFrontmatter` returns the body as a
101
+ * suffix of the file, so the offset is what precedes it; the `indexOf` branch
102
+ * is a guard for a future parser that normalizes the text rather than slicing.
103
+ */
104
+ function bodyStartLine(raw: string, body: string): number {
105
+ const offset = raw.endsWith(body)
106
+ ? raw.length - body.length
107
+ : raw.indexOf(body);
108
+ if (offset <= 0) return 1;
109
+
110
+ let line = 1;
111
+ for (let index = 0; index < offset; index += 1) {
112
+ if (raw.charCodeAt(index) === 10) line += 1;
113
+ }
114
+ return line;
83
115
  }
84
116
 
85
117
  /**
@@ -87,11 +119,14 @@ interface ParsedDocument {
87
119
  * did not; strip it so body offsets in diagnostics are unchanged.
88
120
  */
89
121
  function parseDocument(path: string): ParsedDocument {
90
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
122
+ const raw = readFileSync(path, 'utf8');
123
+ const parsed = parseFrontmatter(raw);
124
+ const body = parsed.content.replace(/^\n/, '');
91
125
  return {
92
126
  file: relative(REPOSITORY_ROOT, path),
93
127
  data: (parsed.frontmatter ?? {}) as Record<string, unknown>,
94
- body: parsed.content.replace(/^\n/, ''),
128
+ body,
129
+ bodyLine: bodyStartLine(raw, body),
95
130
  };
96
131
  }
97
132
 
@@ -234,16 +269,19 @@ export function extractNotationReferences(
234
269
  for (const match of searchable.matchAll(pattern)) {
235
270
  const key = (match[1] ?? '').trim();
236
271
  // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
237
- // `[[tab-key]]`/`[[fig-key]]`/`[[dia-key]]` (and their cross-page forms)
238
- // are keyed-float references, `[[check-key]]` targets a lesson's
239
- // assessment list, and `[[memo-key]]` is a page-local memo reference —
240
- // all resolved by the remark pass and the manifest, not notation keys.
272
+ // `[[tab-key]]`/`[[fig-key]]`/`[[dia-key]]`/`[[ex-key]]` (and their
273
+ // cross-page forms) are keyed-float references, `[[check-key]]`
274
+ // targets a lesson's assessment list, `[[sec-slug]]` a heading, and
275
+ // `[[memo-key]]` is a page-local memo reference — all resolved by the
276
+ // remark pass and the manifest, not notation keys.
241
277
  if (
242
278
  parseEquationRef(key) ||
243
279
  parseKeyedFloatRef(key, { prefix: 'tab' }) ||
244
280
  parseKeyedFloatRef(key, { prefix: 'fig' }) ||
245
281
  parseKeyedFloatRef(key, { prefix: 'dia' }) ||
282
+ parseKeyedFloatRef(key, { prefix: 'ex' }) ||
246
283
  parseKnowledgeCheckRef(key) ||
284
+ parseSectionRef(key) ||
247
285
  parseMemoRef(key)
248
286
  ) {
249
287
  continue;
@@ -294,6 +332,57 @@ function lessonCheckIds(lessonPath: string): string[] {
294
332
  : [];
295
333
  }
296
334
 
335
+ /**
336
+ * One part's **overview page** — `content/docs/<dir>/index.mdx`, the page the
337
+ * part's own directory URL resolves to. `isLessonSlug` already excludes any
338
+ * `<dir>/index`, so this page is not a chapter: it carries no competencies, no
339
+ * knowledge check, and no float numbers, and nothing in the curriculum graph
340
+ * changes when a course adds one.
341
+ */
342
+ export interface PartOverviewPage {
343
+ readonly dir: string;
344
+ readonly slug: string;
345
+ readonly file: string;
346
+ readonly title: string;
347
+ readonly description?: string;
348
+ /** MDX after the frontmatter — what the S2 gate (`gate-part-pages.ts`) reads. */
349
+ readonly body: string;
350
+ /** 1-based file line `body` starts on, for diagnostic locations. */
351
+ readonly bodyLine: number;
352
+ }
353
+
354
+ /**
355
+ * The overview page of every part that has one, keyed by the part's directory.
356
+ * A part without one is simply absent — overview pages are opt-in, one part at
357
+ * a time.
358
+ */
359
+ export function loadPartOverviews(
360
+ parts: readonly { readonly dir: string }[],
361
+ ): PartOverviewPage[] {
362
+ const pages: PartOverviewPage[] = [];
363
+ for (const { dir } of parts) {
364
+ for (const extension of ['.mdx', '.md']) {
365
+ const path = join(CONTENT_ROOT, 'docs', dir, `index${extension}`);
366
+ if (!existsSync(path)) continue;
367
+ const { data, file, body, bodyLine } = parseDocument(path);
368
+ const title = typeof data.title === 'string' ? data.title : dir;
369
+ const description =
370
+ typeof data.description === 'string' ? data.description : undefined;
371
+ pages.push({
372
+ dir,
373
+ slug: `${dir}/index`,
374
+ file,
375
+ title,
376
+ ...(description === undefined ? {} : { description }),
377
+ body,
378
+ bodyLine,
379
+ });
380
+ break;
381
+ }
382
+ }
383
+ return pages;
384
+ }
385
+
297
386
  function lessonEntries(
298
387
  prerequisitesById: ReadonlyMap<string, readonly string[]>,
299
388
  ): LessonDefinition[] {
@@ -479,22 +568,67 @@ export async function loadAbbreviationRegistryInput(): Promise<
479
568
  return abbreviationEntries();
480
569
  }
481
570
 
571
+ /**
572
+ * A lesson's `notation.suspend` (rule N22): decorations that are not
573
+ * operators on this page. A name outside `DECORATION_NAMES`, a decoration
574
+ * suspended twice, and a `notation.local` operator entry for a suspended
575
+ * decoration (the page would contradict itself) each fail the load.
576
+ */
577
+ export function notationSuspensions(
578
+ raw: unknown,
579
+ localDefinitions: readonly LocalNotationDefinitionInput[],
580
+ file: string,
581
+ ): NotationSuspension[] {
582
+ if (raw === undefined) return [];
583
+ if (!Array.isArray(raw)) {
584
+ throw new TypeError(`${file} notation.suspend must be an array`);
585
+ }
586
+ const seen = new Set<string>();
587
+ return raw.map((item, index) => {
588
+ const at = `${file} notation.suspend[${index}]`;
589
+ const data = record(item, at);
590
+ const operator = string(data.operator, `${at}.operator`).trim();
591
+ if (!DECORATION_NAMES.has(operator)) {
592
+ throw new Error(
593
+ `${at}.operator ${JSON.stringify(operator)} is not a decoration; use one of ${[...DECORATION_NAMES].join(', ')}`,
594
+ );
595
+ }
596
+ if (seen.has(operator)) {
597
+ throw new Error(`${at} suspends ${JSON.stringify(operator)} a second time`);
598
+ }
599
+ seen.add(operator);
600
+ const local = localDefinitions.find(
601
+ (definition) => decorationOfEntry(definition.notation) === operator,
602
+ );
603
+ if (local) {
604
+ throw new Error(
605
+ `${at} suspends ${JSON.stringify(operator)}, but notation.local entry ${local.key} explains it as an operator (latex ${JSON.stringify(local.notation)}); drop one of them`,
606
+ );
607
+ }
608
+ const reason = optionalString(data.reason, `${at}.reason`);
609
+ return reason === undefined ? { operator } : { operator, reason: reason.trim() };
610
+ });
611
+ }
612
+
482
613
  function notationLessonEntries(): NotationLessonInput[] {
483
614
  const lessons: NotationLessonInput[] = [];
484
615
  for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
485
616
  const slug = docSlug(path);
486
617
  if (!isLessonSlug(slug)) continue;
487
- const { file, data, body } = parseDocument(path);
618
+ const { file, data, body, bodyLine } = parseDocument(path);
488
619
  const notation = record(data.notation ?? {}, `${file} notation`);
489
620
  const local = Array.isArray(notation.local) ? notation.local : [];
621
+ const localDefinitions = local.map((entry, index) =>
622
+ localDefinition(entry, file, index),
623
+ );
490
624
  lessons.push({
491
625
  lessonId: lessonIdFromSlug(slug),
492
626
  status: status(data.editorialStatus, `${file} editorialStatus`),
493
- localDefinitions: local.map((entry, index) =>
494
- localDefinition(entry, file, index),
495
- ),
627
+ localDefinitions,
628
+ suspended: notationSuspensions(notation.suspend, localDefinitions, file),
496
629
  references: extractNotationReferences(body, file, undefined),
497
630
  body,
631
+ bodyLine,
498
632
  source: { file },
499
633
  });
500
634
  }