explico 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/CHANGELOG.md +894 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1087 -14
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +29 -0
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -2,9 +2,16 @@ import { readFile } from 'node:fs/promises';
2
2
  import { extname, join, resolve } from 'node:path';
3
3
  import { parse } from 'parse5';
4
4
 
5
- import type { Manifest, ManifestLesson } from '../manifest';
5
+ import type { Manifest, ManifestDeck, ManifestLesson } from '../manifest';
6
6
  import { inferBasePrefix } from './html-tree';
7
- import { readInlineFragment, readStaticPage, type Block, type StaticPage } from './static-tree';
7
+ import {
8
+ readInlineFragment,
9
+ readStaticDeck,
10
+ readStaticPage,
11
+ type Block,
12
+ type StaticDeck,
13
+ type StaticPage,
14
+ } from './static-tree';
8
15
  import {
9
16
  captureFigures,
10
17
  courseChromium,
@@ -37,9 +44,15 @@ export function strictMode(): boolean {
37
44
  return (!!ci && ci !== 'false' && ci !== '0') || process.env.EXPLICO_LATEX_STRICT === '1';
38
45
  }
39
46
 
40
- /** The content date as seconds since 1970: every export's timestamps. */
41
- export function epochOf(manifest: Pick<Manifest, 'about'>): number {
42
- const date = manifest.about.knowledge.date;
47
+ /**
48
+ * A document's date as seconds since 1970, for its timestamps: the date of
49
+ * the release its content last changed in (`since`, rule C5), so a document
50
+ * keeps its date across the releases that did not touch it; the newest
51
+ * release's date for a change not yet released, and with no `since`.
52
+ */
53
+ export function epochOf(manifest: Pick<Manifest, 'about'>, since?: string | null): number {
54
+ const { releases, date: newest } = manifest.about.knowledge;
55
+ const date = (since == null ? undefined : (releases ?? []).find((release) => release.version === since)?.date) ?? newest;
43
56
  const parsed = date ? Date.parse(`${date}T00:00:00Z`) : Number.NaN;
44
57
  return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
45
58
  }
@@ -130,14 +143,63 @@ export interface PreparedChapter {
130
143
  readonly page: StaticPage;
131
144
  }
132
145
 
146
+ /** A lesson's deck, read from its page (docs/authoring/deck.md). */
147
+ export interface PreparedDeck {
148
+ readonly deck: ManifestDeck;
149
+ /** Its frames, each lab's live caption clause filled in. */
150
+ readonly page: StaticDeck;
151
+ /**
152
+ * Where each of its figures and diagrams was drawn, by anchor: the deck's
153
+ * own route, or its chapter's for one it recalls (the chapter's capture is
154
+ * reused).
155
+ */
156
+ readonly figureRoutes: ReadonlyMap<string, string>;
157
+ }
158
+
133
159
  export interface PreparedChapters {
134
160
  /** The site's base path, `/` or `/course/`. */
135
161
  readonly base: string;
136
162
  readonly chapters: readonly PreparedChapter[];
163
+ /** Each lesson's deck. */
164
+ readonly decks: readonly PreparedDeck[];
137
165
  /** What the capture pass drew, by route and anchor. */
138
166
  readonly captures: CaptureResults;
139
167
  }
140
168
 
169
+ /** Read one built deck page into its frames. */
170
+ export async function readBuiltDeck(outputRoot: string, route: string): Promise<{ html: string; page: StaticDeck }> {
171
+ const html = await readFile(join(outputRoot, route, 'index.html'), 'utf8');
172
+ return { html, page: readStaticDeck(html, route, route) };
173
+ }
174
+
175
+ /** Every block a deck's frames show, notes aside. */
176
+ export function deckBlocks(page: StaticDeck): Block[] {
177
+ return page.frames.flatMap((frame) => frame.segments.flatMap((segment) => segment.blocks));
178
+ }
179
+
180
+ /** The deck with each figure's live caption clause filled in from where it was drawn. */
181
+ function deckWithLiveClauses(
182
+ page: StaticDeck,
183
+ captures: CaptureResults,
184
+ figureRoutes: ReadonlyMap<string, string>,
185
+ base: string,
186
+ ): StaticDeck {
187
+ const fill = (blocks: readonly Block[]): Block[] =>
188
+ blocks.map((block) => {
189
+ if (block.kind !== 'figure') return block;
190
+ const route = figureRoutes.get(block.anchor);
191
+ const live = route === undefined ? undefined : captures.get(route)?.get(block.anchor)?.liveHtml;
192
+ return live ? { ...block, live: readInlineFragment(live, page.route, base) } : block;
193
+ });
194
+ return {
195
+ ...page,
196
+ frames: page.frames.map((frame) => ({
197
+ ...frame,
198
+ segments: frame.segments.map((segment) => ({ ...segment, blocks: fill(segment.blocks) })),
199
+ })),
200
+ };
201
+ }
202
+
141
203
  /** Read one built page into its reading tree. */
142
204
  export async function readBuiltPage(outputRoot: string, slug: string): Promise<{ html: string; page: StaticPage }> {
143
205
  const route = slug.replace(/(?:^|\/)index$/u, '');
@@ -164,20 +226,52 @@ export async function prepareChapters(
164
226
  const read: { lesson: ManifestLesson; page: StaticPage }[] = [];
165
227
  let base = '/';
166
228
  for (const lesson of manifest.lessons) {
229
+ // A slides-first lesson has no chapter to print: its deck is read below.
230
+ if (lesson.editions && !lesson.editions.includes('chapter')) continue;
167
231
  const { html, page } = await readBuiltPage(outputRoot, lesson.slug);
168
232
  if (read.length === 0) base = inferBasePrefix(parse(html));
169
233
  read.push({ lesson, page });
170
234
  }
171
235
 
236
+ // Each deck's frames. A figure or diagram it recalls is its chapter's, drawn
237
+ // there: a lesson's deck recalls from its chapter, a part's from the
238
+ // chapter each recall names (`recalledFrom`).
239
+ const decksRead: { deck: ManifestDeck; page: StaticDeck; figureRoutes: Map<string, string> }[] = [];
240
+ const pictured = (blocks: readonly Block[]) => [...figuresOf(blocks), ...diagramsOf(blocks)].map((float) => float.anchor);
241
+ for (const deck of manifest.decks ?? []) {
242
+ const { html, page } = await readBuiltDeck(outputRoot, deck.slug);
243
+ if (read.length === 0 && decksRead.length === 0) base = inferBasePrefix(parse(html));
244
+ const routeOf = (anchor: string) => {
245
+ const slug = deck.recalledFrom?.[anchor] ?? deck.chapter;
246
+ const chapter = slug === undefined ? undefined : read.find((entry) => entry.lesson.slug === slug);
247
+ return chapter && pictured(chapter.page.blocks).includes(anchor) ? chapter.page.route : page.route;
248
+ };
249
+ const figureRoutes = new Map(pictured(deckBlocks(page)).map((anchor) => [anchor, routeOf(anchor)]));
250
+ decksRead.push({ deck, page, figureRoutes });
251
+ }
252
+
172
253
  const requests: CaptureRequest[] = (latex || markdown ? read : [])
173
- .map(({ page }) => ({
254
+ .map(({ lesson, page }) => ({
174
255
  route: page.route,
256
+ // A figure's PDF carries its chapter's date (rule C5), as the chapter does.
257
+ epoch: epochOf(manifest, lesson.since),
175
258
  anchors: [
176
259
  ...figuresOf(page.blocks).filter(needsCapture).map((figure) => figure.anchor),
177
260
  ...(markdown ? diagramsOf(page.blocks).map((diagram) => diagram.anchor) : []),
178
261
  ],
179
262
  }))
180
263
  .filter((request) => request.anchors.length > 0);
264
+ // A deck's own figures (and, for Markdown, its own diagrams), drawn on its page.
265
+ if (latex || markdown) {
266
+ for (const { deck, page, figureRoutes } of decksRead) {
267
+ const anchors = [
268
+ ...figuresOf(deckBlocks(page)).filter(needsCapture).map((figure) => figure.anchor),
269
+ ...(markdown ? diagramsOf(deckBlocks(page)).map((diagram) => diagram.anchor) : []),
270
+ ].filter((anchor) => figureRoutes.get(anchor) === page.route);
271
+ // A figure's PDF carries its deck's date (rule C5).
272
+ if (anchors.length > 0) requests.push({ route: page.route, epoch: epochOf(manifest, deck.since), anchors });
273
+ }
274
+ }
181
275
  let captures: CaptureResults = new Map();
182
276
  if (requests.length > 0) {
183
277
  const chromium = await courseChromium(options.projectRoot ?? process.cwd());
@@ -229,5 +323,10 @@ export async function prepareChapters(
229
323
  const captured = captures.get(page.route);
230
324
  return { lesson, page: captured ? withLiveClauses(page, captured, base) : page };
231
325
  }),
326
+ decks: decksRead.map(({ deck, page, figureRoutes }) => ({
327
+ deck,
328
+ page: deckWithLiveClauses(page, captures, figureRoutes, base),
329
+ figureRoutes,
330
+ })),
232
331
  };
233
332
  }
@@ -1,4 +1,4 @@
1
- import type { Manifest } from '../manifest';
1
+ import type { Manifest, ManifestDeck } from '../manifest';
2
2
 
3
3
  /**
4
4
  * Every page a reader can print or download, by docs slug: each chapter
@@ -28,7 +28,51 @@ export function standaloneFilename(slug: string): string {
28
28
  * is in its part's file and the course's (`combined-html.ts`).
29
29
  */
30
30
  export function standalonePageSlugs(manifest: Pick<Manifest, 'lessons'>): string[] {
31
- return manifest.lessons.map((lesson) => lesson.slug);
31
+ return manifest.lessons.filter((lesson) => (lesson.editions ?? ['chapter']).includes('chapter')).map((lesson) => lesson.slug);
32
+ }
33
+
34
+ /**
35
+ * Every page with an HTML download of its own: its route (`slug`) and where
36
+ * it downloads below `downloads/`, without `.html` (`download`). A chapter
37
+ * downloads at its slug; a deck at its owner's {@link deckFileStem}, its
38
+ * Present mode and notes working offline.
39
+ */
40
+ export function standaloneDownloads(
41
+ manifest: Pick<Manifest, 'lessons'> & Partial<Pick<Manifest, 'decks'>>,
42
+ ): { readonly slug: string; readonly download: string }[] {
43
+ return [
44
+ ...standalonePageSlugs(manifest).map((slug) => ({ slug, download: slug })),
45
+ ...(manifest.decks ?? []).map((deck) => ({
46
+ slug: deck.slug,
47
+ download: deckFileStem(deckOwnerSlug(deck, manifest)),
48
+ })),
49
+ ];
50
+ }
51
+
52
+ /**
53
+ * Where a deck downloads below `downloads/`, by its owner's slug — a
54
+ * lesson's (`projectiles/projectile-range`), or a part's directory
55
+ * (`motion`): `projectiles/projectile-range-slides` + `.pdf` (the slides),
56
+ * `-notes.pdf` (each slide with its speaker notes) and `-latex.zip` (their
57
+ * source); a part's deck `motion-slides` + the same. A slides-first lesson's
58
+ * deck is named the same way as a chapter's.
59
+ */
60
+ export function deckFileStem(ownerSlug: string): string {
61
+ return `${ownerSlug}-slides`;
62
+ }
63
+
64
+ /** Whose a deck is, as its downloads name it: its lesson's slug, or its part's directory. */
65
+ export function deckOwnerSlug(
66
+ deck: Pick<ManifestDeck, 'lessonId' | 'part' | 'slug'>,
67
+ manifest: Pick<Manifest, 'lessons'>,
68
+ ): string {
69
+ if (deck.part !== undefined) return deck.part;
70
+ return manifest.lessons.find((lesson) => lesson.id === deck.lessonId)?.slug ?? deck.slug;
71
+ }
72
+
73
+ /** The name a deck's files are saved under: `projectile-range-slides`, a part's `motion-slides`. */
74
+ export function deckSaveName(ownerSlug: string): string {
75
+ return `${ownerSlug.split('/').at(-1) ?? ownerSlug}-slides`;
32
76
  }
33
77
 
34
78
  /** The name the course's files are saved under: `Explico Playground — Motion` → `explico-playground-motion`. */
@@ -0,0 +1,124 @@
1
+ %% beamerthemeexplico.sty — the beamer theme of an explico deck.
2
+ %%
3
+ %% explico writes this file next to every deck's LaTeX download
4
+ %% (docs/authoring/deck.md), and builds the deck's PDFs from that same folder:
5
+ %% `main.tex` (the slides) and `handout.tex` (each slide followed by its notes).
6
+ %% Edit it freely in a download.
7
+ %%
8
+ %% A frame ends on its legend line, the symbols it introduces (\ExplicoLegend,
9
+ %% which the deck file sets per frame), above a strip with the section's name
10
+ %% and the frame's number; a thin bar under it is the deck's progress.
11
+ %% Engines: LuaLaTeX (preferred) or pdfLaTeX, TeX Live 2025 or later.
12
+ \NeedsTeXFormat{LaTeX2e}
13
+ \ProvidesPackage{beamerthemeexplico}[2026/10/10 explico deck theme]
14
+ \DeclareOptionBeamer{scripts}{\PassOptionsToPackage{scripts}{explico-common}}
15
+ \ProcessOptionsBeamer
16
+
17
+ %% unicode-math sets the math fonts; beamer must leave them alone.
18
+ \usefonttheme{professionalfonts}
19
+ \RequirePackage{explico-common}
20
+ \RequirePackage{etoolbox}
21
+
22
+ \setbeamertemplate{navigation symbols}{}
23
+ \setbeamercovered{invisible}
24
+ \setbeamercolor{structure}{fg=explicoaccent}
25
+ \setbeamercolor{title}{fg=explicoaccent}
26
+ \setbeamercolor{frametitle}{fg=explicoaccent,bg=}
27
+ \setbeamerfont{title}{size=\LARGE,series=\bfseries}
28
+ \setbeamerfont{frametitle}{size=\Large,series=\bfseries}
29
+ \setbeamertemplate{frametitle}{%
30
+ \vskip0.7em
31
+ {\usebeamerfont{frametitle}\usebeamercolor[fg]{frametitle}\insertframetitle\par}%
32
+ \vskip0.15em}
33
+ %% A frame that breaks over several (the notation appendix): "Notation", then
34
+ %% "Notation (continued)".
35
+ \setbeamertemplate{frametitle continuation}[from second][(continued)]
36
+ \setbeamercolor{block title}{fg=black,bg=explicoback!60!white}
37
+ \setbeamercolor{block body}{fg=black,bg=explicoback}
38
+ \setbeamercolor{block title alerted}{fg=black,bg=explicoasidecautionback!80!explicoasidecaution}
39
+ \setbeamercolor{block body alerted}{fg=black,bg=explicoasidecautionback}
40
+ \setbeamertemplate{blocks}[rounded]
41
+ \setbeamertemplate{itemize items}[circle]
42
+ \setbeamertemplate{enumerate items}[default]
43
+ \setbeamerfont{caption}{size=\scriptsize}
44
+
45
+ %% The title frame: the deck's title, its course, its authors and version.
46
+ \setbeamertemplate{title page}{%
47
+ \vfill
48
+ {\usebeamerfont{title}\usebeamercolor[fg]{title}\inserttitle\par}%
49
+ \ifx\insertsubtitle\@empty\else\vskip0.6em{\large\insertsubtitle\par}\fi
50
+ \vskip1.2em
51
+ {\small\color{black!65}\insertauthor\par}%
52
+ \vskip0.3em
53
+ {\small\color{black!65}\insertdate\par}%
54
+ \vfill}
55
+
56
+ %% A section's divider frame: its title over a rule.
57
+ \newcommand{\ExplicoSectionPage}{%
58
+ \vfill
59
+ {\usebeamerfont{title}\usebeamercolor[fg]{title}\insertsectionhead\par}%
60
+ \vskip0.3em{\color{explicoaccent}\rule{0.5\linewidth}{1.5pt}}%
61
+ \vfill}
62
+
63
+ %% The deck's own frames (deck.tex sets their count): the progress bar runs
64
+ %% over them, and the notation appendix after them shows none.
65
+ \def\explico@deckframes{\inserttotalframenumber}
66
+ \newcommand{\ExplicoDeckFrames}[1]{\gdef\explico@deckframes{#1}}
67
+
68
+ %% The legend line: the symbols a frame introduces. The frame's body sets it
69
+ %% on every slide of the frame; the footline prints it and clears it.
70
+ \gdef\explico@legend{}
71
+ \newcommand{\ExplicoLegend}[1]{\gdef\explico@legend{#1}}
72
+ \setbeamertemplate{footline}{%
73
+ \leavevmode
74
+ \ifx\explico@legend\@empty\else
75
+ \hbox to \paperwidth{\hskip1.6em\parbox[b]{\dimexpr\paperwidth-3.2em}{%
76
+ \raggedright\scriptsize\color{black!70}\explico@legend}}\par\vskip3pt
77
+ \fi
78
+ \global\let\explico@legend\@empty
79
+ \hbox to \paperwidth{\hskip1.6em{\tiny\color{black!55}\insertsectionhead}\hfill
80
+ {\tiny\color{black!55}\insertframenumber}\hskip1.2em}\par\vskip3pt
81
+ \ifnum\c@framenumber>\explico@deckframes\relax\else
82
+ {\color{explicoaccent}\rule{\dimexpr\paperwidth*\c@framenumber/\explico@deckframes\relax}{1.2pt}}%
83
+ \fi
84
+ }
85
+
86
+ %% A note page of the handout (handout.tex): the slide's number and title
87
+ %% beside a small copy of the slide, then its speaker notes.
88
+ \setbeamerfont{note page}{size=\small}
89
+ \setbeamertemplate{note page}{%
90
+ \vskip1em
91
+ \hbox to \textwidth{%
92
+ \vtop{\hsize=\dimexpr\textwidth-0.3\paperwidth-1.5em\relax
93
+ \vskip0pt
94
+ {\usebeamercolor[fg]{frametitle}\normalsize\bfseries Slide~\insertframenumber\par}%
95
+ \vskip0.3em
96
+ {\small\raggedright\insertframetitle\par}}%
97
+ \hfill
98
+ \vtop{\vskip0pt\hbox{\setlength{\fboxsep}{0pt}\fcolorbox{black!25}{white}{\insertslideintonotes{0.3}}}}}%
99
+ \vskip1.2em
100
+ {\usebeamerfont{note page}\raggedright\insertnote\par}%
101
+ }
102
+
103
+ %% A figure's, a table's or a diagram's caption on a frame: its number (a
104
+ %% float recalled from the chapter keeps the chapter's), its short caption in
105
+ %% bold, then the long one.
106
+ \newcommand{\ExplicoDeckCaption}[3]{%
107
+ \par\smallskip{\usebeamerfont{caption}%
108
+ \if\relax\detokenize{#1}\relax\else{\color{explicoaccent}\textbf{#1}}\enspace\fi
109
+ \if\relax\detokenize{#2}\relax\else\textbf{#2.}\space\fi#3\par}}
110
+
111
+ %% A deck's references frame: the list its citations number, as on the
112
+ %% slides. The frame's title names it, so the list has no heading of its own.
113
+ \newenvironment{ExplicoReferences}[1]{%
114
+ \small
115
+ \list{\@biblabel{\@arabic\c@enumiv}}{%
116
+ \settowidth\labelwidth{\@biblabel{#1}}%
117
+ \leftmargin\labelwidth\advance\leftmargin\labelsep
118
+ \usecounter{enumiv}\let\p@enumiv\@empty
119
+ \renewcommand\theenumiv{\@arabic\c@enumiv}}%
120
+ \sloppy\raggedright
121
+ }{\endlist}
122
+
123
+ \pdfstringdefDisableCommands{\let\ExplicoGreekWord\@firstofone}
124
+ \endinput
@@ -0,0 +1,183 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ import type { ExportLogger } from '../export-chapters';
6
+ import { compileLatex, texFingerprint, type CompiledLatex, type LatexEngine } from './compile';
7
+ import type { SourceFile } from './document';
8
+
9
+ /**
10
+ * A cache of compiled documents (`EXPLICO_LATEX_CACHE=<directory>`), so a
11
+ * build compiles only the documents whose source changed. A document's key
12
+ * is a SHA-256 over everything its compile reads: the cache's format, the
13
+ * engine and a fingerprint of the TeX installation, the epoch the PDF is
14
+ * dated with, `EXPLICO_LATEX_CACHE_SALT`, and every file of its folder
15
+ * (path, length and bytes). A shared notation card, a renumbered chapter, a
16
+ * new `explico.cls` or a figure all change some file, so the key needs no
17
+ * dependency graph and cannot miss one.
18
+ *
19
+ * An entry keeps what `compileLatex` returns, the PDF, the `.aux` and the
20
+ * log, so a reused document is checked like a compiled one: its numbers
21
+ * against the website's, its log for characters no font set. An entry is
22
+ * written under a temporary name and renamed, a failed compile stores
23
+ * nothing, and an entry that does not read back whole is a miss. A hit
24
+ * touches its entry; entries no build has used for `KEEP_DAYS` are deleted.
25
+ */
26
+
27
+ const FORMAT = 'explico-latex-cache 1';
28
+ const KEEP_DAYS = 7;
29
+ const ENTRY = /^[0-9a-f]{64}$/u;
30
+
31
+ export interface CacheContext {
32
+ readonly engine: LatexEngine['name'];
33
+ /** `texFingerprint`'s description of the TeX installation. */
34
+ readonly fingerprint: string;
35
+ readonly epoch: number;
36
+ readonly salt: string;
37
+ }
38
+
39
+ /** The key of one compile: a hex SHA-256 over its context and its files in path order. */
40
+ export function cacheKey(files: readonly SourceFile[], context: CacheContext): string {
41
+ const hash = createHash('sha256');
42
+ for (const line of [FORMAT, context.engine, context.fingerprint, String(context.epoch), context.salt]) {
43
+ hash.update(`${line.length}:${line}\n`);
44
+ }
45
+ for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
46
+ const content = typeof file.content === 'string' ? Buffer.from(file.content, 'utf8') : file.content;
47
+ hash.update(`${Buffer.byteLength(file.path)}:${file.path}\n${content.byteLength}\n`);
48
+ hash.update(content);
49
+ }
50
+ return hash.digest('hex');
51
+ }
52
+
53
+ /** One file per entry: a format line, the parts' lengths and digest as JSON, then the parts. */
54
+ function encodeEntry(result: CompiledLatex): Buffer {
55
+ const parts = [result.pdf, Buffer.from(result.aux, 'utf8'), Buffer.from(result.log, 'utf8')];
56
+ const payload = Buffer.concat(parts);
57
+ const header = {
58
+ pdf: parts[0]!.byteLength,
59
+ aux: parts[1]!.byteLength,
60
+ log: parts[2]!.byteLength,
61
+ sha256: createHash('sha256').update(payload).digest('hex'),
62
+ };
63
+ return Buffer.concat([Buffer.from(`${FORMAT}\n${JSON.stringify(header)}\n`, 'utf8'), payload]);
64
+ }
65
+
66
+ /** The compile an entry holds, or `undefined` when it is not whole. */
67
+ function decodeEntry(bytes: Buffer): CompiledLatex | undefined {
68
+ const formatEnd = bytes.indexOf(0x0a);
69
+ const headerEnd = formatEnd < 0 ? -1 : bytes.indexOf(0x0a, formatEnd + 1);
70
+ if (headerEnd < 0 || bytes.subarray(0, formatEnd).toString('utf8') !== FORMAT) return undefined;
71
+ let header: { pdf?: unknown; aux?: unknown; log?: unknown; sha256?: unknown };
72
+ try {
73
+ header = JSON.parse(bytes.subarray(formatEnd + 1, headerEnd).toString('utf8'));
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ const { pdf, aux, log, sha256 } = header;
78
+ if (typeof pdf !== 'number' || typeof aux !== 'number' || typeof log !== 'number') return undefined;
79
+ const payload = bytes.subarray(headerEnd + 1);
80
+ if (payload.byteLength !== pdf + aux + log) return undefined;
81
+ if (createHash('sha256').update(payload).digest('hex') !== sha256) return undefined;
82
+ return {
83
+ pdf: Buffer.from(payload.subarray(0, pdf)),
84
+ aux: payload.subarray(pdf, pdf + aux).toString('utf8'),
85
+ log: payload.subarray(pdf + aux).toString('utf8'),
86
+ };
87
+ }
88
+
89
+ export interface LatexCache {
90
+ readonly directory: string;
91
+ /** The document compiled, or reused from an entry with the same key. */
92
+ compile(
93
+ files: readonly SourceFile[],
94
+ options: { readonly epoch: number; readonly name?: string },
95
+ ): Promise<{ readonly result: CompiledLatex; readonly reused: boolean }>;
96
+ /** Delete the entries no build has used for `KEEP_DAYS`; how many went. */
97
+ prune(now?: Date): Promise<number>;
98
+ }
99
+
100
+ export function openLatexCache(options: {
101
+ readonly directory: string;
102
+ readonly engine: LatexEngine;
103
+ readonly fingerprint: string;
104
+ readonly salt?: string;
105
+ readonly logger?: ExportLogger;
106
+ /** The compile behind a miss; `compileLatex` but in tests. */
107
+ readonly compile?: typeof compileLatex;
108
+ }): LatexCache {
109
+ const { directory, engine, fingerprint } = options;
110
+ const compile = options.compile ?? compileLatex;
111
+ let warned = false;
112
+ const warnOnce = (message: string) => {
113
+ if (warned) return;
114
+ warned = true;
115
+ options.logger?.warn(message);
116
+ };
117
+ return {
118
+ directory,
119
+ async compile(files, compileOptions) {
120
+ const key = cacheKey(files, { engine: engine.name, fingerprint, epoch: compileOptions.epoch, salt: options.salt ?? '' });
121
+ const path = join(directory, key);
122
+ const cached = await readFile(path).then(decodeEntry, () => undefined);
123
+ if (cached) {
124
+ const now = new Date();
125
+ await utimes(path, now, now).catch(() => undefined);
126
+ return { result: cached, reused: true };
127
+ }
128
+ const result = await compile(files, engine, compileOptions);
129
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
130
+ try {
131
+ await writeFile(temporary, encodeEntry(result));
132
+ await rename(temporary, path);
133
+ } catch (error) {
134
+ await rm(temporary, { force: true });
135
+ warnOnce(
136
+ `LaTeX export: could not store a compile in ${directory} (${error instanceof Error ? error.message : String(error)}); ` +
137
+ 'the build goes on without it.',
138
+ );
139
+ }
140
+ return { result, reused: false };
141
+ },
142
+ async prune(now = new Date()) {
143
+ const cutoff = now.getTime() - KEEP_DAYS * 24 * 60 * 60 * 1000;
144
+ let removed = 0;
145
+ for (const name of await readdir(directory).catch(() => [] as string[])) {
146
+ // An entry, or what an interrupted write left behind.
147
+ if (!ENTRY.test(name) && !/^[0-9a-f]{64}\.tmp-/u.test(name)) continue;
148
+ const path = join(directory, name);
149
+ const modified = await stat(path).then((entry) => entry.mtimeMs, () => undefined);
150
+ if (modified === undefined || modified >= cutoff) continue;
151
+ await rm(path, { force: true });
152
+ removed += 1;
153
+ }
154
+ return removed;
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The cache `EXPLICO_LATEX_CACHE` names, or `undefined` when it is unset
161
+ * (today's build: every document compiles) or the directory cannot be made.
162
+ */
163
+ export async function latexCacheFromEnvironment(engine: LatexEngine, logger: ExportLogger): Promise<LatexCache | undefined> {
164
+ const setting = process.env.EXPLICO_LATEX_CACHE?.trim();
165
+ if (!setting) return undefined;
166
+ const directory = resolve(setting);
167
+ try {
168
+ await mkdir(directory, { recursive: true });
169
+ } catch (error) {
170
+ logger.warn(
171
+ `LaTeX export: EXPLICO_LATEX_CACHE names ${directory}, which cannot be made ` +
172
+ `(${error instanceof Error ? error.message : String(error)}); every document compiles.`,
173
+ );
174
+ return undefined;
175
+ }
176
+ return openLatexCache({
177
+ directory,
178
+ engine,
179
+ fingerprint: await texFingerprint(engine),
180
+ salt: process.env.EXPLICO_LATEX_CACHE_SALT ?? '',
181
+ logger,
182
+ });
183
+ }
@@ -32,6 +32,8 @@ export interface CaptureRequest {
32
32
  readonly route: string;
33
33
  /** Anchor ids of the figures and diagrams to capture (`fig-…`, `dia-…`). */
34
34
  readonly anchors: readonly string[];
35
+ /** This page's date for its figure PDFs, over the capture's `epoch`. */
36
+ readonly epoch?: number;
35
37
  }
36
38
 
37
39
  export interface CapturedFigure {
@@ -140,7 +142,7 @@ async function existingFile(path: string): Promise<string | undefined> {
140
142
  }
141
143
 
142
144
  /** Serve `root` on loopback at `base`; resolves with the origin once listening. */
143
- async function serve(root: string, base: string): Promise<{ origin: string; server: Server }> {
145
+ export async function serve(root: string, base: string): Promise<{ origin: string; server: Server }> {
144
146
  const rootPath = resolve(root);
145
147
  const server = createServer((request, response) => {
146
148
  void (async () => {
@@ -629,7 +631,8 @@ export async function captureFigures(
629
631
  options.measure ?? figureMeasure('letter'),
630
632
  options.png ?? false,
631
633
  );
632
- const pdf = options.epoch === undefined ? printed.pdf : stampPdfDates(printed.pdf, options.epoch);
634
+ const epoch = request.epoch ?? options.epoch;
635
+ const pdf = epoch === undefined ? printed.pdf : stampPdfDates(printed.pdf, epoch);
633
636
  figures.set(anchor, { ...printed, pdf, liveHtml: snapshot.live });
634
637
  }
635
638
  results.set(request.route, figures);
@@ -79,6 +79,24 @@ export async function findLatexEngine(): Promise<LatexEngine | { readonly missin
79
79
  : { name: 'pdflatex', latexmkOption: '-pdf' };
80
80
  }
81
81
 
82
+ /**
83
+ * What the compile cache knows of the TeX installation: the engine's and
84
+ * latexmk's banners and the LaTeX kernel's date and patch level. A TeX Live
85
+ * release or a kernel update changes it; a package updated with `tlmgr` does
86
+ * not, which is what `EXPLICO_LATEX_CACHE_SALT` is for.
87
+ */
88
+ export async function texFingerprint(engine: LatexEngine): Promise<string> {
89
+ const firstLine = (output: string, pattern: RegExp) =>
90
+ output.split('\n').find((line) => pattern.test(line))?.trim() ?? '';
91
+ const banner = firstLine((await run(engine.name, ['--version'])).output, /\S/u);
92
+ const latexmk = firstLine((await run('latexmk', ['-v'])).output, /Version/u);
93
+ const kernelFile = (await run('kpsewhich', ['latex.ltx'])).output.trim();
94
+ const kernel = kernelFile ? await readFile(kernelFile, 'utf8').catch(() => '') : '';
95
+ const date = /\\edef\\fmtversion\s*\{([^}]*)\}/u.exec(kernel)?.[1] ?? '';
96
+ const patch = /\\def\\patch@level\{([^}]*)\}/u.exec(kernel)?.[1] ?? '';
97
+ return [banner, latexmk, `LaTeX2e ${date} patch level ${patch}`].join('\n');
98
+ }
99
+
82
100
  /**
83
101
  * The last LaTeX errors of a log, with their file and line. A Lua error
84
102
  * (`[\directlua]:1: …`, `….lua:12: …`) carries no TeX file and line.