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
@@ -3,8 +3,10 @@ import {
3
3
  compareSourceSpans,
4
4
  isNotationKey,
5
5
  localDefinitionId,
6
+ pageScopeLessonIds,
6
7
  RESERVED_KEY_PREFIXES,
7
8
  reservedKeyPrefix,
9
+ resolvePageReferenceId,
8
10
  resolveReferenceId,
9
11
  sharedDefinitionId,
10
12
  } from './references';
@@ -14,7 +16,6 @@ import type {
14
16
  LocalNotationDefinitionRecord,
15
17
  NotationBacklink,
16
18
  NotationDefinitionRecord,
17
- NotationDefinitionScope,
18
19
  NotationDiagnostic,
19
20
  NotationLessonInput,
20
21
  NotationPageBundle,
@@ -124,16 +125,16 @@ export function buildNotationRegistry(
124
125
  const directReferences = new Map<string, ResolvedNotationReference[]>();
125
126
  for (const lesson of lessons.values()) {
126
127
  const resolved: ResolvedNotationReference[] = [];
127
- const scope: NotationDefinitionScope = {
128
- kind: 'page',
129
- lessonId: lesson.lessonId,
130
- };
128
+ // A deck resolves in its lessons' scopes: a deck beside its chapter in
129
+ // the chapter's, whose `notation.local` entries are the lesson's; a
130
+ // part's deck in its chapters', the first to define a key binding it.
131
+ const scopes = pageScopeLessonIds(lesson);
131
132
 
132
133
  for (const reference of sortedReferences(lesson.references)) {
133
134
  if (!validatePageReferenceKey(reference, lesson, diagnostics)) continue;
134
- const definitionId = resolveReferenceId(
135
+ const definitionId = resolvePageReferenceId(
135
136
  reference.key,
136
- scope,
137
+ scopes,
137
138
  availableDefinitionIds,
138
139
  );
139
140
  if (definitionId === undefined) {
@@ -203,19 +204,45 @@ export function buildNotationRegistry(
203
204
  const reachableDefinitions = new Set<string>();
204
205
 
205
206
  for (const lesson of lessons.values()) {
206
- const references = directReferences.get(lesson.lessonId) ?? [];
207
+ // A deck shows its lessons' symbols: its bundle holds what its chapters
208
+ // name as well as what it names itself. A key is bound as the deck
209
+ // binds it, then as the first of its chapters does.
210
+ const scopes = pageScopeLessonIds(lesson);
211
+ const references = [
212
+ ...(directReferences.get(lesson.lessonId) ?? []),
213
+ // A chapter's reference, bound as this page binds its key: on a part's
214
+ // deck, a key two of its chapters define is the first one's.
215
+ ...scopes
216
+ .filter((lessonId) => lessonId !== lesson.lessonId)
217
+ .flatMap((lessonId) => directReferences.get(lessonId) ?? [])
218
+ .flatMap((reference) => {
219
+ const definitionId = resolvePageReferenceId(reference.key, scopes, availableDefinitionIds);
220
+ return definitionId === undefined ? [] : [{ ...reference, definitionId }];
221
+ }),
222
+ ];
207
223
  const directIds = new Set(
208
224
  references.map((reference) => reference.definitionId),
209
225
  );
226
+ // Every local entry in scope seeds the bundle, the first of a key only.
227
+ const scopedLocalIds: string[] = [];
228
+ const scopedLocalKeys = new Set<string>();
229
+ for (const lessonId of scopes) {
230
+ for (const id of localIdsByLesson.get(lessonId) ?? []) {
231
+ const key = definitions.get(id)?.input.key;
232
+ if (key === undefined || scopedLocalKeys.has(key)) continue;
233
+ scopedLocalKeys.add(key);
234
+ scopedLocalIds.push(id);
235
+ }
236
+ }
210
237
  const definitionIds = transitiveClosure(
211
- new Set([...directIds, ...(localIdsByLesson.get(lesson.lessonId) ?? [])]),
238
+ new Set([...directIds, ...scopedLocalIds]),
212
239
  edgeIndex,
213
240
  );
214
241
  for (const id of definitionIds) reachableDefinitions.add(id);
215
242
 
216
243
  const bindingByKey = new Map<string, string>();
217
244
  for (const reference of references) {
218
- bindingByKey.set(reference.key, reference.definitionId);
245
+ if (!bindingByKey.has(reference.key)) bindingByKey.set(reference.key, reference.definitionId);
219
246
  }
220
247
 
221
248
  bundles.push({
@@ -231,7 +258,10 @@ export function buildNotationRegistry(
231
258
  definitionIds,
232
259
  });
233
260
 
234
- for (const definitionId of definitionIds) {
261
+ // A deck shows its chapters' symbols again: the glossary names each
262
+ // chapter once, not its slides, or its part's, as a page of its own.
263
+ const isDeck = lesson.scopeLessonIds !== undefined;
264
+ for (const definitionId of isDeck ? [] : definitionIds) {
235
265
  const definition = definitions.get(definitionId);
236
266
  if (definition === undefined) continue;
237
267
  backlinks.push({
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The rehype half of a deck page (`<lesson>.deck.mdx`, docs/authoring/deck.md):
3
+ * the page's flow becomes its frames. `rehype-heading-anchors.mjs` hands a
4
+ * deck page here (`remark-notation.mjs` marks one with `file.data.explicoDeck`),
5
+ * so a course wires nothing new.
6
+ *
7
+ * Each `##` (a section's divider) and `###` (a frame) heading, stamped with
8
+ * its frame number by the remark half (`deck-remark.mjs`), opens a
9
+ *
10
+ * <section class="deck-frame" id="slide-N" data-frame="N" data-kind="frame"
11
+ * data-frame-id="3f9a0c1e77b2" what it shows (`ManifestDeckFrame.id`)
12
+ * role="group" aria-roledescription="slide" aria-label="N of T">
13
+ * <div class="deck-stage"> the frame's box on the page
14
+ * <div class="deck-canvas"> the fixed canvas the course's ratio sets, scaled to it
15
+ * <h3 class="deck-frame__title">…</h3>
16
+ * <div class="deck-frame__body">…</div> its steps: `.deck-step[data-step]`
17
+ * <div class="deck-legend">…</div> the symbols it introduces
18
+ * <div class="deck-frame__foot">…</div> section and frame number
19
+ * </div>
20
+ * <aside class="deck-notes">…</aside> the speaker's notes, outside the canvas
21
+ * </section>
22
+ *
23
+ * `<Pause />` splits a frame's body into steps; its props (`control`, `from`,
24
+ * `to`, `seconds`; `highlight`) ride on the step for the page's controller
25
+ * (`components/deck/deck-controller.ts`). Two lists a step boundary splits
26
+ * become one list whose later items are the step.
27
+ */
28
+ import { stripMarkers } from './rehype-heading-anchors.mjs';
29
+
30
+ const HEADING = /^h[1-6]$/;
31
+
32
+ /** A hast property by its attribute name, in either the attribute or the property spelling. */
33
+ function prop(node, name) {
34
+ const properties = node?.properties ?? {};
35
+ if (properties[name] !== undefined) return properties[name];
36
+ const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
37
+ return properties[camel];
38
+ }
39
+
40
+ function classes(node) {
41
+ const value = prop(node, 'className') ?? prop(node, 'class');
42
+ if (Array.isArray(value)) return value.map(String);
43
+ return typeof value === 'string' ? value.split(/\s+/) : [];
44
+ }
45
+
46
+ function element(tagName, properties, children = []) {
47
+ return { type: 'element', tagName, properties, children };
48
+ }
49
+
50
+ function isWhitespace(node) {
51
+ return node?.type === 'text' && !String(node.value ?? '').trim();
52
+ }
53
+
54
+ function isJsx(node, name) {
55
+ return (node?.type === 'mdxJsxFlowElement' || node?.type === 'mdxJsxTextElement') && node.name === name;
56
+ }
57
+
58
+ function jsxAttributes(node) {
59
+ const values = {};
60
+ for (const attribute of node?.attributes ?? []) {
61
+ if (attribute?.type === 'mdxJsxAttribute' && typeof attribute.value === 'string') {
62
+ values[attribute.name] = attribute.value;
63
+ }
64
+ }
65
+ return values;
66
+ }
67
+
68
+ function textOf(node) {
69
+ if (!node) return '';
70
+ if (node.type === 'text') return String(node.value ?? '');
71
+ if (classes(node).includes('katex-mathml') || node.tagName === 'annotation') return '';
72
+ return (node.children ?? []).map(textOf).join('');
73
+ }
74
+
75
+ /** A heading the remark half stamped, and what kind of frame it opens. */
76
+ function frameKindOf(node) {
77
+ if (node?.type !== 'element' || !HEADING.test(node.tagName)) return undefined;
78
+ const kind = prop(node, 'data-deck-kind');
79
+ if (kind === 'section' || kind === 'frame') return kind;
80
+ if (node.tagName === 'h2' && prop(node, 'id') === 'references') return 'references';
81
+ return undefined;
82
+ }
83
+
84
+ function isGeneratedFrame(node) {
85
+ return node?.type === 'element' && node.tagName === 'section' && classes(node).includes('deck-frame');
86
+ }
87
+
88
+ /** A step's data attributes, from its `<Pause>` props. */
89
+ function stepProperties(step, pause) {
90
+ const properties = { 'data-step': String(step) };
91
+ for (const name of ['control', 'from', 'to', 'seconds', 'highlight']) {
92
+ if (pause[name] !== undefined) properties[`data-step-${name}`] = pause[name];
93
+ }
94
+ return properties;
95
+ }
96
+
97
+ /** A frame's content in steps: what follows the k-th `<Pause />` is step k + 1. */
98
+ function stepped(content) {
99
+ const groups = [{ step: 1, pause: {}, nodes: [] }];
100
+ for (const node of content) {
101
+ if (isJsx(node, 'Pause')) {
102
+ groups.push({ step: groups.length + 1, pause: jsxAttributes(node), nodes: [] });
103
+ continue;
104
+ }
105
+ groups.at(-1).nodes.push(node);
106
+ }
107
+ const out = [...groups[0].nodes];
108
+ const lastElement = () => [...out].reverse().find((node) => node.type === 'element');
109
+ for (const group of groups.slice(1)) {
110
+ const properties = stepProperties(group.step, group.pause);
111
+ const nodes = group.nodes.filter((node) => !isWhitespace(node));
112
+ const [first, ...rest] = nodes;
113
+ const previous = lastElement();
114
+ // Two lists the step mark splits are one list: its later items are the step.
115
+ if (
116
+ first?.type === 'element' &&
117
+ (first.tagName === 'ul' || first.tagName === 'ol') &&
118
+ previous?.tagName === first.tagName
119
+ ) {
120
+ let marked = false;
121
+ for (const item of first.children ?? []) {
122
+ if (item.type !== 'element') continue;
123
+ item.properties = {
124
+ ...item.properties,
125
+ className: [...classes(item), 'deck-step'],
126
+ ...(marked ? { 'data-step': String(group.step) } : properties),
127
+ };
128
+ marked = true;
129
+ }
130
+ previous.children.push(...(first.children ?? []));
131
+ if (rest.length > 0) out.push(element('div', { className: ['deck-step'], 'data-step': String(group.step) }, rest));
132
+ continue;
133
+ }
134
+ // A step with nothing to show still drives its figure or lights its symbol.
135
+ out.push(element('div', { className: ['deck-step'], ...properties }, nodes));
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function frameShell({ number, kind, id, frameId, chapter, extra = {}, canvas, notes = [] }) {
141
+ return element(
142
+ 'section',
143
+ {
144
+ className: ['deck-frame', `deck-frame--${kind}`],
145
+ id: `slide-${number}`,
146
+ 'data-frame': String(number),
147
+ ...(frameId ? { 'data-frame-id': frameId } : {}),
148
+ ...(chapter ? { 'data-chapter': chapter } : {}),
149
+ 'data-kind': kind,
150
+ role: 'group',
151
+ 'aria-roledescription': 'slide',
152
+ ...(id ? { 'data-frame-anchor': id } : {}),
153
+ ...extra,
154
+ },
155
+ // The stage takes the frame's size on the page; the canvas inside it is
156
+ // laid out at the course's canvas size and scaled to the stage, so a frame
157
+ // looks the same at every width (`docs/diagram-scaling.md`).
158
+ [element('div', { className: ['deck-stage'] }, [element('div', { className: ['deck-canvas'] }, canvas)]), ...notes],
159
+ );
160
+ }
161
+
162
+ function foot(number, sectionTitle) {
163
+ return element('div', { className: ['deck-frame__foot'], 'aria-hidden': 'true' }, [
164
+ ...(sectionTitle ? [element('span', { className: ['deck-frame__section'] }, [{ type: 'text', value: sectionTitle }])] : []),
165
+ element('span', { className: ['deck-frame__number'] }, [{ type: 'text', value: String(number) }]),
166
+ ]);
167
+ }
168
+
169
+ /**
170
+ * Turn a deck page's flow into its frames, in place. `deck` is
171
+ * `file.data.explicoDeck`: the ratio, the canvas, who sees the notes, each
172
+ * frame's id and, on a part's deck, each frame's chapter.
173
+ */
174
+ export function frameDeck(tree, deck) {
175
+ const esm = [];
176
+ const frames = [];
177
+ let current;
178
+ let sectionTitle;
179
+ let lastNumber = 0;
180
+ const frameIdOf = (number) => deck.frameIds?.[number - 1];
181
+ const chapterOf = (number) => deck.frameChapters?.[number - 1] ?? undefined;
182
+
183
+ const close = () => {
184
+ if (!current) return;
185
+ const { heading, kind, body } = current;
186
+ current = undefined;
187
+ const markers = stripMarkers(heading);
188
+ const stamped = Number(prop(heading, 'data-deck-frame'));
189
+ const number = Number.isFinite(stamped) && stamped > 0 ? stamped : lastNumber + 1;
190
+ lastNumber = number;
191
+ const id = prop(heading, 'id');
192
+ heading.properties = {
193
+ ...heading.properties,
194
+ className: [...classes(heading), 'deck-frame__title'],
195
+ };
196
+ for (const name of ['data-deck-frame', 'data-deck-kind', 'data-deck-steps', 'data-deck-morph']) {
197
+ delete heading.properties[name];
198
+ delete heading.properties[name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())];
199
+ }
200
+ if (kind === 'section') {
201
+ sectionTitle = textOf(heading).trim();
202
+ frames.push(frameShell({ number, kind, id, frameId: frameIdOf(number), chapter: chapterOf(number), canvas: [heading, foot(number)] }));
203
+ return;
204
+ }
205
+ const notes = [];
206
+ const legend = [];
207
+ const content = [];
208
+ for (const node of body) {
209
+ if (isJsx(node, 'Notes')) {
210
+ // A landmark each, so each needs a name of its own (axe: landmark-unique).
211
+ notes.push(element('aside', { className: ['deck-notes'], 'aria-label': `Speaker notes for slide ${number}` }, node.children ?? []));
212
+ } else if (node?.type === 'element' && classes(node).includes('deck-legend')) {
213
+ legend.push(node);
214
+ } else {
215
+ content.push(node);
216
+ }
217
+ }
218
+ const steps = content.filter((node) => isJsx(node, 'Pause')).length + 1;
219
+ frames.push(
220
+ frameShell({
221
+ number,
222
+ kind,
223
+ id,
224
+ frameId: frameIdOf(number),
225
+ chapter: chapterOf(number),
226
+ extra: {
227
+ 'data-steps': String(steps),
228
+ ...(markers.morph || prop(heading, 'data-deck-morph') !== undefined ? { 'data-morph': '' } : {}),
229
+ },
230
+ canvas: [
231
+ heading,
232
+ element('div', { className: ['deck-frame__body'] }, kind === 'frame' ? stepped(content) : content),
233
+ ...legend,
234
+ foot(number, kind === 'frame' ? sectionTitle : undefined),
235
+ ],
236
+ notes: deck.notes === 'off' ? [] : notes,
237
+ }),
238
+ );
239
+ };
240
+
241
+ for (const node of tree.children ?? []) {
242
+ if (node?.type === 'mdxjsEsm') {
243
+ esm.push(node);
244
+ continue;
245
+ }
246
+ if (isGeneratedFrame(node)) {
247
+ close();
248
+ const number = Number(prop(node, 'data-deck-frame')) || lastNumber + 1;
249
+ lastNumber = number;
250
+ const kind = prop(node, 'data-deck-kind') ?? 'frame';
251
+ frames.push(frameShell({ number, kind, frameId: frameIdOf(number), chapter: chapterOf(number), canvas: [...(node.children ?? []), foot(number)] }));
252
+ continue;
253
+ }
254
+ const kind = frameKindOf(node);
255
+ if (kind) {
256
+ close();
257
+ current = { heading: node, kind, body: [] };
258
+ continue;
259
+ }
260
+ if (current) current.body.push(node);
261
+ else if (!isWhitespace(node)) frames.push(node);
262
+ }
263
+ close();
264
+
265
+ const total = frames.filter((node) => isGeneratedFrame(node)).length;
266
+ for (const frame of frames) {
267
+ if (!isGeneratedFrame(frame)) continue;
268
+ frame.properties['aria-label'] = `${frame.properties['data-frame']} of ${total}`;
269
+ }
270
+ const canvas = deck.canvas ?? { width: 1280, height: 720 };
271
+ tree.children = [
272
+ ...esm,
273
+ element(
274
+ 'div',
275
+ {
276
+ className: ['deck'],
277
+ 'data-deck': deck.slug ?? '',
278
+ 'data-aspect': deck.aspectRatio ?? '16:9',
279
+ 'data-notes': deck.notes ?? 'reader',
280
+ 'data-frames': String(total),
281
+ style: `--deck-canvas-width: ${canvas.width}px; --deck-canvas-height: ${canvas.height}px;`,
282
+ },
283
+ frames,
284
+ ),
285
+ ];
286
+ }
@@ -60,6 +60,7 @@
60
60
  * Astro's slug, renamed only if it would repeat an id given upstream.
61
61
  */
62
62
 
63
+ import { frameDeck } from './rehype-deck.mjs';
63
64
  import { rehypeHeadingIds } from '@astrojs/markdown-remark';
64
65
 
65
66
  const HEADING_TAG = /^h([1-6])$/;
@@ -103,7 +104,12 @@ function headingText(node) {
103
104
  // (e.g. `{#id .class}`) — this codebase has no remark-attr-style plugin, and
104
105
  // a handful of purpose-built tokens are enough for what an author needs to
105
106
  // say about a heading here.
106
- const HEADING_MARKER = /\s*\[(collapsible|collapsed|no-collapsible|card|card=meta)\]\s*$/i;
107
+ // `[morph]` and `[chapter: <slug>]` are a deck's: on a frame title `[morph]`
108
+ // asks for the frame to morph from the one before it, and on a part's deck a
109
+ // section's `[chapter: <slug>]` names the chapter it covers
110
+ // (`docs/authoring/deck.md`); a chapter ignores both.
111
+ const HEADING_MARKER = /\s*\[(collapsible|collapsed|no-collapsible|card|card=meta|morph|chapter:[^\]]*)\]\s*$/i;
112
+ const CHAPTER_MARKER = /^chapter:\s*(.*?)\s*$/i;
107
113
 
108
114
  const CARD_TIER = { card: 'accent', 'card=meta': 'meta' };
109
115
 
@@ -138,13 +144,39 @@ export function headingCardTier(text) {
138
144
  return tier;
139
145
  }
140
146
 
147
+ /** True when a heading's source text carries the deck's `[morph]` marker. */
148
+ export function headingMorphs(text) {
149
+ let rest = String(text);
150
+ for (let match = HEADING_MARKER.exec(rest); match; match = HEADING_MARKER.exec(rest)) {
151
+ if (match[1].toLowerCase() === 'morph') return true;
152
+ rest = rest.slice(0, match.index);
153
+ }
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * The chapter a part deck's section names, `## Title [chapter: <slug>]`, as
159
+ * written, or `null` without the marker. With two, the one written last wins.
160
+ */
161
+ export function headingChapter(text) {
162
+ let rest = String(text);
163
+ for (let match = HEADING_MARKER.exec(rest); match; match = HEADING_MARKER.exec(rest)) {
164
+ const chapter = CHAPTER_MARKER.exec(match[1]);
165
+ if (chapter) return chapter[1];
166
+ rest = rest.slice(0, match.index);
167
+ }
168
+ return null;
169
+ }
170
+
141
171
  /**
142
172
  * Strip every trailing marker off a hast heading and report them as
143
- * `{ collapse, card }` — the collapse marker and the card tier, each `null`
144
- * when absent. With two of one kind, the one written last wins.
173
+ * `{ collapse, card, morph, chapter }` — the collapse marker and the card
174
+ * tier, each `null` when absent, whether a deck frame morphs, and the chapter
175
+ * a deck's section names (`null` without one). With two of one kind, the one
176
+ * written last wins.
145
177
  */
146
- function stripMarkers(headingNode) {
147
- const found = { collapse: null, card: null };
178
+ export function stripMarkers(headingNode) {
179
+ const found = { collapse: null, card: null, morph: false, chapter: null };
148
180
  const children = headingNode.children;
149
181
  for (let index = children.length - 1; index >= 0; index--) {
150
182
  const node = children[index];
@@ -152,7 +184,10 @@ function stripMarkers(headingNode) {
152
184
  const match = HEADING_MARKER.exec(node.value);
153
185
  if (match) {
154
186
  const marker = match[1].toLowerCase();
187
+ const chapter = CHAPTER_MARKER.exec(match[1]);
155
188
  if (marker in CARD_TIER) found.card ??= CARD_TIER[marker];
189
+ else if (marker === 'morph') found.morph = true;
190
+ else if (chapter) found.chapter ??= chapter[1];
156
191
  else found.collapse ??= marker;
157
192
  node.value = node.value.slice(0, match.index);
158
193
  if (!node.value) children.splice(index, 1);
@@ -163,7 +198,7 @@ function stripMarkers(headingNode) {
163
198
  // trailing whitespace-only text node: keep looking further back
164
199
  }
165
200
  const last = children.at(-1);
166
- if ((found.collapse || found.card) && last?.type === 'text') {
201
+ if ((found.collapse || found.card || found.morph || found.chapter !== null) && last?.type === 'text') {
167
202
  last.value = last.value.trimEnd();
168
203
  }
169
204
  return found;
@@ -244,6 +279,13 @@ export default function rehypeHeadingAnchors(options = {}) {
244
279
  const assignHeadingIds = rehypeHeadingIds();
245
280
 
246
281
  return function transform(tree, file) {
282
+ // A deck's page is its frames (`rehype-deck.mjs`): its headings are frame
283
+ // titles, anchored by the manifest, never collapsible sections.
284
+ if (file?.data?.explicoDeck) {
285
+ frameDeck(tree, file.data.explicoDeck);
286
+ assignHeadingIds(tree, file);
287
+ return;
288
+ }
247
289
  // Markers must be stripped *before* `rehypeHeadingIds` runs — otherwise
248
290
  // the generated slug is built from text that still contains it (e.g.
249
291
  // `two-ways-to-name-the-same-point-collapsed` instead of the clean