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
@@ -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,15 @@ import {
170
182
  resolveKnowledgeVersion,
171
183
  type KnowledgeVersionDiagnostic,
172
184
  } from './knowledge-version';
185
+ import {
186
+ deckStaleDiagnostics,
187
+ nextPageVersions,
188
+ PAGE_VERSIONS_FILE,
189
+ pageSourceDigest,
190
+ readPageVersions,
191
+ sinceOf,
192
+ type ManifestPageVersions,
193
+ } from './page-versions';
173
194
  import {
174
195
  loadAbbreviationRegistryInput,
175
196
  loadContentRecords,
@@ -180,12 +201,23 @@ import {
180
201
  loadNotationSpecs,
181
202
  loadPartOverviews,
182
203
  loadSourceRecords,
204
+ loadDeckFiles,
205
+ loadDeckNotationPages,
206
+ loadLessonFiles,
207
+ type LessonDeckFile,
208
+ type PartDeckFile,
183
209
  } from './collections';
210
+ import { buildDecks, chapterEquationLatex, type BuiltDeck, type DeckPartFacts } from './decks';
211
+ import { readDeckSource } from '../reference/deck-source';
212
+ import { coveredSections, type ChapterFloats } from '../reference/decks-validate';
184
213
  import {
185
214
  buildSidebar,
186
215
  deriveSources,
187
216
  isLessonSlug,
188
217
  lessonIdFromSlug,
218
+ mergeTrackOrder,
219
+ sidebarLessonSlug,
220
+ withPartDeckFrames,
189
221
  type SidebarGroup,
190
222
  } from './lesson-derivation';
191
223
 
@@ -346,8 +378,65 @@ import {
346
378
  * `formulaTemplate` when its entry sets them, and a bundle definition its
347
379
  * `range`. `diagnostics.math` carries `index-explained`, `index-swallowed`,
348
380
  * `index-range` and `derived-key-taken`.
381
+ *
382
+ * v32 (a page's own version, rule C5): `lessons[].since` and
383
+ * `parts[].overview.since` carry the knowledge version each chapter's and
384
+ * part page's content last changed in (`null` for a change not yet
385
+ * released), read from the committed `content/versions.json` through
386
+ * `pageVersions` — the record as `validate:content` writes it.
387
+ * `about.knowledge` gains `releases` (every released entry with its date)
388
+ * and `unreleased` (whether `## [Unreleased]` holds bullets).
389
+ * `diagnostics.knowledge` can carry `page-versions-invalid`.
390
+ * `sidebarMarks` carries the course's `sidebarMarks` setting, defaults
391
+ * applied: whether the sidebar marks a page the reader has not opened yet,
392
+ * and one whose content changed since they last did. `readingMemory` says
393
+ * whether the course asks the reader to keep that memory (`ask`) or never
394
+ * keeps it (`off`).
395
+ * v33 (decks, additive): `decks` lists each lesson's slide edition
396
+ * (`<lesson>.deck.mdx`) with its frames, numbered as the deck shows them;
397
+ * `deck` carries the course's `deck` settings, defaults applied, and the
398
+ * canvas; `lessons[].editions` says which editions a lesson has
399
+ * (`chapter`, `deck`). A slides-first lesson (a deck with no chapter) is a
400
+ * lesson like any other, with `editions: ["deck"]`. `decks[].since` is the
401
+ * version a deck's page last changed in (rule C5), as a chapter's `since`.
402
+ * In `sidebar`, a deck beside its chapter follows the chapter's slug as
403
+ * `{ slug, label: "Slides", attrs: { class: "explico-deck-row" } }`, and a
404
+ * slides-first lesson is `{ slug, attrs: { class: "explico-deck-lesson" } }`
405
+ * in place of its slug (`sidebarLessonSlug` reads either kind of lesson).
406
+ *
407
+ * v34 (editions in the sidebar): `sidebarEditions` carries the course's
408
+ * `sidebarEditions` setting, defaults applied: the edition a lesson's row
409
+ * opens (`lead`: `chapter` or `slides`) and whether the row lists the other
410
+ * (`other`: `listed` or `hidden`). In `sidebar`, every item of a lesson
411
+ * carries `data-edition` (`chapter` or `slides`), `data-lesson` (the
412
+ * lesson's slug) and `data-title` (its title) in `attrs`, so a chapter is
413
+ * `{ slug, attrs }` where it was a bare slug. An item lists its lesson when
414
+ * it is served at the lesson's own URL, `data-lesson` equal to its `slug`:
415
+ * a chapter, or a slides-first lesson's deck; a chapter's slides row
416
+ * follows it as before.
417
+ *
418
+ * v35 (the slides a reader has seen): every frame of `decks[].frames`
419
+ * carries `id`, 12 hex digits of a SHA-256 over what it shows, which a
420
+ * reader's record of the frames shown in Present mode keeps
421
+ * (`ManifestDeckFrame.id`).
422
+ *
423
+ * v36 (a part's deck): `decks` lists each part's deck
424
+ * (`<part>/index.deck.mdx`, at `<part>/slides`) too, with `part` (its
425
+ * directory) and no `lessonId`, which is now optional. Every deck carries
426
+ * `covers`, the chapters a part's deck covers with the frame each one's
427
+ * section opens on (empty on a lesson's deck); a part's deck carries
428
+ * `recalledFrom`, the chapter of each float it recalls, and each frame of
429
+ * a covered section `chapter`. In `sidebar`, a part's deck follows the
430
+ * part's Overview as `{ slug, label: "Slides", attrs: { class:
431
+ * "explico-deck-row explico-part-deck", "data-edition": "slides",
432
+ * "data-part" } }`, and a chapter it covers that has no deck of its own
433
+ * carries `data-deck-href`, the address of its section.
434
+ *
435
+ * v37 (the page's zones and slide edges): `appearance` carries the course's
436
+ * `appearance` setting, defaults applied: `zones` (`tonal`, `accent-header`,
437
+ * `paper` or `none`) and `slides` (`lifted`, `edge` or `sorter`).
349
438
  */
350
- export const MANIFEST_SCHEMA_VERSION = 31;
439
+ export const MANIFEST_SCHEMA_VERSION = 37;
351
440
 
352
441
  const REPOSITORY_ROOT = process.cwd();
353
442
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -453,6 +542,17 @@ export interface ManifestDerivedNotation {
453
542
  readonly formulaScope: readonly GlyphScopeEntry[];
454
543
  }
455
544
 
545
+ /**
546
+ * One lesson's deck (v33), or a part's (v36): its frames as `decks.ts` reads
547
+ * them, and the notation its page shows — its own bundle, which holds its
548
+ * chapters'.
549
+ */
550
+ export interface ManifestDeck extends BuiltDeck {
551
+ readonly notation: ManifestLesson['notation'];
552
+ /** The version the deck's page last changed in (rule C5), as a chapter's `since`. */
553
+ readonly since: string | null;
554
+ }
555
+
456
556
  /**
457
557
  * One keyed float of a lesson (v21): its key and number, and its caption pair
458
558
  * — `short` (an equation's `\caption{…}`, or the `short` prop) and, for a
@@ -484,6 +584,12 @@ export interface ManifestLesson {
484
584
  readonly id: string;
485
585
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
486
586
  readonly slug: string;
587
+ /**
588
+ * The lesson's editions (v33): `chapter` (its `.mdx`), `deck` (its
589
+ * `.deck.mdx`, listed in `manifest.decks`), or both. A slides-first lesson
590
+ * is `["deck"]`: its slug is its deck's route and its floats are unnumbered.
591
+ */
592
+ readonly editions: readonly ('chapter' | 'deck')[];
487
593
  /**
488
594
  * The 1-based ordinal of the **part** this lesson sits in — its sidebar
489
595
  * group's position in `courseConfig.parts`. It is the first number of every
@@ -532,6 +638,15 @@ export interface ManifestLesson {
532
638
  readonly file: string;
533
639
  readonly title: string;
534
640
  readonly description: string;
641
+ /**
642
+ * The knowledge version this chapter's content last changed in (v32, rule
643
+ * C5): its file, its checks and assessments, the shared notation it uses,
644
+ * the sources it cites and its numbers, against `content/versions.json`.
645
+ * `null` while it has a change `## [Unreleased]` has not released. What
646
+ * the exports and the `[?]` panel print for the page; with rule C5 off,
647
+ * the course's own version.
648
+ */
649
+ readonly since: string | null;
535
650
  /** Authored reader-facing estimate; `40` and `[30, 40]` both land here. */
536
651
  readonly studyMinutes?: { readonly min: number; readonly max: number };
537
652
  /** Ordered — the curriculum contract. */
@@ -614,6 +729,13 @@ export interface ManifestAbout {
614
729
  readonly date?: string;
615
730
  /** Bullet notes from that entry. */
616
731
  readonly notes: readonly string[];
732
+ /**
733
+ * Every released entry, newest first, with its date (v32): what dates a
734
+ * document whose content last changed in an earlier release (rule C5).
735
+ */
736
+ readonly releases: readonly { readonly version: string; readonly date?: string }[];
737
+ /** `## [Unreleased]` holds bullets: a change is waiting for a release (v32). */
738
+ readonly unreleased: boolean;
617
739
  /** Content fingerprint at compile time — ties the shown version to a build. */
618
740
  readonly contentTree: string;
619
741
  };
@@ -677,6 +799,8 @@ export interface ManifestPart {
677
799
  readonly title: string;
678
800
  readonly file: string;
679
801
  readonly description?: string;
802
+ /** The version the page's content last changed in (v32, rule C5), as a chapter's `since`. */
803
+ readonly since: string | null;
680
804
  };
681
805
  }
682
806
 
@@ -692,6 +816,30 @@ export interface Manifest {
692
816
  readonly blockLabels: BlockLabels;
693
817
  /** How the explanation cards start for a reader, defaults applied (`CourseConfig.cards`). */
694
818
  readonly cards: NormalizedCards;
819
+ /**
820
+ * Which marks the sidebar shows a reader beside a chapter or part page
821
+ * (v32, `CourseConfig.sidebarMarks`): not opened yet, changed since opened.
822
+ */
823
+ readonly sidebarMarks: NormalizedSidebarMarks;
824
+ /**
825
+ * Which edition a lesson's sidebar row opens for a reader who has not
826
+ * chosen, and whether it lists the other (v34, `CourseConfig.sidebarEditions`).
827
+ */
828
+ readonly sidebarEditions: NormalizedSidebarEditions;
829
+ /**
830
+ * Whether the course asks the reader to keep their reading memory (v32,
831
+ * `CourseConfig.readingMemory`): `ask` or `off`.
832
+ */
833
+ readonly readingMemory: ReadingMemorySetting;
834
+ /**
835
+ * How the page tells its zones apart and how slides sit on it, defaults
836
+ * applied (v37, `CourseConfig.appearance`).
837
+ */
838
+ readonly appearance: NormalizedAppearance;
839
+ /** The course's `deck` settings, defaults applied, and its canvas (v33). */
840
+ readonly deck: NormalizedDeck;
841
+ /** Each lesson's deck, by route (v33). */
842
+ readonly decks: readonly ManifestDeck[];
695
843
  /** Reader-facing identity + versions (the `[?]` About panel). */
696
844
  readonly about: ManifestAbout;
697
845
  readonly notation: {
@@ -711,6 +859,13 @@ export interface Manifest {
711
859
  /** The course's parts in config order, with their chapters and overview pages. */
712
860
  readonly parts: readonly ManifestPart[];
713
861
  readonly lessons: readonly ManifestLesson[];
862
+ /**
863
+ * The record of every chapter's and part page's own version (v32, rule
864
+ * C5), as `validate:content` writes it to `content/versions.json`
865
+ * (`syncPageVersions`); `lessons[].since` and `parts[].overview.since`
866
+ * are read from it.
867
+ */
868
+ readonly pageVersions: ManifestPageVersions;
714
869
  /** Every docs page that is not a chapter, by id (v25). */
715
870
  readonly pages: readonly ManifestPage[];
716
871
  /** Full competency prerequisite graph. */
@@ -872,11 +1027,12 @@ function lessonNotationResolution(
872
1027
  definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
873
1028
  availableDefinitionIds: ReadonlySet<string>,
874
1029
  ): ManifestNotationResolution[] {
875
- const scope = { kind: 'page' as const, lessonId: lesson.lessonId };
1030
+ // A deck's references resolve in its lessons' scopes, as the registry resolves them.
1031
+ const scopes = pageScopeLessonIds(lesson);
876
1032
  return lesson.references.map((reference) => {
877
- const definitionId = resolveReferenceId(
1033
+ const definitionId = resolvePageReferenceId(
878
1034
  reference.key,
879
- scope,
1035
+ scopes,
880
1036
  availableDefinitionIds,
881
1037
  );
882
1038
  const definition =
@@ -982,7 +1138,6 @@ function lessonFrontmatterById(): Map<
982
1138
  studyMinutes?: { min: number; max: number };
983
1139
  }
984
1140
  > {
985
- const base = join(CONTENT_ROOT, 'docs');
986
1141
  const result = new Map<
987
1142
  string,
988
1143
  {
@@ -991,29 +1146,18 @@ function lessonFrontmatterById(): Map<
991
1146
  studyMinutes?: { min: number; max: number };
992
1147
  }
993
1148
  >();
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);
1149
+ // Every lesson's defining file: its chapter, or the deck of a slides-first lesson.
1150
+ for (const { path, lessonId: id } of loadLessonFiles()) {
1151
+ const data = (parseFrontmatter(readFileSync(path, 'utf8')).frontmatter ??
1152
+ {}) as Record<string, unknown>;
1153
+ const studyMinutes = studyMinutesRange(data.studyMinutes);
1154
+ result.set(id, {
1155
+ title: typeof data.title === 'string' ? data.title : id,
1156
+ description:
1157
+ typeof data.description === 'string' ? data.description : '',
1158
+ ...(studyMinutes === undefined ? {} : { studyMinutes }),
1159
+ });
1160
+ }
1017
1161
  return result;
1018
1162
  }
1019
1163
 
@@ -1050,6 +1194,7 @@ export async function compileManifest(
1050
1194
  // Throws on a bad `numbering` or `blockLabels` value, like `resolveRules` above.
1051
1195
  const numberingSettings = normalizeNumbering(courseConfig);
1052
1196
  const blockLabels = normalizeBlockLabels(courseConfig);
1197
+ const deckSettings = normalizeDeck(courseConfig);
1053
1198
 
1054
1199
  const [catalog, notationInput, abbreviationsInput] = await Promise.all([
1055
1200
  loadCurriculumCatalog(),
@@ -1062,11 +1207,41 @@ export async function compileManifest(
1062
1207
  // text and math pass the same gates. It stays out of `notationInput.lessons`,
1063
1208
  // which is the chapter list every numbering and curriculum pass reads.
1064
1209
  const nonChapterPages = loadNonChapterPages();
1210
+ // A deck beside its chapter is a page of its own with its chapter's scope
1211
+ // (`loadDeckNotationPages`), and a part's deck one with its chapters': its
1212
+ // text and math pass the same gates. A slides-first lesson's deck is the
1213
+ // lesson itself, already in `notationInput.lessons`; it numbers nothing,
1214
+ // so the chapter-only passes below (`chapterInputs`) leave it out.
1215
+ const deckFiles = loadDeckFiles();
1216
+ const lessonDeckFiles = deckFiles.filter((deck): deck is LessonDeckFile => deck.scope === 'lesson');
1217
+ // A part's lessons in the order the tracks read them, as the sidebar
1218
+ // lists them: a part's deck binds a key its chapters define to the first.
1219
+ const trackRank = new Map(
1220
+ mergeTrackOrder(catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons }))).map(
1221
+ (lessonId, index) => [lessonId, index],
1222
+ ),
1223
+ );
1224
+ const readingOrder = (lessonIds: readonly string[]): string[] =>
1225
+ [...lessonIds].sort(
1226
+ (left, right) =>
1227
+ (trackRank.get(left) ?? Number.POSITIVE_INFINITY) - (trackRank.get(right) ?? Number.POSITIVE_INFINITY) ||
1228
+ left.localeCompare(right),
1229
+ );
1230
+ const deckPages = loadDeckNotationPages(deckFiles, { order: readingOrder });
1231
+ const deckLessonIds = new Set(
1232
+ loadLessonFiles()
1233
+ .filter((file) => file.edition === 'deck')
1234
+ .map((file) => file.lessonId),
1235
+ );
1236
+ const isChapter = (lessonId: string): boolean => !deckLessonIds.has(lessonId);
1237
+ const deckPageIds = new Set(deckPages.map((page) => page.notation.lessonId));
1238
+ const chapterInputs = notationInput.lessons.filter((lesson) => isChapter(lesson.lessonId));
1065
1239
  const withPages: typeof notationInput = {
1066
1240
  ...notationInput,
1067
1241
  lessons: [
1068
1242
  ...notationInput.lessons,
1069
1243
  ...nonChapterPages.map((page) => page.notation),
1244
+ ...deckPages.map((page) => page.notation),
1070
1245
  ],
1071
1246
  };
1072
1247
  const registry = buildNotationRegistry(withPages);
@@ -1120,9 +1295,9 @@ export async function compileManifest(
1120
1295
  notationInput.sharedDefinitions.map((definition) => [definition.key, definition.label]),
1121
1296
  );
1122
1297
  const localLabelsByLesson = new Map(
1123
- notationInput.lessons.map((lesson) => [
1298
+ withPages.lessons.map((lesson) => [
1124
1299
  lesson.lessonId,
1125
- new Map(lesson.localDefinitions.map((definition) => [definition.key, definition.label])),
1300
+ new Map(scopeLocals(lesson).map((definition) => [definition.key, definition.label])),
1126
1301
  ]),
1127
1302
  );
1128
1303
  const termLabel = (lessonId: string, key: string): string | undefined =>
@@ -1178,19 +1353,27 @@ export async function compileManifest(
1178
1353
  })
1179
1354
  : []),
1180
1355
  ...(rules.isEnabled('S1')
1181
- ? gateHeadingLength(notationInput.lessons, {
1356
+ ? gateHeadingLength(chapterInputs, {
1182
1357
  maxLength: rules.number('S1', 'maxLength'),
1183
1358
  labelFor: termLabel,
1184
1359
  })
1185
1360
  : []),
1186
- ...gateHeadingStructure(notationInput.lessons, {
1361
+ ...gateHeadingStructure(chapterInputs, {
1187
1362
  levels: rules.isEnabled('S6'),
1188
1363
  adjacency: rules.isEnabled('S7'),
1189
1364
  }),
1190
1365
  // The course's own rules (`courseRules`): one pass for every enabled `X`
1191
1366
  // rule that carries a `forbid` pattern. A disabled one, and a guide rule
1192
1367
  // with no pattern, do no work here.
1193
- ...gateCourseRules(withPages.lessons, rules.course),
1368
+ // A course rule about one edition is checked on that edition's pages.
1369
+ ...gateCourseRules(
1370
+ withPages.lessons.filter((lesson) => isChapter(lesson.lessonId) && !deckPageIds.has(lesson.lessonId)),
1371
+ rules.course.filter((rule) => rule.editions !== 'deck'),
1372
+ ),
1373
+ ...gateCourseRules(
1374
+ withPages.lessons.filter((lesson) => !isChapter(lesson.lessonId) || deckPageIds.has(lesson.lessonId)),
1375
+ rules.course.filter((rule) => rule.editions !== 'chapter'),
1376
+ ),
1194
1377
  // S10 is locked: a float on a non-chapter page has no number to render.
1195
1378
  ...gateNonChapterPages(nonChapterPages),
1196
1379
  ];
@@ -1311,6 +1494,17 @@ export async function compileManifest(
1311
1494
  'is left out of the sidebar and the numbering until it has one.',
1312
1495
  });
1313
1496
  }
1497
+ // A part's deck, of a part the sidebar lists: one in a directory no part
1498
+ // lists is `deck-outside-part` (`decks-validate.ts`).
1499
+ const configuredDirs = new Set(configuredParts.map((part) => part.dir));
1500
+ const partDeckFiles = deckFiles.filter(
1501
+ (deck): deck is PartDeckFile => deck.scope === 'part' && configuredDirs.has(deck.partDir),
1502
+ );
1503
+ /** A part's chapters, by their names in its directory (what `[chapter: <name>]` writes). */
1504
+ const partChapterNames = (dir: string): string[] =>
1505
+ lessonSlugs
1506
+ .filter((slug) => slug.startsWith(`${dir}/`) && isChapter(lessonIdFromSlug(slug)))
1507
+ .map((slug) => slug.slice(dir.length + 1));
1314
1508
  // A lesson whose directory no part lists has no part, no chapter number and
1315
1509
  // no place in the sidebar.
1316
1510
  const partDirs = new Set(listedParts.map((part) => part.dir));
@@ -1339,21 +1533,6 @@ export async function compileManifest(
1339
1533
  ),
1340
1534
  );
1341
1535
  }
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
1536
 
1358
1537
  const sidebar = buildSidebar(
1359
1538
  catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
@@ -1368,6 +1547,34 @@ export async function compileManifest(
1368
1547
  floatIndex: ['.mdx', '.md'].some((extension) =>
1369
1548
  existsSync(join(CONTENT_ROOT, 'docs', `figures-and-tables${extension}`)),
1370
1549
  ),
1550
+ // A deck beside its chapter is listed right under it.
1551
+ decks: new Map(
1552
+ lessonDeckFiles
1553
+ .filter((deck) => deck.chapterPath !== undefined && deck.inPart)
1554
+ .map((deck) => [deck.lessonSlug, deck.route]),
1555
+ ),
1556
+ // A deck with no chapter is the lesson: its row carries the deck's glyph.
1557
+ slidesFirst: new Set(
1558
+ lessonDeckFiles
1559
+ .filter((deck) => deck.chapterPath === undefined && deck.inPart)
1560
+ .map((deck) => deck.lessonSlug),
1561
+ ),
1562
+ // A part's deck follows the part's Overview; a chapter it covers that
1563
+ // has no deck of its own links into it, at its section's divider.
1564
+ partDecks: new Map(
1565
+ partDeckFiles.map((deck) => {
1566
+ const { covered } = coveredSections(readDeckSource(deck.body), new Set(partChapterNames(deck.partDir)));
1567
+ return [
1568
+ deck.partDir,
1569
+ {
1570
+ route: deck.route,
1571
+ covers: new Map(covered.map(({ name, position }) => [`${deck.partDir}/${name}`, position + 2])),
1572
+ },
1573
+ ];
1574
+ }),
1575
+ ),
1576
+ // Each edition's row names its lesson, which the sidebar's own row shows.
1577
+ titleOf: (slug) => frontmatterById.get(lessonIdFromSlug(slug))?.title,
1371
1578
  },
1372
1579
  );
1373
1580
 
@@ -1397,9 +1604,10 @@ export async function compileManifest(
1397
1604
  // after them is no part. An overview-only part counts, as `parts[]` does.
1398
1605
  for (const group of sidebar.slice(0, configuredParts.length)) {
1399
1606
  partCursor += 1;
1400
- const lessonIds = group.items
1401
- .filter((item): item is string => typeof item === 'string')
1402
- .map(lessonIdFromSlug);
1607
+ const lessonIds = group.items.flatMap((item) => {
1608
+ const slug = sidebarLessonSlug(item);
1609
+ return slug === undefined ? [] : [lessonIdFromSlug(slug)];
1610
+ });
1403
1611
  if (lessonIds.length === 0) continue;
1404
1612
  // Running totals for this part, advanced one chapter at a time; they
1405
1613
  // only reach a number under the part scope.
@@ -1413,7 +1621,8 @@ export async function compileManifest(
1413
1621
  };
1414
1622
  let chapterCursor = 0;
1415
1623
  for (const lessonId of lessonIds) {
1416
- const body = bodyByLessonId.get(lessonId) ?? '';
1624
+ // A slides-first lesson's floats are unnumbered (rule D8): it adds none.
1625
+ const body = isChapter(lessonId) ? (bodyByLessonId.get(lessonId) ?? '') : '';
1417
1626
  const chapter = ++chapterCursor;
1418
1627
  placementByLessonId.set(lessonId, {
1419
1628
  part: partCursor,
@@ -1448,7 +1657,8 @@ export async function compileManifest(
1448
1657
  return [
1449
1658
  lesson.lessonId,
1450
1659
  scanSectionHeadings(lesson.body, {
1451
- ...(placement
1660
+ // A deck's headings are sections and frames, numbered as frames.
1661
+ ...(placement && isChapter(lesson.lessonId)
1452
1662
  ? { prefix: `${placement.part}.${placement.chapter}` }
1453
1663
  : {}),
1454
1664
  depth: numberingSettings.sections.depth,
@@ -1475,7 +1685,11 @@ export async function compileManifest(
1475
1685
  // The parts themselves, in config order, with their chapters in reading
1476
1686
  // order — the one place a part-level page or a breadcrumb reads, so neither
1477
1687
  // has to re-derive a part from lesson slugs.
1478
- const manifestParts: ManifestPart[] = configuredParts.map((part, index) => {
1688
+ // A part's overview page gets its `since` with the lessons, below.
1689
+ type PartBeforeVersions = Omit<ManifestPart, 'overview'> & {
1690
+ readonly overview?: Omit<NonNullable<ManifestPart['overview']>, 'since'>;
1691
+ };
1692
+ const manifestParts: PartBeforeVersions[] = configuredParts.map((part, index) => {
1479
1693
  const overview = overviewByDir.get(part.dir);
1480
1694
  const lessonIds = [...placementByLessonId]
1481
1695
  .filter(([lessonId]) => lessonId.startsWith(`${part.dir}.`))
@@ -1520,7 +1734,7 @@ export async function compileManifest(
1520
1734
  const floatInputs = (
1521
1735
  kind: 'equations' | 'tables' | 'figures' | 'diagrams' | 'examples',
1522
1736
  ) =>
1523
- notationInput.lessons.map((lesson) => {
1737
+ chapterInputs.map((lesson) => {
1524
1738
  const placement = placementByLessonId.get(lesson.lessonId);
1525
1739
  return {
1526
1740
  lessonId: lesson.lessonId,
@@ -1658,6 +1872,8 @@ export async function compileManifest(
1658
1872
  // `<AssessmentSet />` draws every check of the lesson, or the one its `id`
1659
1873
  // names; `<CompactExamples>` draws its own set.
1660
1874
  const titledBlocksFor = (lessonId: string): ManifestTitledBlock[] => {
1875
+ // A deck's question and example frames are frames, listed as frames.
1876
+ if (!isChapter(lessonId)) return [];
1661
1877
  const slug = lessonId.replace(/\./g, '/');
1662
1878
  const body = bodyByLessonId.get(lessonId) ?? '';
1663
1879
  const headings = headingsByLessonId.get(lessonId) ?? [];
@@ -1688,7 +1904,7 @@ export async function compileManifest(
1688
1904
  // Rule V3: the course files a lesson's imports reach draw math through
1689
1905
  // `renderLabMath`, never KaTeX itself.
1690
1906
  const labKatexDiagnostics = validateLabKatexImports(
1691
- notationInput.lessons.map((lesson) => ({
1907
+ [...notationInput.lessons, ...deckPages.map((page) => page.notation)].map((lesson) => ({
1692
1908
  lessonId: lesson.lessonId,
1693
1909
  file: lesson.source.file,
1694
1910
  body: lesson.body,
@@ -1696,7 +1912,7 @@ export async function compileManifest(
1696
1912
  REPOSITORY_ROOT,
1697
1913
  );
1698
1914
  const rawFloatDiagnostics = validateRawFloats(
1699
- notationInput.lessons.map((lesson) => ({
1915
+ [...notationInput.lessons, ...deckPages.map((page) => page.notation)].map((lesson) => ({
1700
1916
  lessonId: lesson.lessonId,
1701
1917
  slug: lesson.lessonId.replace(/\./g, '/'),
1702
1918
  file: lesson.source.file,
@@ -1704,7 +1920,124 @@ export async function compileManifest(
1704
1920
  })),
1705
1921
  );
1706
1922
 
1707
- const lessons: ManifestLesson[] = [];
1923
+ // The decks (rules D1–D10 and the `decks` invariant): each read as frames,
1924
+ // against its page's scope and its chapter's numbers.
1925
+ const pageInputById = new Map(withPages.lessons.map((lesson) => [lesson.lessonId, lesson]));
1926
+ const chapterBodyById = new Map(chapterInputs.map((lesson) => [lesson.lessonId, lesson.body]));
1927
+ const floatNumbers = (floats: readonly { readonly key: string; readonly number: string }[] | undefined) =>
1928
+ new Map((floats ?? []).map((float) => [float.key, float.number]));
1929
+ // A competency's `outcome` (`content-schemas.ts`), which the outcomes frame lists.
1930
+ const outcomeById = new Map(
1931
+ catalog.competencies.flatMap((competency): [string, string][] => {
1932
+ const outcome = (competency as { readonly outcome?: unknown }).outcome;
1933
+ return typeof outcome === 'string' ? [[competency.id, outcome]] : [];
1934
+ }),
1935
+ );
1936
+ /** What a chapter numbers, which a deck may recall: each float's number, an equation's LaTeX. */
1937
+ const chapterFloatsOf = (lessonId: string): ChapterFloats => {
1938
+ const slug = lessonId.replace(/\./g, '/');
1939
+ const chapterBody = chapterBodyById.get(lessonId);
1940
+ const latex = chapterBody === undefined ? new Map<string, string>() : chapterEquationLatex(chapterBody);
1941
+ return {
1942
+ equations: new Map(
1943
+ (equationsBySlug.get(slug) ?? []).map((equation) => [
1944
+ equation.key,
1945
+ { number: equation.number, ...(latex.has(equation.key) ? { latex: latex.get(equation.key) } : {}) },
1946
+ ]),
1947
+ ),
1948
+ figures: floatNumbers(figuresBySlug.get(slug)),
1949
+ tables: floatNumbers(tablesBySlug.get(slug)),
1950
+ diagrams: floatNumbers(diagramsBySlug.get(slug)),
1951
+ };
1952
+ };
1953
+ const outcomesOf = (lessonId: string): string[] =>
1954
+ (lessonMetaById.get(lessonId)?.teaches ?? []).flatMap((id) => {
1955
+ const outcome = outcomeById.get(id);
1956
+ return outcome ? [outcome] : [];
1957
+ });
1958
+ // Each part with a deck: its chapters in reading order, what each numbers
1959
+ // and teaches; its slides-first lessons, which no section can cover.
1960
+ const deckParts = new Map<string, DeckPartFacts>(
1961
+ partDeckFiles.map((deck) => {
1962
+ const part = manifestParts.find((entry) => entry.dir === deck.partDir);
1963
+ const lessonIds = part?.lessons ?? [];
1964
+ const nameOf = (lessonId: string) => lessonId.replace(/\./g, '/').slice(deck.partDir.length + 1);
1965
+ return [
1966
+ deck.partDir,
1967
+ {
1968
+ label: part?.label ?? deck.partDir,
1969
+ chapters: lessonIds.filter(isChapter).map((lessonId) => ({
1970
+ name: nameOf(lessonId),
1971
+ slug: lessonId.replace(/\./g, '/'),
1972
+ teaches: lessonMetaById.get(lessonId)?.teaches ?? [],
1973
+ outcomes: outcomesOf(lessonId),
1974
+ floats: chapterFloatsOf(lessonId),
1975
+ })),
1976
+ slidesFirst: lessonIds.filter((lessonId) => !isChapter(lessonId)).map(nameOf),
1977
+ },
1978
+ ];
1979
+ }),
1980
+ );
1981
+ const deckModel = buildDecks({
1982
+ decks: deckFiles,
1983
+ settings: deckSettings,
1984
+ rules: {
1985
+ maxWords: rules.number('D2', 'maxWords'),
1986
+ maxBullets: rules.number('D2', 'maxBullets'),
1987
+ maxTitleLength: rules.number('D5', 'maxLength'),
1988
+ maxSteps: rules.number('D6', 'maxSteps'),
1989
+ maxNewSymbols: rules.number('D9', 'maxNewSymbols'),
1990
+ },
1991
+ lessons: new Map(
1992
+ lessonDeckFiles.map((deck) => [
1993
+ deck.lessonId,
1994
+ {
1995
+ title: frontmatterById.get(deck.lessonId)?.title ?? deck.lessonId,
1996
+ teaches: lessonMetaById.get(deck.lessonId)?.teaches ?? [],
1997
+ outcomes: outcomesOf(deck.lessonId),
1998
+ ...(deck.chapterPath === undefined ? {} : { chapterFloats: chapterFloatsOf(deck.lessonId) }),
1999
+ },
2000
+ ]),
2001
+ ),
2002
+ parts: deckParts,
2003
+ // As the title frame shows them (`titleFrameNode` in `deck-remark.mjs`).
2004
+ course: {
2005
+ name: courseConfig.about.materialName ?? courseConfig.title,
2006
+ authors: courseConfig.about.authors.map((author) => author.name),
2007
+ },
2008
+ scopeOf: (pageId) => {
2009
+ const page = pageInputById.get(pageId);
2010
+ return page === undefined ? [] : pageScope(page, cardsByKey);
2011
+ },
2012
+ suspendedOf: (pageId) => {
2013
+ const page = pageInputById.get(pageId);
2014
+ return page === undefined ? [] : suspendedOperators(page);
2015
+ },
2016
+ labelFor: termLabel,
2017
+ });
2018
+ mathIssues.push(...deckModel.diagnostics);
2019
+ // The part's deck and each chapter's section of it, as the decks number their frames.
2020
+ const sidebarWithFrames = withPartDeckFrames(sidebar, deckModel.decks);
2021
+
2022
+ // Per-place waivers: drop the located findings each one covers, and
2023
+ // report the waivers themselves — a stale one is a warning, a bad one an
2024
+ // error. Chapters, decks and non-chapter pages are scanned alike, since the
2025
+ // text and math gates run on all of them; S2 and S10 are per-file or
2026
+ // locked, so no waiver reaches them. Applied once every located finding is
2027
+ // in, the deck rules' included.
2028
+ const waiverDiagnostics: WaiverDiagnostic[] = [];
2029
+ const waiverRules = { courseRules: rules.course };
2030
+ const waivers = withPages.lessons.flatMap((lesson) => {
2031
+ const collected = collectWaivers(lesson, waiverRules);
2032
+ waiverDiagnostics.push(...collected.diagnostics);
2033
+ return collected.waivers;
2034
+ });
2035
+ const waived = applyWaivers(mathIssues, waivers, waiverRules);
2036
+ mathIssues.splice(0, mathIssues.length, ...waived.kept);
2037
+ waiverDiagnostics.push(...waived.unused);
2038
+
2039
+ // Each lesson gets its `since` below, once every page's inputs are known.
2040
+ const lessons: Omit<ManifestLesson, 'since'>[] = [];
1708
2041
  for (const notationLesson of notationInput.lessons) {
1709
2042
  const id = notationLesson.lessonId;
1710
2043
  const meta = lessonMetaById.get(id);
@@ -1717,6 +2050,10 @@ export async function compileManifest(
1717
2050
  lessons.push({
1718
2051
  id,
1719
2052
  slug: id.replace(/\./g, '/'),
2053
+ editions: [
2054
+ ...(isChapter(id) ? (['chapter'] as const) : []),
2055
+ ...(lessonDeckFiles.some((deck) => deck.lessonId === id) ? (['deck'] as const) : []),
2056
+ ],
1720
2057
  // 0 = the lesson is not placed in any configured part; a curriculum
1721
2058
  // diagnostic covers that separately, and both numbering paths then fall
1722
2059
  // back to the legacy `##`-section scheme.
@@ -1836,6 +2173,127 @@ export async function compileManifest(
1836
2173
  file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, '/'),
1837
2174
  });
1838
2175
 
2176
+ // Each chapter's and part page's own version (rule C5): the committed
2177
+ // `content/versions.json`, brought to the pages' inputs as they are now —
2178
+ // the page's file, its checks and assessments, the shared notation entries
2179
+ // it uses, the sources it cites, and its numbers. With C5 off, nothing is
2180
+ // read or tracked and every page carries the course's version.
2181
+ const trackPageVersions = !rules.disabled.includes('C5');
2182
+ const previousVersions = trackPageVersions
2183
+ ? readPageVersions(join(CONTENT_ROOT, 'versions.json'), PAGE_VERSIONS_FILE)
2184
+ : { record: undefined, diagnostics: [] };
2185
+ const digestOf = (file: string): string => hashes.content[file] ?? 'missing';
2186
+ const presentFiles = (files: readonly string[]) => files.filter((file) => hashes.content[file] !== undefined);
2187
+ const sourceFileById = new Map<string, string>();
2188
+ for (const record of contentRecords) {
2189
+ const file = record.file.replace(/\\/g, '/');
2190
+ if (file.startsWith('content/sources/') && typeof record.data.id === 'string') {
2191
+ sourceFileById.set(record.data.id, file);
2192
+ }
2193
+ }
2194
+ const sharedEntryInputs = (definitionIds: readonly string[]) =>
2195
+ definitionIds.flatMap((definitionId) => {
2196
+ const definition = definitionsById.get(definitionId);
2197
+ return definition?.kind === 'shared'
2198
+ ? [{ label: `notation ${definition.source.file}`, digest: digestOf(definition.source.file) }]
2199
+ : [];
2200
+ });
2201
+ /** Each float a part's deck recalls, by ref, as its chapter numbers, captions and (an equation) writes it. */
2202
+ const recalledFloatFacts = (recalledFrom: Readonly<Record<string, string>>) =>
2203
+ Object.fromEntries(
2204
+ Object.entries(recalledFrom).map(([ref, chapter]) => {
2205
+ const [, prefix, key] = /^(eq|fig|tab|dia)-(.+)$/u.exec(ref) ?? [];
2206
+ const floats =
2207
+ prefix === 'eq'
2208
+ ? equationsBySlug
2209
+ : prefix === 'fig'
2210
+ ? figuresBySlug
2211
+ : prefix === 'tab'
2212
+ ? tablesBySlug
2213
+ : diagramsBySlug;
2214
+ const float = floats.get(chapter)?.find((entry) => entry.key === key);
2215
+ const latex = prefix === 'eq' ? chapterFloatsOf(chapter.replace(/\//g, '.')).equations.get(key ?? '')?.latex : undefined;
2216
+ return [ref, { chapter, ...float, ...(latex === undefined ? {} : { latex }) }];
2217
+ }),
2218
+ );
2219
+ const trackedPages = [
2220
+ ...lessons.map((lesson) => ({
2221
+ slug: lesson.slug,
2222
+ source: pageSourceDigest(
2223
+ [
2224
+ { label: `page ${lesson.file}`, digest: digestOf(lesson.file) },
2225
+ ...presentFiles([lesson.file.replace(/\.mdx?$/u, '.checks.yml')]).map((file) => ({
2226
+ label: `checks ${file}`,
2227
+ digest: digestOf(file),
2228
+ })),
2229
+ ...presentFiles(lesson.assessments.map((id) => `content/assessments/${id}.json`)).map((file) => ({
2230
+ label: `assessment ${file}`,
2231
+ digest: digestOf(file),
2232
+ })),
2233
+ ...sharedEntryInputs(lesson.notation.definitionIds),
2234
+ ...lesson.sources.map((id) => {
2235
+ const file = sourceFileById.get(id);
2236
+ return { label: `source ${id}`, digest: file === undefined ? 'missing' : digestOf(file) };
2237
+ }),
2238
+ ],
2239
+ { part: lesson.part, chapter: lesson.chapter, numbering: lesson.numbering },
2240
+ ),
2241
+ })),
2242
+ // A deck beside its chapter has a version of its own, by its route, and
2243
+ // so has a part's deck; a slides-first lesson's deck is the lesson, above.
2244
+ ...deckModel.decks.flatMap((deck) => {
2245
+ if (deck.chapter === undefined && deck.part === undefined) return [];
2246
+ const page = deckPages.find((entry) => entry.deck.route === deck.slug);
2247
+ return [
2248
+ {
2249
+ slug: deck.slug,
2250
+ source: pageSourceDigest(
2251
+ [
2252
+ { label: `page ${deck.file}`, digest: digestOf(deck.file) },
2253
+ ...sharedEntryInputs(bundlesByLesson.get(deck.pageId)?.definitionIds ?? []),
2254
+ ...(page === undefined ? [] : deriveSources(page.notation.body)).map((id) => {
2255
+ const file = sourceFileById.get(id);
2256
+ return { label: `source ${id}`, digest: file === undefined ? 'missing' : digestOf(file) };
2257
+ }),
2258
+ ],
2259
+ // A part's deck shows each float it recalls as its chapter
2260
+ // numbers and captions it: those count as the page's too.
2261
+ deck.recalledFrom === undefined ? {} : { recalls: recalledFloatFacts(deck.recalledFrom) },
2262
+ ),
2263
+ },
2264
+ ];
2265
+ }),
2266
+ ...manifestParts.flatMap((part) => {
2267
+ if (!part.overview) return [];
2268
+ const slug = `${part.dir}/index`;
2269
+ const page = pages.find((entry) => entry.slug === slug);
2270
+ return [
2271
+ {
2272
+ slug,
2273
+ source: pageSourceDigest([
2274
+ { label: `page ${part.overview.file}`, digest: digestOf(part.overview.file) },
2275
+ ...sharedEntryInputs(page?.notation.definitionIds ?? []),
2276
+ ]),
2277
+ },
2278
+ ];
2279
+ }),
2280
+ ];
2281
+ const pageVersionsRecord = nextPageVersions({
2282
+ previous: previousVersions.record,
2283
+ version: knowledge.version,
2284
+ unreleased: knowledge.unreleased.length > 0,
2285
+ pages: trackedPages,
2286
+ });
2287
+ const pageVersions: ManifestPageVersions = {
2288
+ enabled: trackPageVersions,
2289
+ file: PAGE_VERSIONS_FILE,
2290
+ record: pageVersionsRecord,
2291
+ };
2292
+ const sinceOfPage = (slug: string): string | null => {
2293
+ const entry = pageVersionsRecord.pages[slug];
2294
+ return trackPageVersions && entry !== undefined ? sinceOf(entry) : knowledge.version;
2295
+ };
2296
+
1839
2297
  const enginePkg = readEnginePackage();
1840
2298
  const about: ManifestAbout = {
1841
2299
  engine: {
@@ -1855,6 +2313,11 @@ export async function compileManifest(
1855
2313
  ? {}
1856
2314
  : { date: knowledge.latest.date }),
1857
2315
  notes: knowledge.latest?.notes ?? [],
2316
+ releases: knowledge.entries.map((entry) => ({
2317
+ version: entry.version,
2318
+ ...(entry.date === undefined ? {} : { date: entry.date }),
2319
+ })),
2320
+ unreleased: knowledge.unreleased.length > 0,
1858
2321
  contentTree: hashes.contentTree,
1859
2322
  },
1860
2323
  authors: courseConfig.about.authors.map((author) => ({
@@ -1867,6 +2330,37 @@ export async function compileManifest(
1867
2330
  : { changelogHref: courseConfig.about.changelogHref }),
1868
2331
  };
1869
2332
 
2333
+ const lessonsWithSince = lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) }));
2334
+ const decksWithSince = deckModel.decks.map((deck) => {
2335
+ const page = pageInputById.get(deck.pageId);
2336
+ return {
2337
+ ...deck,
2338
+ since: sinceOfPage(deck.slug),
2339
+ notation: lessonNotationBundle(
2340
+ bundlesByLesson.get(deck.pageId),
2341
+ definitionsById,
2342
+ page === undefined ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
2343
+ cardsByKey,
2344
+ // A deck shows its lessons' symbols: the derived cards (rule N23)
2345
+ // its chapters' bodies and checks use ship with it, as its bundle
2346
+ // holds the chapters' entries.
2347
+ [
2348
+ ...(indexGate.derivedByLesson.get(deck.pageId) ?? []),
2349
+ ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
2350
+ ...(indexGate.derivedByLesson.get(lessonId) ?? []),
2351
+ ...(assessmentDerived.get(lessonId)?.values() ?? []),
2352
+ ]),
2353
+ ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index),
2354
+ ),
2355
+ };
2356
+ });
2357
+ // Rule D11: a lesson's own deck whose chapter changed after the deck did.
2358
+ // `since` is only known this late, so this runs after `mathIssues` (D1–D10's
2359
+ // frame codes) is already waived; pushed in after, it joins `diagnostics.math`
2360
+ // the same way, just outside the waiver pass — not a per-place finding to waive.
2361
+ const chapterSince = new Map(lessonsWithSince.map((lesson) => [lesson.slug, lesson.since] as const));
2362
+ mathIssues.push(...deckStaleDiagnostics(decksWithSince, chapterSince));
2363
+
1870
2364
  return {
1871
2365
  schemaVersion: MANIFEST_SCHEMA_VERSION,
1872
2366
  lessonExports: normalizeLessonExports(courseConfig),
@@ -1874,6 +2368,12 @@ export async function compileManifest(
1874
2368
  numbering: numberingSettings,
1875
2369
  blockLabels,
1876
2370
  cards: normalizeCards(courseConfig),
2371
+ sidebarMarks: normalizeSidebarMarks(courseConfig),
2372
+ sidebarEditions: normalizeSidebarEditions(courseConfig),
2373
+ readingMemory: normalizeReadingMemory(courseConfig),
2374
+ appearance: normalizeAppearance(courseConfig),
2375
+ deck: deckSettings,
2376
+ decks: decksWithSince,
1877
2377
  about,
1878
2378
  notation: {
1879
2379
  definitions: registry.definitions,
@@ -1886,9 +2386,14 @@ export async function compileManifest(
1886
2386
  sources,
1887
2387
  tracks,
1888
2388
  abbreviations,
1889
- sidebar,
1890
- parts: manifestParts,
1891
- lessons,
2389
+ sidebar: sidebarWithFrames,
2390
+ parts: manifestParts.map(({ overview, ...part }) =>
2391
+ overview
2392
+ ? { ...part, overview: { ...overview, since: sinceOfPage(`${part.dir}/index`) } }
2393
+ : part,
2394
+ ),
2395
+ lessons: lessonsWithSince,
2396
+ pageVersions,
1892
2397
  pages,
1893
2398
  prereqEdges,
1894
2399
  equations: {
@@ -1930,7 +2435,7 @@ export async function compileManifest(
1930
2435
  checks: dropDisabled(checkValidation.diagnostics, off),
1931
2436
  examples: dropDisabled(exampleValidation.diagnostics, off),
1932
2437
  folds: dropDisabled(foldValidation.diagnostics, off),
1933
- knowledge: dropDisabled(knowledge.diagnostics, off),
2438
+ knowledge: dropDisabled([...knowledge.diagnostics, ...previousVersions.diagnostics], off),
1934
2439
  rawFloats: dropDisabled(rawFloatDiagnostics, off),
1935
2440
  config: rules.diagnostics,
1936
2441
  waivers: waiverDiagnostics,