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
@@ -4,11 +4,12 @@ import { dirname, extname, join } from 'node:path';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import type { Manifest } from '../manifest';
8
- import type { StaticPage } from './static-tree';
7
+ import type { Manifest, ManifestLesson } from '../manifest';
8
+ import type { Block } from './static-tree';
9
9
  import { zip } from './zip';
10
10
  import {
11
11
  consoleLogger,
12
+ deckBlocks,
12
13
  epochOf,
13
14
  figuresOf,
14
15
  imageFile,
@@ -19,9 +20,21 @@ import {
19
20
  type ExportLogger,
20
21
  type PreparedChapters,
21
22
  } from './export-chapters';
23
+ import type { CaptureResults } from './latex/capture';
24
+ import { latexCacheFromEnvironment } from './latex/cache';
22
25
  import { compileLatex, missingCharacters, findLatexEngine } from './latex/compile';
23
- import { bookSource, chapterFolderName, chapterSource, type BookPart, type SourceFile } from './latex/document';
24
- import { courseFileStem } from './export-pages';
26
+ import { deckNumberingProblems, deckOverflows, deckSource } from './latex/deck';
27
+ import {
28
+ bookSince,
29
+ bookSource,
30
+ chapterFolderName,
31
+ chapterSource,
32
+ deckCheckItems,
33
+ type BookPart,
34
+ type BookSlidesLesson,
35
+ type SourceFile,
36
+ } from './latex/document';
37
+ import { courseFileStem, deckFileStem, deckOwnerSlug, deckSaveName } from './export-pages';
25
38
  import { auxLabels, numberingMismatches, websiteNumbers } from './latex/numbering';
26
39
 
27
40
  export type { ExportLogger } from './export-chapters';
@@ -32,61 +45,107 @@ export type { ExportLogger } from './export-chapters';
32
45
  * figures — is zipped to `downloads/<slug>-latex.zip`, and the same folder is
33
46
  * compiled to `downloads/<slug>.pdf`. Each part and the course are books
34
47
  * (`bookSource`): `downloads/part/<dir>-latex.zip` and `.pdf`,
35
- * `downloads/course-latex.zip` and `.pdf`. Every document compiles at once,
36
- * up to one per core.
48
+ * `downloads/course-latex.zip` and `.pdf`. Each deck is beamer slides
49
+ * (`latex/deck.ts`): `downloads/<lesson>-slides.pdf`, its handout
50
+ * `-slides-notes.pdf` and its source `-slides-latex.zip`; a book keeps a
51
+ * slides-first lesson's place with a short chapter. Every document compiles at once,
52
+ * up to one per core, the longest first. With `EXPLICO_LATEX_CACHE` set, a
53
+ * document whose folder a build already compiled is reused (`latex/cache.ts`).
37
54
  *
38
- * The build fails when a compiled number differs from the website's, when
39
- * LaTeX reports an error, and — in CI (`CI` set) or with
55
+ * The build fails when a compiled number differs from the website's (a
56
+ * deck's frame numbers included), when LaTeX reports an error, and — in CI (`CI` set) or with
40
57
  * `EXPLICO_LATEX_STRICT=1` — when the TeX engine or the browser for figures
41
58
  * is missing. On a developer's machine without them it warns and still
42
59
  * writes the source zips.
43
60
  */
44
61
 
62
+ /**
63
+ * The pictures of a page's figures, by anchor: what the capture pass drew,
64
+ * or the image the page shows. `drawnOn` says which page drew a figure (a
65
+ * deck's recalled figure is its chapter's); `folder` is where the pictures
66
+ * go in the document's folder.
67
+ */
45
68
  async function pagePictures(
46
69
  outputRoot: string,
47
70
  base: string,
48
- page: StaticPage,
49
- captured: ReadonlyMap<string, { pdf: Uint8Array }> | undefined,
71
+ page: { readonly route: string; readonly blocks: readonly Block[] },
72
+ captures: CaptureResults,
73
+ drawnOn: (anchor: string) => string = () => page.route,
74
+ folder = `figures/${page.route}`,
50
75
  ): Promise<Map<string, { path: string; content: Uint8Array }>> {
51
76
  const pictures = new Map<string, { path: string; content: Uint8Array }>();
52
77
  for (const figure of figuresOf(page.blocks)) {
53
- const capturedFigure = captured?.get(figure.anchor);
78
+ const capturedFigure = captures.get(drawnOn(figure.anchor))?.get(figure.anchor);
54
79
  if (capturedFigure) {
55
- pictures.set(figure.anchor, {
56
- path: `figures/${page.route}/${figure.anchor}.pdf`,
57
- content: capturedFigure.pdf,
58
- });
80
+ pictures.set(figure.anchor, { path: `${folder}/${figure.anchor}.pdf`, content: capturedFigure.pdf });
59
81
  continue;
60
82
  }
61
83
  if (figure.picture.kind !== 'image' || needsCapture(figure)) continue;
62
84
  const file = imageFile(outputRoot, base, page.route, figure.picture.src);
63
85
  pictures.set(figure.anchor, {
64
- path: `figures/${page.route}/${figure.anchor}${extname(file).toLowerCase()}`,
86
+ path: `${folder}/${figure.anchor}${extname(file).toLowerCase()}`,
65
87
  content: await readFile(file),
66
88
  });
67
89
  }
68
90
  return pictures;
69
91
  }
70
92
 
93
+ /** What a slides-first lesson's place in a book says: its outcomes and where its slides are. */
94
+ function slidesLesson(manifest: Manifest, lesson: ManifestLesson): BookSlidesLesson {
95
+ const byId = new Map(manifest.competencies.map((competency) => [competency.id, competency]));
96
+ const outcomes = lesson.teaches.flatMap((id) => {
97
+ const outcome = byId.get(id)?.outcome;
98
+ return typeof outcome === 'string' && outcome.trim() !== '' ? [outcome.trim()] : [];
99
+ });
100
+ const base = manifest.lessonExports.canonicalBaseUrl;
101
+ return { lesson, outcomes, ...(base ? { slidesUrl: `${base}${lesson.slug}/` } : {}) };
102
+ }
103
+
71
104
  interface PendingFile {
72
105
  readonly target: string;
73
106
  readonly content: Uint8Array;
74
107
  }
75
108
 
76
- /** `work` over every item, at most `limit` at a time; results in the items' order. */
77
- async function mapWithLimit<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
109
+ /**
110
+ * `work` over every item, at most `limit` at a time, started in `order` (the
111
+ * items' indices, by default their own order); results in the items' order.
112
+ */
113
+ export async function mapWithLimit<T, R>(
114
+ items: readonly T[],
115
+ limit: number,
116
+ work: (item: T) => Promise<R>,
117
+ order: readonly number[] = items.map((_item, index) => index),
118
+ ): Promise<R[]> {
78
119
  const results: R[] = new Array(items.length);
79
120
  let next = 0;
80
121
  const worker = async () => {
81
- while (next < items.length) {
82
- const index = next++;
122
+ while (next < order.length) {
123
+ const index = order[next++]!;
83
124
  results[index] = await work(items[index]!);
84
125
  }
85
126
  };
86
- await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
127
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, order.length)) }, worker));
87
128
  return results;
