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
@@ -28,6 +28,7 @@
28
28
 
29
29
  /** The part of a page a rule is about. The first tag on an entry is primary. */
30
30
  export type Element =
31
+ | 'slide'
31
32
  | 'prose'
32
33
  | 'math'
33
34
  | 'equation'
@@ -43,6 +44,7 @@ export type Element =
43
44
 
44
45
  /** Every element, in the order the rules-in-effect page lists them. */
45
46
  export const ELEMENTS: readonly Element[] = [
47
+ 'slide',
46
48
  'prose',
47
49
  'math',
48
50
  'equation',
@@ -58,10 +60,20 @@ export const ELEMENTS: readonly Element[] = [
58
60
  ];
59
61
 
60
62
  /**
61
- * The five areas the engine ships, plus `X` — reserved for a course's own rules
63
+ * The six areas the engine ships, plus `X` — reserved for a course's own rules
62
64
  * and never used by an engine rule (see {@link CourseRuleId}).
63
65
  */
64
- export type RuleArea = 'N' | 'V' | 'C' | 'S' | 'P' | 'X';
66
+ export type RuleArea = 'N' | 'V' | 'C' | 'S' | 'P' | 'D' | 'X';
67
+
68
+ /**
69
+ * Which edition of a lesson a rule is about (`docs/authoring/deck.md`): its
70
+ * chapter, its deck (`<lesson>.deck.mdx`), or `both`. A rule without one is
71
+ * `both`. The rules-in-effect page is written per edition, so an author of
72
+ * either reads one list: a chapter-only rule is off on a deck, where a `D`
73
+ * rule says the same thing about a frame.
74
+ */
75
+ export type Edition = 'chapter' | 'deck';
76
+ export type Editions = Edition | 'both';
65
77
  export type RuleId = `${RuleArea}${number}`;
66
78
 
67
79
  /**
@@ -145,6 +157,8 @@ export interface RuleEntry {
145
157
  * absent means "shipped with the engine".
146
158
  */
147
159
  readonly source?: 'course';
160
+ /** The edition the rule is about; absent is `both` ({@link Editions}). */
161
+ readonly editions?: Editions;
148
162
  }
149
163
 
