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
@@ -0,0 +1,600 @@
1
+ /**
2
+ * The remark half of a deck page (`<lesson>.deck.mdx`, docs/authoring/deck.md).
3
+ * `remark-notation.mjs` calls it on a deck: before its own pass, to expand
4
+ * each `<Recall ref>` into the chapter's float, stamp the frame headings with
5
+ * the manifest's anchors and numbers, and open the deck on its title frame;
6
+ * after it, to write each frame's legend line and close the deck on its
7
+ * outcomes frame. The rehype half (`rehype-deck.mjs`) wraps the frames.
8
+ *
9
+ * A deck is a page of its own lesson: its math binds in the chapter's scope
10
+ * (the chapter's `notation.local`), its numbers are the chapter's for what it
11
+ * recalls and its own (1), (2) for the equations it labels, and its figures,
12
+ * tables and diagrams are otherwise unnumbered (rule D8).
13
+ */
14
+ import { existsSync, readFileSync, statSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { parseFrontmatter } from '@astrojs/markdown-remark';
17
+ import { EQ_CAPTION_PATTERN, EQ_LABEL_PATTERN, equationCaption } from './equations.mjs';
18
+ import { injectMdxStringAttributes } from './mdx-attributes.mjs';
19
+
20
+ const DECK_DOC_PATH = /[\\/]content[\\/]docs[\\/](.+?)\.deck\.(?:md|mdx)$/;
21
+ const PART_DECK_SLUG = /^([^/]+)\/index$/;
22
+
23
+ /**
24
+ * The deck a docs file is, or `undefined`. A lesson's: `{ scope: 'lesson',
25
+ * lessonSlug, lessonId, path, chapterPath }`, the chapter being the lesson's
26
+ * `.mdx` (or `.md`) beside it. A part's (`<part>/index.deck.mdx`): `{ scope:
27
+ * 'part', partDir, pageId, path }`, its page id `<part>.slides`.
28
+ */
29
+ export function deckOfFile(file) {
30
+ for (const value of [file?.path, file?.history?.[0], file?.data?.file]) {
31
+ if (typeof value !== 'string') continue;
32
+ const match = DECK_DOC_PATH.exec(value);
33
+ if (!match) continue;
34
+ const slug = match[1].replace(/\\/g, '/');
35
+ const part = PART_DECK_SLUG.exec(slug);
36
+ if (part) return { scope: 'part', path: value, partDir: part[1], pageId: `${part[1]}.slides` };
37
+ const stem = value.replace(/\.deck\.(?:md|mdx)$/, '');
38
+ const chapterPath = ['.mdx', '.md'].map((extension) => `${stem}${extension}`).find((path) => existsSync(path));
39
+ return {
40
+ scope: 'lesson',
41
+ path: value,
42
+ lessonSlug: slug,
43
+ lessonId: slug.replace(/\//g, '.'),
44
+ ...(chapterPath === undefined ? {} : { chapterPath }),
45
+ };
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * The chapter file of a lesson of a part's deck's part (`motion/range` →
52
+ * `…/motion/range.mdx`, or `.md`), beside the deck, if there is one.
53
+ */
54
+ export function partChapterPath(deck, lessonSlug) {
55
+ const name = String(lessonSlug).split('/').at(-1) ?? '';
56
+ return ['.mdx', '.md'].map((extension) => join(dirname(deck.path), `${name}${extension}`)).find((path) => existsSync(path));
57
+ }
58
+
59
+ /**
60
+ * The chapter files whose scope a deck's symbols bind in, in order: a
61
+ * lesson's deck its chapter (none for a slides-first lesson); a part's deck
62
+ * every chapter of its part, in reading order (`manifest.parts[].lessons`).
63
+ */
64
+ export function deckScopeChapters(deck, manifest) {
65
+ if (deck.scope !== 'part') return deck.chapterPath ? [deck.chapterPath] : [];
66
+ const lessons = new Map((manifest?.lessons ?? []).map((lesson) => [lesson.id, lesson]));
67
+ const part = (manifest?.parts ?? []).find((entry) => entry.dir === deck.partDir);
68
+ return (part?.lessons ?? []).flatMap((id) => {
69
+ const lesson = lessons.get(id);
70
+ if (!lesson || !(lesson.editions ?? ['chapter']).includes('chapter')) return [];
71
+ const path = partChapterPath(deck, lesson.slug);
72
+ return path ? [path] : [];
73
+ });
74
+ }
75
+
76
+ const chapterCache = new Map();
77
+
78
+ /** A chapter file's frontmatter and body, read once per change of the file. */
79
+ export function readChapter(chapterPath) {
80
+ const mtime = statSync(chapterPath).mtimeMs;
81
+ const cached = chapterCache.get(chapterPath);
82
+ if (cached && cached.mtime === mtime) return cached;
83
+ const raw = readFileSync(chapterPath, 'utf8');
84
+ const parsed = parseFrontmatter(raw);
85
+ const entry = {
86
+ mtime,
87
+ data: parsed.frontmatter ?? {},
88
+ body: parsed.content.replace(/^\n/, ''),
89
+ };
90
+ chapterCache.set(chapterPath, entry);
91
+ return entry;
92
+ }
93
+
94
+ /**
95
+ * The manifest's deck for a deck file (`deckOfFile`) — a lesson's or a
96
+ * part's — or, given a lesson id, for that lesson, if there is one.
97
+ */
98
+ export function manifestDeckOf(manifest, owner) {
99
+ const decks = manifest?.decks ?? [];
100
+ if (typeof owner === 'string') return decks.find((deck) => deck.lessonId === owner);
101
+ return owner?.scope === 'part'
102
+ ? decks.find((deck) => deck.part === owner.partDir)
103
+ : decks.find((deck) => deck.lessonId === owner?.lessonId);
104
+ }
105
+
106
+ // --- tree helpers -------------------------------------------------------
107
+
108
+ function el(hName, hProperties, children = []) {
109
+ return { type: 'paragraph', data: { hName, hProperties }, children };
110
+ }
111
+
112
+ function text(value) {
113
+ return { type: 'text', value };
114
+ }
115
+
116
+ function stringAttribute(node, name) {
117
+ const attribute = (node?.attributes ?? []).find(
118
+ (candidate) => candidate?.type === 'mdxJsxAttribute' && candidate.name === name,
119
+ );
120
+ return typeof attribute?.value === 'string' ? attribute.value : undefined;
121
+ }
122
+
123
+ function stripPositions(node) {
124
+ if (!node || typeof node !== 'object') return node;
125
+ delete node.position;
126
+ if (Array.isArray(node.children)) node.children.forEach(stripPositions);
127
+ if (Array.isArray(node.attributes)) node.attributes.forEach(stripPositions);
128
+ return node;
129
+ }
130
+
131
+ /** Visit every node with its parent and index; `skip` returning true keeps out of a subtree. */
132
+ function walk(node, visit, parent, index) {
133
+ if (!node || typeof node !== 'object') return;
134
+ if (visit(node, parent, index) === false) return;
135
+ if (!Array.isArray(node.children)) return;
136
+ for (let position = 0; position < node.children.length; position += 1) {
137
+ walk(node.children[position], visit, node, position);
138
+ }
139
+ }
140
+
141
+ function labelKeyOf(latex) {
142
+ EQ_LABEL_PATTERN.lastIndex = 0;
143
+ const match = EQ_LABEL_PATTERN.exec(String(latex ?? ''));
144
+ EQ_LABEL_PATTERN.lastIndex = 0;
145
+ return match ? match[1] : undefined;
146
+ }
147
+
148
+ /** The identifiers a JSX subtree uses as components: `Figure`, `RangeLab`, `Plot` of `Plot.Line`. */
149
+ function componentNames(node) {
150
+ const names = new Set();
151
+ walk(node, (child) => {
152
+ if ((child.type === 'mdxJsxFlowElement' || child.type === 'mdxJsxTextElement') && typeof child.name === 'string') {
153
+ const root = child.name.split('.')[0];
154
+ if (/^[A-Z]/.test(root)) names.add(root);
155
+ }
156
+ });
157
+ return names;
158
+ }
159
+
160
+ /** Each local name an ESM node of `tree` binds: `{ name → { source, imported } }`. */
161
+ function importedNames(tree) {
162
+ const names = new Map();
163
+ for (const node of tree.children ?? []) {
164
+ if (node?.type !== 'mdxjsEsm') continue;
165
+ for (const statement of node.data?.estree?.body ?? []) {
166
+ if (statement.type !== 'ImportDeclaration') continue;
167
+ const source = statement.source?.value;
168
+ for (const specifier of statement.specifiers ?? []) {
169
+ const local = specifier.local?.name;
170
+ if (!local) continue;
171
+ names.set(local, {
172
+ source,
173
+ imported:
174
+ specifier.type === 'ImportDefaultSpecifier'
175
+ ? 'default'
176
+ : specifier.type === 'ImportNamespaceSpecifier'
177
+ ? '*'
178
+ : (specifier.imported?.name ?? specifier.imported?.value ?? local),
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return names;
184
+ }
185
+
186
+ function importStatement(local, { source, imported }) {
187
+ const from = JSON.stringify(source);
188
+ if (imported === 'default') return `import ${local} from ${from};`;
189
+ if (imported === '*') return `import * as ${local} from ${from};`;
190
+ return imported === local ? `import { ${local} } from ${from};` : `import { ${imported} as ${local} } from ${from};`;
191
+ }
192
+
193
+ // --- recall -------------------------------------------------------------
194
+
195
+ const RECALL_REF = /^(eq|fig|tab|dia)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
196
+ const FLOAT_TAG = { fig: 'Figure', tab: 'Table', dia: 'Diagram' };
197
+ const NUMBER_TABLE = { eq: 'equations', fig: 'figures', tab: 'tables', dia: 'diagrams' };
198
+
199
+ /**
200
+ * Replace each `<Recall ref="eq-…" />` (or `fig-`, `tab-`, `dia-`) with the
201
+ * chapter's own float, parsed from the chapter with this page's parser.
202
+ * A recalled equation's math node and a recalled float's element carry
203
+ * `data.explicoRecall = { prefix, key, number, href }`; the imports a
204
+ * recalled float's components need are added to the deck's own.
205
+ */
206
+ /**
207
+ * What MDX's compile does to a tree before any course plugin sees it: a
208
+ * paragraph holding only JSX (and white space) becomes that JSX, as flow.
209
+ * An inline SVG's `<text x="…">0</text>` line parses as such a paragraph;
210
+ * kept, it would put a `<p>` inside the `<svg>`. `processor.parse` stops
211
+ * short of that step, so a chapter read for a recall takes it here
212
+ * (`remark-mark-and-unravel` in `@mdx-js/mdx`).
213
+ */
214
+ export function unravelJsxParagraphs(tree) {
215
+ const visit = (node) => {
216
+ if (!Array.isArray(node.children)) return;
217
+ for (let index = 0; index < node.children.length; index += 1) {
218
+ const child = node.children[index];
219
+ if (child.type === 'paragraph') {
220
+ const jsx = child.children.filter((inner) => inner.type === 'mdxJsxTextElement' || inner.type === 'mdxTextExpression');
221
+ const onlyJsx = child.children.every(
222
+ (inner) => jsx.includes(inner) || (inner.type === 'text' && inner.value.trim() === ''),
223
+ );
224
+ if (jsx.length > 0 && onlyJsx) {
225
+ const flow = jsx.map((inner) => ({
226
+ ...inner,
227
+ type: inner.type === 'mdxJsxTextElement' ? 'mdxJsxFlowElement' : 'mdxFlowExpression',
228
+ }));
229
+ node.children.splice(index, 1, ...flow);
230
+ index += flow.length - 1;
231
+ for (const element of flow) visit(element);
232
+ continue;
233
+ }
234
+ }
235
+ visit(child);
236
+ }
237
+ };
238
+ visit(tree);
239
+ return tree;
240
+ }
241
+
242
+ export function expandRecalls(tree, { processor, deck, deckEntry, manifest, base, fail }) {
243
+ const recalls = [];
244
+ walk(tree, (node, parent, index) => {
245
+ if (node.type === 'mdxJsxFlowElement' && node.name === 'Recall' && parent) {
246
+ recalls.push({ node, parent, index });
247
+ return false;
248
+ }
249
+ return true;
250
+ });
251
+ if (recalls.length === 0) return;
252
+ if (deck.scope !== 'part' && !deck.chapterPath) {
253
+ fail(
254
+ `<Recall> on a deck with no chapter: a slides-first lesson has nothing to recall. Show the float itself (rule D8).`,
255
+ recalls[0].node,
256
+ );
257
+ }
258
+ if (typeof processor?.parse !== 'function') {
259
+ fail('<Recall> needs remark-notation to run inside a unified processor (its parser reads the chapter).', recalls[0].node);
260
+ }
261
+ // Each chapter a recall reads, parsed once: a lesson's deck reads its
262
+ // chapter, a part's the chapter the manifest found for each recall.
263
+ const chapters = new Map();
264
+ const chapterOf = (lessonSlug) => {
265
+ if (!chapters.has(lessonSlug)) {
266
+ const path = deck.scope === 'part' ? partChapterPath(deck, lessonSlug) : deck.chapterPath;
267
+ const chapterTree = path ? unravelJsxParagraphs(processor.parse(readChapter(path).body)) : undefined;
268
+ chapters.set(lessonSlug, chapterTree && { tree: chapterTree, imports: importedNames(chapterTree) });
269
+ }
270
+ return chapters.get(lessonSlug);
271
+ };
272
+ const deckImports = importedNames(tree);
273
+ const needed = new Map();
274
+ const baseUrl = String(base ?? '').replace(/\/$/, '');
275
+
276
+ // Walk from the last recall back, so an earlier one's index stays right.
277
+ for (const { node, parent, index } of [...recalls].reverse()) {
278
+ const ref = stringAttribute(node, 'ref');
279
+ const match = ref === undefined ? null : RECALL_REF.exec(ref);
280
+ if (!match) {
281
+ fail(`<Recall ref="${ref ?? ''}">: a recall names \`eq-\`, \`fig-\`, \`tab-\` or \`dia-\` and a key of the chapter.`, node);
282
+ }
283
+ const [, prefix, key] = match;
284
+ const lessonSlug = deck.scope === 'part' ? deckEntry?.recalledFrom?.[ref] : deck.lessonSlug;
285
+ if (lessonSlug === undefined) {
286
+ fail(
287
+ `<Recall ref="${ref}">: no chapter this part's deck covers numbers it (\`recall-unresolved\`). Recompile the manifest if you just added it.`,
288
+ node,
289
+ );
290
+ }
291
+ const chapter = chapterOf(lessonSlug);
292
+ const number = manifest?.[NUMBER_TABLE[prefix]]?.numbersBySlug?.[lessonSlug]?.[key];
293
+ let found;
294
+ walk(chapter?.tree, (candidate) => {
295
+ if (found) return false;
296
+ if (prefix === 'eq' && candidate.type === 'math' && labelKeyOf(candidate.value) === key) found = candidate;
297
+ else if (
298
+ prefix !== 'eq' &&
299
+ candidate.type === 'mdxJsxFlowElement' &&
300
+ candidate.name === FLOAT_TAG[prefix] &&
301
+ stringAttribute(candidate, 'id') === key
302
+ ) {
303
+ found = candidate;
304
+ }
305
+ return !found;
306
+ });
307
+ if (!found || number === undefined) {
308
+ fail(
309
+ `<Recall ref="${ref}">: the chapter ${lessonSlug} numbers no ${prefix === 'eq' ? `equation labelled eq:${key}` : `<${FLOAT_TAG[prefix]} id="${key}">`}. Recompile the manifest if you just added it.`,
310
+ node,
311
+ );
312
+ }
313
+ const recalled = stripPositions(structuredClone(found));
314
+ recalled.data = {
315
+ ...recalled.data,
316
+ explicoRecall: { prefix, key, number, href: `${baseUrl}/${lessonSlug}/#${prefix}-${key}` },
317
+ };
318
+ if (prefix === 'eq') {
319
+ // Its own label is the chapter's; the deck shows it with the chapter's number.
320
+ recalled.value = String(recalled.value ?? '').replace(EQ_LABEL_PATTERN, '');
321
+ } else {
322
+ for (const name of componentNames(recalled)) {
323
+ if (deckImports.has(name)) continue;
324
+ const binding = chapter.imports.get(name);
325
+ if (!binding) {
326
+ fail(`<Recall ref="${ref}">: the chapter's <${name}> is not an import the deck can repeat.`, node);
327
+ }
328
+ const earlier = needed.get(name);
329
+ if (earlier && (earlier.source !== binding.source || earlier.imported !== binding.imported)) {
330
+ fail(
331
+ `<Recall ref="${ref}">: ${lessonSlug} imports <${name}> from ${binding.source}, and a chapter recalled ` +
332
+ `before it from ${earlier.source}. One page imports a name once: rename the import in one chapter.`,
333
+ node,
334
+ );
335
+ }
336
+ needed.set(name, binding);
337
+ }
338
+ }
339
+ parent.children.splice(index, 1, recalled);
340
+ }
341
+
342
+ if (needed.size > 0) {
343
+ const source = [...needed].map(([name, binding]) => importStatement(name, binding)).join('\n');
344
+ const esm = processor.parse(`${source}\n`).children.filter((child) => child.type === 'mdxjsEsm');
345
+ const lastImport = tree.children.reduce((last, child, position) => (child.type === 'mdxjsEsm' ? position : last), -1);
346
+ tree.children.splice(lastImport + 1, 0, ...esm.map(stripPositions));
347
+ }
348
+ }
349
+
350
+ // --- numbering ------------------------------------------------------------
351
+
352
+ const TRANSPARENT = new Set(['Memo', 'ShortNote', 'Fold']);
353
+
354
+ /**
355
+ * The deck page's equations and floats, in its reading order, and what each
356
+ * shows: a recalled one the chapter's number, a labelled equation of the
357
+ * deck its own (1), (2) in label order (`manifest.decks[].equations`), and
358
+ * the deck's own floats no number.
359
+ */
360
+ export function collectDeckFloats(tree, deckEntry) {
361
+ const ownNumbers = new Map((deckEntry?.equations ?? []).map((equation) => [equation.key, equation.number]));
362
+ const equations = new Map();
363
+ const floats = new Map();
364
+ // The deck's own floats of each kind, counted for an unkeyed one's anchor.
365
+ const positions = { fig: 0, tab: 0, dia: 0 };
366
+ const visit = (node) => {
367
+ if (!node || typeof node !== 'object') return;
368
+ if (node.type === 'math') {
369
+ const recall = node.data?.explicoRecall;
370
+ if (recall) {
371
+ equations.set(node, { key: recall.key, number: recall.number, short: equationCaption(node.value), href: recall.href });
372
+ return;
373
+ }
374
+ const key = labelKeyOf(node.value);
375
+ if (key !== undefined) {
376
+ equations.set(node, { key, number: ownNumbers.get(key) ?? '?', short: equationCaption(node.value) });
377
+ }
378
+ return;
379
+ }
380
+ if (node.type === 'mdxJsxFlowElement') {
381
+ const prefix = Object.entries(FLOAT_TAG).find(([, tag]) => tag === node.name)?.[0];
382
+ if (prefix) {
383
+ const recall = node.data?.explicoRecall;
384
+ if (recall) {
385
+ floats.set(node, { prefix, key: recall.key, number: recall.number, href: recall.href });
386
+ } else {
387
+ positions[prefix] += 1;
388
+ floats.set(node, { prefix, key: stringAttribute(node, 'id'), position: positions[prefix] });
389
+ }
390
+ return;
391
+ }
392
+ if (!TRANSPARENT.has(node.name) && node.name !== 'Notes') return;
393
+ }
394
+ if (Array.isArray(node.children)) node.children.forEach(visit);
395
+ };
396
+ visit(tree);
397
+ return { equations, floats };
398
+ }
399
+
400
+ // --- frames ---------------------------------------------------------------
401
+
402
+ /**
403
+ * Give the deck's `##` and `###` headings the anchors and frame numbers the
404
+ * manifest decided, in order; a different count fails the build (a stale
405
+ * manifest), as a chapter's headings do.
406
+ */
407
+ export function stampDeckHeadings(tree, deckEntry, fail) {
408
+ const headed = (deckEntry?.frames ?? []).filter((frame) => frame.kind === 'section' || frame.kind === 'frame');
409
+ const nodes = (tree.children ?? []).filter((node) => node?.type === 'heading' && (node.depth === 2 || node.depth === 3));
410
+ if (nodes.length !== headed.length) {
411
+ fail(
412
+ `Stale Explico manifest for the deck ${deckEntry?.slug ?? ''}: this page has ${nodes.length} section and frame headings, ` +
413
+ `the manifest lists ${headed.length}. Recompile the manifest from the current content.`,
414
+ );
415
+ }
416
+ nodes.forEach((node, index) => {
417
+ const frame = headed[index];
418
+ node.data = node.data ?? {};
419
+ node.data.hProperties = {
420
+ ...node.data.hProperties,
421
+ id: frame.slug,
422
+ 'data-deck-frame': String(frame.number),
423
+ 'data-deck-kind': frame.kind,
424
+ 'data-deck-steps': String(frame.steps),
425
+ ...(frame.morph ? { 'data-deck-morph': '' } : {}),
426
+ };
427
+ });
428
+ }
429
+
430
+ /**
431
+ * On a part's deck, give each `<AssessmentSet>` the lesson whose checks it
432
+ * asks (`lesson`, the component's prop): the chapter its frame's section
433
+ * covers (`frames[].chapter`). A frame in no such section asks none, which
434
+ * the manifest reports as `deck-check-no-chapter`.
435
+ */
436
+ export function markDeckChecks(tree, deckEntry, fail) {
437
+ const headed = (deckEntry?.frames ?? []).filter((frame) => frame.kind === 'section' || frame.kind === 'frame');
438
+ let position = -1;
439
+ for (const node of tree.children ?? []) {
440
+ if (node?.type === 'heading' && (node.depth === 2 || node.depth === 3)) {
441
+ position += 1;
442
+ continue;
443
+ }
444
+ const chapter = headed[position]?.chapter;
445
+ walk(node, (child) => {
446
+ if (child.type !== 'mdxJsxFlowElement' || child.name !== 'AssessmentSet') return true;
447
+ if (!chapter) {
448
+ fail(
449
+ "<AssessmentSet> on a part's deck, in a section that names no chapter (`deck-check-no-chapter`): " +
450
+ 'move it into a `## Title [chapter: <name>]` section, whose chapter it asks.',
451
+ child,
452
+ );
453
+ }
454
+ injectMdxStringAttributes(child, { lesson: chapter.replace(/\//g, '.') });
455
+ return false;
456
+ });
457
+ }
458
+ }
459
+
460
+ /**
461
+ * The generated title frame: the deck's title, its course, its authors and
462
+ * the version its content is at (rules D10 and C5), in the words of a
463
+ * chapter's PDF (`versionPhrase` in compiler/page-versions.ts).
464
+ */
465
+ export function titleFrameNode(manifest, deckEntry) {
466
+ const about = manifest?.about ?? {};
467
+ const authors = (about.authors ?? []).map((author) => author.name).filter(Boolean);
468
+ const course = about.material?.name;
469
+ const current = about.knowledge?.version;
470
+ const since = deckEntry && 'since' in deckEntry ? deckEntry.since : current;
471
+ const version = current
472
+ ? since === null
473
+ ? `with changes after version ${current}, not yet released`
474
+ : `unchanged since version ${since}`
475
+ : undefined;
476
+ return el(
477
+ 'section',
478
+ { className: ['deck-frame', 'deck-frame--title'], 'data-deck-frame': '1', 'data-deck-kind': 'title' },
479
+ [
480
+ el('p', { className: ['deck-title__title'] }, [text(deckEntry?.title ?? '')]),
481
+ ...(course ? [el('p', { className: ['deck-title__course'] }, [text(course)])] : []),
482
+ ...(authors.length > 0 ? [el('p', { className: ['deck-title__authors'] }, [text(authors.join(', '))])] : []),
483
+ ...(version ? [el('p', { className: ['deck-title__version'] }, [text(`Content ${version}`)])] : []),
484
+ ],
485
+ );
486
+ }
487
+
488
+ /**
489
+ * The competencies a deck's outcomes frame lists: its lesson's, or on a
490
+ * part's deck every covered chapter's, in reading order, each once.
491
+ */
492
+ export function deckTeaches(manifest, deckEntry) {
493
+ const lessons = new Map((manifest?.lessons ?? []).map((lesson) => [lesson.id, lesson]));
494
+ if (deckEntry?.part === undefined) return lessons.get(deckEntry?.lessonId)?.teaches ?? [];
495
+ const covered = new Set((deckEntry.covers ?? []).map((cover) => cover.chapter));
496
+ const part = (manifest?.parts ?? []).find((entry) => entry.dir === deckEntry.part);
497
+ const chapters = (part?.lessons ?? []).filter((id) => covered.has(id.replace(/\./g, '/')));
498
+ return [...new Set(chapters.flatMap((id) => lessons.get(id)?.teaches ?? []))];
499
+ }
500
+
501
+ /** The generated outcomes frame: what the lesson's competencies say a student can now do. */
502
+ export function outcomesFrameNode(manifest, deckEntry, lesson) {
503
+ const frame = (deckEntry?.frames ?? []).find((entry) => entry.kind === 'outcomes');
504
+ if (!frame) return undefined;
505
+ const byId = new Map((manifest?.competencies ?? []).map((competency) => [competency.id, competency]));
506
+ const outcomes = (lesson?.teaches ?? []).map((id) => byId.get(id)?.outcome).filter(Boolean);
507
+ return el(
508
+ 'section',
509
+ {
510
+ className: ['deck-frame', 'deck-frame--outcomes'],
511
+ 'data-deck-frame': String(frame.number),
512
+ 'data-deck-kind': 'outcomes',
513
+ },
514
+ [
515
+ el('p', { className: ['deck-frame__title'], role: 'heading', 'aria-level': '3' }, [text(frame.title)]),
516
+ {
517
+ type: 'list',
518
+ ordered: false,
519
+ spread: false,
520
+ children: outcomes.map((outcome) => ({
521
+ type: 'listItem',
522
+ spread: false,
523
+ children: [{ type: 'paragraph', children: [text(outcome)] }],
524
+ })),
525
+ },
526
+ ],
527
+ );
528
+ }
529
+
530
+ const EXPLAIN_KEY = /\\explain\s*\{([^{}]+)\}/g;
531
+
532
+ /** The notation keys a subtree shows, in order: bound math symbols and `[[key]]` terms, not its notes. */
533
+ function keysIn(nodes) {
534
+ const keys = [];
535
+ const add = (key) => {
536
+ if (key && !keys.includes(key)) keys.push(key);
537
+ };
538
+ for (const root of nodes) {
539
+ walk(root, (node) => {
540
+ if (node.type === 'mdxJsxFlowElement' && node.name === 'Notes') return false;
541
+ if ((node.type === 'math' || node.type === 'inlineMath') && typeof node.value === 'string') {
542
+ for (const match of node.value.matchAll(EXPLAIN_KEY)) add(match[1].trim());
543
+ }
544
+ const key = node.data?.hProperties?.['data-notation-key'];
545
+ if (typeof key === 'string') add(key);
546
+ return true;
547
+ });
548
+ }
549
+ return keys;
550
+ }
551
+
552
+ /**
553
+ * After notation ran: give each `###` frame its legend line — the symbols it
554
+ * shows for the first time in the deck (`first-use`), or all it shows
555
+ * (`every-frame`) — as math the page's KaTeX pass renders, each symbol bound
556
+ * to its card. `resolved` is the page's resolved notation (`key → entry`).
557
+ */
558
+ export function insertLegends(tree, { resolved, mode }) {
559
+ if (mode === 'off') return;
560
+ const children = tree.children ?? [];
561
+ const starts = [];
562
+ children.forEach((node, index) => {
563
+ if (node?.type === 'heading' && (node.depth === 2 || node.depth === 3)) starts.push(index);
564
+ });
565
+ const seen = new Set();
566
+ const inserts = [];
567
+ starts.forEach((start, position) => {
568
+ const node = children[start];
569
+ const end = starts[position + 1] ?? children.length;
570
+ const keys = keysIn(children.slice(start + 1, end));
571
+ if (node.depth !== 3) {
572
+ for (const key of keys) seen.add(key);
573
+ return;
574
+ }
575
+ const shown = (mode === 'every-frame' ? keys : keys.filter((key) => !seen.has(key))).filter((key) => resolved[key]);
576
+ for (const key of keys) seen.add(key);
577
+ if (shown.length === 0) return;
578
+ inserts.push({
579
+ at: end,
580
+ node: el(
581
+ 'div',
582
+ { className: ['deck-legend'], 'data-deck-legend': '' },
583
+ shown.flatMap((key) => {
584
+ const entry = resolved[key];
585
+ const latex = String(entry.notation ?? entry.latex ?? key);
586
+ return [
587
+ el('span', { className: ['deck-legend__item'] }, [
588
+ { type: 'inlineMath', value: `\\explain{${key}}{${latex}}`, data: { hName: 'code', hProperties: { className: ['language-math', 'math-inline'] }, hChildren: [{ type: 'text', value: `\\explain{${key}}{${latex}}` }] } },
589
+ text(' '),
590
+ el('span', { className: ['deck-legend__label'] }, [text(String(entry.label ?? key))]),
591
+ ]),
592
+ ];
593
+ }),
594
+ ),
595
+ });
596
+ });
597
+ for (const { at, node } of inserts.reverse()) children.splice(at, 0, node);
598
+ }
599
+
600
+ export const DECK_CAPTION_PATTERN = EQ_CAPTION_PATTERN;