88
129
  }
89
130
 
131
+ /**
132
+ * The order to start documents in: the course, then the parts, then the
133
+ * chapters, the largest folder first within each. The course takes longest
134
+ * and is the one document a cache never reuses after a change; started last,
135
+ * it ran alone at the end while the other cores had nothing left to do.
136
+ */
137
+ export function startOrder(
138
+ jobs: readonly { readonly kind: 'chapter' | 'deck' | 'part' | 'course'; readonly files: readonly SourceFile[] }[],
139
+ ): number[] {
140
+ const rank = { course: 0, part: 1, chapter: 2, deck: 2 } as const;
141
+ const size = jobs.map(({ files }) =>
142
+ files.reduce((sum, file) => sum + (typeof file.content === 'string' ? Buffer.byteLength(file.content) : file.content.byteLength), 0),
143
+ );
144
+ return jobs
145
+ .map((_job, index) => index)
146
+ .sort((a, b) => rank[jobs[a]!.kind] - rank[jobs[b]!.kind] || size[b]! - size[a]! || a - b);
147
+ }
148
+
90
149
  /**
91
150
  * Write each chapter's, each part's and the course's LaTeX source zip and PDF
92
151
  * under `downloads/`, from the chapters `prepareChapters` read and drew (read
@@ -105,7 +164,7 @@ export async function writeLatexExports(
105
164
  const logger = options.logger ?? consoleLogger;
106
165
  const strict = strictMode();
107
166
  const outputRoot = fileURLToPath(directory);
108
- const { base, chapters, captures } =
167
+ const { base, chapters, decks, captures } =
109
168
  options.prepared ?? (await prepareChapters(outputRoot, manifest, { logger, projectRoot: options.projectRoot }));
110
169
 
111
170
  const engine = await findLatexEngine();
@@ -116,17 +175,25 @@ export async function writeLatexExports(
116
175
 
117
176
  /** One LaTeX document: its folder, where its zip and PDF go, and the numbers it must print. */