150
164
  export interface InvariantEntry {
@@ -156,10 +170,18 @@ export interface InvariantEntry {
156
170
  readonly doc: string;
157
171
  readonly summary: string;
158
172
  readonly checks: readonly [RuleCheck, ...RuleCheck[]];
173
+ /** The edition the invariant is about; absent is `both` ({@link Editions}). */
174
+ readonly editions?: Editions;
159
175
  }
160
176
 
161
177
  export type CatalogEntry = RuleEntry | InvariantEntry;
162
178
 
179
+ /** True when `entry` is in force for `edition` ({@link Editions}). */
180
+ export function appliesTo(entry: { readonly editions?: Editions }, edition: Edition): boolean {
181
+ const editions = entry.editions ?? 'both';
182
+ return editions === 'both' || editions === edition;
183
+ }
184
+
163
185
  export type Enforcement = 'script' | 'partial' | 'guide';
164
186
 
165
187
  // --- emitters ------------------------------------------------------------
@@ -169,6 +191,7 @@ const GLYPHS: Emitter = { module: 'src/reference/math-glyphs.mjs', fn: 'resolveM
169
191
  const REGISTRY: Emitter = { module: 'src/reference/registry.ts', fn: 'buildNotationRegistry' };
170
192
  const KEYED_FLOATS: Emitter = { module: 'src/reference/keyed-float-validate.ts', fn: 'validateKeyedFloats' };
171
193
  const INDICES: Emitter = { module: 'src/reference/gate-indices.ts', fn: 'gateIndices' };
194
+ const DECKS: Emitter = { module: 'src/reference/decks-validate.ts', fn: 'validateDecks' };
172
195
 
173
196
  function consistency(fn: string): Emitter {
174
197
  return { module: 'src/reference/consistency.ts', fn };
@@ -334,12 +357,14 @@ const RULES: readonly RuleEntry[] = [
334
357
  elements: ['math', 'notation'],
335
358
  doc: 'notation.md#n4',
336
359
  summary:
337
- 'Every glyph in rendered math resolves to exactly one notation entry on the page: introduce it with `[[key]]` in prose before its first `$…$`, define a page-local one in `notation.local`, or bind a compound with `\\explain{key}{latex}`. Do not reuse a glyph for a second meaning on the same page.',
360
+ 'Every glyph in rendered math resolves to exactly one notation entry on the page: introduce it with `[[key]]` in prose before its first `$…$`, define a page-local one in `notation.local`, or bind a compound with `\\explain{key}{latex}`. Do not reuse a glyph for a second meaning on the same page. Declare a meaning once — a shared card unless the letter already means something else in the course, and only then `notation.local` or a gloss — and reference it everywhere else.',
338
361
  checks: [
339
362
  check('unresolved-glyph', 'error', true, GATE_MATH),
340
363
  check('glyph-unique-in-corpus', 'warning', false, consistency('checkGlyphUniqueness')),
341
364
  check('weak-local', 'info', false, consistency('checkWeakLocal')),
342
- check('gloss-wants-promoting', 'info', false, consistency('checkGlossWantsPromoting')),
365
+ check('definition-repeated', 'warning', false, consistency('checkDefinitionRepeated')),
366
+ check('meaning-repeated', 'info', false, consistency('checkMeaningRepeated')),
367
+ check('explain-redundant', 'info', true, GATE_MATH),
343
368
  check('card-wants-demoting', 'info', false, consistency('checkCardWantsDemoting')),
344
369
  ],
345
370
  locked:
@@ -729,6 +754,8 @@ const RULES: readonly RuleEntry[] = [
729
754
  {
730
755
  kind: 'rule',
731
756
  id: 'V7',
757
+ // Chapter-only: on a deck, rule D2 (a frame fits its canvas) says it of a frame.
758
+ editions: 'chapter',
732
759
  short: 'A figure’s picture fits on one screen',
733
760
  title: 'A figure’s picture takes at most `maxPercent` of the screen height.',
734
761
  elements: ['plot'],
@@ -755,6 +782,8 @@ const RULES: readonly RuleEntry[] = [
755
782
  {
756
783
  kind: 'rule',
757
784
  id: 'V8',
785
+ // Chapter-only: on a deck, rule D7 (a float is the frame) says it of a frame.
786
+ editions: 'chapter',
758
787
  short: 'A diagram fits the lesson column',
759
788
  title: 'A diagram is no wider than the lesson column.',
760
789
  elements: ['diagram'],
@@ -829,11 +858,36 @@ const RULES: readonly RuleEntry[] = [
829
858
  unchecked:
830
859
  'that the two edits landed in one commit — the build only ever sees the end state, so a release split across commits warns until the second half lands.',
831
860
  },
861
+ {
862
+ kind: 'rule',
863
+ id: 'C5',
864
+ short: 'Each chapter and part page carries the release its content last changed in; `content/versions.json` is generated and committed',
865
+ title:
866
+ 'Each chapter and part page carries the knowledge version its content last changed in, kept in `content/versions.json`, which `validate:content` writes and the course commits with the change.',
867
+ elements: ['changelog'],
868
+ doc: 'versioning.md#c5',
869
+ summary:
870
+ 'A page’s PDF, Markdown and `[?]` panel say "unchanged since version 0.2.0", the release its content last changed in, or "with changes after version 0.4.0, not yet released" while `## [Unreleased]` holds its change. The record is `content/versions.json`: `validate:content` works it out from the committed file and the content, writes it on a developer’s machine, and in CI fails when the committed file is not the one it would write. Never edit it by hand; commit what `validate:content` wrote with the content change, and with a release.',
871
+ checks: [
872
+ check('page-versions-stale', 'error', false, {
873
+ module: 'src/compiler/page-versions.ts',
874
+ fn: 'syncPageVersions',
875
+ }),
876
+ check('page-versions-invalid', 'warning', false, {
877
+ module: 'src/compiler/page-versions.ts',
878
+ fn: 'readPageVersions',
879
+ }),
880
+ ],
881
+ unchecked:
882
+ 'which bullet of `## [Unreleased]` belongs to which page — every page changed while there is any bullet counts as not yet released; and a change to a lab, `content/domain/` or a part’s label, which changes the page’s PDF without changing its version.',
883
+ },
832
884
 
833
885
  // ---- S — structure ----------------------------------------------------------
834
886
  {
835
887
  kind: 'rule',
836
888
  id: 'S1',
889
+ // Chapter-only: on a deck, rule D5 (a frame title fits one line) says it of a frame.
890
+ editions: 'chapter',
837
891
  short: 'No heading, short caption or short-note title over 65 characters',
838
892
  title:
839
893
  'No heading, no short caption (`short`, an equation’s `\\caption{}`) and no short-note title runs past 65 characters.',
@@ -945,6 +999,8 @@ const RULES: readonly RuleEntry[] = [
945
999
  {
946
1000
  kind: 'rule',
947
1001
  id: 'S6',
1002
+ // Chapter-only: on a deck, rule D1 (sections of frames) says it of a frame.
1003
+ editions: 'chapter',
948
1004
  short: 'Headings descend one level at a time, from `##`',
949
1005
  title:
950
1006
  'Headings descend one level at a time: a lesson body starts at `##` and never skips a level on the way down.',
@@ -968,6 +1024,8 @@ const RULES: readonly RuleEntry[] = [
968
1024
  {
969
1025
  kind: 'rule',
970
1026
  id: 'S7',
1027
+ // Chapter-only: on a deck, rule D1 (sections of frames) says it of a frame.
1028
+ editions: 'chapter',
971
1029
  short: 'A heading is followed by body content, never by another heading',
972
1030
  title: 'A heading is followed by body content, never directly by another heading.',
973
1031
  elements: ['heading'],
@@ -1135,6 +1193,179 @@ const RULES: readonly RuleEntry[] = [
1135
1193
  'The sentence before a display equation names the quantity it yields and the step or law it follows from — not “we have:”.',
1136
1194
  checks: [],
1137
1195
  },
1196
+ // ---- D — decks --------------------------------------------------------------
1197
+ {
1198
+ kind: 'rule',
1199
+ id: 'D1',
1200
+ editions: 'deck',
1201
+ short: 'A deck is sections of frames: `##` a section, `###` a frame',
1202
+ title:
1203
+ 'A deck is sections of frames: `##` opens a section, `###` is a frame, and nothing is deeper or outside a frame.',
1204
+ elements: ['slide', 'heading'],
1205
+ doc: 'deck.md#d1',
1206
+ summary:
1207
+ 'In a `<lesson>.deck.mdx`, `##` opens a section, a divider frame that shows only its title, and `###` is a frame whose title is the heading. Write every line of content inside a `###` frame: no `#`, no `####` or deeper, nothing before the first heading and nothing between a `##` and its first `###`.',
1208
+ checks: [
1209
+ check('frame-depth', 'error', true, DECKS),
1210
+ check('deck-loose-content', 'error', true, DECKS),
1211
+ check('deck-empty', 'error', false, DECKS),
1212
+ ],
1213
+ locked:
1214
+ 'a frame is the unit the deck page, Present mode and the PDF are built from — content outside one has no frame to show in.',
1215
+ },
1216
+ {
1217
+ kind: 'rule',
1218
+ id: 'D2',
1219
+ editions: 'deck',
1220
+ short: 'A frame fits its canvas in every step',
1221
+ title: 'A frame fits its canvas in every step, and never continues on the next frame.',
1222
+ elements: ['slide'],
1223
+ doc: 'deck.md#d2',
1224
+ summary:
1225
+ 'Everything a frame shows — title, body, legend line, at its last step — fits the course’s canvas (`deck.aspectRatio`). `content check` warns when a frame passes `maxWords` words or `maxBullets` list items. A frame that does not fit is two frames; never shrink its type.',
1226
+ checks: [
1227
+ check('frame-dense', 'warning', true, DECKS),
1228
+ check('frame-overflow', 'error', false, { module: 'src/compiler/exports/deck-fit.ts', fn: 'checkFrameFit' }),
1229
+ ],
1230
+ unchecked:
1231
+ 'the paper: a frame that fits its canvas on the web can still run over in the PDF, which the export reports as a warning naming the frame.',
1232
+ settings: [
1233
+ {
1234
+ key: 'maxWords',
1235
+ title: 'Most words on a frame',
1236
+ description: 'Words a frame may show (its prose, not its math or notes) before `content check` warns.',
1237
+ schema: { type: 'integer', minimum: 10, default: 70 },
1238
+ },
1239
+ {
1240
+ key: 'maxBullets',
1241
+ title: 'Most list items on a frame',
1242
+ description: 'List items a frame may show before `content check` warns.',
1243
+ schema: { type: 'integer', minimum: 1, default: 7 },
1244
+ },
1245
+ ],
1246
+ },
1247
+ {
1248
+ kind: 'rule',
1249
+ id: 'D3',
1250
+ editions: 'deck',
1251
+ short: 'A frame makes one claim, and its title states it',
1252
+ title: 'A frame makes one claim, and its title states the claim.',
1253
+ elements: ['slide', 'prose'],
1254
+ doc: 'deck.md#d3',
1255
+ summary:
1256
+ 'Title a frame with the sentence it shows to be true — “Range is largest at a 45° launch angle”, not “Range”. Everything on the frame serves that one claim; a second claim is a second frame.',
1257
+ checks: [],
1258
+ },
1259
+ {
1260
+ kind: 'rule',
1261
+ id: 'D4',
1262
+ editions: 'deck',
1263
+ short: 'A frame with text carries speaker notes',
1264
+ title: 'A frame with text carries speaker notes: the argument is in `<Notes>`, the frame shows its claims.',
1265
+ elements: ['slide', 'prose'],
1266
+ doc: 'deck.md#d4',
1267
+ summary:
1268
+ 'A `###` frame whose body has words carries a `<Notes>…</Notes>` block with what the speaker says: the reasoning, the transitions, the question to ask the room. A frame that shows only a picture, an equation or a question needs none. The prose rules hold for the notes.',
1269
+ checks: [check('frame-notes-missing', 'warning', true, DECKS)],
1270
+ },
1271
+ {
1272
+ kind: 'rule',
1273
+ id: 'D5',
1274
+ editions: 'deck',
1275
+ short: 'A frame title fits one line',
1276
+ title: 'A frame title fits one line: at most `maxLength` characters.',
1277
+ elements: ['slide', 'heading'],
1278
+ doc: 'deck.md#d5',
1279
+ summary:
1280
+ 'A `##` or `###` heading in a deck runs to at most `maxLength` characters as the reader sees it (math as it reads, a `[[key]]` term as its label), so it stays on one line of the canvas. Move the rest into the frame.',
1281
+ checks: [check('frame-title-length', 'error', true, DECKS)],
1282
+ settings: [
1283
+ {
1284
+ key: 'maxLength',
1285
+ title: 'Longest frame title',
1286
+ description: 'Characters a frame or section title may run to.',
1287
+ schema: { type: 'integer', minimum: 10, default: 50 },
1288
+ },
1289
+ ],
1290
+ },
1291
+ {
1292
+ kind: 'rule',
1293
+ id: 'D6',
1294
+ editions: 'deck',
1295
+ short: 'A frame shows in at most `maxSteps` steps, each adding to the last',
1296
+ title: 'A frame shows in at most `maxSteps` steps, and each step adds to what is shown.',
1297
+ elements: ['slide'],
1298
+ doc: 'deck.md#d6',
1299
+ summary:
1300
+ '`<Pause />` is the one step mark: what follows it appears on the next key press, and nothing shown earlier changes or goes, so the last step is the whole frame — what the scroll view, the print and the PDF show. Keep to `maxSteps` steps; more is a second frame.',
1301
+ checks: [check('frame-steps', 'warning', true, DECKS)],
1302
+ unchecked: 'that a later step contradicts nothing already shown.',
1303
+ settings: [
1304
+ {
1305
+ key: 'maxSteps',
1306
+ title: 'Most steps in a frame',
1307
+ description: 'Steps a frame may show in (one more than its `<Pause />` marks).',
1308
+ schema: { type: 'integer', minimum: 1, default: 4 },
1309
+ },
1310
+ ],
1311
+ },
1312
+ {
1313
+ kind: 'rule',
1314
+ id: 'D7',
1315
+ editions: 'deck',
1316
+ short: 'A frame holds one figure, table or diagram',
1317
+ title: 'A frame holds at most one figure, table or diagram, and it is the frame’s picture.',
1318
+ elements: ['slide', 'plot', 'table', 'diagram'],
1319
+ doc: 'deck.md#d7',
1320
+ summary:
1321
+ 'One `<Figure>`, `<Table>`, `<Diagram>` or recalled float per frame, laid out at the canvas width; a lab’s controls are the frame’s tools, live in Present mode. A second picture is a second frame.',
1322
+ checks: [check('frame-floats', 'warning', true, DECKS)],
1323
+ },
1324
+ {
1325
+ kind: 'rule',
1326
+ id: 'D8',
1327
+ editions: 'deck',
1328
+ short: 'Recall a chapter’s result; never retype it',
1329
+ title:
1330
+ 'A result the chapter numbers is recalled with `<Recall>`, never retyped; the deck’s own floats carry no number.',
1331
+ elements: ['slide', 'equation'],
1332
+ doc: 'deck.md#d8',
1333
+ summary:
1334
+ 'Show the chapter’s equation, figure, table or diagram with `<Recall ref="eq-range" />` (`fig-`, `tab-`, `dia-`): it renders as in the chapter, with the chapter’s number, and links back to it. A display equation retyped from the chapter is a warning. An equation the deck labels itself numbers (1), (2) within the deck; the deck’s figures, tables and diagrams are unnumbered.',
1335
+ checks: [check('frame-retyped', 'warning', true, DECKS)],
1336
+ },
1337
+ {
1338
+ kind: 'rule',
1339
+ id: 'D9',
1340
+ editions: 'deck',
1341
+ short: 'A frame introduces at most `maxNewSymbols` symbols',
1342
+ title: 'A frame introduces at most `maxNewSymbols` symbols the deck has not shown before.',
1343
+ elements: ['slide', 'math', 'notation'],
1344
+ doc: 'deck.md#d9',
1345
+ summary:
1346
+ 'The room reads a frame once. Count the symbols it shows for the first time in the deck and keep them to `maxNewSymbols`; the legend line under the frame names them. More than that is a frame of its own.',
1347
+ checks: [check('frame-new-symbols', 'info', true, DECKS)],
1348
+ settings: [
1349
+ {
1350
+ key: 'maxNewSymbols',
1351
+ title: 'Most new symbols on a frame',
1352
+ description: 'Symbols a frame may show for the first time in its deck.',
1353
+ schema: { type: 'integer', minimum: 1, default: 4 },
1354
+ },
1355
+ ],
1356
+ },
1357
+ {
1358
+ kind: 'rule',
1359
+ id: 'D10',
1360
+ editions: 'deck',
1361
+ short: 'A deck opens on its title and closes on what the student can now do',
1362
+ title: 'A deck opens on its title frame and closes on what the student can now do.',
1363
+ elements: ['slide'],
1364
+ doc: 'deck.md#d10',
1365
+ summary:
1366
+ 'The title frame (the lesson, the course, its authors and version) and the closing frame (the outcomes of the competencies the lesson teaches, `deck.outcomesFrame`) are generated: do not write them. With `deck.outcomesFrame` off, end on a `###` frame that says what the student can now do.',
1367
+ checks: [],
1368
+ },
1138
1369
  ];
1139
1370
 
1140
1371
  /**
@@ -1424,6 +1655,28 @@ const INVARIANTS: readonly InvariantEntry[] = [
1424
1655
  }),
1425
1656
  ],
1426
1657
  },
1658
+ {
1659
+ kind: 'invariant',
1660
+ id: 'decks',
1661
+ editions: 'deck',
1662
+ title: 'A deck belongs to a lesson or a part, and recalls what its chapters number.',
1663
+ elements: ['slide'],
1664
+ doc: 'deck.md#the-deck-file',
1665
+ summary:
1666
+ 'A deck is `content/docs/<part>/<lesson>.deck.mdx`, or a part’s `content/docs/<part>/index.deck.mdx`. Beside a chapter, and as a part’s, it sets only `title` and `description` — the chapter holds the lesson’s `teaches`, `assumptions`, `studyMinutes` and `notation`; alone, it is the lesson and carries them. On a part’s deck, `## Title [chapter: <name>]` names the chapter a section covers: a chapter of the part, once, and only on a `##` of a part’s deck. `<Recall ref>` names an equation, figure, table or diagram of the deck’s own chapter, or of a chapter a part’s deck covers; `<AssessmentSet>` on a part’s deck sits in a section that names a chapter; `<Pause>` takes only `control`, `from`, `to`, `seconds` (a driven figure) or `highlight` (a spotlight).',
1667
+ checks: [
1668
+ check('deck-frontmatter', 'error', false, DECKS),
1669
+ check('deck-outside-part', 'error', false, DECKS),
1670
+ check('recall-unresolved', 'error', true, DECKS),
1671
+ check('pause-invalid', 'error', true, DECKS),
1672
+ check('deck-chapter-unknown', 'error', true, DECKS),
1673
+ check('deck-chapter-repeated', 'error', true, DECKS),
1674
+ check('deck-chapter-outside-part', 'error', true, DECKS),
1675
+ check('deck-marker-misplaced', 'error', true, DECKS),
1676
+ check('deck-check-no-chapter', 'error', true, DECKS),
1677
+ check('deck-route-taken', 'error', false, DECKS),
1678
+ ],
1679
+ },
1427
1680
  ];
1428
1681
 
1429
1682
  export const CATALOG: readonly CatalogEntry[] = [...RULES, ...INVARIANTS];
@@ -42,6 +42,7 @@ import {
42
42
  type Element,
43
43
  type RuleCheck,
44
44
  type RuleEntry,
45
+ type Editions,
45
46
  } from './catalog';
46
47
 
47
48
  /** Where a `forbid` pattern is matched. */
@@ -77,6 +78,8 @@ export interface CourseRuleInput {
77
78
  readonly forbid?: readonly CourseRulePatternInput[];
78
79
  readonly unchecked?: string;
79
80
  readonly doc?: string;
81
+ /** `chapter`, `deck` or `both` (the default): the edition of a lesson the rule is about. */
82
+ readonly editions?: string;
80
83
  }
81
84
 
82
85
  /** `CourseConfig.courseRules` — an array, so the settings form can add and remove one. */
@@ -100,6 +103,8 @@ export interface CourseRule {
100
103
  readonly forbid: readonly CourseRulePattern[];
101
104
  readonly unchecked?: string;
102
105
  readonly doc?: string;
106
+ /** The edition of a lesson the rule is about (`docs/authoring/deck.md`). */
107
+ readonly editions: Editions;
103
108
  }
104
109
 
105
110
  /**
@@ -225,7 +230,7 @@ export function resolveCourseRules(config: {
225
230
  );
226
231
  }
227
232
  const raw = input as Record<string, unknown>;
228
- const known = ['id', 'title', 'summary', 'elements', 'enabled', 'severity', 'forbid', 'unchecked', 'doc'];
233
+ const known = ['id', 'title', 'summary', 'elements', 'enabled', 'severity', 'forbid', 'unchecked', 'doc', 'editions'];
229
234
  for (const key of Object.keys(raw)) {
230
235
  if (!known.includes(key)) {
231
236
  throw new Error(`${at(index)}: unknown key \`${key}\`. Takes: ${known.join(', ')}.`);
@@ -259,6 +264,11 @@ export function resolveCourseRules(config: {
259
264
  `${where}.severity must be "error" or "warning"; received ${describe(raw.severity)}.`,
260
265
  );
261
266
  }
267
+ if (raw.editions !== undefined && raw.editions !== 'both' && raw.editions !== 'chapter' && raw.editions !== 'deck') {
268
+ throw new Error(
269
+ `${where}.editions must be "both", "chapter" or "deck"; received ${describe(raw.editions)}.`,
270
+ );
271
+ }
262
272
  const forbidInput = raw.forbid;
263
273
  if (forbidInput !== undefined && !Array.isArray(forbidInput)) {
264
274
  throw new Error(`${where}.forbid must be an array of patterns; received ${describe(forbidInput)}.`);
@@ -283,6 +293,7 @@ export function resolveCourseRules(config: {
283
293
  forbid,
284
294
  ...(raw.unchecked === undefined ? {} : { unchecked: requireString(raw.unchecked, `${where}.unchecked`) }),
285
295
  ...(raw.doc === undefined ? {} : { doc: requireString(raw.doc, `${where}.doc`) }),
296
+ editions: (raw.editions as Editions | undefined) ?? 'both',
286
297
  });
287
298
  });
288
299
 
@@ -319,6 +330,7 @@ export function courseRuleEntry(rule: CourseRule): RuleEntry {
319
330
  checks,
320
331
  ...(rule.unchecked === undefined ? {} : { unchecked: rule.unchecked }),
321
332
  source: 'course',
333
+ ...(rule.editions === 'both' ? {} : { editions: rule.editions }),
322
334
  };
323
335
  }
324
336
 
@@ -377,6 +389,14 @@ export function courseRulesJsonSchema(): JsonSchema {
377
389
  default: ['prose'],
378
390
  items: { title: 'Element', type: 'string', enum: [...ELEMENTS] },
379
391
  },
392
+ editions: {
393
+ title: 'Editions',
394
+ description:
395
+ 'The edition of a lesson the rule is about: its chapter, its deck (`<lesson>.deck.mdx`), or both. A rule about one edition is checked there only, and listed on that edition’s rules-in-effect page.',
396
+ type: 'string',
397
+ enum: ['both', 'chapter', 'deck'],
398
+ default: 'both',
399
+ },
380
400
  enabled: {
381
401
  title: 'Enabled',
382
402
  description:
@@ -11,15 +11,24 @@
11
11
  * `pnpm validate:content` writes it to `build/rules-in-effect.md` (git-ignored,
12
12
  * like the manifest — regenerated every run); `pnpm content rules` prints it,
13
13
  * optionally filtered to an element or an enforcement kind.
14
+ *
15
+ * The page is per edition of a lesson (`docs/authoring/deck.md`): the default
16
+ * is the chapter's, which lists no deck rule, so a course without decks reads
17
+ * the page it always did. A course with decks also gets
18
+ * `build/rules-in-effect-deck.md` (`content rules --edition deck`), where the
19
+ * `D` rules lead and a chapter-only rule is listed as such.
14
20
  */
15
- import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
16
22
  import { join } from 'node:path';
17
23
  import {
18
24
  ELEMENTS,
25
+ appliesTo,
19
26
  enforcementOf,
20
27
  invariants,
21
28
  rules as catalogRules,
22
29
  type CatalogEntry,
30
+ type Edition,
31
+ type Editions,
23
32
  type Element,
24
33
  type Enforcement,
25
34
  type RuleEntry,
@@ -36,12 +45,16 @@ export interface RulesInEffectInput {
36
45
  readonly material: { readonly name: string };
37
46
  readonly knowledge: { readonly version: string };
38
47
  };
48
+ /** The course's decks (`manifest.decks`): with any, the deck page is written too. */
49
+ readonly decks?: readonly unknown[];
39
50
  }
40
51
 
41
52
  export interface RulesFilter {
42
53
  /** Keep a rule whose elements (any, not just primary) include one of these. */
43
54
  readonly elements?: readonly Element[];
44
55
  readonly enforcement?: Enforcement | 'locked';
56
+ /** The edition the page is for: `chapter` (the default) or `deck`. */
57
+ readonly edition?: Edition;
45
58
  }
46
59
 
47
60
  /** One rule as `content rules --json` reports it. */
@@ -60,6 +73,8 @@ export interface RuleInEffect {
60
73
  readonly settings: Readonly<Record<string, unknown>>;
61
74
  readonly summary: string;
62
75
  readonly doc: string;
76
+ /** The edition it is about: `chapter`, `deck`, or `both`. */
77
+ readonly editions: Editions;
63
78
  }
64
79
 
65
80
  /** Where a course's copy of the rule text lives, relative to `build/`. */
@@ -84,6 +99,7 @@ function enforcementLabel(entry: CatalogEntry): Enforcement | 'locked' {
84
99
  }
85
100
 
86
101
  function matches(entry: CatalogEntry, filter: RulesFilter): boolean {
102
+ if (!appliesTo(entry, filter.edition ?? 'chapter')) return false;
87
103
  if (filter.elements && !entry.elements.some((e) => filter.elements!.includes(e))) return false;
88
104
  if (filter.enforcement && enforcementLabel(entry) !== filter.enforcement) return false;
89
105
  return true;
@@ -105,6 +121,7 @@ export function rulesInEffect(input: RulesInEffectInput, filter: RulesFilter = {
105
121
  settings: entry.kind === 'rule' ? (input.rules.settings[entry.id] ?? {}) : {},
106
122
  summary: entry.summary,
107
123
  doc: docLink(entry),
124
+ editions: entry.editions ?? 'both',
108
125
  }));
109
126
  }
110
127
 
@@ -152,17 +169,29 @@ function ruleLine(rule: RuleEntry, settings: Readonly<Record<string, unknown>>):
152
169
  * the reader knows what was left out on purpose.
153
170
  */
154
171
  export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilter = {}): string {
172
+ const edition = filter.edition ?? 'chapter';
155
173
  const off = new Set<string>(input.rules.disabled);
156
- const on = liveRules(input).filter((r) => !off.has(r.id));
174
+ const live = liveRules(input).filter((r) => appliesTo(r, edition));
175
+ const on = live.filter((r) => !off.has(r.id));
176
+ const offHere = [...off].filter((id) => live.some((r) => r.id === id));
157
177
  const ours = on.filter((r) => r.source === 'course').length;
158
178
  const locked = on.filter((r) => r.locked !== undefined).map((r) => r.id);
159
179
  const filtered = filter.elements || filter.enforcement;
160
180
  const visible = on.filter((r) => matches(r, filter));
181
+ const editionInvariants = invariants().filter((i) => appliesTo(i, edition));
161
182
 
162
183
  const lines: string[] = [];
163
184
  lines.push(
164
- `# Rules in effect — ${input.about.material.name} (knowledge ${input.about.knowledge.version}, ${input.about.engine.name} ${input.about.engine.version})`,
185
+ `# Rules in effect${edition === 'deck' ? ' for decks' : ''} — ${input.about.material.name} (knowledge ${input.about.knowledge.version}, ${input.about.engine.name} ${input.about.engine.version})`,
165
186
  '',
187
+ ...(edition === 'deck'
188
+ ? [
189
+ 'For a lesson’s deck, `<lesson>.deck.mdx` (`docs/authoring/deck.md`): the `D` rules',
190
+ 'lead, then every rule that holds for a deck as for a chapter. A chapter-only rule is',
191
+ 'listed under "Chapter only" at the end; on a frame, a `D` rule says it instead.',
192
+ '',
193
+ ]
194
+ : []),
166
195
  'Generated by `pnpm validate:content` (and `pnpm content rules`) from the rule',
167
196
  'catalog and `rules` / `courseRules` in `course.settings.json`. Do not edit. Read this before',
168
197
  'drafting, and open a rule page only when it is listed here and you need the',
@@ -170,7 +199,7 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
170
199
  'not exist, and is neither applied nor flagged in review.',
171
200
  '',
172
201
  `${on.length} rules on (${locked.length} locked${ours === 0 ? '' : `, ${ours} this course's own`}), ` +
173
- `${off.size} off, ${invariants().length} invariants always on.` +
202
+ `${offHere.length} off, ${editionInvariants.length} invariants always on.` +
174
203
  (filtered
175
204
  ? ` Filtered: ${[
176
205
  filter.elements ? `element ${filter.elements.join(', ')}` : '',
@@ -200,7 +229,7 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
200
229
  for (const rule of here) lines.push(ruleLine(rule, input.rules.settings[rule.id] ?? {}));
201
230
  }
202
231
 
203
- const visibleInvariants = invariants().filter((i) => matches(i, filter));
232
+ const visibleInvariants = editionInvariants.filter((i) => matches(i, filter));
204
233
  lines.push('', '## Always on (invariants)', '');
205
234
  if (visibleInvariants.length === 0) {
206
235
  lines.push('- (none match the filter)');
@@ -214,22 +243,39 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
214
243
 
215
244
  lines.push('', '## Off', '');
216
245
  lines.push(
217
- off.size === 0
246
+ offHere.length === 0
218
247
  ? '- Nothing — every rule is on.'
219
- : `- ${[...off].join(', ')} — turned off in \`course.settings.json\` (\`rules\`, or \`courseRules\` for an \`X\` rule). Do not apply them; do not flag them in review.`,
248
+ : `- ${offHere.join(', ')} — turned off in \`course.settings.json\` (\`rules\`, or \`courseRules\` for an \`X\` rule). Do not apply them; do not flag them in review.`,
220
249
  );
221
250
 
222
251
  lines.push('', '## Locked', '');
223
252
  lines.push(`- ${locked.join(', ')} — cannot be turned off; listed above under their element.`);
224
253
 
254
+ if (edition === 'deck') {
255
+ const chapterOnly = liveRules(input).filter((r) => !appliesTo(r, 'deck')).map((r) => r.id);
256
+ lines.push('', '## Chapter only', '');
257
+ lines.push(
258
+ chapterOnly.length === 0
259
+ ? '- Nothing — every rule holds for a deck too.'
260
+ : `- ${chapterOnly.join(', ')} — about a chapter’s page, not a frame. On a deck, neither apply them nor flag them; the \`D\` rules above say the same of a frame.`,
261
+ );
262
+ }
263
+
225
264
  return `${lines.join('\n')}\n`;
226
265
  }
227
266
 
228
- /** Write `build/rules-in-effect.md` under `root` (the course package). Returns the path. */
267
+ /**
268
+ * Write `build/rules-in-effect.md` under `root` (the course package), and
269
+ * `build/rules-in-effect-deck.md` when the course has a deck (a stale one goes
270
+ * when it has none). Returns the chapter page's path.
271
+ */
229
272
  export function writeRulesInEffect(input: RulesInEffectInput, root = process.cwd()): string {
230
273
  const dir = join(root, 'build');
231
274
  mkdirSync(dir, { recursive: true });
232
275
  const path = join(dir, 'rules-in-effect.md');
233
276
  writeFileSync(path, renderRulesInEffect(input));
277
+ const deckPath = join(dir, 'rules-in-effect-deck.md');
278
+ if ((input.decks?.length ?? 0) > 0) writeFileSync(deckPath, renderRulesInEffect(input, { edition: 'deck' }));
279
+ else if (existsSync(deckPath)) rmSync(deckPath);
234
280
  return path;
235
281
  }
@@ -23,6 +23,12 @@ export interface PreferenceStore {
23
23
  * fits; a caller without it writes an empty value instead.
24
24
  */
25
25
  remove?(key: string): void;
26
+ /**
27
+ * Every key the store holds. Optional like `remove`; the reading-memory
28
+ * question (`reading-memory.ts`) uses it to forget a course's per-page
29
+ * entries, whose names it cannot list otherwise.
30
+ */
31
+ keys?(): string[];
26
32
  }
27
33
 
28
34
  /**
@@ -53,6 +59,14 @@ export function createBrowserPreferenceStore(): PreferenceStore {
53
59
  // Storage unavailable — there is nothing stored to forget.
54
60
  }
55
61
  },
62
+ keys() {
63
+ try {
64
+ const storage = window.localStorage;
65
+ return Array.from({ length: storage.length }, (_, index) => storage.key(index) ?? '').filter(Boolean);
66
+ } catch {
67
+ return [];
68
+ }
69
+ },
56
70
  };
57
71
  }
58
72
 
@@ -83,6 +97,14 @@ export function createSessionPreferenceStore(): PreferenceStore {
83
97
  // Storage unavailable — there is nothing stored to forget.
84
98
  }
85
99
  },
100
+ keys() {
101
+ try {
102
+ const storage = window.sessionStorage;
103
+ return Array.from({ length: storage.length }, (_, index) => storage.key(index) ?? '').filter(Boolean);
104
+ } catch {
105
+ return [];
106
+ }
107
+ },
86
108
  };
87
109
  }
88
110
 
@@ -118,5 +140,6 @@ export function createMemoryPreferenceStore(): PreferenceStore {
118
140
  remove: (key) => {
119
141
  map.delete(key);
120
142
  },
143
+ keys: () => [...map.keys()],
121
144
  };
122
145
  }