explico 0.13.1 → 0.15.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 (256) hide show
  1. package/CHANGELOG.md +990 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +100 -0
  6. package/dist/compiler/cli-main.js +433 -0
  7. package/dist/compiler/cli-reference.d.ts +50 -0
  8. package/dist/compiler/cli-reference.js +213 -0
  9. package/dist/compiler/cli-verbs.d.ts +45 -0
  10. package/dist/compiler/cli-verbs.js +224 -0
  11. package/dist/compiler/cli-verify.d.ts +51 -0
  12. package/dist/compiler/cli-verify.js +151 -0
  13. package/dist/compiler/cli.d.ts +408 -24
  14. package/dist/compiler/cli.js +1328 -8
  15. package/dist/compiler/collections.d.ts +88 -1
  16. package/dist/compiler/collections.js +170 -25
  17. package/dist/compiler/content-release.d.ts +49 -0
  18. package/dist/compiler/content-release.js +63 -0
  19. package/dist/compiler/content-schemas.d.ts +2 -2
  20. package/dist/compiler/course-config.d.ts +1 -1
  21. package/dist/compiler/course-config.js +105 -1
  22. package/dist/compiler/decks.d.ts +161 -0
  23. package/dist/compiler/decks.js +235 -0
  24. package/dist/compiler/exports/combined-html.d.ts +5 -1
  25. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  26. package/dist/compiler/exports/deck-fit.js +90 -0
  27. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  28. package/dist/compiler/exports/export-chapters.js +64 -5
  29. package/dist/compiler/exports/export-pages.d.ts +30 -3
  30. package/dist/compiler/exports/export-pages.js +24 -1
  31. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  32. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  33. package/dist/compiler/exports/latex/cache.js +129 -0
  34. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  35. package/dist/compiler/exports/latex/capture.js +3 -1
  36. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  37. package/dist/compiler/exports/latex/compile.js +12 -1
  38. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  39. package/dist/compiler/exports/latex/deck.js +349 -0
  40. package/dist/compiler/exports/latex/document.d.ts +51 -6
  41. package/dist/compiler/exports/latex/document.js +71 -12
  42. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  43. package/dist/compiler/exports/latex/explico.cls +13 -183
  44. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  45. package/dist/compiler/exports/latex/serialize.js +4 -2
  46. package/dist/compiler/exports/latex-export.d.ts +24 -2
  47. package/dist/compiler/exports/latex-export.js +107 -26
  48. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  49. package/dist/compiler/exports/markdown/serialize.js +85 -8
  50. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  51. package/dist/compiler/exports/markdown-export.js +50 -11
  52. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  53. package/dist/compiler/exports/standalone-html.js +3 -3
  54. package/dist/compiler/exports/static-tree.d.ts +38 -1
  55. package/dist/compiler/exports/static-tree.js +120 -4
  56. package/dist/compiler/knowledge-version.d.ts +26 -2
  57. package/dist/compiler/knowledge-version.js +27 -17
  58. package/dist/compiler/lesson-derivation.d.ts +143 -6
  59. package/dist/compiler/lesson-derivation.js +145 -13
  60. package/dist/compiler/manifest.d.ts +133 -4
  61. package/dist/compiler/manifest.js +364 -58
  62. package/dist/compiler/page-versions.d.ts +184 -0
  63. package/dist/compiler/page-versions.js +217 -0
  64. package/dist/compiler/render-diagnostics.d.ts +5 -1
  65. package/dist/compiler/resolution-report.d.ts +24 -6
  66. package/dist/compiler/resolution-report.js +22 -4
  67. package/dist/compiler/validate-content.d.ts +7 -0
  68. package/dist/compiler/validate-content.js +160 -0
  69. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  70. package/dist/curriculum/competency-graph.d.ts +54 -0
  71. package/dist/curriculum/competency-graph.js +41 -0
  72. package/dist/reference/base-library.d.mts +8 -0
  73. package/dist/reference/base-library.mjs +19 -0
  74. package/dist/reference/consistency.d.ts +8 -4
  75. package/dist/reference/consistency.js +82 -26
  76. package/dist/reference/deck-remark.d.mts +123 -0
  77. package/dist/reference/deck-remark.mjs +600 -0
  78. package/dist/reference/deck-source.d.ts +107 -0
  79. package/dist/reference/deck-source.js +140 -0
  80. package/dist/reference/decks-validate.d.ts +133 -0
  81. package/dist/reference/decks-validate.js +359 -0
  82. package/dist/reference/derived-entries.d.ts +5 -1
  83. package/dist/reference/diagram-fit.d.ts +6 -2
  84. package/dist/reference/diagram-fit.js +5 -1
  85. package/dist/reference/figure-symbols.d.ts +23 -6
  86. package/dist/reference/figure-symbols.js +51 -15
  87. package/dist/reference/float-name.d.ts +43 -0
  88. package/dist/reference/float-name.js +58 -0
  89. package/dist/reference/folds-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-indices.js +5 -4
  92. package/dist/reference/gate-math.d.ts +29 -2
  93. package/dist/reference/gate-math.js +99 -18
  94. package/dist/reference/gate-part-pages.d.ts +1 -1
  95. package/dist/reference/lab-katex-validate.d.ts +10 -6
  96. package/dist/reference/lab-katex-validate.js +17 -9
  97. package/dist/reference/lab-math-scope.js +29 -4
  98. package/dist/reference/math-glyphs.d.mts +25 -0
  99. package/dist/reference/math-glyphs.mjs +189 -25
  100. package/dist/reference/page-notation.d.ts +22 -3
  101. package/dist/reference/page-notation.js +32 -10
  102. package/dist/reference/references.d.ts +14 -1
  103. package/dist/reference/references.js +13 -0
  104. package/dist/reference/registry-scope.d.mts +33 -0
  105. package/dist/reference/registry-scope.mjs +34 -0
  106. package/dist/reference/registry.js +29 -10
  107. package/dist/reference/rehype-deck.d.mts +6 -0
  108. package/dist/reference/rehype-deck.mjs +286 -0
  109. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  110. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  111. package/dist/reference/remark-notation.mjs +249 -40
  112. package/dist/reference/source-lines.d.mts +12 -0
  113. package/dist/reference/source-lines.mjs +65 -0
  114. package/dist/reference/types.d.ts +28 -0
  115. package/dist/rules/catalog.d.ts +21 -4
  116. package/dist/rules/catalog.js +237 -2
  117. package/dist/rules/course-rules.d.ts +5 -1
  118. package/dist/rules/course-rules.js +17 -3
  119. package/dist/rules/course-rules.schema.json +12 -0
  120. package/dist/rules/in-effect.d.ts +13 -3
  121. package/dist/rules/in-effect.js +34 -10
  122. package/dist/rules/rules.schema.json +280 -1
  123. package/dist/rules/settings.d.ts +1 -1
  124. package/dist/session/PreferenceStore.d.ts +6 -0
  125. package/dist/session/PreferenceStore.js +18 -1
  126. package/dist/session/deck-memory.d.ts +116 -0
  127. package/dist/session/deck-memory.js +143 -0
  128. package/dist/session/reading-memory.d.ts +96 -0
  129. package/dist/session/reading-memory.js +133 -0
  130. package/docs/VERSIONING.md +21 -18
  131. package/docs/authoring/README.md +31 -9
  132. package/docs/authoring/agents.md +72 -18
  133. package/docs/authoring/cli-reference.json +2172 -0
  134. package/docs/authoring/cli.md +887 -0
  135. package/docs/authoring/components.md +15 -0
  136. package/docs/authoring/deck.md +612 -0
  137. package/docs/authoring/migrating.md +1195 -16
  138. package/docs/authoring/notation.md +49 -6
  139. package/docs/authoring/structure.md +80 -1
  140. package/docs/authoring/versioning.md +56 -3
  141. package/docs/authoring/visualizations.md +7 -2
  142. package/docs/course-agent/AGENTS.md +40 -0
  143. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  144. package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
  145. package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
  146. package/docs/course-agent/skills/release-content/SKILL.md +26 -0
  147. package/docs/diagram-scaling.md +148 -0
  148. package/docs/privacy.md +59 -0
  149. package/package.json +8 -2
  150. package/src/compiler/astro.ts +60 -21
  151. package/src/compiler/cli-main.ts +557 -0
  152. package/src/compiler/cli-reference.ts +285 -0
  153. package/src/compiler/cli-verbs.ts +270 -0
  154. package/src/compiler/cli-verify.ts +241 -0
  155. package/src/compiler/cli.ts +2119 -170
  156. package/src/compiler/collections.ts +284 -25
  157. package/src/compiler/content-release.ts +91 -0
  158. package/src/compiler/course-config.ts +267 -0
  159. package/src/compiler/decks.ts +455 -0
  160. package/src/compiler/exports/deck-fit.ts +127 -0
  161. package/src/compiler/exports/export-chapters.ts +105 -6
  162. package/src/compiler/exports/export-pages.ts +46 -2
  163. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  164. package/src/compiler/exports/latex/cache.ts +183 -0
  165. package/src/compiler/exports/latex/capture.ts +5 -2
  166. package/src/compiler/exports/latex/compile.ts +18 -0
  167. package/src/compiler/exports/latex/deck.ts +419 -0
  168. package/src/compiler/exports/latex/document.ts +117 -21
  169. package/src/compiler/exports/latex/explico-common.sty +198 -0
  170. package/src/compiler/exports/latex/explico.cls +13 -183
  171. package/src/compiler/exports/latex/serialize.ts +25 -21
  172. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  173. package/src/compiler/exports/latex-export.ts +187 -45
  174. package/src/compiler/exports/markdown/serialize.ts +118 -9
  175. package/src/compiler/exports/markdown-export.ts +56 -11
  176. package/src/compiler/exports/standalone-html.ts +8 -7
  177. package/src/compiler/exports/static-tree.ts +175 -3
  178. package/src/compiler/knowledge-version.ts +56 -21
  179. package/src/compiler/lesson-derivation.ts +282 -16
  180. package/src/compiler/manifest.ts +568 -63
  181. package/src/compiler/page-versions.ts +401 -0
  182. package/src/compiler/resolution-report.ts +38 -7
  183. package/src/compiler/validate-content.ts +215 -0
  184. package/src/components/CourseContents.astro +124 -6
  185. package/src/components/CurriculumMap.astro +49 -1
  186. package/src/components/FloatIndex.astro +1 -1
  187. package/src/components/PartOverview.astro +51 -5
  188. package/src/components/assessments/AssessmentSet.astro +41 -15
  189. package/src/components/block-heading.ts +15 -6
  190. package/src/components/card-carry.ts +7 -4
  191. package/src/components/card-manager.ts +14 -2
  192. package/src/components/card-memory.ts +13 -9
  193. package/src/components/deck/DeckEnhancer.astro +13 -0
  194. package/src/components/deck/deck-context.ts +104 -0
  195. package/src/components/deck/deck-controller.ts +1287 -0
  196. package/src/components/deck/deck-map.ts +91 -0
  197. package/src/components/deck-lane.ts +163 -0
  198. package/src/components/diagram/Diagram.astro +131 -86
  199. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  200. package/src/components/figure/Figure.astro +7 -2
  201. package/src/components/figure/FloatCaption.astro +5 -3
  202. package/src/components/fold/Fold.astro +2 -2
  203. package/src/components/notation/NotationFormula.astro +81 -11
  204. package/src/components/notation/NotationGlossary.astro +132 -1
  205. package/src/components/notation/NotationLayer.astro +15 -7
  206. package/src/components/starlight/AboutPanel.astro +186 -3
  207. package/src/components/starlight/CollapsibleSections.astro +24 -3
  208. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  209. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  210. package/src/components/starlight/LayoutHeader.astro +17 -0
  211. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  212. package/src/components/starlight/LayoutSidebar.astro +807 -21
  213. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  214. package/src/components/starlight/LessonExportActions.astro +27 -9
  215. package/src/components/starlight/LessonFooter.astro +59 -0
  216. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  217. package/src/components/starlight/TextSizeControl.astro +24 -13
  218. package/src/components/starlight/page-marks.ts +139 -0
  219. package/src/components/starlight/section-summary.ts +1 -1
  220. package/src/components/starlight/sidebar-editions.ts +245 -0
  221. package/src/components/table/Table.astro +15 -7
  222. package/src/content-config.ts +87 -1
  223. package/src/curriculum/competency-graph.ts +95 -0
  224. package/src/reference/base-library.mjs +19 -0
  225. package/src/reference/consistency.ts +137 -39
  226. package/src/reference/deck-remark.mjs +600 -0
  227. package/src/reference/deck-source.ts +273 -0
  228. package/src/reference/decks-validate.ts +604 -0
  229. package/src/reference/diagram-fit.ts +18 -4
  230. package/src/reference/diagram-layout.ts +3 -1
  231. package/src/reference/figure-symbols.ts +108 -31
  232. package/src/reference/folds-validate.ts +1 -1
  233. package/src/reference/gate-indices.ts +5 -3
  234. package/src/reference/gate-math.ts +169 -25
  235. package/src/reference/lab-katex-validate.ts +30 -15
  236. package/src/reference/lab-math-scope.ts +34 -3
  237. package/src/reference/math-glyphs.mjs +189 -25
  238. package/src/reference/page-notation.ts +69 -15
  239. package/src/reference/references.ts +28 -0
  240. package/src/reference/registry-scope.mjs +34 -0
  241. package/src/reference/registry.ts +41 -11
  242. package/src/reference/rehype-deck.mjs +286 -0
  243. package/src/reference/rehype-heading-anchors.mjs +48 -6
  244. package/src/reference/remark-notation.mjs +249 -40
  245. package/src/reference/source-lines.mjs +65 -0
  246. package/src/reference/types.ts +28 -0
  247. package/src/rules/catalog.ts +274 -4
  248. package/src/rules/course-rules.ts +21 -1
  249. package/src/rules/in-effect.ts +58 -11
  250. package/src/session/PreferenceStore.ts +23 -0
  251. package/src/session/deck-memory.ts +263 -0
  252. package/src/session/reading-memory.ts +243 -0
  253. package/src/styles/deck.css +1150 -0
  254. package/src/styles/global.css +958 -27
  255. package/src/styles/zones.css +277 -0
  256. /package/src/{components → reference}/float-name.ts +0 -0