118
177
  interface Job {
119
- readonly kind: 'chapter' | 'part' | 'course';
178
+ readonly kind: 'chapter' | 'deck' | 'part' | 'course';
120
179
  readonly name: string;
121
180
  readonly files: SourceFile[];
122
181
  readonly target: string;
123
182
  readonly folder: string;
124
- readonly website: Map<string, string>;
183
+ /** No source zip of its own: a deck's handout is built from its slides' folder. */
184
+ readonly noZip?: boolean;
185
+ /** Where LaTeX's numbers differ from the website's, from the compiled `.aux`. */
186
+ readonly numbering: (aux: string) => string[];
187
+ /** What else its log says that a reader would see: a deck's frames that run over on paper. */
188
+ readonly review?: (log: string) => void;
189
+ /** The version the document's content is at (rule C5): its date, and its cache key through its files. */
190
+ readonly since: string | null;
125
191
  }
126
192
  const jobs: Job[] = [];
193
+ const againstWebsite = (website: Map<string, string>) => (aux: string) => numberingMismatches(website, auxLabels(aux));
127
194
  const pictures = new Map<string, Awaited<ReturnType<typeof pagePictures>>>();
128
195
  for (const { lesson, page } of chapters) {
129
- const chapterPictures = await pagePictures(outputRoot, base, page, captures.get(page.route));
196
+ const chapterPictures = await pagePictures(outputRoot, base, page, captures);
130
197
  pictures.set(lesson.id, chapterPictures);
131
198
  jobs.push({
132
199
  kind: 'chapter',
@@ -134,8 +201,57 @@ export async function writeLatexExports(
134
201
  files: chapterSource({ manifest, lesson, page, documentRoutes: new Set([page.route]), pictures: chapterPictures }),
135
202
  target: lesson.slug,
136
203
  folder: chapterFolderName(lesson),
137
- website: websiteNumbers(page, `${lesson.part}.${lesson.chapter}`),
204
+ numbering: againstWebsite(websiteNumbers(page, `${lesson.part}.${lesson.chapter}`)),
205
+ since: lesson.since,
206
+ });
207
+ }
208
+
209
+ // Each deck as beamer slides, and as a handout of each slide with its notes:
210
+ // a lesson's, named after the lesson, and a part's, after the part.
211
+ const lessonsById = new Map(manifest.lessons.map((lesson) => [lesson.id, lesson]));
212
+ for (const { deck, page, figureRoutes } of decks) {
213
+ if (deck.part === undefined && !lessonsById.has(deck.lessonId ?? '')) continue;
214
+ const owner = deckOwnerSlug(deck, manifest);
215
+ const deckPictures = await pagePictures(
216
+ outputRoot,
217
+ base,
218
+ { route: page.route, blocks: deckBlocks(page) },
219
+ captures,
220
+ (anchor) => figureRoutes.get(anchor) ?? page.route,
221
+ 'figures',
222
+ );
223
+ const { files, frameEnds } = deckSource({ manifest, deck, page, checkItems: deckCheckItems(manifest, deck), pictures: deckPictures });
224
+ const stem = deckFileStem(owner);
225
+ const numbering = (aux: string) => deckNumberingProblems(aux, page.frames.length);
226
+ const common = { kind: 'deck', folder: `${deckSaveName(owner)}-latex`, numbering, since: deck.since } as const;
227
+ jobs.push({
228
+ ...common,
229
+ name: stem,
230
+ files,
231
+ target: stem,
232
+ review: (log) => {
233
+ const over = deckOverflows(log, frameEnds);
234
+ if (over.length === 0) return;
235
+ logger.warn(
236
+ `LaTeX export: on paper, ${over.length === 1 ? 'a slide' : `${over.length} slides`} of ${stem} ` +
237
+ `run${over.length === 1 ? 's' : ''} over the bottom: ` +
238
+ `${over.map(({ frame, points }) => `slide ${frame} by ${points} pt`).join(', ')}. ` +
239
+ 'The web page fits them (rule D2), but the PDF cuts them off. Shorten the slide, or split it.',
240
+ );
241
+ },
138
242
  });
243
+ if (manifest.deck.handoutPdf && manifest.deck.notes !== 'off') {
244
+ const handout = files.find((file) => file.path === 'handout.tex')!;
245
+ jobs.push({
246
+ ...common,
247
+ name: `${stem}-notes`,
248
+ files: files.flatMap((file) =>
249
+ file.path === 'handout.tex' ? [] : file.path === 'main.tex' ? [{ path: 'main.tex', content: handout.content }] : [file],
250
+ ),
251
+ target: `${stem}-notes`,
252
+ noZip: true,
253
+ });
254
+ }
139
255
  }
140
256
 
141
257
  // Each part, and the course, as a book (plan step 6).
@@ -147,11 +263,16 @@ export async function writeLatexExports(
147
263
  const chapter = byId.get(id);
148
264
  return chapter ? [{ ...chapter, pictures: pictures.get(id)! }] : [];
149
265
  });
