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
@@ -40,7 +40,11 @@ import remarkMdx from 'remark-mdx';
40
40
  import { createNotationKatexOptions } from '../reference/katex-options.mjs';
41
41
  import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
42
42
  import remarkCitation from '../reference/remark-citation.mjs';
43
- import remarkNotation from '../reference/remark-notation.mjs';
43
+ import remarkMemo from '../reference/remark-memo.mjs';
44
+ import remarkNotation, {
45
+ createRemarkNotationOptions,
46
+ } from '../reference/remark-notation.mjs';
47
+ import remarkShortNote from '../reference/remark-short-note.mjs';
44
48
  import type { Manifest, ManifestLesson } from './manifest';
45
49
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
46
50
 
@@ -173,6 +177,9 @@ export function lessonDiagnostics(
173
177
  for (const diagnostic of [
174
178
  ...manifest.diagnostics.tables,
175
179
  ...manifest.diagnostics.figures,
180
+ ...manifest.diagnostics.diagrams,
181
+ ...manifest.diagnostics.checks,
182
+ ...manifest.diagnostics.rawFloats,
176
183
  ]) {
177
184
  if (
178
185
  diagnostic.lessonId !== id &&
@@ -221,15 +228,18 @@ function processorFor(manifest: Manifest) {
221
228
  // shape here that it does there (D7/F-tables-figures parity).
222
229
  remarkMdx,
223
230
  remarkMath,
224
- [
225
- remarkNotation,
226
- {
227
- definitions: manifest.notation.raw,
228
- equations: manifest.equations.numbersBySlug,
229
- tables: manifest.tables.numbersBySlug,
230
- figures: manifest.figures.numbersBySlug,
231
- },
232
- ],
231
+ remarkShortNote,
232
+ remarkMemo,
233
+ // `manifest` carries the number tables *and* the part/offset placement
234
+ // together, so this check numbers a page exactly as the Astro build
235
+ // does. Passing the tables alone silently numbered pages by `##` section
236
+ // here while cross-page references read the part-scheme manifest.
237
+ // No `strict: true` here: `renderLesson()` below is also called
238
+ // directly with a synthetic path/body that has no manifest entry at all
239
+ // (fixture renderability checks — see its own doc comment), which
240
+ // strict mode would reject as "catalogued lesson with no numbering",
241
+ // even though it was never meant to be catalogued in the first place.
242
+ [remarkNotation, createRemarkNotationOptions(manifest)],
233
243
  [remarkCitation, { sources: manifest.sources }],
234
244
  ],
235
245
  rehypePlugins: [
@@ -480,6 +490,7 @@ export interface StatusReport {
480
490
  readonly keyedEquations: number;
481
491
  readonly keyedTables: number;
482
492
  readonly keyedFigures: number;
493
+ readonly keyedDiagrams: number;
483
494
  };
484
495
  readonly draftDebt: {
485
496
  readonly lessons: readonly string[];
@@ -525,6 +536,9 @@ export function contentStatus(manifest: Manifest): StatusReport {
525
536
  ...manifest.diagnostics.equations.map((d) => d.severity),
526
537
  ...manifest.diagnostics.tables.map((d) => d.severity),
527
538
  ...manifest.diagnostics.figures.map((d) => d.severity),
539
+ ...manifest.diagnostics.diagrams.map((d) => d.severity),
540
+ ...manifest.diagnostics.checks.map((d) => d.severity),
541
+ ...manifest.diagnostics.rawFloats.map((d) => d.severity),
528
542
  ...manifest.diagnostics.consistency.map(() => 'warning' as const),
529
543
  ...manifest.diagnostics.knowledge.map((d) => d.severity),
530
544
  ];
@@ -541,6 +555,10 @@ export function contentStatus(manifest: Manifest): StatusReport {
541
555
  (count, lesson) => count + lesson.figures.length,
542
556
  0,
543
557
  );
558
+ const keyedDiagrams = manifest.lessons.reduce(
559
+ (count, lesson) => count + lesson.diagrams.length,
560
+ 0,
561
+ );
544
562
  const assessments = new Set(
545
563
  manifest.lessons.flatMap((lesson) => lesson.assessments),
546
564
  ).size;
@@ -557,6 +575,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
557
575
  keyedEquations,
558
576
  keyedTables,
559
577
  keyedFigures,
578
+ keyedDiagrams,
560
579
  },
561
580
  draftDebt: { lessons: draftLessons, notation: draftNotation },
562
581
  orphans,
@@ -597,6 +616,8 @@ export interface ContextReport {
597
616
  readonly equations: readonly { key: string; number: string }[];
598
617
  readonly tables: readonly { key: string; number: string }[];
599
618
  readonly figures: readonly { key: string; number: string }[];
619
+ readonly diagrams: readonly { key: string; number: string }[];
620
+ readonly knowledgeChecks: readonly { key: string; number: string }[];
600
621
  readonly sources: readonly {
601
622
  id: string;
602
623
  title: string;
@@ -667,6 +688,8 @@ export function contentContext(
667
688
  equations: lesson.equations.map((equation) => ({ ...equation })),
668
689
  tables: lesson.tables.map((table) => ({ ...table })),
669
690
  figures: lesson.figures.map((figure) => ({ ...figure })),
691
+ diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
692
+ knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
670
693
  sources: lesson.sources.map((id) => {
671
694
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
672
695
  return {
@@ -24,6 +24,7 @@ import type {
24
24
  TrackDefinition,
25
25
  } from '../curriculum/validation';
26
26
  import type {
27
+ AbbreviationDefinitionInput,
27
28
  EditorialStatus,
28
29
  LocalNotationDefinitionInput,
29
30
  NotationAlignment,
@@ -37,6 +38,8 @@ import type {
37
38
  import { isSubstantiveMeaning } from '../reference/prose';
38
39
  import { parseEquationRef } from '../reference/equations.mjs';
39
40
  import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
41
+ import { parseKnowledgeCheckRef } from '../reference/knowledge-checks.mjs';
42
+ import { parseMemoRef } from '../reference/remark-memo.mjs';
40
43
  import { resolveLabel } from '../reference/label';
41
44
  import { resolveGlosses } from '../reference/gloss';
42
45
  import {
@@ -231,13 +234,17 @@ export function extractNotationReferences(
231
234
  for (const match of searchable.matchAll(pattern)) {
232
235
  const key = (match[1] ?? '').trim();
233
236
  // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
234
- // `[[tab-key]]`/`[[fig-key]]` (and their cross-page forms) are
235
- // `<Table>`/`<Figure>` references (F-tables-figures) — all resolved by
236
- // the remark pass and the manifest, not notation keys.
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.
237
241
  if (
238
242
  parseEquationRef(key) ||
239
243
  parseKeyedFloatRef(key, { prefix: 'tab' }) ||
240
- parseKeyedFloatRef(key, { prefix: 'fig' })
244
+ parseKeyedFloatRef(key, { prefix: 'fig' }) ||
245
+ parseKeyedFloatRef(key, { prefix: 'dia' }) ||
246
+ parseKnowledgeCheckRef(key) ||
247
+ parseMemoRef(key)
241
248
  ) {
242
249
  continue;
243
250
  }
@@ -438,6 +445,40 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
438
445
  });
439
446
  }
440
447
 
448
+ /**
449
+ * Abbreviations table (rule N14) — a lighter tier than a notation card, read
450
+ * straight off disk the same way `sharedNotationEntries` reads `notation/`.
451
+ * The directory is optional: a course that never uses this feature need not
452
+ * create an empty `content/abbreviations/`.
453
+ */
454
+ function abbreviationEntries(): AbbreviationDefinitionInput[] {
455
+ const directory = join(CONTENT_ROOT, 'abbreviations');
456
+ if (!existsSync(directory)) return [];
457
+ return filesBelow(directory, ['.md']).map((path) => {
458
+ const { file, data } = parseDocument(path);
459
+ const key = string(data.key, `${file} key`);
460
+ const filenameKey = basename(path, '.md');
461
+ if (key !== filenameKey) {
462
+ throw new Error(`${file} declares key ${key}; expected ${filenameKey}`);
463
+ }
464
+ const domain = optionalString(data.domain, `${file} domain`);
465
+ return {
466
+ key,
467
+ abbr: string(data.abbr, `${file} abbr`),
468
+ expansion: meaningText(data.expansion, `${file} expansion`),
469
+ aliases: strings(data.aliases, `${file} aliases`),
470
+ source: { file },
471
+ ...(domain === undefined ? {} : { domain }),
472
+ };
473
+ });
474
+ }
475
+
476
+ export async function loadAbbreviationRegistryInput(): Promise<
477
+ readonly AbbreviationDefinitionInput[]
478
+ > {
479
+ return abbreviationEntries();
480
+ }
481
+
441
482
  function notationLessonEntries(): NotationLessonInput[] {
442
483
  const lessons: NotationLessonInput[] = [];
443
484
  for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
@@ -7,13 +7,25 @@
7
7
  * supplies its own value of this shape.
8
8
  */
9
9
 
10
- export interface CourseSection {
10
+ /**
11
+ * One **part** — a sidebar group, holding the chapters (lessons) whose slugs
12
+ * start with `dir`. A part's 1-based position in `CourseConfig.parts` is the
13
+ * number before the dot in every equation, table, and figure number inside it.
14
+ * See `docs/authoring/structure.md` for the full level taxonomy.
15
+ */
16
+ export interface CoursePart {
11
17
  /** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
12
18
  readonly dir: string;
13
19
  /** Sidebar group heading. */
14
20
  readonly label: string;
15
21
  }
16
22
 
23
+ /**
24
+ * @deprecated Renamed to {@link CoursePart}. "Section" now only ever means an
25
+ * `##` heading inside a lesson. Removed in the next MINOR release.
26
+ */
27
+ export type CourseSection = CoursePart;
28
+
17
29
  export interface CourseConventions {
18
30
  /** Notation key a lesson's stated sign convention must resolve to. */
19
31
  readonly signConventionKey: string;
@@ -37,6 +49,39 @@ export interface CourseOperators {
37
49
  readonly deny?: readonly string[];
38
50
  }
39
51
 
52
+ /**
53
+ * A course's edits to the shipped roman-label baseline (`BASE_ROMAN_LABELS`),
54
+ * consumed by the `roman-label-explained` consistency check (rule N10). A
55
+ * `\mathrm{…}` multi-letter label a lesson uses is fine when it is on the
56
+ * baseline plus `allow` minus `deny`; otherwise it needs a notation entry the
57
+ * reader can hover. Same shape as {@link CourseOperators}, one level down:
58
+ * this governs `\mathrm{KE}`-style labels, not `\operatorname{Var}`-style
59
+ * operators.
60
+ */
61
+ export interface CourseRomanLabels {
62
+ /** Labels this course also treats as universal qualifiers (`eff`, `net`, …). */
63
+ readonly allow?: readonly string[];
64
+ /** Baseline labels this course wants explained anyway. */
65
+ readonly deny?: readonly string[];
66
+ }
67
+
68
+ /**
69
+ * A course's edits to the shipped abbreviation baseline (`BASE_ABBREVIATIONS`),
70
+ * consumed by the `abbreviation-explained` gate (rule N14). An abbreviation-
71
+ * shaped token — an all-caps acronym or a dotted form like `w.r.t.` — a
72
+ * lesson uses in prose is fine when it is on the baseline plus `allow` minus
73
+ * `deny`; otherwise it needs an entry in the `abbreviations` content
74
+ * collection, or — if it also names a math quantity — a notation entry the
75
+ * reader can hover. Same shape as {@link CourseOperators} / {@link
76
+ * CourseRomanLabels}.
77
+ */
78
+ export interface CourseAbbreviations {
79
+ /** Abbreviations this course also treats as universal (`GDP`, `CPU`, …). */
80
+ readonly allow?: readonly string[];
81
+ /** Baseline abbreviations this course wants explained anyway. */
82
+ readonly deny?: readonly string[];
83
+ }
84
+
40
85
  /** One person or team credited for the study material. */
41
86
  export interface CourseAuthor {
42
87
  readonly name: string;
@@ -71,11 +116,39 @@ export interface CourseAbout {
71
116
  readonly changelogHref?: string;
72
117
  }
73
118
 
119
+ /** Optional, build-time lesson artifacts exposed to readers. */
120
+ export interface LessonExportsConfig {
121
+ /** Emit one self-contained, offline-capable HTML file per lesson. */
122
+ readonly standaloneHtml?: boolean;
123
+ /** Offer the browser's native print dialog with explico's lesson print layout. */
124
+ readonly print?: boolean;
125
+ /**
126
+ * Published course root used for links that intentionally leave the exported
127
+ * lesson. Required at export time when such a link is present.
128
+ */
129
+ readonly canonicalBaseUrl?: string;
130
+ }
131
+
132
+ /** The stable, non-optional export settings carried by the manifest. */
133
+ export interface NormalizedLessonExports {
134
+ readonly standaloneHtml: boolean;
135
+ readonly print: boolean;
136
+ readonly canonicalBaseUrl?: string;
137
+ }
138
+
74
139
  export interface CourseConfig {
75
140
  readonly title: string;
76
141
  readonly description: string;
77
- /** Sidebar section groups, in order. */
78
- readonly sections: readonly CourseSection[];
142
+ /**
143
+ * The course's parts (sidebar groups), in order. Exactly one of `parts` or
144
+ * the deprecated `sections` must be given.
145
+ */
146
+ readonly parts?: readonly CoursePart[];
147
+ /**
148
+ * @deprecated Renamed to {@link CourseConfig.parts}. Still honoured, so a
149
+ * course on the old name keeps building; removed in the next MINOR release.
150
+ */
151
+ readonly sections?: readonly CoursePart[];
79
152
  /** Namespaces used by competency / notation `domain` fields. */
80
153
  readonly domains: readonly string[];
81
154
  readonly conventions: CourseConventions;
@@ -83,4 +156,59 @@ export interface CourseConfig {
83
156
  readonly about: CourseAbout;
84
157
  /** Edits to the shipped named-operator baseline (rule N8). Optional. */
85
158
  readonly operators?: CourseOperators;
159
+ /** Edits to the shipped roman-label baseline (rule N10). Optional. */
160
+ readonly romanLabels?: CourseRomanLabels;
161
+ /** Edits to the shipped abbreviation baseline (rule N14). Optional. */
162
+ readonly abbreviations?: CourseAbbreviations;
163
+ /** Per-lesson export actions and downloadable artifacts. Disabled when omitted. */
164
+ readonly lessonExports?: LessonExportsConfig;
165
+ }
166
+
167
+ /** Validate and canonicalize the build-facing lesson-export configuration. */
168
+ export function normalizeLessonExports(
169
+ config: CourseConfig,
170
+ ): NormalizedLessonExports {
171
+ const standaloneHtml = config.lessonExports?.standaloneHtml === true;
172
+ const print = config.lessonExports?.print === true;
173
+ const rawBase = config.lessonExports?.canonicalBaseUrl;
174
+ if (rawBase === undefined) return { standaloneHtml, print };
175
+
176
+ let parsed: URL;
177
+ try {
178
+ parsed = new URL(rawBase);
179
+ } catch {
180
+ throw new Error(
181
+ `CourseConfig lessonExports.canonicalBaseUrl must be an absolute ` +
182
+ `http(s) URL; received ${JSON.stringify(rawBase)}.`,
183
+ );
184
+ }
185
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
186
+ throw new Error(
187
+ `CourseConfig lessonExports.canonicalBaseUrl must use http(s); ` +
188
+ `received ${JSON.stringify(rawBase)}.`,
189
+ );
190
+ }
191
+ if (parsed.search || parsed.hash) {
192
+ throw new Error(
193
+ 'CourseConfig lessonExports.canonicalBaseUrl cannot contain a query or fragment.',
194
+ );
195
+ }
196
+ if (!parsed.pathname.endsWith('/')) parsed.pathname += '/';
197
+ return { standaloneHtml, print, canonicalBaseUrl: parsed.toString() };
198
+ }
199
+
200
+ /**
201
+ * The course's parts, from whichever field the config uses. Throws rather than
202
+ * silently producing a course with no sidebar groups — a config with neither
203
+ * field would otherwise number every float by the `##`-section fallback.
204
+ */
205
+ export function coursePartsOf(config: CourseConfig): readonly CoursePart[] {
206
+ const parts = config.parts ?? config.sections;
207
+ if (!parts || parts.length === 0) {
208
+ throw new Error(
209
+ 'CourseConfig needs at least one entry in `parts` (a sidebar group: ' +
210
+ '{ dir, label }). The old name `sections` is still accepted.',
211
+ );
212
+ }
213
+ return parts;
86
214
  }