@@ -51,11 +51,19 @@ import { indexEntryFormula } from '../reference/derived-notation.mjs';
51
51
  import { isIndexEntryLatex } from './content-schemas';
52
52
  import type { ContentRecord } from '../reference/gate-retired-keys';
53
53
  import {
54
+ deckLessonSlug,
55
+ deckPageId,
56
+ deckRouteSlug,
54
57
  deriveRequires,
55
58
  deriveSources,
59
+ isDeckSlug,
56
60
  isLessonSlug,
61
+ isPartDeckSlug,
57
62
  lessonIdFromSlug,
58
63
  parseCheckList,
64
+ partDeckDir,
65
+ partDeckPageId,
66
+ partDeckRouteSlug,
59
67
  } from './lesson-derivation';
60
68
 
61
69
  // Every invocation — the CLI, `astro build`/`check`, and vitest — runs with the
@@ -325,14 +333,166 @@ function docSlug(path: string): string {
325
333
  return relative(join(CONTENT_ROOT, 'docs'), path).replace(/\\/g, '/');
326
334
  }
327
335
 
328
- /** Assessment ids from a lesson's colocated `<name>.checks.yml`, or `[]`. */
336
+ /**
337
+ * Assessment ids from a lesson's colocated `<name>.checks.yml`, or `[]`. A
338
+ * deck file (`<name>.deck.mdx`) reads the same file as its chapter: the checks
339
+ * belong to the lesson, whichever edition shows them.
340
+ */
329
341
  function lessonCheckIds(lessonPath: string): string[] {
330
- const checksPath = lessonPath.replace(/\.mdx?$/, '.checks.yml');
342
+ const checksPath = lessonPath.replace(/(?:\.deck)?\.mdx?$/, '.checks.yml');
331
343
  return existsSync(checksPath)
332
344
  ? parseCheckList(readFileSync(checksPath, 'utf8'))
333
345
  : [];
334
346
  }