266
+ // A slides-first lesson keeps its place: the chapters after it keep their numbers.
267
+ const slides = part.lessons.flatMap((id) => {
268
+ const lesson = lessonsById.get(id);
269
+ return lesson && !lesson.editions.includes('chapter') ? [slidesLesson(manifest, lesson)] : [];
270
+ });
150
271
  if (partChapters.length === 0) continue;
151
272
  const overview = pageSlugs.has(`${part.dir}/index`)
152
273
  ? (await readBuiltPage(outputRoot, `${part.dir}/index`)).page
153
274
  : undefined;
154
- parts.push({ part, ...(overview ? { overview } : {}), chapters: partChapters });
275
+ parts.push({ part, ...(overview ? { overview } : {}), chapters: partChapters, ...(slides.length > 0 ? { slides } : {}) });
155
276
  }
156
277
  const bookNumbers = (bookParts: readonly BookPart[]) =>
157
278
  new Map(
@@ -173,7 +294,8 @@ export async function writeLatexExports(
173
294
  }),
174
295
  target: `part/${entry.part.dir}`,
175
296
  folder: `${entry.part.dir}-latex`,
176
- website: bookNumbers([entry]),
297
+ numbering: againstWebsite(bookNumbers([entry])),
298
+ since: bookSince([entry]),
177
299
  });
178
300
  }
179
301
  if (parts.length > 0) {
@@ -183,31 +305,44 @@ export async function writeLatexExports(
183
305
  files: bookSource({ manifest, parts, document: 'course', title: course }),
184
306
  target: 'course',
185
307
  folder: `${courseFileStem(manifest)}-latex`,
186
- website: bookNumbers(parts),
308
+ numbering: againstWebsite(bookNumbers(parts)),
309
+ since: bookSince(parts),
187
310
  });
188
311
  }
189
312
 
190
- const pending: PendingFile[] = jobs.map((job) => ({
191
- target: join(outputRoot, 'downloads', `${job.target}-latex.zip`),
192
- content: zip(job.files.map((file) => ({ path: `${job.folder}/${file.path}`, content: file.content }))),
193
- }));
313
+ const pending: PendingFile[] = jobs
314
+ .filter((job) => !job.noZip)
315
+ .map((job) => ({
316
+ target: join(outputRoot, 'downloads', `${job.target}-latex.zip`),
317
+ content: zip(job.files.map((file) => ({ path: `${job.folder}/${file.path}`, content: file.content }))),
318
+ }));
194
319
 
195
- // Compile every document at once, up to one per core; a book numbers
196
- // every chapter, so each is checked against the website whole.
320
+ // Compile every document at once, up to one per core, the longest first;
321
+ // a book numbers every chapter, so each is checked against the website
322
+ // whole. A document reused from the cache is checked the same way.
197
323
  const durations: { kind: Job['kind']; seconds: number }[] = [];
324
+ const cache = 'missing' in engine ? undefined : await latexCacheFromEnvironment(engine, logger);
325
+ let reused = 0;
198
326
  const started = performance.now();
