explico 0.13.1 → 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 +879 -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 +1045 -16
  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 +15 -7
  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
@@ -0,0 +1,263 @@
1
+ /**
2
+ * What a reader's decks remember (`docs/authoring/deck.md`, "Present mode and
3
+ * the study page"): for each deck, the frame and the step Present mode last
4
+ * stood on, and every frame it showed them, as it was. A frame's content is
5
+ * named by its id (`ManifestDeckFrame.id`, 12 hex digits of a hash over what
6
+ * it shows), so a frame that changed since the reader was shown it is told
7
+ * from one they were never shown, as `page-marks.ts` tells pages apart.
8
+ *
9
+ * One small JSON map per course,
10
+ * `{ "v": 2, "decks": { slug: { at, step, seen } } }`, kept through the
11
+ * `PreferenceStore` under `deckPositionsKey(base)`. It is reading memory
12
+ * (`reading-memory.ts`): with the reader's yes it is kept
13
+ * across visits; before an answer it is kept for the tab, as kept cards are;
14
+ * after a no, only where each deck stopped is, for the tab. Version 1,
15
+ * `{ slug: number }`, held the stopping place alone, and reads as version 2
16
+ * with no frame shown.
17
+ *
18
+ * Pure: `components/deck/deck-controller.ts` does the DOM and the storage on
19
+ * a deck's page, the sidebar's script (`LayoutSidebar.astro`) its two
20
+ * actions, which cover every deck, `reading-memory.ts` what the reader's
21
+ * answer does to the tab's record, and `tests/unit/deck-memory.test.ts`
22
+ * checks this in Node.
23
+ */
24
+
25
+ /** A frame as the page knows it: its number in the deck, and its content's id. */
26
+ export interface DeckFrameId {
27
+ readonly number: number;
28
+ readonly id: string;
29
+ }
30
+
31
+ /** A frame as the study page knows it, with the steps it has (`data-steps`). */
32
+ export interface DeckFrameSteps extends DeckFrameId {
33
+ readonly steps: number;
34
+ }
35
+
36
+ /** A deck as the sidebar's two actions need it. */
37
+ export interface DeckFrames {
38
+ /** Its route, as `.deck[data-deck]` names it: `motion/position-and-coordinates/slides`. */
39
+ readonly slug: string;
40
+ readonly frames: readonly DeckFrameId[];
41
+ }
42
+
43
+ /** What one deck remembers. */
44
+ export interface DeckRecord {
45
+ /** The frame Present mode last stood on, by number. */
46
+ readonly at?: number;
47
+ /** The step of that frame it stood on: none in a record kept before steps were. */
48
+ readonly step?: number;
49
+ /** The frames Present mode showed, by number: the id each had then. */
50
+ readonly seen: ReadonlyMap<number, string>;
51
+ }
52
+
53
+ /** Where "Continue" presents from (`resumeAt`). */
54
+ export interface Resume {
55
+ /** The frame, by its number now; the first frame for "Present again". */
56
+ readonly number: number;
57
+ /** The reader was shown every step of the deck's last frame: "Present again". */
58
+ readonly again: boolean;
59
+ }
60
+
61
+ /** Every deck's record, by its slug. */
62
+ export type DeckMemory = ReadonlyMap<string, DeckRecord>;
63
+
64
+ /** A frame's mark on the study page: not shown in Present mode yet, or changed since it was. */
65
+ export type FrameMark = 'unseen' | 'changed';
66
+
67
+ /**
68
+ * Told on a page after deck memory was written there, so the deck's page and
69
+ * the sidebar read it again; another tab hears the `storage` event.
70
+ */
71
+ export const DECK_MEMORY_EVENT = 'explico:deck-memory';
72
+
73
+ /** Bumped whenever the stored shape changes. */
74
+ const VERSION = 2;
75
+ const ID = /^[0-9a-f]{12}$/u;
76
+ const NUMBER = /^[1-9][0-9]*$/u;
77
+
78
+ function isFrameNumber(value: unknown): value is number {
79
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
80
+ }
81
+
82
+ /** A record holding `at`, and `step` with it when there is one. */
83
+ function place(at: number | undefined, step: number | undefined): { at?: number; step?: number } {
84
+ if (at === undefined) return {};
85
+ return step === undefined ? { at } : { at, step };
86
+ }
87
+
88
+ function byText(a: string, b: string): number {
89
+ return a < b ? -1 : a > b ? 1 : 0;
90
+ }
91
+
92
+ /** One deck's stored record, or none when nothing in it can be trusted. */
93
+ function readRecord(value: unknown): DeckRecord | undefined {
94
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
95
+ const { at, step, seen } = value as { at?: unknown; step?: unknown; seen?: unknown };
96
+ const frames = new Map<number, string>();
97
+ if (seen && typeof seen === 'object' && !Array.isArray(seen)) {
98
+ for (const [number, id] of Object.entries(seen as Record<string, unknown>)) {
99
+ if (NUMBER.test(number) && isFrameNumber(Number(number)) && typeof id === 'string' && ID.test(id)) {
100
+ frames.set(Number(number), id);
101
+ }
102
+ }
103
+ }
104
+ if (!isFrameNumber(at) && frames.size === 0) return undefined;
105
+ // A step belongs to its frame: none without one.
106
+ return { ...(isFrameNumber(at) ? place(at, isFrameNumber(step) ? step : undefined) : {}), seen: frames };
107
+ }
108
+
109
+ /**
110
+ * Every deck's record, from the stored text. Version 1 becomes version 2,
111
+ * each deck's place kept and no frame shown. Anything else, and any entry
112
+ * that is not a frame number or a frame's id, is dropped, never trusted.
113
+ */
114
+ export function decodeDeckMemory(raw: string | null): DeckMemory {
115
+ const memory = new Map<string, DeckRecord>();
116
+ if (!raw) return memory;
117
+ let parsed: unknown;
118
+ try {
119
+ parsed = JSON.parse(raw);
120
+ } catch {
121
+ return memory;
122
+ }
123
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return memory;
124
+ const stored = parsed as Record<string, unknown>;
125
+ if (!('v' in stored)) {
126
+ for (const [slug, at] of Object.entries(stored)) {
127
+ if (slug.length > 0 && isFrameNumber(at)) memory.set(slug, { at, seen: new Map() });
128
+ }
129
+ return memory;
130
+ }
131
+ const { v, decks } = stored;
132
+ if (v !== VERSION || !decks || typeof decks !== 'object' || Array.isArray(decks)) return memory;
133
+ for (const [slug, value] of Object.entries(decks as Record<string, unknown>)) {
134
+ const record = slug.length > 0 ? readRecord(value) : undefined;
135
+ if (record) memory.set(slug, record);
136
+ }
137
+ return memory;
138
+ }
139
+
140
+ /** The stored text: decks sorted by slug, frames by number, so the same state is always the same string. */
141
+ export function encodeDeckMemory(memory: DeckMemory): string {
142
+ const decks = [...memory]
143
+ .sort(([a], [b]) => byText(a, b))
144
+ .map(([slug, record]) => [
145
+ slug,
146
+ {
147
+ ...place(record.at, record.step),
148
+ seen: Object.fromEntries([...record.seen].sort(([a], [b]) => a - b).map(([number, id]) => [String(number), id])),
149
+ },
150
+ ]);
151
+ return JSON.stringify({ v: VERSION, decks: Object.fromEntries(decks) });
152
+ }
153
+
154
+ /** `memory` with `slug`'s record replaced; a record that holds nothing goes. */
155
+ function withRecord(memory: DeckMemory, slug: string, record: DeckRecord | undefined): Map<string, DeckRecord> {
156
+ const next = new Map(memory);
157
+ if (record && (record.at !== undefined || record.seen.size > 0)) next.set(slug, record);
158
+ else next.delete(slug);
159
+ return next;
160
+ }
161
+
162
+ /**
163
+ * The record after Present mode stood on step `step` of frame `number` of
164
+ * `slug`, while the frames shown are not kept: the place alone, the frames
165
+ * shown left as they were.
166
+ */
167
+ export function withPosition(memory: DeckMemory, slug: string, number: number, step: number): Map<string, DeckRecord> {
168
+ return withRecord(memory, slug, { ...place(number, step), seen: memory.get(slug)?.seen ?? new Map() });
169
+ }
170
+
171
+ /**
172
+ * The record after Present mode showed `frame` of `slug` at step `step`, in
173
+ * the room's window or the presenter's (D8): the reader stands there, and has
174
+ * seen the frame as it is now, whichever of its steps they saw.
175
+ */
176
+ export function withShown(memory: DeckMemory, slug: string, frame: DeckFrameId, step: number): Map<string, DeckRecord> {
177
+ const seen = new Map(memory.get(slug)?.seen);
178
+ seen.set(frame.number, frame.id);
179
+ return withRecord(memory, slug, { ...place(frame.number, step), seen });
180
+ }
181
+
182
+ /** "Mark all as seen" on a deck's page: every frame of `slug` seen as it is now. Where it stopped stays. */
183
+ export function withAllSeen(memory: DeckMemory, slug: string, frames: readonly DeckFrameId[]): Map<string, DeckRecord> {
184
+ const record = memory.get(slug);
185
+ return withRecord(memory, slug, {
186
+ ...place(record?.at, record?.step),
187
+ seen: new Map(frames.map((frame) => [frame.number, frame.id])),
188
+ });
189
+ }
190
+
191
+ /** "Mark all as unseen" on a deck's page: no frame of `slug` seen. Where it stopped stays. */
192
+ export function withNoneSeen(memory: DeckMemory, slug: string): Map<string, DeckRecord> {
193
+ const record = memory.get(slug);
194
+ return withRecord(memory, slug, record?.at === undefined ? undefined : { ...place(record.at, record.step), seen: new Map() });
195
+ }
196
+
197
+ /**
198
+ * Two records as one, for the reader's yes (`reading-memory.ts`): what the
199
+ * tab kept before it (`newer`) joins what the browser kept from an earlier
200
+ * visit (`older`). Each deck stops where `newer` says, when it says; a frame
201
+ * shown in both is as `newer` saw it.
202
+ */
203
+ export function mergeDeckMemory(older: DeckMemory, newer: DeckMemory): Map<string, DeckRecord> {
204
+ const merged = new Map(older);
205
+ for (const [slug, record] of newer) {
206
+ const before = older.get(slug);
207
+ const at = record.at ?? before?.at;
208
+ merged.set(slug, {
209
+ ...place(at, record.at === undefined ? before?.step : record.step),
210
+ seen: new Map([...(before?.seen ?? []), ...record.seen]),
211
+ });
212
+ }
213
+ return merged;
214
+ }
215
+
216
+ /** The sidebar's "Mark all as seen" (D9): every frame of every deck seen as it is now. */
217
+ export function withEveryDeckSeen(memory: DeckMemory, decks: readonly DeckFrames[]): Map<string, DeckRecord> {
218
+ let next = new Map(memory);
219
+ for (const deck of decks) next = withAllSeen(next, deck.slug, deck.frames);
220
+ return next;
221
+ }
222
+
223
+ /** The sidebar's "Mark all as unseen" (D9): no frame of any deck seen. Where each stopped stays. */
224
+ export function withEveryDeckUnseen(memory: DeckMemory): Map<string, DeckRecord> {
225
+ let next = new Map(memory);
226
+ for (const slug of memory.keys()) next = withNoneSeen(next, slug);
227
+ return next;
228
+ }
229
+
230
+ /**
231
+ * A frame's mark for a reader whose record of its deck is `record`: none
232
+ * when they were shown it as it is now, whatever its number was then (a
233
+ * frame added before it moves it, and it is still the frame they saw);
234
+ * `changed` when its place showed them something else; else `unseen`.
235
+ */
236
+ export function frameMark(frame: DeckFrameId, record: DeckRecord | undefined): FrameMark | undefined {
237
+ const seen = record?.seen;
238
+ if (!seen || seen.size === 0) return 'unseen';
239
+ if (seen.get(frame.number) === frame.id) return undefined;
240
+ for (const id of seen.values()) if (id === frame.id) return undefined;
241
+ return seen.has(frame.number) ? 'changed' : 'unseen';
242
+ }
243
+
244
+ /**
245
+ * Where "Continue at slide N" presents from (D7): the frame the reader
246
+ * stopped at, unless they were shown its every step, then the frame after it
247
+ * — or, after the last frame, "Present again" from the first. The frame is
248
+ * followed to its number now when frames were added or taken out before it,
249
+ * by the id it had. None when nothing is remembered or the deck no longer
250
+ * has that frame. A record kept before steps were counts as stopped partway.
251
+ */
252
+ export function resumeAt(record: DeckRecord | undefined, frames: readonly DeckFrameSteps[]): Resume | undefined {
253
+ const at = record?.at;
254
+ if (at === undefined) return undefined;
255
+ const id = record?.seen.get(at);
256
+ const there = frames.find((frame) => frame.number === at);
257
+ const moved = id === undefined || there?.id === id ? undefined : frames.find((frame) => frame.id === id);
258
+ const stopped = moved ?? there;
259
+ if (!stopped) return undefined;
260
+ if (record?.step === undefined || record.step < stopped.steps) return { number: stopped.number, again: false };
261
+ const after = frames[frames.indexOf(stopped) + 1];
262
+ return after ? { number: after.number, again: false } : { number: frames[0]!.number, again: true };
263
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The one question explico asks a reader: may this course remember their
3
+ * reading in this browser? (`readingMemory` in the course settings.)
4
+ *
5
+ * **Reading memory** is what records what the reader did, across visits:
6
+ * which chapters they opened (the sidebar marks and the "changed since you
7
+ * last opened it" note, `page-marks.ts`), the cards they kept open on a page
8
+ * (`card-memory.ts`), the cards they carry from page to page
9
+ * (`card-carry.ts`), and where each deck stopped and which slides Present
10
+ * mode showed them (`deck-memory.ts`). It is kept only with the reader's yes.
11
+ * Without it — before they answer, after a no, or when the course turns the
12
+ * question off — nothing of their reading outlives the tab: no visit is
13
+ * recorded and no page is marked, and kept and carried cards and where a deck
14
+ * stopped live in the tab's session storage, so they still work until the tab
15
+ * is closed. The slides Present mode showed live there too before an answer,
16
+ * marked on the deck's page; a no clears them, and none is recorded after it.
17
+ *
18
+ * **Settings** are not reading memory: the theme, the text size, the panels'
19
+ * layout, a folded section or sidebar part, which edition a lesson's sidebar
20
+ * row opens, the "Carry cards" switch. The
21
+ * reader sets each of them, and they are kept because the reader set them,
22
+ * the exemption the ePrivacy rules give a preference the user asked for. They
23
+ * need no answer. Nothing explico stores is a cookie, and nothing leaves the
24
+ * browser.
25
+ *
26
+ * The answer itself is kept (storing a consent choice needs no consent), per
27
+ * course, for 13 months, after which the course asks again. A no deletes
28
+ * whatever reading memory the course had; "Forget what this course stored"
29
+ * (the `[?]` panel) deletes everything, the answer included.
30
+ *
31
+ * Storage goes through the `PreferenceStore` only. The pure half
32
+ * (`decodeAnswer`, `courseKeys`, …) is checked in Node by
33
+ * `tests/unit/reading-memory.test.ts`.
34
+ */
35
+ import {
36
+ createBrowserPreferenceStore,
37
+ createSessionPreferenceStore,
38
+ type PreferenceStore,
39
+ } from './PreferenceStore';
40
+ import {
41
+ decodeDeckMemory,
42
+ encodeDeckMemory,
43
+ mergeDeckMemory,
44
+ withEveryDeckUnseen,
45
+ } from './deck-memory';
46
+
47
+ /** The reader's answer: yes, no, or not asked yet (or asked too long ago). */
48
+ export type ReadingAnswer = 'yes' | 'no' | 'unasked';
49
+
50
+ /** The course's `readingMemory` setting: ask the reader, or never keep reading memory. */
51
+ export type ReadingMemoryMode = 'ask' | 'off';
52
+
53
+ /** How long an answer stands before the course asks again: 13 months. */
54
+ export const ANSWER_LIFETIME_DAYS = 396;
55
+
56
+ /** Bumped whenever the stored answer's shape changes; another version counts as not asked. */
57
+ const VERSION = 1;
58
+ const DAY = 24 * 60 * 60 * 1000;
59
+
60
+ /** The event every part of the page listens to when the reader answers or forgets. */
61
+ export const READING_MEMORY_EVENT = 'explico:reading-memory';
62
+
63
+ export interface ReadingMemoryChange {
64
+ readonly base: string;
65
+ readonly answer: ReadingAnswer;
66
+ }
67
+
68
+ /** Where a course keeps the reader's answer. */
69
+ export function answerKey(base: string): string {
70
+ return `explico-reading-memory:${base}`;
71
+ }
72
+
73
+ /** The stored answer's text. */
74
+ export function encodeAnswer(yes: boolean, now: Date): string {
75
+ return JSON.stringify({ v: VERSION, remember: yes, at: now.toISOString().slice(0, 10) });
76
+ }
77
+
78
+ /** The answer a stored text holds on `now`: anything unreadable, or older than its lifetime, is not asked. */
79
+ export function decodeAnswer(raw: string | null, now: Date): ReadingAnswer {
80
+ if (!raw) return 'unasked';
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ } catch {
85
+ return 'unasked';
86
+ }
87
+ if (!parsed || typeof parsed !== 'object') return 'unasked';
88
+ const { v, remember, at } = parsed as { v?: unknown; remember?: unknown; at?: unknown };
89
+ if (v !== VERSION || typeof remember !== 'boolean' || typeof at !== 'string') return 'unasked';
90
+ const given = Date.parse(`${at}T00:00:00Z`);
91
+ if (!Number.isFinite(given) || now.getTime() - given > ANSWER_LIFETIME_DAYS * DAY || given - now.getTime() > DAY) {
92
+ return 'unasked';
93
+ }
94
+ return remember ? 'yes' : 'no';
95
+ }
96
+
97
+ /** Explico's settings that every course on one site shares: not named by a course's base path. */
98
+ const SHARED_SETTINGS = [
99
+ /^explico:display:/u,
100
+ /^explico:layout:/u,
101
+ /^explico-sidebar-groups$/u,
102
+ /^explico-sidebar-editions$/u,
103
+ /^explico:cards:carry$/u,
104
+ ];
105
+
106
+ /**
107
+ * Where a course keeps, for each of its decks, the frame Present mode last
108
+ * stood on and the frames it showed (`deck-memory.ts`, `deck-controller.ts`).
109
+ */
110
+ export function deckPositionsKey(base: string): string {
111
+ return `explico:decks:${base}`;
112
+ }
113
+
114
+ /**
115
+ * A key that is this course's reading memory: its visits, its kept and its
116
+ * carried cards, where its decks stopped and the slides they showed.
117
+ */
118
+ export function isReadingMemoryKey(key: string, base: string): boolean {
119
+ return (
120
+ key === `explico-page-marks:${base}` ||
121
+ key === `explico:cards:carried:${base}` ||
122
+ key === deckPositionsKey(base) ||
123
+ (key.startsWith(`explico:cards:${base}`) && !key.startsWith('explico:cards:carried:'))
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Every key this course stored, of the keys a store holds: its reading
129
+ * memory, its answer, its sections' and cards' per-page state, and the
130
+ * settings every explico course on this site shares, which forgetting this
131
+ * course resets too. Starlight's own theme key goes with them.
132
+ */
133
+ export function courseKeys(keys: readonly string[], base: string): string[] {
134
+ return keys.filter(
135
+ (key) =>
136
+ isReadingMemoryKey(key, base) ||
137
+ key === answerKey(base) ||
138
+ key.startsWith(`disclosure:${base}`) ||
139
+ key === 'explico:cards:opened-at' ||
140
+ key === 'starlight-theme' ||
141
+ key === 'sl-sidebar-state' ||
142
+ SHARED_SETTINGS.some((pattern) => pattern.test(key)),
143
+ );
144
+ }
145
+
146
+ // --- in the browser -----------------------------------------------------
147
+
148
+ const local = createBrowserPreferenceStore();
149
+ const session = createSessionPreferenceStore();
150
+
151
+ function remove(store: PreferenceStore, key: string): void {
152
+ if (store.remove) store.remove(key);
153
+ else store.set(key, '');
154
+ }
155
+
156
+ /** The course's setting, from the element `ReadingMemoryNotice.astro` renders on every page. */
157
+ export function readingMemoryMode(): ReadingMemoryMode {
158
+ const element = document.querySelector<HTMLElement>('[data-explico-reading-memory]');
159
+ return element?.dataset.explicoReadingMemory === 'off' ? 'off' : 'ask';
160
+ }
161
+
162
+ /** The reader's answer for the course at `base`. */
163
+ export function readingAnswer(base: string, now = new Date()): ReadingAnswer {
164
+ return decodeAnswer(local.get(answerKey(base)), now);
165
+ }
166
+
167
+ /** Whether the course may keep the reader's reading memory: they said yes, and the course asks. */
168
+ export function rememberReading(base: string): boolean {
169
+ return readingMemoryMode() === 'ask' && readingAnswer(base) === 'yes';
170
+ }
171
+
172
+ /**
173
+ * Where reading memory goes: kept in the browser with the reader's yes, else
174
+ * the tab's session only. Read the session copy too when kept, so the cards
175
+ * of a visit that started before the yes are not lost.
176
+ */
177
+ export function readingMemoryStore(base: string): PreferenceStore {
178
+ if (!rememberReading(base)) return session;
179
+ return {
180
+ get: (key) => local.get(key) ?? session.get(key),
181
+ set: (key, value) => local.set(key, value),
182
+ remove: (key) => {
183
+ remove(local, key);
184
+ remove(session, key);
185
+ },
186
+ };
187
+ }
188
+
189
+ /** Delete the course's reading memory from the browser (the tab's session copy stays). */
190
+ export function forgetReadingMemory(base: string): void {
191
+ for (const key of local.keys?.() ?? []) if (isReadingMemoryKey(key, base)) remove(local, key);
192
+ }
193
+
194
+ function announce(base: string): void {
195
+ window.dispatchEvent(
196
+ new CustomEvent<ReadingMemoryChange>(READING_MEMORY_EVENT, { detail: { base, answer: readingAnswer(base) } }),
197
+ );
198
+ }
199
+
200
+ /**
201
+ * The slides this tab recorded as shown before an answer, and where each deck
202
+ * stopped, kept in the browser with the reader's yes: the next visit marks
203
+ * them too.
204
+ */
205
+ function keepShownSlides(base: string): void {
206
+ const raw = session.get(deckPositionsKey(base));
207
+ if (!raw) return;
208
+ const kept = mergeDeckMemory(decodeDeckMemory(local.get(deckPositionsKey(base))), decodeDeckMemory(raw));
209
+ local.set(deckPositionsKey(base), encodeDeckMemory(kept));
210
+ }
211
+
212
+ /**
213
+ * The slides this tab recorded as shown before an answer, cleared after a
214
+ * no: where each deck stopped stays for the tab, as kept cards do.
215
+ */
216
+ function forgetShownSlides(base: string): void {
217
+ const raw = session.get(deckPositionsKey(base));
218
+ if (raw) session.set(deckPositionsKey(base), encodeDeckMemory(withEveryDeckUnseen(decodeDeckMemory(raw))));
219
+ }
220
+
221
+ /**
222
+ * Record the reader's answer. A yes keeps the slides the tab recorded before
223
+ * it; a no deletes the course's reading memory, and those slides. Everything
224
+ * on the page is told.
225
+ */
226
+ export function answerReadingMemory(base: string, yes: boolean, now = new Date()): void {
227
+ local.set(answerKey(base), encodeAnswer(yes, now));
228
+ if (yes) {
229
+ keepShownSlides(base);
230
+ } else {
231
+ forgetReadingMemory(base);
232
+ forgetShownSlides(base);
233
+ }
234
+ announce(base);
235
+ }
236
+
237
+ /** "Forget what this course stored": every key of the course, in the browser and the tab; the course asks again. */
238
+ export function forgetCourse(base: string): void {
239
+ for (const store of [local, session]) {
240
+ for (const key of courseKeys(store.keys?.() ?? [], base)) remove(store, key);
241
+ }
242
+ announce(base);
243
+ }