explico 0.13.0 → 0.14.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 (239) hide show
  1. package/CHANGELOG.md +894 -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 +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1087 -14
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +29 -0
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -92,6 +92,8 @@ import {
92
92
  gateContentMath,
93
93
  pageScope,
94
94
  scopeEntry,
95
+ scopeLocals,
96
+ suspendedOperators,
95
97
  type MathGateDiagnostic,
96
98
  } from '../reference/gate-math';
97
99
  import { gateIndices, type DerivedUse } from '../reference/gate-indices';
@@ -129,7 +131,7 @@ import { loadLintIgnore } from '../reference/lint-ignore';
129
131
  import { buildNotationRegistry } from '../reference/registry';
130
132
  import { formulaGlyphScope } from '../reference/gloss';
131
133
  import type { GlyphScopeEntry } from '../reference/gloss';
132
- import { resolveReferenceId } from '../reference/references';
134
+ import { pageScopeLessonIds, resolvePageReferenceId } from '../reference/references';
133
135
  import type { NotationGlossEntry } from '../reference/types';
134
136
  import type {
135
137
  NotationBacklink,
@@ -142,12 +144,22 @@ import {
142
144
  coursePartsOf,
143
145
  normalizeBlockLabels,
144
146
  normalizeCards,
147
+ normalizeSidebarMarks,
148
+ normalizeSidebarEditions,
149
+ normalizeReadingMemory,
150
+ normalizeAppearance,
145
151
  normalizeLessonExports,
146
152
  normalizeDisclosure,
147
153
  normalizeNumbering,
154
+ normalizeDeck,
148
155
  type BlockLabels,
149
156
  type CourseConfig,
157
+ type NormalizedDeck,
150
158
  type NormalizedCards,
159
+ type NormalizedSidebarMarks,
160
+ type NormalizedSidebarEditions,
161
+ type ReadingMemorySetting,
162
+ type NormalizedAppearance,
151
163
  type NormalizedLessonExports,
152
164
  type NormalizedDisclosure,
153
165
  type NormalizedNumbering,
@@ -170,6 +182,14 @@ import {
170
182
  resolveKnowledgeVersion,
171
183
  type KnowledgeVersionDiagnostic,
172
184
  } from './knowledge-version';
185
+ import {
186
+ nextPageVersions,
187
+ PAGE_VERSIONS_FILE,
188
+ pageSourceDigest,
189
+ readPageVersions,
190
+ sinceOf,
191
+ type ManifestPageVersions,
192
+ } from './page-versions';
173
193
  import {
174
194
  loadAbbreviationRegistryInput,
175
195
  loadContentRecords,
@@ -180,12 +200,23 @@ import {
180
200
  loadNotationSpecs,
181
201
  loadPartOverviews,
182
202
  loadSourceRecords,
203
+ loadDeckFiles,
204
+ loadDeckNotationPages,
205
+ loadLessonFiles,
206
+ type LessonDeckFile,
207
+ type PartDeckFile,
183
208
  } from './collections';
209
+ import { buildDecks, chapterEquationLatex, type BuiltDeck, type DeckPartFacts } from './decks';
210
+ import { readDeckSource } from '../reference/deck-source';
211
+ import { coveredSections, type ChapterFloats } from '../reference/decks-validate';
184
212
  import {
185
213
  buildSidebar,
186
214
  deriveSources,
187
215
  isLessonSlug,
188
216
  lessonIdFromSlug,
217
+ mergeTrackOrder,
218
+ sidebarLessonSlug,
219
+ withPartDeckFrames,
189
220
  type SidebarGroup,
190
221
  } from './lesson-derivation';
191
222
 
@@ -346,8 +377,65 @@ import {
346
377
  * `formulaTemplate` when its entry sets them, and a bundle definition its
347
378
  * `range`. `diagnostics.math` carries `index-explained`, `index-swallowed`,
348
379
  * `index-range` and `derived-key-taken`.
380
+ *
381
+ * v32 (a page's own version, rule C5): `lessons[].since` and
382
+ * `parts[].overview.since` carry the knowledge version each chapter's and
383
+ * part page's content last changed in (`null` for a change not yet
384
+ * released), read from the committed `content/versions.json` through
385
+ * `pageVersions` — the record as `validate:content` writes it.
386
+ * `about.knowledge` gains `releases` (every released entry with its date)
387
+ * and `unreleased` (whether `## [Unreleased]` holds bullets).
388
+ * `diagnostics.knowledge` can carry `page-versions-invalid`.
389
+ * `sidebarMarks` carries the course's `sidebarMarks` setting, defaults
390
+ * applied: whether the sidebar marks a page the reader has not opened yet,
391
+ * and one whose content changed since they last did. `readingMemory` says
392
+ * whether the course asks the reader to keep that memory (`ask`) or never
393
+ * keeps it (`off`).
394
+ * v33 (decks, additive): `decks` lists each lesson's slide edition
395
+ * (`<lesson>.deck.mdx`) with its frames, numbered as the deck shows them;
396
+ * `deck` carries the course's `deck` settings, defaults applied, and the
397
+ * canvas; `lessons[].editions` says which editions a lesson has
398
+ * (`chapter`, `deck`). A slides-first lesson (a deck with no chapter) is a
399
+ * lesson like any other, with `editions: ["deck"]`. `decks[].since` is the
400
+ * version a deck's page last changed in (rule C5), as a chapter's `since`.
401
+ * In `sidebar`, a deck beside its chapter follows the chapter's slug as
402
+ * `{ slug, label: "Slides", attrs: { class: "explico-deck-row" } }`, and a
403
+ * slides-first lesson is `{ slug, attrs: { class: "explico-deck-lesson" } }`
404
+ * in place of its slug (`sidebarLessonSlug` reads either kind of lesson).
405
+ *
406
+ * v34 (editions in the sidebar): `sidebarEditions` carries the course's
407
+ * `sidebarEditions` setting, defaults applied: the edition a lesson's row
408
+ * opens (`lead`: `chapter` or `slides`) and whether the row lists the other
409
+ * (`other`: `listed` or `hidden`). In `sidebar`, every item of a lesson
410
+ * carries `data-edition` (`chapter` or `slides`), `data-lesson` (the
411
+ * lesson's slug) and `data-title` (its title) in `attrs`, so a chapter is
412
+ * `{ slug, attrs }` where it was a bare slug. An item lists its lesson when
413
+ * it is served at the lesson's own URL, `data-lesson` equal to its `slug`:
414
+ * a chapter, or a slides-first lesson's deck; a chapter's slides row
415
+ * follows it as before.
416
+ *
417
+ * v35 (the slides a reader has seen): every frame of `decks[].frames`
418
+ * carries `id`, 12 hex digits of a SHA-256 over what it shows, which a
419
+ * reader's record of the frames shown in Present mode keeps
420
+ * (`ManifestDeckFrame.id`).
421
+ *
422
+ * v36 (a part's deck): `decks` lists each part's deck
423
+ * (`<part>/index.deck.mdx`, at `<part>/slides`) too, with `part` (its
424
+ * directory) and no `lessonId`, which is now optional. Every deck carries
425
+ * `covers`, the chapters a part's deck covers with the frame each one's
426
+ * section opens on (empty on a lesson's deck); a part's deck carries
427
+ * `recalledFrom`, the chapter of each float it recalls, and each frame of
428
+ * a covered section `chapter`. In `sidebar`, a part's deck follows the
429
+ * part's Overview as `{ slug, label: "Slides", attrs: { class:
430
+ * "explico-deck-row explico-part-deck", "data-edition": "slides",
431
+ * "data-part" } }`, and a chapter it covers that has no deck of its own
432
+ * carries `data-deck-href`, the address of its section.
433
+ *
434
+ * v37 (the page's zones and slide edges): `appearance` carries the course's
435
+ * `appearance` setting, defaults applied: `zones` (`tonal`, `accent-header`,
436
+ * `paper` or `none`) and `slides` (`lifted`, `edge` or `sorter`).
349
437
  */
350
- export const MANIFEST_SCHEMA_VERSION = 31;
438
+ export const MANIFEST_SCHEMA_VERSION = 37;
351
439
 
352
440
  const REPOSITORY_ROOT = process.cwd();
353
441
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -453,6 +541,17 @@ export interface ManifestDerivedNotation {
453
541
  readonly formulaScope: readonly GlyphScopeEntry[];
454
542
  }
455
543
 
544
+ /**
545
+ * One lesson's deck (v33), or a part's (v36): its frames as `decks.ts` reads
546
+ * them, and the notation its page shows — its own bundle, which holds its
547
+ * chapters'.
548
+ */
549
+ export interface ManifestDeck extends BuiltDeck {
550
+ readonly notation: ManifestLesson['notation'];
551
+ /** The version the deck's page last changed in (rule C5), as a chapter's `since`. */
552
+ readonly since: string | null;
553
+ }
554
+
456
555
  /**
457
556
  * One keyed float of a lesson (v21): its key and number, and its caption pair
458
557
  * — `short` (an equation's `\caption{…}`, or the `short` prop) and, for a
@@ -484,6 +583,12 @@ export interface ManifestLesson {
484
583
  readonly id: string;
485
584
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
486
585
  readonly slug: string;
586
+ /**
587
+ * The lesson's editions (v33): `chapter` (its `.mdx`), `deck` (its
588
+ * `.deck.mdx`, listed in `manifest.decks`), or both. A slides-first lesson
589
+ * is `["deck"]`: its slug is its deck's route and its floats are unnumbered.
590
+ */
591
+ readonly editions: readonly ('chapter' | 'deck')[];
487
592
  /**
488
593
  * The 1-based ordinal of the **part** this lesson sits in — its sidebar
489
594
  * group's position in `courseConfig.parts`. It is the first number of every
@@ -532,6 +637,15 @@ export interface ManifestLesson {
532
637
  readonly file: string;
533
638
  readonly title: string;
534
639
  readonly description: string;
640
+ /**
641
+ * The knowledge version this chapter's content last changed in (v32, rule
642
+ * C5): its file, its checks and assessments, the shared notation it uses,
643
+ * the sources it cites and its numbers, against `content/versions.json`.
644
+ * `null` while it has a change `## [Unreleased]` has not released. What
645
+ * the exports and the `[?]` panel print for the page; with rule C5 off,
646
+ * the course's own version.
647
+ */
648
+ readonly since: string | null;
535
649
  /** Authored reader-facing estimate; `40` and `[30, 40]` both land here. */
536
650
  readonly studyMinutes?: { readonly min: number; readonly max: number };
537
651
  /** Ordered — the curriculum contract. */
@@ -614,6 +728,13 @@ export interface ManifestAbout {
614
728
  readonly date?: string;
615
729
  /** Bullet notes from that entry. */
616
730
  readonly notes: readonly string[];
731
+ /**
732
+ * Every released entry, newest first, with its date (v32): what dates a
733
+ * document whose content last changed in an earlier release (rule C5).
734
+ */
735
+ readonly releases: readonly { readonly version: string; readonly date?: string }[];
736
+ /** `## [Unreleased]` holds bullets: a change is waiting for a release (v32). */
737
+ readonly unreleased: boolean;
617
738
  /** Content fingerprint at compile time — ties the shown version to a build. */
618
739
  readonly contentTree: string;
619
740
  };
@@ -677,6 +798,8 @@ export interface ManifestPart {
677
798
  readonly title: string;
678
799
  readonly file: string;
679
800
  readonly description?: string;
801
+ /** The version the page's content last changed in (v32, rule C5), as a chapter's `since`. */
802
+ readonly since: string | null;
680
803
  };
681
804
  }
682
805
 
@@ -692,6 +815,30 @@ export interface Manifest {
692
815
  readonly blockLabels: BlockLabels;
693
816
  /** How the explanation cards start for a reader, defaults applied (`CourseConfig.cards`). */
694
817
  readonly cards: NormalizedCards;
818
+ /**
819
+ * Which marks the sidebar shows a reader beside a chapter or part page
820
+ * (v32, `CourseConfig.sidebarMarks`): not opened yet, changed since opened.
821
+ */
822
+ readonly sidebarMarks: NormalizedSidebarMarks;
823
+ /**
824
+ * Which edition a lesson's sidebar row opens for a reader who has not
825
+ * chosen, and whether it lists the other (v34, `CourseConfig.sidebarEditions`).
826
+ */
827
+ readonly sidebarEditions: NormalizedSidebarEditions;
828
+ /**
829
+ * Whether the course asks the reader to keep their reading memory (v32,
830
+ * `CourseConfig.readingMemory`): `ask` or `off`.
831
+ */
832
+ readonly readingMemory: ReadingMemorySetting;
833
+ /**
834
+ * How the page tells its zones apart and how slides sit on it, defaults
835
+ * applied (v37, `CourseConfig.appearance`).
836
+ */
837
+ readonly appearance: NormalizedAppearance;
838
+ /** The course's `deck` settings, defaults applied, and its canvas (v33). */
839
+ readonly deck: NormalizedDeck;
840
+ /** Each lesson's deck, by route (v33). */
841
+ readonly decks: readonly ManifestDeck[];
695
842
  /** Reader-facing identity + versions (the `[?]` About panel). */
696
843
  readonly about: ManifestAbout;
697
844
  readonly notation: {
@@ -711,6 +858,13 @@ export interface Manifest {
711
858
  /** The course's parts in config order, with their chapters and overview pages. */
712
859
  readonly parts: readonly ManifestPart[];
713
860
  readonly lessons: readonly ManifestLesson[];
861
+ /**
862
+ * The record of every chapter's and part page's own version (v32, rule
863
+ * C5), as `validate:content` writes it to `content/versions.json`
864
+ * (`syncPageVersions`); `lessons[].since` and `parts[].overview.since`
865
+ * are read from it.
866
+ */
867
+ readonly pageVersions: ManifestPageVersions;
714
868
  /** Every docs page that is not a chapter, by id (v25). */
715
869
  readonly pages: readonly ManifestPage[];
716
870
  /** Full competency prerequisite graph. */
@@ -872,11 +1026,12 @@ function lessonNotationResolution(
872
1026
  definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
873
1027
  availableDefinitionIds: ReadonlySet<string>,
874
1028
  ): ManifestNotationResolution[] {
875
- const scope = { kind: 'page' as const, lessonId: lesson.lessonId };
1029
+ // A deck's references resolve in its lessons' scopes, as the registry resolves them.
1030
+ const scopes = pageScopeLessonIds(lesson);
876
1031
  return lesson.references.map((reference) => {
877
- const definitionId = resolveReferenceId(
1032
+ const definitionId = resolvePageReferenceId(
878
1033
  reference.key,
879
- scope,
1034
+ scopes,
880
1035
  availableDefinitionIds,
881
1036
  );
882
1037
  const definition =
@@ -982,7 +1137,6 @@ function lessonFrontmatterById(): Map<
982
1137
  studyMinutes?: { min: number; max: number };
983
1138
  }
984
1139
  > {
985
- const base = join(CONTENT_ROOT, 'docs');
986
1140
  const result = new Map<
987
1141
  string,
988
1142
  {
@@ -991,29 +1145,18 @@ function lessonFrontmatterById(): Map<
991
1145
  studyMinutes?: { min: number; max: number };
992
1146
  }
993
1147
  >();
994
- const walk = (directory: string): void => {
995
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
996
- const path = join(directory, entry.name);
997
- if (entry.isDirectory()) {
998
- walk(path);
999
- continue;
1000
- }
1001
- if (!entry.isFile() || !/\.mdx?$/.test(entry.name)) continue;
1002
- const slug = relative(base, path).replace(/\\/g, '/');
1003
- if (!isLessonSlug(slug)) continue;
1004
- const id = slug.replace(/\.(md|mdx)$/, '').replace(/\//g, '.');
1005
- const data = (parseFrontmatter(readFileSync(path, 'utf8')).frontmatter ??
1006
- {}) as Record<string, unknown>;
1007
- const studyMinutes = studyMinutesRange(data.studyMinutes);
1008
- result.set(id, {
1009
- title: typeof data.title === 'string' ? data.title : id,
1010
- description:
1011
- typeof data.description === 'string' ? data.description : '',
1012
- ...(studyMinutes === undefined ? {} : { studyMinutes }),
1013
- });
1014
- }
1015
- };
1016
- walk(base);
1148
+ // Every lesson's defining file: its chapter, or the deck of a slides-first lesson.
1149
+ for (const { path, lessonId: id } of loadLessonFiles()) {
1150
+ const data = (parseFrontmatter(readFileSync(path, 'utf8')).frontmatter ??
1151
+ {}) as Record<string, unknown>;
1152
+ const studyMinutes = studyMinutesRange(data.studyMinutes);
1153
+ result.set(id, {
1154
+ title: typeof data.title === 'string' ? data.title : id,
1155
+ description:
1156
+ typeof data.description === 'string' ? data.description : '',
1157
+ ...(studyMinutes === undefined ? {} : { studyMinutes }),
1158
+ });
1159
+ }
1017
1160
  return result;
1018
1161
  }
1019
1162
 
@@ -1050,6 +1193,7 @@ export async function compileManifest(
1050
1193
  // Throws on a bad `numbering` or `blockLabels` value, like `resolveRules` above.
1051
1194
  const numberingSettings = normalizeNumbering(courseConfig);
1052
1195
  const blockLabels = normalizeBlockLabels(courseConfig);
1196
+ const deckSettings = normalizeDeck(courseConfig);
1053
1197
 
1054
1198
  const [catalog, notationInput, abbreviationsInput] = await Promise.all([
1055
1199
  loadCurriculumCatalog(),
@@ -1062,11 +1206,41 @@ export async function compileManifest(
1062
1206
  // text and math pass the same gates. It stays out of `notationInput.lessons`,
1063
1207
  // which is the chapter list every numbering and curriculum pass reads.
1064
1208
  const nonChapterPages = loadNonChapterPages();
1209
+ // A deck beside its chapter is a page of its own with its chapter's scope
1210
+ // (`loadDeckNotationPages`), and a part's deck one with its chapters': its
1211
+ // text and math pass the same gates. A slides-first lesson's deck is the
1212
+ // lesson itself, already in `notationInput.lessons`; it numbers nothing,
1213
+ // so the chapter-only passes below (`chapterInputs`) leave it out.
1214
+ const deckFiles = loadDeckFiles();
1215
+ const lessonDeckFiles = deckFiles.filter((deck): deck is LessonDeckFile => deck.scope === 'lesson');
1216
+ // A part's lessons in the order the tracks read them, as the sidebar
1217
+ // lists them: a part's deck binds a key its chapters define to the first.
1218
+ const trackRank = new Map(
1219
+ mergeTrackOrder(catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons }))).map(
1220
+ (lessonId, index) => [lessonId, index],
1221
+ ),
1222
+ );
1223
+ const readingOrder = (lessonIds: readonly string[]): string[] =>
1224
+ [...lessonIds].sort(
1225
+ (left, right) =>
1226
+ (trackRank.get(left) ?? Number.POSITIVE_INFINITY) - (trackRank.get(right) ?? Number.POSITIVE_INFINITY) ||
1227
+ left.localeCompare(right),
1228
+ );
1229
+ const deckPages = loadDeckNotationPages(deckFiles, { order: readingOrder });
1230
+ const deckLessonIds = new Set(
1231
+ loadLessonFiles()
1232
+ .filter((file) => file.edition === 'deck')
1233
+ .map((file) => file.lessonId),
1234
+ );
1235
+ const isChapter = (lessonId: string): boolean => !deckLessonIds.has(lessonId);
1236
+ const deckPageIds = new Set(deckPages.map((page) => page.notation.lessonId));
1237
+ const chapterInputs = notationInput.lessons.filter((lesson) => isChapter(lesson.lessonId));
1065
1238
  const withPages: typeof notationInput = {
1066
1239
  ...notationInput,
1067
1240
  lessons: [
1068
1241
  ...notationInput.lessons,
1069
1242
  ...nonChapterPages.map((page) => page.notation),
1243
+ ...deckPages.map((page) => page.notation),
1070
1244
  ],
1071
1245
  };
1072
1246
  const registry = buildNotationRegistry(withPages);
@@ -1120,9 +1294,9 @@ export async function compileManifest(
1120
1294
  notationInput.sharedDefinitions.map((definition) => [definition.key, definition.label]),
1121
1295
  );
1122
1296
  const localLabelsByLesson = new Map(
1123
- notationInput.lessons.map((lesson) => [
1297
+ withPages.lessons.map((lesson) => [
1124
1298
  lesson.lessonId,
1125
- new Map(lesson.localDefinitions.map((definition) => [definition.key, definition.label])),
1299
+ new Map(scopeLocals(lesson).map((definition) => [definition.key, definition.label])),
1126
1300
  ]),
1127
1301
  );
1128
1302
  const termLabel = (lessonId: string, key: string): string | undefined =>
@@ -1178,19 +1352,27 @@ export async function compileManifest(
1178
1352
  })
1179
1353
  : []),
1180
1354
  ...(rules.isEnabled('S1')
1181
- ? gateHeadingLength(notationInput.lessons, {
1355
+ ? gateHeadingLength(chapterInputs, {
1182
1356
  maxLength: rules.number('S1', 'maxLength'),
1183
1357
  labelFor: termLabel,
1184
1358
  })
1185
1359
  : []),
1186
- ...gateHeadingStructure(notationInput.lessons, {
1360
+ ...gateHeadingStructure(chapterInputs, {
1187
1361
  levels: rules.isEnabled('S6'),
1188
1362
  adjacency: rules.isEnabled('S7'),
1189
1363
  }),
1190
1364
  // The course's own rules (`courseRules`): one pass for every enabled `X`
1191
1365
  // rule that carries a `forbid` pattern. A disabled one, and a guide rule
1192
1366
  // with no pattern, do no work here.
1193
- ...gateCourseRules(withPages.lessons, rules.course),
1367
+ // A course rule about one edition is checked on that edition's pages.
1368
+ ...gateCourseRules(
1369
+ withPages.lessons.filter((lesson) => isChapter(lesson.lessonId) && !deckPageIds.has(lesson.lessonId)),
1370
+ rules.course.filter((rule) => rule.editions !== 'deck'),
1371
+ ),
1372
+ ...gateCourseRules(
1373
+ withPages.lessons.filter((lesson) => !isChapter(lesson.lessonId) || deckPageIds.has(lesson.lessonId)),
1374
+ rules.course.filter((rule) => rule.editions !== 'chapter'),
1375
+ ),
1194
1376
  // S10 is locked: a float on a non-chapter page has no number to render.
1195
1377
  ...gateNonChapterPages(nonChapterPages),
1196
1378
  ];
@@ -1311,6 +1493,17 @@ export async function compileManifest(
1311
1493
  'is left out of the sidebar and the numbering until it has one.',
1312
1494
  });
1313
1495
  }
1496
+ // A part's deck, of a part the sidebar lists: one in a directory no part
1497
+ // lists is `deck-outside-part` (`decks-validate.ts`).
1498
+ const configuredDirs = new Set(configuredParts.map((part) => part.dir));
1499
+ const partDeckFiles = deckFiles.filter(
1500
+ (deck): deck is PartDeckFile => deck.scope === 'part' && configuredDirs.has(deck.partDir),
1501
+ );
1502
+ /** A part's chapters, by their names in its directory (what `[chapter: <name>]` writes). */
1503
+ const partChapterNames = (dir: string): string[] =>
1504
+ lessonSlugs
1505
+ .filter((slug) => slug.startsWith(`${dir}/`) && isChapter(lessonIdFromSlug(slug)))
1506
+ .map((slug) => slug.slice(dir.length + 1));
1314
1507
  // A lesson whose directory no part lists has no part, no chapter number and
1315
1508
  // no place in the sidebar.
1316
1509
  const partDirs = new Set(listedParts.map((part) => part.dir));
@@ -1339,21 +1532,6 @@ export async function compileManifest(
1339
1532
  ),
1340
1533
  );
1341
1534
  }
1342
- // Per-place waivers: drop the located findings each one covers, and
1343
- // report the waivers themselves — a stale one is a warning, a bad one an
1344
- // error. Chapters and non-chapter pages are scanned alike, since the text
1345
- // and math gates run on both; S2 and S10 are per-file or locked, so no
1346
- // waiver reaches them.
1347
- const waiverDiagnostics: WaiverDiagnostic[] = [];
1348
- const waiverRules = { courseRules: rules.course };
1349
- const waivers = withPages.lessons.flatMap((lesson) => {
1350
- const collected = collectWaivers(lesson, waiverRules);
1351
- waiverDiagnostics.push(...collected.diagnostics);
1352
- return collected.waivers;
1353
- });
1354
- const waived = applyWaivers(mathIssues, waivers, waiverRules);
1355
- mathIssues.splice(0, mathIssues.length, ...waived.kept);
1356
- waiverDiagnostics.push(...waived.unused);
1357
1535
 
1358
1536
  const sidebar = buildSidebar(
1359
1537
  catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
@@ -1368,6 +1546,34 @@ export async function compileManifest(
1368
1546
  floatIndex: ['.mdx', '.md'].some((extension) =>
1369
1547
  existsSync(join(CONTENT_ROOT, 'docs', `figures-and-tables${extension}`)),
1370
1548
  ),
1549
+ // A deck beside its chapter is listed right under it.
1550
+ decks: new Map(
1551
+ lessonDeckFiles
1552
+ .filter((deck) => deck.chapterPath !== undefined && deck.inPart)
1553
+ .map((deck) => [deck.lessonSlug, deck.route]),
1554
+ ),
1555
+ // A deck with no chapter is the lesson: its row carries the deck's glyph.
1556
+ slidesFirst: new Set(
1557
+ lessonDeckFiles
1558
+ .filter((deck) => deck.chapterPath === undefined && deck.inPart)
1559
+ .map((deck) => deck.lessonSlug),
1560
+ ),
1561
+ // A part's deck follows the part's Overview; a chapter it covers that
1562
+ // has no deck of its own links into it, at its section's divider.
1563
+ partDecks: new Map(
1564
+ partDeckFiles.map((deck) => {
1565
+ const { covered } = coveredSections(readDeckSource(deck.body), new Set(partChapterNames(deck.partDir)));
1566
+ return [
1567
+ deck.partDir,
1568
+ {
1569
+ route: deck.route,
1570
+ covers: new Map(covered.map(({ name, position }) => [`${deck.partDir}/${name}`, position + 2])),
1571
+ },
1572
+ ];
1573
+ }),
1574
+ ),
1575
+ // Each edition's row names its lesson, which the sidebar's own row shows.
1576
+ titleOf: (slug) => frontmatterById.get(lessonIdFromSlug(slug))?.title,
1371
1577
  },
1372
1578
  );
1373
1579
 
@@ -1397,9 +1603,10 @@ export async function compileManifest(
1397
1603
  // after them is no part. An overview-only part counts, as `parts[]` does.
1398
1604
  for (const group of sidebar.slice(0, configuredParts.length)) {
1399
1605
  partCursor += 1;
1400
- const lessonIds = group.items
1401
- .filter((item): item is string => typeof item === 'string')
1402
- .map(lessonIdFromSlug);
1606
+ const lessonIds = group.items.flatMap((item) => {
1607
+ const slug = sidebarLessonSlug(item);
1608
+ return slug === undefined ? [] : [lessonIdFromSlug(slug)];
1609
+ });
1403
1610
  if (lessonIds.length === 0) continue;
1404
1611
  // Running totals for this part, advanced one chapter at a time; they
1405
1612
  // only reach a number under the part scope.
@@ -1413,7 +1620,8 @@ export async function compileManifest(
1413
1620
  };
1414
1621
  let chapterCursor = 0;
1415
1622
  for (const lessonId of lessonIds) {
1416
- const body = bodyByLessonId.get(lessonId) ?? '';
1623
+ // A slides-first lesson's floats are unnumbered (rule D8): it adds none.
1624
+ const body = isChapter(lessonId) ? (bodyByLessonId.get(lessonId) ?? '') : '';
1417
1625
  const chapter = ++chapterCursor;
1418
1626
  placementByLessonId.set(lessonId, {
1419
1627
  part: partCursor,
@@ -1448,7 +1656,8 @@ export async function compileManifest(
1448
1656
  return [
1449
1657
  lesson.lessonId,
1450
1658
  scanSectionHeadings(lesson.body, {
1451
- ...(placement
1659
+ // A deck's headings are sections and frames, numbered as frames.
1660
+ ...(placement && isChapter(lesson.lessonId)
1452
1661
  ? { prefix: `${placement.part}.${placement.chapter}` }
1453
1662
  : {}),
1454
1663
  depth: numberingSettings.sections.depth,
@@ -1475,7 +1684,11 @@ export async function compileManifest(
1475
1684
  // The parts themselves, in config order, with their chapters in reading
1476
1685
  // order — the one place a part-level page or a breadcrumb reads, so neither
1477
1686
  // has to re-derive a part from lesson slugs.
1478
- const manifestParts: ManifestPart[] = configuredParts.map((part, index) => {
1687
+ // A part's overview page gets its `since` with the lessons, below.
1688
+ type PartBeforeVersions = Omit<ManifestPart, 'overview'> & {
1689
+ readonly overview?: Omit<NonNullable<ManifestPart['overview']>, 'since'>;
1690
+ };
1691
+ const manifestParts: PartBeforeVersions[] = configuredParts.map((part, index) => {
1479
1692
  const overview = overviewByDir.get(part.dir);
1480
1693
  const lessonIds = [...placementByLessonId]
1481
1694
  .filter(([lessonId]) => lessonId.startsWith(`${part.dir}.`))
@@ -1520,7 +1733,7 @@ export async function compileManifest(
1520
1733
  const floatInputs = (
1521
1734
  kind: 'equations' | 'tables' | 'figures' | 'diagrams' | 'examples',
1522
1735
  ) =>
1523
- notationInput.lessons.map((lesson) => {
1736
+ chapterInputs.map((lesson) => {
1524
1737
  const placement = placementByLessonId.get(lesson.lessonId);
1525
1738
  return {
1526
1739
  lessonId: lesson.lessonId,
@@ -1658,6 +1871,8 @@ export async function compileManifest(
1658
1871
  // `<AssessmentSet />` draws every check of the lesson, or the one its `id`
1659
1872
  // names; `<CompactExamples>` draws its own set.
1660
1873
  const titledBlocksFor = (lessonId: string): ManifestTitledBlock[] => {
1874
+ // A deck's question and example frames are frames, listed as frames.
1875
+ if (!isChapter(lessonId)) return [];
1661
1876
  const slug = lessonId.replace(/\./g, '/');
1662
1877
  const body = bodyByLessonId.get(lessonId) ?? '';
1663
1878
  const headings = headingsByLessonId.get(lessonId) ?? [];
@@ -1688,7 +1903,7 @@ export async function compileManifest(
1688
1903
  // Rule V3: the course files a lesson's imports reach draw math through
1689
1904
  // `renderLabMath`, never KaTeX itself.
1690
1905
  const labKatexDiagnostics = validateLabKatexImports(
1691
- notationInput.lessons.map((lesson) => ({
1906
+ [...notationInput.lessons, ...deckPages.map((page) => page.notation)].map((lesson) => ({
1692
1907
  lessonId: lesson.lessonId,
1693
1908
  file: lesson.source.file,
1694
1909
  body: lesson.body,
@@ -1696,7 +1911,7 @@ export async function compileManifest(
1696
1911
  REPOSITORY_ROOT,
1697
1912
  );
1698
1913
  const rawFloatDiagnostics = validateRawFloats(
1699
- notationInput.lessons.map((lesson) => ({
1914
+ [...notationInput.lessons, ...deckPages.map((page) => page.notation)].map((lesson) => ({
1700
1915
  lessonId: lesson.lessonId,
1701
1916
  slug: lesson.lessonId.replace(/\./g, '/'),
1702
1917
  file: lesson.source.file,
@@ -1704,7 +1919,124 @@ export async function compileManifest(
1704
1919
  })),
1705
1920
  );
1706
1921
 
1707
- const lessons: ManifestLesson[] = [];
1922
+ // The decks (rules D1–D10 and the `decks` invariant): each read as frames,
1923
+ // against its page's scope and its chapter's numbers.
1924
+ const pageInputById = new Map(withPages.lessons.map((lesson) => [lesson.lessonId, lesson]));
1925
+ const chapterBodyById = new Map(chapterInputs.map((lesson) => [lesson.lessonId, lesson.body]));
1926
+ const floatNumbers = (floats: readonly { readonly key: string; readonly number: string }[] | undefined) =>
1927
+ new Map((floats ?? []).map((float) => [float.key, float.number]));
1928
+ // A competency's `outcome` (`content-schemas.ts`), which the outcomes frame lists.
1929
+ const outcomeById = new Map(
1930
+ catalog.competencies.flatMap((competency): [string, string][] => {
1931
+ const outcome = (competency as { readonly outcome?: unknown }).outcome;
1932
+ return typeof outcome === 'string' ? [[competency.id, outcome]] : [];
1933
+ }),
1934
+ );
1935
+ /** What a chapter numbers, which a deck may recall: each float's number, an equation's LaTeX. */
1936
+ const chapterFloatsOf = (lessonId: string): ChapterFloats => {
1937
+ const slug = lessonId.replace(/\./g, '/');
1938
+ const chapterBody = chapterBodyById.get(lessonId);
1939
+ const latex = chapterBody === undefined ? new Map<string, string>() : chapterEquationLatex(chapterBody);
1940
+ return {
1941
+ equations: new Map(
1942
+ (equationsBySlug.get(slug) ?? []).map((equation) => [
1943
+ equation.key,
1944
+ { number: equation.number, ...(latex.has(equation.key) ? { latex: latex.get(equation.key) } : {}) },
1945
+ ]),
1946
+ ),
1947
+ figures: floatNumbers(figuresBySlug.get(slug)),
1948
+ tables: floatNumbers(tablesBySlug.get(slug)),
1949
+ diagrams: floatNumbers(diagramsBySlug.get(slug)),
1950
+ };
1951
+ };
1952
+ const outcomesOf = (lessonId: string): string[] =>
1953
+ (lessonMetaById.get(lessonId)?.teaches ?? []).flatMap((id) => {
1954
+ const outcome = outcomeById.get(id);
1955
+ return outcome ? [outcome] : [];
1956
+ });
1957
+ // Each part with a deck: its chapters in reading order, what each numbers
1958
+ // and teaches; its slides-first lessons, which no section can cover.
1959
+ const deckParts = new Map<string, DeckPartFacts>(
1960
+ partDeckFiles.map((deck) => {
1961
+ const part = manifestParts.find((entry) => entry.dir === deck.partDir);
1962
+ const lessonIds = part?.lessons ?? [];
1963
+ const nameOf = (lessonId: string) => lessonId.replace(/\./g, '/').slice(deck.partDir.length + 1);
1964
+ return [
1965
+ deck.partDir,
1966
+ {
1967
+ label: part?.label ?? deck.partDir,
1968
+ chapters: lessonIds.filter(isChapter).map((lessonId) => ({
1969
+ name: nameOf(lessonId),
1970
+ slug: lessonId.replace(/\./g, '/'),
1971
+ teaches: lessonMetaById.get(lessonId)?.teaches ?? [],
1972
+ outcomes: outcomesOf(lessonId),
1973
+ floats: chapterFloatsOf(lessonId),
1974
+ })),
1975
+ slidesFirst: lessonIds.filter((lessonId) => !isChapter(lessonId)).map(nameOf),
1976
+ },
1977
+ ];
1978
+ }),
1979
+ );
1980
+ const deckModel = buildDecks({
1981
+ decks: deckFiles,
1982
+ settings: deckSettings,
1983
+ rules: {
1984
+ maxWords: rules.number('D2', 'maxWords'),
1985
+ maxBullets: rules.number('D2', 'maxBullets'),
1986
+ maxTitleLength: rules.number('D5', 'maxLength'),
1987
+ maxSteps: rules.number('D6', 'maxSteps'),
1988
+ maxNewSymbols: rules.number('D9', 'maxNewSymbols'),
1989
+ },
1990
+ lessons: new Map(
1991
+ lessonDeckFiles.map((deck) => [
1992
+ deck.lessonId,
1993
+ {
1994
+ title: frontmatterById.get(deck.lessonId)?.title ?? deck.lessonId,
1995
+ teaches: lessonMetaById.get(deck.lessonId)?.teaches ?? [],
1996
+ outcomes: outcomesOf(deck.lessonId),
1997
+ ...(deck.chapterPath === undefined ? {} : { chapterFloats: chapterFloatsOf(deck.lessonId) }),
1998
+ },
1999
+ ]),
2000
+ ),
2001
+ parts: deckParts,
2002
+ // As the title frame shows them (`titleFrameNode` in `deck-remark.mjs`).
2003
+ course: {
2004
+ name: courseConfig.about.materialName ?? courseConfig.title,
2005
+ authors: courseConfig.about.authors.map((author) => author.name),
2006
+ },
2007
+ scopeOf: (pageId) => {
2008
+ const page = pageInputById.get(pageId);
2009
+ return page === undefined ? [] : pageScope(page, cardsByKey);
2010
+ },
2011
+ suspendedOf: (pageId) => {
2012
+ const page = pageInputById.get(pageId);
2013
+ return page === undefined ? [] : suspendedOperators(page);
2014
+ },
2015
+ labelFor: termLabel,
2016
+ });
2017
+ mathIssues.push(...deckModel.diagnostics);
2018
+ // The part's deck and each chapter's section of it, as the decks number their frames.
2019
+ const sidebarWithFrames = withPartDeckFrames(sidebar, deckModel.decks);
2020
+
2021
+ // Per-place waivers: drop the located findings each one covers, and
2022
+ // report the waivers themselves — a stale one is a warning, a bad one an
2023
+ // error. Chapters, decks and non-chapter pages are scanned alike, since the
2024
+ // text and math gates run on all of them; S2 and S10 are per-file or
2025
+ // locked, so no waiver reaches them. Applied once every located finding is
2026
+ // in, the deck rules' included.
2027
+ const waiverDiagnostics: WaiverDiagnostic[] = [];
2028
+ const waiverRules = { courseRules: rules.course };
2029
+ const waivers = withPages.lessons.flatMap((lesson) => {
2030
+ const collected = collectWaivers(lesson, waiverRules);
2031
+ waiverDiagnostics.push(...collected.diagnostics);
2032
+ return collected.waivers;
2033
+ });
2034
+ const waived = applyWaivers(mathIssues, waivers, waiverRules);
2035
+ mathIssues.splice(0, mathIssues.length, ...waived.kept);
2036
+ waiverDiagnostics.push(...waived.unused);
2037
+
2038
+ // Each lesson gets its `since` below, once every page's inputs are known.
2039
+ const lessons: Omit<ManifestLesson, 'since'>[] = [];
1708
2040
  for (const notationLesson of notationInput.lessons) {
1709
2041
  const id = notationLesson.lessonId;
1710
2042
  const meta = lessonMetaById.get(id);
@@ -1717,6 +2049,10 @@ export async function compileManifest(
1717
2049
  lessons.push({
1718
2050
  id,
1719
2051
  slug: id.replace(/\./g, '/'),
2052
+ editions: [
2053
+ ...(isChapter(id) ? (['chapter'] as const) : []),
2054
+ ...(lessonDeckFiles.some((deck) => deck.lessonId === id) ? (['deck'] as const) : []),
2055
+ ],
1720
2056
  // 0 = the lesson is not placed in any configured part; a curriculum
1721
2057
  // diagnostic covers that separately, and both numbering paths then fall
1722
2058
  // back to the legacy `##`-section scheme.
@@ -1836,6 +2172,127 @@ export async function compileManifest(
1836
2172
  file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, '/'),
1837
2173
  });
1838
2174
 
2175
+ // Each chapter's and part page's own version (rule C5): the committed
2176
+ // `content/versions.json`, brought to the pages' inputs as they are now —
2177
+ // the page's file, its checks and assessments, the shared notation entries
2178
+ // it uses, the sources it cites, and its numbers. With C5 off, nothing is
2179
+ // read or tracked and every page carries the course's version.
2180
+ const trackPageVersions = !rules.disabled.includes('C5');
2181
+ const previousVersions = trackPageVersions
2182
+ ? readPageVersions(join(CONTENT_ROOT, 'versions.json'), PAGE_VERSIONS_FILE)
2183
+ : { record: undefined, diagnostics: [] };
2184
+ const digestOf = (file: string): string => hashes.content[file] ?? 'missing';
2185
+ const presentFiles = (files: readonly string[]) => files.filter((file) => hashes.content[file] !== undefined);
2186
+ const sourceFileById = new Map<string, string>();
2187
+ for (const record of contentRecords) {
2188
+ const file = record.file.replace(/\\/g, '/');
2189
+ if (file.startsWith('content/sources/') && typeof record.data.id === 'string') {
2190
+ sourceFileById.set(record.data.id, file);
2191
+ }
2192
+ }
2193
+ const sharedEntryInputs = (definitionIds: readonly string[]) =>
2194
+ definitionIds.flatMap((definitionId) => {
2195
+ const definition = definitionsById.get(definitionId);
2196
+ return definition?.kind === 'shared'
2197
+ ? [{ label: `notation ${definition.source.file}`, digest: digestOf(definition.source.file) }]
2198
+ : [];
2199
+ });
2200
+ /** Each float a part's deck recalls, by ref, as its chapter numbers, captions and (an equation) writes it. */
2201
+ const recalledFloatFacts = (recalledFrom: Readonly<Record<string, string>>) =>
2202
+ Object.fromEntries(
2203
+ Object.entries(recalledFrom).map(([ref, chapter]) => {
2204
+ const [, prefix, key] = /^(eq|fig|tab|dia)-(.+)$/u.exec(ref) ?? [];
2205
+ const floats =
2206
+ prefix === 'eq'
2207
+ ? equationsBySlug
2208
+ : prefix === 'fig'
2209
+ ? figuresBySlug
2210
+ : prefix === 'tab'
2211
+ ? tablesBySlug
2212
+ : diagramsBySlug;
2213
+ const float = floats.get(chapter)?.find((entry) => entry.key === key);
2214
+ const latex = prefix === 'eq' ? chapterFloatsOf(chapter.replace(/\//g, '.')).equations.get(key ?? '')?.latex : undefined;
2215
+ return [ref, { chapter, ...float, ...(latex === undefined ? {} : { latex }) }];
2216
+ }),
2217
+ );
2218
+ const trackedPages = [
2219
+ ...lessons.map((lesson) => ({
2220
+ slug: lesson.slug,
2221
+ source: pageSourceDigest(
2222
+ [
2223
+ { label: `page ${lesson.file}`, digest: digestOf(lesson.file) },
2224
+ ...presentFiles([lesson.file.replace(/\.mdx?$/u, '.checks.yml')]).map((file) => ({
2225
+ label: `checks ${file}`,
2226
+ digest: digestOf(file),
2227
+ })),
2228
+ ...presentFiles(lesson.assessments.map((id) => `content/assessments/${id}.json`)).map((file) => ({
2229
+ label: `assessment ${file}`,
2230
+ digest: digestOf(file),
2231
+ })),
2232
+ ...sharedEntryInputs(lesson.notation.definitionIds),
2233
+ ...lesson.sources.map((id) => {
2234
+ const file = sourceFileById.get(id);
2235
+ return { label: `source ${id}`, digest: file === undefined ? 'missing' : digestOf(file) };
2236
+ }),
2237
+ ],
2238
+ { part: lesson.part, chapter: lesson.chapter, numbering: lesson.numbering },
2239
+ ),
2240
+ })),
2241
+ // A deck beside its chapter has a version of its own, by its route, and
2242
+ // so has a part's deck; a slides-first lesson's deck is the lesson, above.
2243
+ ...deckModel.decks.flatMap((deck) => {
2244
+ if (deck.chapter === undefined && deck.part === undefined) return [];
2245
+ const page = deckPages.find((entry) => entry.deck.route === deck.slug);
2246
+ return [
2247
+ {
2248
+ slug: deck.slug,
2249
+ source: pageSourceDigest(
2250
+ [
2251
+ { label: `page ${deck.file}`, digest: digestOf(deck.file) },
2252
+ ...sharedEntryInputs(bundlesByLesson.get(deck.pageId)?.definitionIds ?? []),
2253
+ ...(page === undefined ? [] : deriveSources(page.notation.body)).map((id) => {
2254
+ const file = sourceFileById.get(id);
2255
+ return { label: `source ${id}`, digest: file === undefined ? 'missing' : digestOf(file) };
2256
+ }),
2257
+ ],
2258
+ // A part's deck shows each float it recalls as its chapter
2259
+ // numbers and captions it: those count as the page's too.
2260
+ deck.recalledFrom === undefined ? {} : { recalls: recalledFloatFacts(deck.recalledFrom) },
2261
+ ),
2262
+ },
2263
+ ];
2264
+ }),
2265
+ ...manifestParts.flatMap((part) => {
2266
+ if (!part.overview) return [];
2267
+ const slug = `${part.dir}/index`;
2268
+ const page = pages.find((entry) => entry.slug === slug);
2269
+ return [
2270
+ {
2271
+ slug,
2272
+ source: pageSourceDigest([
2273
+ { label: `page ${part.overview.file}`, digest: digestOf(part.overview.file) },
2274
+ ...sharedEntryInputs(page?.notation.definitionIds ?? []),
2275
+ ]),
2276
+ },
2277
+ ];
2278
+ }),
2279
+ ];
2280
+ const pageVersionsRecord = nextPageVersions({
2281
+ previous: previousVersions.record,
2282
+ version: knowledge.version,
2283
+ unreleased: knowledge.unreleased.length > 0,
2284
+ pages: trackedPages,
2285
+ });
2286
+ const pageVersions: ManifestPageVersions = {
2287
+ enabled: trackPageVersions,
2288
+ file: PAGE_VERSIONS_FILE,
2289
+ record: pageVersionsRecord,
2290
+ };
2291
+ const sinceOfPage = (slug: string): string | null => {
2292
+ const entry = pageVersionsRecord.pages[slug];
2293
+ return trackPageVersions && entry !== undefined ? sinceOf(entry) : knowledge.version;
2294
+ };
2295
+
1839
2296
  const enginePkg = readEnginePackage();
1840
2297
  const about: ManifestAbout = {
1841
2298
  engine: {
@@ -1855,6 +2312,11 @@ export async function compileManifest(
1855
2312
  ? {}
1856
2313
  : { date: knowledge.latest.date }),
1857
2314
  notes: knowledge.latest?.notes ?? [],
2315
+ releases: knowledge.entries.map((entry) => ({
2316
+ version: entry.version,
2317
+ ...(entry.date === undefined ? {} : { date: entry.date }),
2318
+ })),
2319
+ unreleased: knowledge.unreleased.length > 0,
1858
2320
  contentTree: hashes.contentTree,
1859
2321
  },
1860
2322
  authors: courseConfig.about.authors.map((author) => ({
@@ -1874,6 +2336,34 @@ export async function compileManifest(
1874
2336
  numbering: numberingSettings,
1875
2337
  blockLabels,
1876
2338
  cards: normalizeCards(courseConfig),
2339
+ sidebarMarks: normalizeSidebarMarks(courseConfig),
2340
+ sidebarEditions: normalizeSidebarEditions(courseConfig),
2341
+ readingMemory: normalizeReadingMemory(courseConfig),
2342
+ appearance: normalizeAppearance(courseConfig),
2343
+ deck: deckSettings,
2344
+ decks: deckModel.decks.map((deck) => {
2345
+ const page = pageInputById.get(deck.pageId);
2346
+ return {
2347
+ ...deck,
2348
+ since: sinceOfPage(deck.slug),
2349
+ notation: lessonNotationBundle(
2350
+ bundlesByLesson.get(deck.pageId),
2351
+ definitionsById,
2352
+ page === undefined ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
2353
+ cardsByKey,
2354
+ // A deck shows its lessons' symbols: the derived cards (rule N23)
2355
+ // its chapters' bodies and checks use ship with it, as its bundle
2356
+ // holds the chapters' entries.
2357
+ [
2358
+ ...(indexGate.derivedByLesson.get(deck.pageId) ?? []),
2359
+ ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
2360
+ ...(indexGate.derivedByLesson.get(lessonId) ?? []),
2361
+ ...(assessmentDerived.get(lessonId)?.values() ?? []),
2362
+ ]),
2363
+ ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index),
2364
+ ),
2365
+ };
2366
+ }),
1877
2367
  about,
1878
2368
  notation: {
1879
2369
  definitions: registry.definitions,
@@ -1886,9 +2376,14 @@ export async function compileManifest(
1886
2376
  sources,
1887
2377
  tracks,
1888
2378
  abbreviations,
1889
- sidebar,
1890
- parts: manifestParts,
1891
- lessons,
2379
+ sidebar: sidebarWithFrames,
2380
+ parts: manifestParts.map(({ overview, ...part }) =>
2381
+ overview
2382
+ ? { ...part, overview: { ...overview, since: sinceOfPage(`${part.dir}/index`) } }
2383
+ : part,
2384
+ ),
2385
+ lessons: lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) })),
2386
+ pageVersions,
1892
2387
  pages,
1893
2388
  prereqEdges,
1894
2389
  equations: {
@@ -1930,7 +2425,7 @@ export async function compileManifest(
1930
2425
  checks: dropDisabled(checkValidation.diagnostics, off),
1931
2426
  examples: dropDisabled(exampleValidation.diagnostics, off),
1932
2427
  folds: dropDisabled(foldValidation.diagnostics, off),
1933
- knowledge: dropDisabled(knowledge.diagnostics, off),
2428
+ knowledge: dropDisabled([...knowledge.diagnostics, ...previousVersions.diagnostics], off),
1934
2429
  rawFloats: dropDisabled(rawFloatDiagnostics, off),
1935
2430
  config: rules.diagnostics,
1936
2431
  waivers: waiverDiagnostics,