199
327
  if (!('missing' in engine)) {
200
328
  const compiled = await mapWithLimit(jobs, availableParallelism(), async (job) => {
201
329
  const jobStarted = performance.now();
202
- const result = await compileLatex(job.files, engine, { epoch: epochOf(manifest), name: job.name });
203
- durations.push({ kind: job.kind, seconds: (performance.now() - jobStarted) / 1000 });
204
- const problems = numberingMismatches(job.website, auxLabels(result.aux));
330
+ const options = { epoch: epochOf(manifest, job.since), name: job.name };
331
+ const compile = cache
332
+ ? await cache.compile(job.files, options)
333
+ : { result: await compileLatex(job.files, engine, options), reused: false };
334
+ const { result } = compile;
335
+ if (compile.reused) reused += 1;
336
+ else durations.push({ kind: job.kind, seconds: (performance.now() - jobStarted) / 1000 });
337
+ const problems = job.numbering(result.aux);
205
338
  if (problems.length > 0) {
206
339
  throw new Error(`LaTeX numbers ${job.name} differently from the website:\n ${problems.join('\n ')}`);
207
340
  }
208
- // A part's and the course's PDFs repeat their chapters' text, so each
209
- // character is reported once, by the chapter that has it.
210
- const missing = job.kind === 'chapter' ? missingCharacters(result.log) : [];
341
+ job.review?.(result.log);
342
+ // A part's and the course's PDFs repeat their chapters' text, and a
343
+ // handout its slides', so each character is reported once, by the
344
+ // document that has it first.
345
+ const missing = (job.kind === 'chapter' || job.kind === 'deck') && !job.noZip ? missingCharacters(result.log) : [];
211
346
  if (missing.length > 0) {
212
347
  logger.warn(
213
348
  `LaTeX export: the PDF of ${job.name} leaves ${missing.length} character(s) blank, ` +
@@ -220,7 +355,7 @@ export async function writeLatexExports(
220
355
  );
221
356
  }
222
357
  return result.pdf;
223
- });
358
+ }, startOrder(jobs));
224
359
  jobs.forEach((job, index) => {
225
360
  pending.push({ target: join(outputRoot, 'downloads', `${job.target}.pdf`), content: compiled[index]! });
226
361
  });
@@ -238,15 +373,22 @@ export async function writeLatexExports(
238
373
  } finally {
239
374
  await Promise.all(temporary.map((file) => rm(file.temporary, { force: true })));
240
375
  }
376
+ const pruned = cache ? await cache.prune() : 0;
241
377
  const pdfs = pending.filter((file) => file.target.endsWith('.pdf')).length;
242
378
  const seconds = (kind: Job['kind']) =>
243
379
  durations.filter((entry) => entry.kind === kind).reduce((sum, entry) => sum + entry.seconds, 0);
244
380
  logger.info(
245
- `LaTeX export: ${chapters.length} chapter(s), ${parts.length} part(s)${parts.length > 0 ? ' and the course' : ''}: ` +
246
- `${jobs.length} source zip(s)` +
381
+ `LaTeX export: ${chapters.length} chapter(s), ${decks.length} deck(s), ` +
382
+ `${parts.length} part(s)${parts.length > 0 ? ' and the course' : ''}: ` +
383
+ `${jobs.filter((job) => !job.noZip).length} source zip(s)` +
247
384
  (pdfs > 0
248
385
  ? `, ${pdfs} PDF(s) with ${'name' in engine ? engine.name : ''} in ${wall.toFixed(1)} s on up to ` +
249
- `${availableParallelism()} core(s) (compile time: chapters ${seconds('chapter').toFixed(1)} s, ` +
386
+ `${availableParallelism()} core(s) (` +
387
+ (cache
388
+ ? `${pdfs - reused} compiled, ${reused} reused from ${cache.directory}` +
389
+ `${pruned > 0 ? `, ${pruned} unused entr${pruned === 1 ? 'y' : 'ies'} deleted` : ''}; `
390
+ : '') +
391
+ `compile time: chapters ${seconds('chapter').toFixed(1)} s, decks ${seconds('deck').toFixed(1)} s, ` +
250
392
  `parts ${seconds('part').toFixed(1)} s, course ${seconds('course').toFixed(1)} s)`
251
393
  : '') +
252
394
  (captures.size > 0 ? `, figures from ${captures.size} page(s) drawn as vector PDF` : '') +
@@ -15,7 +15,7 @@ import { gfmTableToMarkdown } from 'mdast-util-gfm-table';
15
15
  import { mathToMarkdown } from 'mdast-util-math';
16
16
  import { toMarkdown } from 'mdast-util-to-markdown';
17
17
 
18
- import { captionTitle } from '../../../components/float-name';
18
+ import { captionTitle } from '../../../reference/float-name';
19
19
  import { stripNotationMarkers } from '../../../reference/math-glyphs.mjs';
20
20
  import type { ContentSegment } from '../../../reference/mixed-content';
21
21
  import type { CheckItem } from '../latex/serialize';
@@ -25,6 +25,7 @@ import type {
25
25
  DiagramGeometry,
26
26
  Inline,
27
27
  NotationEntry,
28
+ StaticDeck,
28
29
  StaticPage,
29
30
  TableCell,
30
31
  } from '../static-tree';
@@ -55,8 +56,11 @@ export interface MarkdownChapterContext {
55
56
  readonly pictures: ReadonlyMap<string, string>;
56
57
  /** Answers of this page's checks, by assessment id, in authored order. */
57
58
  readonly checkItems: ReadonlyMap<string, readonly CheckItem[]>;
58
- /** Course title, version and the page's live URL, for the line under its title. */
59
- readonly source?: { readonly course: string; readonly version?: string; readonly url?: string };
59
+ /**
60
+ * The line under the title: the course, the version the page's content is
61
+ * at (`versionPhrase`, rule C5) and its live URL.
62
+ */
63
+ readonly source?: { readonly course: string; readonly since?: string; readonly url?: string };
60
64
  }
61
65
 
62
66
  type Phrasing = PhrasingContent;
@@ -526,16 +530,19 @@ class MarkdownWriter {
526
530
  ];
527
531
  }