335
347
 
348
+ /** Every `.md` / `.mdx` file under `content/docs/`, sorted. */
349
+ function docsFiles(): string[] {
350
+ const root = join(CONTENT_ROOT, 'docs');
351
+ return existsSync(root) ? filesBelow(root, ['.md', '.mdx']) : [];
352
+ }
353
+
354
+ /** What every deck file is, whichever it belongs to ({@link DeckFile}). */
355
+ interface DeckFileBase {
356
+ /** Absolute path of the deck file. */
357
+ readonly path: string;
358
+ /** Repository-relative path, the `file` of its diagnostics. */
359
+ readonly file: string;
360
+ /** Docs slug of the deck file, extension included: `motion/range.deck.mdx`. */
361
+ readonly slug: string;
362
+ /** Where the deck is served: `motion/range/slides`, `motion/range` alone, or a part's `motion/slides`. */
363
+ readonly route: string;
364
+ /** The id of the deck's page: `motion.range.slides`, the lesson's id alone, or a part's `motion.slides`. */
365
+ readonly pageId: string;
366
+ /** The part directory the deck sits in: `motion`; empty for a deck outside one. */
367
+ readonly partDir: string;
368
+ /** True when the deck sits in a part's directory, as a lesson does. */
369
+ readonly inPart: boolean;
370
+ /** The deck file's frontmatter. */
371
+ readonly data: Record<string, unknown>;
372
+ readonly body: string;
373
+ /** 1-based file line {@link body} starts on. */
374
+ readonly bodyLine: number;
375
+ }
376
+
377
+ /**
378
+ * A lesson's deck (`<lesson>.deck.mdx`): its slide edition, beside the
379
+ * lesson's chapter or, for a lesson that is only slides, alone. See
380
+ * `docs/authoring/deck.md`.
381
+ */
382
+ export interface LessonDeckFile extends DeckFileBase {
383
+ readonly scope: 'lesson';
384
+ /** The lesson's slug: `motion/range`. */
385
+ readonly lessonSlug: string;
386
+ readonly lessonId: string;
387
+ /** The lesson's chapter file, when it has one. */
388
+ readonly chapterPath?: string;
389
+ }
390
+
391
+ /**
392
+ * A part's deck (`<part>/index.deck.mdx`, beside the part's page): the
393
+ * slides of the part, whose sections may name the chapters they cover. It
394
+ * belongs to no lesson.
395
+ */
396
+ export interface PartDeckFile extends DeckFileBase {
397
+ readonly scope: 'part';
398
+ }
399
+
400
+ /** A deck file: a lesson's slide edition, or a part's deck. */
401
+ export type DeckFile = LessonDeckFile | PartDeckFile;
402
+
403
+ /** A lesson's chapter file, `.mdx` before `.md`, if it has one. */
404
+ function chapterPathOf(lessonSlug: string): string | undefined {
405
+ for (const extension of ['.mdx', '.md']) {
406
+ const path = join(CONTENT_ROOT, 'docs', `${lessonSlug}${extension}`);
407
+ if (existsSync(path)) return path;
408
+ }
409
+ return undefined;
410
+ }
411
+
412
+ /**
413
+ * Every deck file, by slug: each lesson's, and each part's. A part's deck
414
+ * (`<part>/index.deck.mdx`) is never read as the deck of a lesson
415
+ * `<part>/index` whose chapter is the part's page.
416
+ */
417
+ export function loadDeckFiles(): DeckFile[] {
418
+ const decks: DeckFile[] = [];
419
+ for (const path of docsFiles()) {
420
+ const slug = docSlug(path);
421
+ if (!isDeckSlug(slug)) continue;
422
+ const { file, data, body, bodyLine } = parseDocument(path);
423
+ if (isPartDeckSlug(slug)) {
424
+ const partDir = partDeckDir(slug);
425
+ decks.push({
426
+ scope: 'part',
427
+ path,
428
+ file,
429
+ slug,
430
+ route: partDeckRouteSlug(partDir),
431
+ pageId: partDeckPageId(partDir),
432
+ partDir,
433
+ inPart: true,
434
+ data,
435
+ body,
436
+ bodyLine,
437
+ });
438
+ continue;
439
+ }
440
+ const lessonSlug = deckLessonSlug(slug);
441
+ const lessonId = lessonIdFromSlug(lessonSlug);
442
+ const chapterPath = chapterPathOf(lessonSlug);
443
+ const hasChapter = chapterPath !== undefined;
444
+ decks.push({
445
+ scope: 'lesson',
446
+ path,
447
+ file,
448
+ slug,
449
+ lessonSlug,
450
+ lessonId,
451
+ ...(chapterPath === undefined ? {} : { chapterPath }),
452
+ route: deckRouteSlug(lessonSlug, hasChapter),
453
+ pageId: deckPageId(lessonId, hasChapter),
454
+ partDir: lessonSlug.includes('/') ? lessonSlug.slice(0, lessonSlug.indexOf('/')) : '',
455
+ inPart: lessonSlug.includes('/') && !lessonSlug.endsWith('/index'),
456
+ data,
457
+ body,
458
+ bodyLine,
459
+ });
460
+ }
461
+ return decks;
462
+ }
463
+
464
+ /**
465
+ * A file that defines a lesson: every chapter, and every deck whose lesson has
466
+ * no chapter (a slides-first lesson). Its frontmatter is the lesson's —
467
+ * `teaches`, `assumptions`, `studyMinutes`, `notation` — and its body is what
468
+ * the lesson's numbering and curriculum passes read.
469
+ */
470
+ export interface LessonFile {
471
+ readonly path: string;
472
+ /** Docs slug of the file, extension included. */
473
+ readonly slug: string;
474
+ readonly lessonSlug: string;
475
+ readonly lessonId: string;
476
+ readonly edition: 'chapter' | 'deck';
477
+ }
478
+
479
+ /** Every lesson's defining file, sorted by lesson slug. */
480
+ export function loadLessonFiles(): LessonFile[] {
481
+ const lessons: LessonFile[] = [];
482
+ for (const path of docsFiles()) {
483
+ const slug = docSlug(path);
484
+ if (isLessonSlug(slug)) {
485
+ const lessonSlug = slug.replace(/\.(md|mdx)$/, '');
486
+ lessons.push({ path, slug, lessonSlug, lessonId: lessonIdFromSlug(slug), edition: 'chapter' });
487
+ } else if (isDeckSlug(slug) && slug.includes('/') && !isPartDeckSlug(slug)) {
488
+ const lessonSlug = deckLessonSlug(slug);
489
+ if (chapterPathOf(lessonSlug) !== undefined) continue;
490
+ lessons.push({ path, slug, lessonSlug, lessonId: lessonIdFromSlug(lessonSlug), edition: 'deck' });
491
+ }
492
+ }
493
+ return lessons.sort((left, right) => left.lessonSlug.localeCompare(right.lessonSlug));
494
+ }
495
+
336
496
  /**
337
497
  * One part's **overview page** — `content/docs/<dir>/index.mdx`, the page the
338
498
  * part's own directory URL resolves to. `isLessonSlug` already excludes any
@@ -388,17 +548,26 @@ function lessonEntries(
388
548
  prerequisitesById: ReadonlyMap<string, readonly string[]>,
389
549
  ): LessonDefinition[] {
390
550
  const lessons: LessonDefinition[] = [];
391
- for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
392
- const slug = docSlug(path);
393
- if (!isLessonSlug(slug)) continue;
551
+ // A deck beside its chapter cites what its chapter does not need to: its
552
+ // sources count for the lesson too.
553
+ const deckBodies = new Map(
554
+ loadDeckFiles()
555
+ .filter((deck): deck is LessonDeckFile => deck.scope === 'lesson' && deck.chapterPath !== undefined)
556
+ .map((deck) => [deck.lessonId, parseDocument(deck.path).body]),
557
+ );
558
+ for (const { path, lessonId } of loadLessonFiles()) {
394
559
  const { data, body } = parseDocument(path);
395
560
  const teaches = (data.teaches as string[] | undefined) ?? [];
561
+ const deckBody = deckBodies.get(lessonId);
396
562
  lessons.push({
397
- id: lessonIdFromSlug(slug),
563
+ id: lessonId,
398
564
  requires: deriveRequires(teaches, prerequisitesById),
399
565
  teaches,
400
566
  assessments: lessonCheckIds(path),
401
- sources: deriveSources(body),
567
+ sources:
568
+ deckBody === undefined
569
+ ? deriveSources(body)
570
+ : [...new Set([...deriveSources(body), ...deriveSources(deckBody)])].sort(),
402
571
  assumptions: (data.assumptions as string[] | undefined) ?? [],
403
572
  body,
404
573
  });
@@ -538,7 +707,7 @@ function localDefinition(
538
707
  function sharedNotationEntries(): SharedNotationDefinitionInput[] {
539
708
  const directory = join(CONTENT_ROOT, 'notation');
540
709
  return filesBelow(directory, ['.md']).map((path) => {
541
- const { file, data, body } = parseDocument(path);
710
+ const { file, data, body, bodyLine } = parseDocument(path);
542
711
  const key = string(data.key, `${file} key`);
543
712
  const filenameKey = basename(path, '.md');
544
713
  if (key !== filenameKey) {
@@ -566,6 +735,7 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
566
735
  ? false
567
736
  : boolean(data.aiAssisted, `${file} aiAssisted`),
568
737
  body,
738
+ bodyLine,
569
739
  references: extractNotationReferences(body, file, undefined),
570
740
  glosses: glossEntries(data.glosses, key, `${file} glosses`),
571
741
  source: { file },
@@ -652,21 +822,31 @@ export function notationSuspensions(
652
822
  });
653
823
  }
654
824
 
825
+ /** A lesson file's `notation.local` entries and `notation.suspend` list, parsed. */
826
+ function lessonNotationOf(
827
+ file: string,
828
+ data: Record<string, unknown>,
829
+ ): { localDefinitions: LocalNotationDefinitionInput[]; suspended: NotationSuspension[] } {
830
+ const notation = record(data.notation ?? {}, `${file} notation`);
831
+ const local = Array.isArray(notation.local) ? notation.local : [];
832
+ const localDefinitions = local.map((entry, index) =>
833
+ localDefinition(entry, file, index),
834
+ );
835
+ return {
836
+ localDefinitions,
837
+ suspended: notationSuspensions(notation.suspend, localDefinitions, file),
838
+ };
839
+ }
840
+
655
841
  function notationLessonEntries(): NotationLessonInput[] {
656
842
  const lessons: NotationLessonInput[] = [];
657
- for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
658
- const slug = docSlug(path);
659
- if (!isLessonSlug(slug)) continue;
843
+ for (const { path, lessonId } of loadLessonFiles()) {
660
844
  const { file, data, body, bodyLine } = parseDocument(path);
661
- const notation = record(data.notation ?? {}, `${file} notation`);
662
- const local = Array.isArray(notation.local) ? notation.local : [];
663
- const localDefinitions = local.map((entry, index) =>
664
- localDefinition(entry, file, index),
665
- );
845
+ const { localDefinitions, suspended } = lessonNotationOf(file, data);
666
846
  lessons.push({
667
- lessonId: lessonIdFromSlug(slug),
847
+ lessonId,
668
848
  localDefinitions,
669
- suspended: notationSuspensions(notation.suspend, localDefinitions, file),
849
+ suspended,
670
850
  references: extractNotationReferences(body, file, undefined),
671
851
  body,
672
852
  bodyLine,
@@ -676,6 +856,87 @@ function notationLessonEntries(): NotationLessonInput[] {
676
856
  return lessons;
677
857
  }
678
858
 
859
+ /**
860
+ * A deck beside its chapter, as a page of the notation registry: its own id
861
+ * (`<lessonId>.slides`), no entries of its own, and its chapter's scope — the
862
+ * chapter's `notation.local` entries are the lesson's, so a deck names them
863
+ * as the chapter does, and it suspends what the chapter suspends. A part's
864
+ * deck (`<part>.slides`) has its part's scope: every chapter of the part, in
865
+ * the order the tracks read them, a key one chapter defines locally binding
866
+ * to the first that does; it suspends what any of them suspends. A deck that
867
+ * is a lesson's only edition is not here: it is that lesson's page
868
+ * (`loadNotationRegistryInput`).
869
+ */
870
+ export interface DeckNotationPage {
871
+ readonly deck: DeckFile;
872
+ readonly data: Record<string, unknown>;
873
+ readonly notation: NotationLessonInput;
874
+ }
875
+
876
+ export function loadDeckNotationPages(
877
+ decks: readonly DeckFile[] = loadDeckFiles(),
878
+ {
879
+ order = (lessonIds) => lessonIds,
880
+ }: {
881
+ /** The lessons of a part in reading order (the tracks'); by slug without it. */
882
+ readonly order?: (lessonIds: readonly string[]) => readonly string[];
883
+ } = {},
884
+ ): DeckNotationPage[] {
885
+ const pages: DeckNotationPage[] = [];
886
+ const chapterFiles = loadLessonFiles().filter((lesson) => lesson.edition === 'chapter');
887
+ for (const deck of decks) {
888
+ let chapters: { readonly lessonId: string; readonly path: string }[];
889
+ if (deck.scope === 'part') {
890
+ const inPart = chapterFiles.filter((lesson) => lesson.lessonSlug.startsWith(`${deck.partDir}/`));
891
+ const pathById = new Map(inPart.map((lesson) => [lesson.lessonId, lesson.path]));
892
+ chapters = order(inPart.map((lesson) => lesson.lessonId)).flatMap((lessonId) => {
893
+ const path = pathById.get(lessonId);
894
+ return path === undefined ? [] : [{ lessonId, path }];
895
+ });
896
+ } else if (deck.chapterPath !== undefined) {
897
+ chapters = [{ lessonId: deck.lessonId, path: deck.chapterPath }];
898
+ } else {
899
+ continue;
900
+ }
901
+ const inheritedLocalDefinitions: LocalNotationDefinitionInput[] = [];
902
+ const suspended: NotationSuspension[] = [];
903
+ const inheritedKeys = new Set<string>();
904
+ for (const { path } of chapters) {
905
+ const chapter = parseDocument(path);
906
+ const notation = lessonNotationOf(chapter.file, chapter.data);
907
+ for (const definition of notation.localDefinitions) {
908
+ if (!inheritedLocalDefinitions.some((known) => known.key === definition.key)) {
909
+ inheritedLocalDefinitions.push(definition);
910
+ }
911
+ }
912
+ for (const suspension of notation.suspended) {
913
+ if (!suspended.some((known) => known.operator === suspension.operator)) suspended.push(suspension);
914
+ }
915
+ for (const reference of extractNotationReferences(chapter.body, chapter.file, undefined)) {
916
+ inheritedKeys.add(reference.key);
917
+ }
918
+ }
919
+ const { file, data, body, bodyLine } = deck;
920
+ pages.push({
921
+ deck,
922
+ data,
923
+ notation: {
924
+ lessonId: deck.pageId,
925
+ localDefinitions: [],
926
+ suspended,
927
+ scopeLessonIds: chapters.map((chapter) => chapter.lessonId),
928
+ inheritedLocalDefinitions,
929
+ inheritedKeys: [...inheritedKeys].sort(),
930
+ references: extractNotationReferences(body, file, undefined),
931
+ body,
932
+ bodyLine,
933
+ source: { file },
934
+ },
935
+ });
936
+ }
937
+ return pages;
938
+ }
939
+
679
940
  /**
680
941
  * A docs page that is **not a chapter** (rule S10): the landing page, a part's
681
942
  * overview page, and any other page outside a part directory — every slug
@@ -701,9 +962,10 @@ export interface NonChapterPage {
701
962
 
702
963
  export function loadNonChapterPages(): NonChapterPage[] {
703
964
  const pages: NonChapterPage[] = [];
704
- for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
965
+ for (const path of docsFiles()) {
705
966
  const slug = docSlug(path);
706
- if (isLessonSlug(slug)) continue;
967
+ // A deck is an edition of a lesson (`loadDeckFiles`), not a page of its own kind.
968
+ if (isLessonSlug(slug) || isDeckSlug(slug)) continue;
707
969
  const { file, data, body, bodyLine } = parseDocument(path);
708
970
  const id = lessonIdFromSlug(slug);
709
971
  pages.push({
@@ -799,7 +1061,7 @@ export function loadSourceRecords(): Record<string, unknown>[] {
799
1061
  */
800
1062
  export function loadLessonChecks(lessonId: string): string[] {
801
1063
  const slug = lessonId.replace(/\./g, '/');
802
- for (const extension of ['.mdx', '.md']) {
1064
+ for (const extension of ['.mdx', '.md', '.deck.mdx', '.deck.md']) {
803
1065
  const lessonPath = join(CONTENT_ROOT, 'docs', `${slug}${extension}`);
804
1066
  if (existsSync(lessonPath)) return lessonCheckIds(lessonPath);
805
1067
  }
@@ -861,11 +1123,8 @@ export function loadNotationSpecs(): NotationSpec[] {
861
1123
  });
862
1124
  }
863
1125
 
864
- for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
865
- const slug = docSlug(path);
866
- if (!isLessonSlug(slug)) continue;
1126
+ for (const { path, lessonId } of loadLessonFiles()) {
867
1127
  const { file, data } = parseDocument(path);
868
- const lessonId = lessonIdFromSlug(slug);
869
1128
  const local = (data as { notation?: { local?: unknown } }).notation?.local;
870
1129
  if (!Array.isArray(local)) continue;
871
1130
  for (const raw of local) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `content release [major|minor|patch]` — cut a course content release (rule
3
+ * C3): `## [Unreleased]` in `content/CHANGELOG.md` becomes a dated
4
+ * `## [x.y.z]` heading and `content/course.config.ts` `about.version` becomes
5
+ * `x.y.z`, in one plan, shown as a diff and written only on `--write`, like
6
+ * the other edit verbs. The bump defaults to what the section's own headings
7
+ * imply (rule C2): `### Breaking` or `### Removed` a MAJOR, `### Added` a
8
+ * MINOR, anything else a PATCH.
9
+ *
10
+ * Each page's own version (rule C5) follows on the next `validate:content`,
11
+ * which records the release in `content/versions.json`; the release commit
12
+ * carries that file too.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { ContentCliError, type EditPlan } from './cli';
18
+
19
+ export type ReleaseBump = 'major' | 'minor' | 'patch';
20
+
21
+ const CHANGELOG = 'content/CHANGELOG.md';
22
+ const CONFIG = 'content/course.config.ts';
23
+
24
+ export interface ReleaseEdit {
25
+ readonly from: string;
26
+ readonly to: string;
27
+ readonly bump: ReleaseBump;
28
+ /** True when the bump was read from the section's headings, not asked for. */
29
+ readonly inferred: boolean;
30
+ readonly changelog: string;
31
+ readonly config: string;
32
+ }
33
+
34
+ /** The release of these two files' text on `date` (`YYYY-MM-DD`); `null` when `[Unreleased]` has no entry. */
35
+ export function releaseEdit(changelog: string, config: string, date: string, requested?: ReleaseBump): ReleaseEdit | null {
36
+ const unreleased = /^## \[Unreleased\]\r?$/m.exec(changelog);
37
+ if (!unreleased) throw new ContentCliError('no-changelog', `${CHANGELOG} has no "## [Unreleased]" heading.`);
38
+ const bodyStart = unreleased.index + unreleased[0].length;
39
+ const released = /^## \[\d+\.\d+\.\d+\](?: - .*?)?\r?$/m.exec(changelog.slice(bodyStart));
40
+ if (!released) throw new ContentCliError('no-changelog', `${CHANGELOG} has no released "## [x.y.z]" heading below "## [Unreleased]".`);
41
+ const bodyEnd = bodyStart + released.index;
42
+ const body = changelog.slice(bodyStart, bodyEnd);
43
+ if (!/^-\s+/m.test(body)) return null;
44
+
45
+ const version = /(about:\s*\{[\s\S]*?\bversion:\s*['"])(\d+)\.(\d+)\.(\d+)(['"])/.exec(config);
46
+ if (!version) throw new ContentCliError('unknown-version', `No about.version "x.y.z" in ${CONFIG}.`);
47
+ const [major, minor, patch] = version.slice(2, 5).map(Number) as [number, number, number];
48
+ const implied: ReleaseBump = /^### (Breaking|Removed)\s*$/m.test(body) ? 'major' : /^### Added\s*$/m.test(body) ? 'minor' : 'patch';
49
+ const bump = requested ?? implied;
50
+ const to = bump === 'major' ? `${major + 1}.0.0` : bump === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;
51
+ return {
52
+ from: `${major}.${minor}.${patch}`,
53
+ to,
54
+ bump,
55
+ inferred: requested === undefined,
56
+ changelog: `${changelog.slice(0, bodyStart)}\n\n## [${to}] - ${date}${body}${changelog.slice(bodyEnd)}`,
57
+ config: config.replace(version[0], `${version[1]}${to}${version[5]}`),
58
+ };
59
+ }
60
+
61
+ /** The release as an edit plan over the course in the working directory. */
62
+ /** Today where the release is cut, `YYYY-MM-DD`: a release at 9 pm is dated that day, not the next one in UTC. */
63
+ function localDate(now = new Date()): string {
64
+ return [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n, i) => String(n).padStart(i === 0 ? 4 : 2, '0')).join('-');
65
+ }
66
+
67
+ export function planContentRelease(requested?: string, date = localDate()): EditPlan {
68
+ if (requested !== undefined && requested !== 'major' && requested !== 'minor' && requested !== 'patch') {
69
+ throw new ContentCliError('usage', `content release takes major, minor or patch; got ${requested}.`);
70
+ }
71
+ for (const file of [CHANGELOG, CONFIG]) {
72
+ if (!existsSync(join(process.cwd(), file))) throw new ContentCliError('no-changelog', `No ${file} in this course.`);
73
+ }
74
+ const changelog = readFileSync(join(process.cwd(), CHANGELOG), 'utf8');
75
+ const config = readFileSync(join(process.cwd(), CONFIG), 'utf8');
76
+ const edit = releaseEdit(changelog, config, date, requested);
77
+ if (edit === null) {
78
+ return { summary: ['## [Unreleased] has no entries: nothing to release'], edits: [], next: [] };
79
+ }
80
+ return {
81
+ summary: [
82
+ `about.version ${edit.from} → ${edit.to} (${edit.bump}${edit.inferred ? ", from the section's headings" : ''})`,
83
+ `${CHANGELOG}: ## [Unreleased] → ## [${edit.to}] - ${date}, a fresh ## [Unreleased] above it`,
84
+ ],
85
+ edits: [
86
+ { file: CHANGELOG, before: changelog, after: edit.changelog },
87
+ { file: CONFIG, before: config, after: edit.config },
88
+ ],
89
+ next: ['pnpm validate:content (records the release in content/versions.json)', 'commit the changelog, the config and content/versions.json together'],
90
+ };
91
+ }