528
532
 
529
- answers(checks: readonly Extract<Block, { kind: 'check' }>[]): RootContent[] {
533
+ /**
534
+ * The answers to `checks`: under their own heading at a page's end, or
535
+ * bare (`inline`), as a deck's speaker notes give them.
536
+ */
537
+ answers(checks: readonly Extract<Block, { kind: 'check' }>[], inline = false): RootContent[] {
530
538
  if (checks.length === 0) return [];
531
539
  return [
532
- { type: 'heading', depth: 2, children: [anchor('answers'), text('Answers to the knowledge checks')] },
540
+ ...(inline ? [] : [{ type: 'heading', depth: 2, children: [anchor('answers'), text('Answers to the knowledge checks')] } as RootContent]),
533
541
  ...checks.flatMap((check): RootContent[] => {
534
542
  const items = this.context.checkItems.get(check.assessmentId) ?? [];
543
+ const name = text(`Knowledge check ${check.number}`);
535
544
  return [
536
- paragraph([
537
- strong([link(`#${check.anchor}`, [text(`Knowledge check ${check.number}`)]), text(': '), ...this.inlines(check.title)]),
538
- ]),
545
+ paragraph([strong([inline ? name : link(`#${check.anchor}`, [name]), text(': '), ...this.inlines(check.title)])]),
539
546
  {
540
547
  type: 'list',
541
548
  ordered: true,
@@ -608,7 +615,7 @@ export function serializeMarkdownPage(
608
615
  ? [
609
616
  text('From '),
610
617
  emphasis([text(source.course)]),
611
- ...(source.version ? [text(`, version ${source.version}`)] : []),
618
+ ...(source.since ? [text(`, ${source.since}`)] : []),
612
619
  text('.'),
613
620
  ...(source.url ? [text(' Interactive version: '), link(source.url, [text(source.url)]), text('.')] : []),
614
621
  ]
@@ -626,6 +633,108 @@ export function serializeMarkdownPage(
626
633
  ]);
627
634
  }
628
635
 
636
+ /** The slide sizes Marp's built-in themes have. */
637
+ export type MarpSize = '16:9' | '4:3';
638
+
639
+ export interface MarpDeckContext extends Omit<MarkdownChapterContext, 'page'> {
640
+ readonly deck: StaticDeck;
641
+ /** The symbols its frames use, by first use: its closing notation slide. */
642
+ readonly symbols: readonly NotationEntry[];
643
+ readonly size: MarpSize;
644
+ }
645
+
646
+ /**
647
+ * A deck in Marp's Markdown (decision D10): front matter that turns Marp on,
648
+ * one slide per frame between `---` lines, numbered as the deck is, each
649
+ * frame's notes and its check's answers as an HTML comment (Marp's speaker
650
+ * notes), and a closing slide of the symbols the frames use. A step shows
651
+ * its frame's final state, as the handout does. The file reads as plain
652
+ * Markdown too; Marp, its VS Code extension and Deckset present it.
653
+ */
654
+ export function serializeMarpDeck(context: MarpDeckContext): string {
655
+ const { deck, source } = context;
656
+ const page: StaticPage = {
657
+ slug: deck.slug,
658
+ route: deck.route,
659
+ title: deck.title,
660
+ blocks: [],
661
+ notation: deck.notation,
662
+ notationRemarks: [],
663
+ };
664
+ const writer = new MarkdownWriter({ ...context, page });
665
+ const heading = (depth: 1 | 2, children: Phrasing[]): RootContent => ({ type: 'heading', depth, children });
666
+ // Marp's own class for a slide whose content is centred: the title and a divider.
667
+ const lead: RootContent = { type: 'html', value: '<!-- _class: lead -->' };
668
+ const notes = (blocks: RootContent[]): RootContent[] =>
669
+ blocks.length === 0
670
+ ? []
671
+ : [{ type: 'html', value: `<!--\n${serializeNodes(blocks).trim().replace(/--/gu, '- -')}\n-->` }];
672
+ const slides: RootContent[][] = deck.frames.map((frame) => {
673
+ const title = writer.inlines(frame.title);
674
+ if (frame.kind === 'title') {
675
+ return [
676
+ lead,
677
+ heading(1, title),
678
+ ...(source
679
+ ? [
680
+ paragraph([
681
+ emphasis([text(source.course)]),
682
+ ...(source.since ? [text(`, ${source.since}`)] : []),
683
+ text('.'),
684
+ ...(source.url ? [text(' Interactive version: '), link(source.url, [text(source.url)]), text('.')] : []),
685
+ ]),
686
+ ]
687
+ : []),
688
+ ];
689
+ }
690
+ if (frame.kind === 'section') return [lead, heading(1, title)];
691
+ const blocks = frame.segments.flatMap((segment) => segment.blocks);
692
+ const legend = frame.legend.filter((item) => item.tex);
693
+ return [
694
+ heading(2, title),
695
+ ...writer.blocks(blocks),
696
+ ...(legend.length > 0
697
+ ? [
698
+ paragraph([
699
+ emphasis(
700
+ legend.flatMap((item, index): Phrasing[] => [
701
+ ...(index > 0 ? [text(' · ')] : []),
702
+ { type: 'inlineMath', value: plainTex(item.tex) } as Phrasing,
703
+ text(' '),
704
+ ...writer.inlines(item.label),
705
+ ]),
706
+ ),
707
+ ]),
708
+ ]
709
+ : []),
710
+ ...notes([...writer.blocks(frame.notes), ...writer.answers(checksOf(blocks), true)]),
711
+ ];
712
+ });
713
+ if (context.symbols.length > 0) {
714
+ slides.push([heading(2, [text('Notation')]), ...writer.notation(context.symbols, []).slice(1)]);
715
+ }
716
+ const body = serializeNodes(
717
+ slides.flatMap((slide, index): RootContent[] => [...(index > 0 ? [{ type: 'thematicBreak' } as RootContent] : []), ...slide]),
718
+ );
719
+ const frontMatter = [
720
+ '---',
721
+ 'marp: true',
722
+ `title: ${JSON.stringify(deck.title)}`,
723
+ `size: ${context.size}`,
724
+ 'paginate: true',
725
+ 'math: katex',
726
+ '---',
727
+ '',
728
+ ].join('\n');
729
+ // Marp shows raw HTML as text: the `<a id>` anchors a page's links land on
730
+ // go, and a link to one of them keeps its words.
731
+ const markdown = body
732
+ .replace(/<a id="[^"]*"><\/a> ?/gu, '')
733
+ .replace(/(?<!!)\[([^\]]*)\]\(#[^)]*\)/gu, '$1')
734
+ .replace(/\n{3,}/gu, '\n\n');
735
+ return `${frontMatter}\n${markdown}`;
736
+ }
737
+
629
738
  /** Markdown of a few blocks of mdast, written the same way (for a download's `index.md`). */
630
739
  export function serializeMarkdownNodes(children: RootContent[]): string {
631
740
  return serializeNodes(children);