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
@@ -2,7 +2,11 @@ import { readFile } from "node:fs/promises";
2
2
  import { extname, join, resolve } from "node:path";
3
3
  import { parse } from "parse5";
4
4
  import { inferBasePrefix } from "./html-tree.js";
5
- import { readInlineFragment, readStaticPage } from "./static-tree.js";
5
+ import {
6
+ readInlineFragment,
7
+ readStaticDeck,
8
+ readStaticPage
9
+ } from "./static-tree.js";
6
10
  import {
7
11
  captureFigures,
8
12
  courseChromium,
@@ -17,8 +21,9 @@ function strictMode() {
17
21
  const ci = process.env.CI;
18
22
  return !!ci && ci !== "false" && ci !== "0" || process.env.EXPLICO_LATEX_STRICT === "1";
19
23
  }
20
- function epochOf(manifest) {
21
- const date = manifest.about.knowledge.date;
24
+ function epochOf(manifest, since) {
25
+ const { releases, date: newest } = manifest.about.knowledge;
26
+ const date = (since == null ? void 0 : (releases ?? []).find((release) => release.version === since)?.date) ?? newest;
22
27
  const parsed = date ? Date.parse(`${date}T00:00:00Z`) : Number.NaN;
23
28
  return Number.isFinite(parsed) ? Math.floor(parsed / 1e3) : 0;
24
29
  }
@@ -78,6 +83,28 @@ function withLiveClauses(page, captured, base) {
78
83
  });
79
84
  return { ...page, blocks: update(page.blocks) };
80
85
  }
86
+ async function readBuiltDeck(outputRoot, route) {
87
+ const html = await readFile(join(outputRoot, route, "index.html"), "utf8");
88
+ return { html, page: readStaticDeck(html, route, route) };
89
+ }
90
+ function deckBlocks(page) {
91
+ return page.frames.flatMap((frame) => frame.segments.flatMap((segment) => segment.blocks));
92
+ }
93
+ function deckWithLiveClauses(page, captures, figureRoutes, base) {
94
+ const fill = (blocks) => blocks.map((block) => {
95
+ if (block.kind !== "figure") return block;
96
+ const route = figureRoutes.get(block.anchor);
97
+ const live = route === void 0 ? void 0 : captures.get(route)?.get(block.anchor)?.liveHtml;
98
+ return live ? { ...block, live: readInlineFragment(live, page.route, base) } : block;
99
+ });
100
+ return {
101
+ ...page,
102
+ frames: page.frames.map((frame) => ({
103
+ ...frame,
104
+ segments: frame.segments.map((segment) => ({ ...segment, blocks: fill(segment.blocks) }))
105
+ }))
106
+ };
107
+ }
81
108
  async function readBuiltPage(outputRoot, slug) {
82
109
  const route = slug.replace(/(?:^|\/)index$/u, "");
83
110
  const html = await readFile(join(outputRoot, route, "index.html"), "utf8");
@@ -90,17 +117,42 @@ async function prepareChapters(outputRoot, manifest, options = {}) {
90
117
  const read = [];
91
118
  let base = "/";
92
119
  for (const lesson of manifest.lessons) {
120
+ if (lesson.editions && !lesson.editions.includes("chapter")) continue;
93
121
  const { html, page } = await readBuiltPage(outputRoot, lesson.slug);
94
122
  if (read.length === 0) base = inferBasePrefix(parse(html));
95
123
  read.push({ lesson, page });
96
124
  }
97
- const requests = (latex || markdown ? read : []).map(({ page }) => ({
125
+ const decksRead = [];
126
+ const pictured = (blocks) => [...figuresOf(blocks), ...diagramsOf(blocks)].map((float) => float.anchor);
127
+ for (const deck of manifest.decks ?? []) {
128
+ const { html, page } = await readBuiltDeck(outputRoot, deck.slug);
129
+ if (read.length === 0 && decksRead.length === 0) base = inferBasePrefix(parse(html));
130
+ const routeOf = (anchor) => {
131
+ const slug = deck.recalledFrom?.[anchor] ?? deck.chapter;
132
+ const chapter = slug === void 0 ? void 0 : read.find((entry) => entry.lesson.slug === slug);
133
+ return chapter && pictured(chapter.page.blocks).includes(anchor) ? chapter.page.route : page.route;
134
+ };
135
+ const figureRoutes = new Map(pictured(deckBlocks(page)).map((anchor) => [anchor, routeOf(anchor)]));
136
+ decksRead.push({ deck, page, figureRoutes });
137
+ }
138
+ const requests = (latex || markdown ? read : []).map(({ lesson, page }) => ({
98
139
  route: page.route,
140
+ // A figure's PDF carries its chapter's date (rule C5), as the chapter does.
141
+ epoch: epochOf(manifest, lesson.since),
99
142
  anchors: [
100
143
  ...figuresOf(page.blocks).filter(needsCapture).map((figure) => figure.anchor),
101
144
  ...markdown ? diagramsOf(page.blocks).map((diagram) => diagram.anchor) : []
102
145
  ]
103
146
  })).filter((request) => request.anchors.length > 0);
147
+ if (latex || markdown) {
148
+ for (const { deck, page, figureRoutes } of decksRead) {
149
+ const anchors = [
150
+ ...figuresOf(deckBlocks(page)).filter(needsCapture).map((figure) => figure.anchor),
151
+ ...markdown ? diagramsOf(deckBlocks(page)).map((diagram) => diagram.anchor) : []
152
+ ].filter((anchor) => figureRoutes.get(anchor) === page.route);
153
+ if (anchors.length > 0) requests.push({ route: page.route, epoch: epochOf(manifest, deck.since), anchors });
154
+ }
155
+ }
104
156
  let captures = /* @__PURE__ */ new Map();
105
157
  if (requests.length > 0) {
106
158
  const chromium = await courseChromium(options.projectRoot ?? process.cwd());
@@ -145,11 +197,17 @@ async function prepareChapters(outputRoot, manifest, options = {}) {
145
197
  chapters: read.map(({ lesson, page }) => {
146
198
  const captured = captures.get(page.route);
147
199
  return { lesson, page: captured ? withLiveClauses(page, captured, base) : page };
148
- })
200
+ }),
201
+ decks: decksRead.map(({ deck, page, figureRoutes }) => ({
202
+ deck,
203
+ page: deckWithLiveClauses(page, captures, figureRoutes, base),
204
+ figureRoutes
205
+ }))
149
206
  };
150
207
  }
151
208
  export {
152
209
  consoleLogger,
210
+ deckBlocks,
153
211
  diagramsOf,
154
212
  epochOf,
155
213
  figuresOf,
@@ -158,6 +216,7 @@ export {
158
216
  imagesOf,
159
217
  needsCapture,
160
218
  prepareChapters,
219
+ readBuiltDeck,
161
220
  readBuiltPage,
162
221
  strictMode,
163
222
  withLiveClauses
@@ -1,7 +1,7 @@
1
- import { Manifest } from '../manifest.js';
1
+ import { Manifest, ManifestDeck } from '../manifest.js';
2
2
  import '../../curriculum/validation.js';
3
3
  import '../../reference/consistency.js';
4
- import '../../course-config-rRtME5UV.js';
4
+ import '../../course-config-BLtIdkei.js';
5
5
  import '../../rules/catalog.js';
6
6
  import '../../rules/course-rules.js';
7
7
  import '../collections.js';
@@ -23,6 +23,10 @@ import '../../reference/mixed-content.js';
23
23
  import '../../reference/gloss.js';
24
24
  import '../../rules/waivers.js';
25
25
  import '../knowledge-version.js';
26
+ import '../page-versions.js';
27
+ import '../decks.js';
28
+ import '../../reference/decks-validate.js';
29
+ import '../../reference/deck-source.js';
26
30
  import '../lesson-derivation.js';
27
31
 
28
32
  /**
@@ -42,7 +46,30 @@ declare function standaloneFilename(slug: string): string;
42
46
  * is in its part's file and the course's (`combined-html.ts`).
43
47
  */
44
48
  declare function standalonePageSlugs(manifest: Pick<Manifest, 'lessons'>): string[];
49
+ /**
50
+ * Every page with an HTML download of its own: its route (`slug`) and where
51
+ * it downloads below `downloads/`, without `.html` (`download`). A chapter
52
+ * downloads at its slug; a deck at its owner's {@link deckFileStem}, its
53
+ * Present mode and notes working offline.
54
+ */
55
+ declare function standaloneDownloads(manifest: Pick<Manifest, 'lessons'> & Partial<Pick<Manifest, 'decks'>>): {
56
+ readonly slug: string;
57
+ readonly download: string;
58
+ }[];
59
+ /**
60
+ * Where a deck downloads below `downloads/`, by its owner's slug — a
61
+ * lesson's (`projectiles/projectile-range`), or a part's directory
62
+ * (`motion`): `projectiles/projectile-range-slides` + `.pdf` (the slides),
63
+ * `-notes.pdf` (each slide with its speaker notes) and `-latex.zip` (their
64
+ * source); a part's deck `motion-slides` + the same. A slides-first lesson's
65
+ * deck is named the same way as a chapter's.
66
+ */
67
+ declare function deckFileStem(ownerSlug: string): string;
68
+ /** Whose a deck is, as its downloads name it: its lesson's slug, or its part's directory. */
69
+ declare function deckOwnerSlug(deck: Pick<ManifestDeck, 'lessonId' | 'part' | 'slug'>, manifest: Pick<Manifest, 'lessons'>): string;
70
+ /** The name a deck's files are saved under: `projectile-range-slides`, a part's `motion-slides`. */
71
+ declare function deckSaveName(ownerSlug: string): string;
45
72
  /** The name the course's files are saved under: `Explico Playground — Motion` → `explico-playground-motion`. */
46
73
  declare function courseFileStem(manifest: Pick<Manifest, 'about'>): string;
47
74
 
48
- export { courseFileStem, exportPageRoute, exportPageSlugs, standaloneFilename, standalonePageSlugs };
75
+ export { courseFileStem, deckFileStem, deckOwnerSlug, deckSaveName, exportPageRoute, exportPageSlugs, standaloneDownloads, standaloneFilename, standalonePageSlugs };
@@ -8,15 +8,38 @@ function standaloneFilename(slug) {
8
8
  return `${exportPageRoute(slug).split("/").at(-1) || "index"}.html`;
9
9
  }
10
10
  function standalonePageSlugs(manifest) {
11
- return manifest.lessons.map((lesson) => lesson.slug);
11
+ return manifest.lessons.filter((lesson) => (lesson.editions ?? ["chapter"]).includes("chapter")).map((lesson) => lesson.slug);
12
+ }
13
+ function standaloneDownloads(manifest) {
14
+ return [
15
+ ...standalonePageSlugs(manifest).map((slug) => ({ slug, download: slug })),
16
+ ...(manifest.decks ?? []).map((deck) => ({
17
+ slug: deck.slug,
18
+ download: deckFileStem(deckOwnerSlug(deck, manifest))
19
+ }))
20
+ ];
21
+ }
22
+ function deckFileStem(ownerSlug) {
23
+ return `${ownerSlug}-slides`;
24
+ }
25
+ function deckOwnerSlug(deck, manifest) {
26
+ if (deck.part !== void 0) return deck.part;
27
+ return manifest.lessons.find((lesson) => lesson.id === deck.lessonId)?.slug ?? deck.slug;
28
+ }
29
+ function deckSaveName(ownerSlug) {
30
+ return `${ownerSlug.split("/").at(-1) ?? ownerSlug}-slides`;
12
31
  }
13
32
  function courseFileStem(manifest) {
14
33
  return manifest.about.material.name.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "course";
15
34
  }
16
35
  export {
17
36
  courseFileStem,
37
+ deckFileStem,
38
+ deckOwnerSlug,
39
+ deckSaveName,
18
40
  exportPageRoute,
19
41
  exportPageSlugs,
42
+ standaloneDownloads,
20
43
  standaloneFilename,
21
44
  standalonePageSlugs
22
45
  };
@@ -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,78 @@
1
+ import { ExportLogger } from '../export-chapters.js';
2
+ import { LatexEngine, CompiledLatex, compileLatex } from './compile.js';
3
+ import { SourceFile } from './document.js';
4
+ import '../../manifest.js';
5
+ import '../../../curriculum/validation.js';
6
+ import '../../../reference/consistency.js';
7
+ import '../../../course-config-BLtIdkei.js';
8
+ import '../../../rules/catalog.js';
9
+ import '../../../rules/course-rules.js';
10
+ import '../../collections.js';
11
+ import '../../../reference/types.js';
12
+ import '../../../reference/gate-retired-keys.js';
13
+ import '../../../reference/lint-ignore.js';
14
+ import '../../../reference/equations-validate.js';
15
+ import '../../../reference/tables-validate.js';
16
+ import '../../../reference/keyed-float-validate.js';
17
+ import '../../../reference/figures-validate.js';
18
+ import '../../../reference/diagrams-validate.js';
19
+ import '../../../reference/examples-validate.js';
20
+ import '../../../reference/folds-validate.js';
21
+ import '../../../reference/knowledge-checks-validate.js';
22
+ import '../../../reference/raw-floats-validate.js';
23
+ import '../../../reference/lab-katex-validate.js';
24
+ import '../../../reference/gate-math.js';
25
+ import '../../../reference/mixed-content.js';
26
+ import '../../../reference/gloss.js';
27
+ import '../../../rules/waivers.js';
28
+ import '../../knowledge-version.js';
29
+ import '../../page-versions.js';
30
+ import '../../decks.js';
31
+ import '../../../reference/decks-validate.js';
32
+ import '../../../reference/deck-source.js';
33
+ import '../../lesson-derivation.js';
34
+ import '../static-tree.js';
35
+ import '../html-tree.js';
36
+ import 'parse5';
37
+ import './capture.js';
38
+ import 'node:http';
39
+ import './serialize.js';
40
+
41
+ interface CacheContext {
42
+ readonly engine: LatexEngine['name'];
43
+ /** `texFingerprint`'s description of the TeX installation. */
44
+ readonly fingerprint: string;
45
+ readonly epoch: number;
46
+ readonly salt: string;
47
+ }
48
+ /** The key of one compile: a hex SHA-256 over its context and its files in path order. */
49
+ declare function cacheKey(files: readonly SourceFile[], context: CacheContext): string;
50
+ interface LatexCache {
51
+ readonly directory: string;
52
+ /** The document compiled, or reused from an entry with the same key. */
53
+ compile(files: readonly SourceFile[], options: {
54
+ readonly epoch: number;
55
+ readonly name?: string;
56
+ }): Promise<{
57
+ readonly result: CompiledLatex;
58
+ readonly reused: boolean;
59
+ }>;
60
+ /** Delete the entries no build has used for `KEEP_DAYS`; how many went. */
61
+ prune(now?: Date): Promise<number>;
62
+ }
63
+ declare function openLatexCache(options: {
64
+ readonly directory: string;
65
+ readonly engine: LatexEngine;
66
+ readonly fingerprint: string;
67
+ readonly salt?: string;
68
+ readonly logger?: ExportLogger;
69
+ /** The compile behind a miss; `compileLatex` but in tests. */
70
+ readonly compile?: typeof compileLatex;
71
+ }): LatexCache;
72
+ /**
73
+ * The cache `EXPLICO_LATEX_CACHE` names, or `undefined` when it is unset
74
+ * (today's build: every document compiles) or the directory cannot be made.
75
+ */
76
+ declare function latexCacheFromEnvironment(engine: LatexEngine, logger: ExportLogger): Promise<LatexCache | undefined>;
77
+
78
+ export { type CacheContext, type LatexCache, cacheKey, latexCacheFromEnvironment, openLatexCache };
@@ -0,0 +1,129 @@
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
+ import { compileLatex, texFingerprint } from "./compile.js";
5
+ const FORMAT = "explico-latex-cache 1";
6
+ const KEEP_DAYS = 7;
7
+ const ENTRY = /^[0-9a-f]{64}$/u;
8
+ function cacheKey(files, context) {
9
+ const hash = createHash("sha256");
10
+ for (const line of [FORMAT, context.engine, context.fingerprint, String(context.epoch), context.salt]) {
11
+ hash.update(`${line.length}:${line}
12
+ `);
13
+ }
14
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
15
+ const content = typeof file.content === "string" ? Buffer.from(file.content, "utf8") : file.content;
16
+ hash.update(`${Buffer.byteLength(file.path)}:${file.path}
17
+ ${content.byteLength}
18
+ `);
19
+ hash.update(content);
20
+ }
21
+ return hash.digest("hex");
22
+ }
23
+ function encodeEntry(result) {
24
+ const parts = [result.pdf, Buffer.from(result.aux, "utf8"), Buffer.from(result.log, "utf8")];
25
+ const payload = Buffer.concat(parts);
26
+ const header = {
27
+ pdf: parts[0].byteLength,
28
+ aux: parts[1].byteLength,
29
+ log: parts[2].byteLength,
30
+ sha256: createHash("sha256").update(payload).digest("hex")
31
+ };
32
+ return Buffer.concat([Buffer.from(`${FORMAT}
33
+ ${JSON.stringify(header)}
34
+ `, "utf8"), payload]);
35
+ }
36
+ function decodeEntry(bytes) {
37
+ const formatEnd = bytes.indexOf(10);
38
+ const headerEnd = formatEnd < 0 ? -1 : bytes.indexOf(10, formatEnd + 1);
39
+ if (headerEnd < 0 || bytes.subarray(0, formatEnd).toString("utf8") !== FORMAT) return void 0;
40
+ let header;
41
+ try {
42
+ header = JSON.parse(bytes.subarray(formatEnd + 1, headerEnd).toString("utf8"));
43
+ } catch {
44
+ return void 0;
45
+ }
46
+ const { pdf, aux, log, sha256 } = header;
47
+ if (typeof pdf !== "number" || typeof aux !== "number" || typeof log !== "number") return void 0;
48
+ const payload = bytes.subarray(headerEnd + 1);
49
+ if (payload.byteLength !== pdf + aux + log) return void 0;
50
+ if (createHash("sha256").update(payload).digest("hex") !== sha256) return void 0;
51
+ return {
52
+ pdf: Buffer.from(payload.subarray(0, pdf)),
53
+ aux: payload.subarray(pdf, pdf + aux).toString("utf8"),
54
+ log: payload.subarray(pdf + aux).toString("utf8")
55
+ };
56
+ }
57
+ function openLatexCache(options) {
58
+ const { directory, engine, fingerprint } = options;
59
+ const compile = options.compile ?? compileLatex;
60
+ let warned = false;
61
+ const warnOnce = (message) => {
62
+ if (warned) return;
63
+ warned = true;
64
+ options.logger?.warn(message);
65
+ };
66
+ return {
67
+ directory,
68
+ async compile(files, compileOptions) {
69
+ const key = cacheKey(files, { engine: engine.name, fingerprint, epoch: compileOptions.epoch, salt: options.salt ?? "" });
70
+ const path = join(directory, key);
71
+ const cached = await readFile(path).then(decodeEntry, () => void 0);
72
+ if (cached) {
73
+ const now = /* @__PURE__ */ new Date();
74
+ await utimes(path, now, now).catch(() => void 0);
75
+ return { result: cached, reused: true };
76
+ }
77
+ const result = await compile(files, engine, compileOptions);
78
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
79
+ try {
80
+ await writeFile(temporary, encodeEntry(result));
81
+ await rename(temporary, path);
82
+ } catch (error) {
83
+ await rm(temporary, { force: true });
84
+ warnOnce(
85
+ `LaTeX export: could not store a compile in ${directory} (${error instanceof Error ? error.message : String(error)}); the build goes on without it.`
86
+ );
87
+ }
88
+ return { result, reused: false };
89
+ },
90
+ async prune(now = /* @__PURE__ */ new Date()) {
91
+ const cutoff = now.getTime() - KEEP_DAYS * 24 * 60 * 60 * 1e3;
92
+ let removed = 0;
93
+ for (const name of await readdir(directory).catch(() => [])) {
94
+ if (!ENTRY.test(name) && !/^[0-9a-f]{64}\.tmp-/u.test(name)) continue;
95
+ const path = join(directory, name);
96
+ const modified = await stat(path).then((entry) => entry.mtimeMs, () => void 0);
97
+ if (modified === void 0 || modified >= cutoff) continue;
98
+ await rm(path, { force: true });
99
+ removed += 1;
100
+ }
101
+ return removed;
102
+ }
103
+ };
104
+ }
105
+ async function latexCacheFromEnvironment(engine, logger) {
106
+ const setting = process.env.EXPLICO_LATEX_CACHE?.trim();
107
+ if (!setting) return void 0;
108
+ const directory = resolve(setting);
109
+ try {
110
+ await mkdir(directory, { recursive: true });
111
+ } catch (error) {
112
+ logger.warn(
113
+ `LaTeX export: EXPLICO_LATEX_CACHE names ${directory}, which cannot be made (${error instanceof Error ? error.message : String(error)}); every document compiles.`
114
+ );
115
+ return void 0;
116
+ }
117
+ return openLatexCache({
118
+ directory,
119
+ engine,
120
+ fingerprint: await texFingerprint(engine),
121
+ salt: process.env.EXPLICO_LATEX_CACHE_SALT ?? "",
122
+ logger
123
+ });
124
+ }
125
+ export {
126
+ cacheKey,
127
+ latexCacheFromEnvironment,
128
+ openLatexCache
129
+ };
@@ -1,3 +1,5 @@
1
+ import { Server } from 'node:http';
2
+
1
3
  /**
2
4
  * The capture pass: draw each figure a browser has to draw (a hydrated lab, a
3
5
  * course component's SVG) and print it as a vector PDF for the LaTeX source.
@@ -24,6 +26,8 @@ interface CaptureRequest {
24
26
  readonly route: string;
25
27
  /** Anchor ids of the figures and diagrams to capture (`fig-…`, `dia-…`). */
26
28
  readonly anchors: readonly string[];
29
+ /** This page's date for its figure PDFs, over the capture's `epoch`. */
30
+ readonly epoch?: number;
27
31
  }
28
32
  interface CapturedFigure {
29
33
  /** The figure's picture as a one-page vector PDF. */
@@ -67,6 +71,11 @@ interface PageLike {
67
71
  }
68
72
  /** Playwright's Chromium from the course's own dependencies, or `undefined`. */
69
73
  declare function courseChromium(projectRoot: string): Promise<ChromiumLike | undefined>;
74
+ /** Serve `root` on loopback at `base`; resolves with the origin once listening. */
75
+ declare function serve(root: string, base: string): Promise<{
76
+ origin: string;
77
+ server: Server;
78
+ }>;
70
79
  /** The size a figure's most common label prints at, as `\footnotesize` in the book's 11 pt. */
71
80
  declare const LABEL_PT = 9;
72
81
  /** The book's measure, in PostScript points: the most a figure is set at. */
@@ -103,4 +112,4 @@ interface CaptureOptions {
103
112
  /** Capture every requested figure, one browser for the whole build. */
104
113
  declare function captureFigures(requests: readonly CaptureRequest[], options: CaptureOptions): Promise<CaptureResults>;
105
114
 
106
- export { type CaptureOptions, type CaptureRequest, type CaptureResults, type CapturedFigure, type FigureMeasure, LABEL_PT, captureFigures, courseChromium, figureMeasure, stampPdfDates };
115
+ export { type CaptureOptions, type CaptureRequest, type CaptureResults, type CapturedFigure, type FigureMeasure, LABEL_PT, captureFigures, courseChromium, figureMeasure, serve, stampPdfDates };
@@ -408,7 +408,8 @@ async function captureFigures(requests, options) {
408
408
  options.measure ?? figureMeasure("letter"),
409
409
  options.png ?? false
410
410
  );
411
- const pdf = options.epoch === void 0 ? printed.pdf : stampPdfDates(printed.pdf, options.epoch);
411
+ const epoch = request.epoch ?? options.epoch;
412
+ const pdf = epoch === void 0 ? printed.pdf : stampPdfDates(printed.pdf, epoch);
412
413
  figures.set(anchor, { ...printed, pdf, liveHtml: snapshot.live });
413
414
  }
414
415
  results.set(request.route, figures);
@@ -431,5 +432,6 @@ export {
431
432
  captureFigures,
432
433
  courseChromium,
433
434
  figureMeasure,
435
+ serve,
434
436
  stampPdfDates
435
437
  };
@@ -2,7 +2,7 @@ import { SourceFile } from './document.js';
2
2
  import '../../manifest.js';
3
3
  import '../../../curriculum/validation.js';
4
4
  import '../../../reference/consistency.js';
5
- import '../../../course-config-rRtME5UV.js';
5
+ import '../../../course-config-BLtIdkei.js';
6
6
  import '../../../rules/catalog.js';
7
7
  import '../../../rules/course-rules.js';
8
8
  import '../../collections.js';
@@ -24,6 +24,10 @@ import '../../../reference/mixed-content.js';
24
24
  import '../../../reference/gloss.js';
25
25
  import '../../../rules/waivers.js';
26
26
  import '../../knowledge-version.js';
27
+ import '../../page-versions.js';
28
+ import '../../decks.js';
29
+ import '../../../reference/decks-validate.js';
30
+ import '../../../reference/deck-source.js';
27
31
  import '../../lesson-derivation.js';
28
32
  import '../static-tree.js';
29
33
  import '../html-tree.js';
@@ -53,6 +57,13 @@ interface CompiledLatex {
53
57
  declare function findLatexEngine(): Promise<LatexEngine | {
54
58
  readonly missing: string;
55
59
  }>;
60
+ /**
61
+ * What the compile cache knows of the TeX installation: the engine's and
62
+ * latexmk's banners and the LaTeX kernel's date and patch level. A TeX Live
63
+ * release or a kernel update changes it; a package updated with `tlmgr` does
64
+ * not, which is what `EXPLICO_LATEX_CACHE_SALT` is for.
65
+ */
66
+ declare function texFingerprint(engine: LatexEngine): Promise<string>;
56
67
  /**
57
68
  * The characters a compile could not draw, in first-seen order: LuaLaTeX
58
69
  * leaves a blank where the font has no glyph and only logs `Missing
@@ -70,4 +81,4 @@ declare function compileLatex(files: readonly SourceFile[], engine: LatexEngine,
70
81
  readonly name?: string;
71
82
  }): Promise<CompiledLatex>;
72
83
 
73
- export { type CompiledLatex, type LatexEngine, compileLatex, findLatexEngine, missingCharacters };
84
+ export { type CompiledLatex, type LatexEngine, compileLatex, findLatexEngine, missingCharacters, texFingerprint };
@@ -42,6 +42,16 @@ async function findLatexEngine() {
42
42
  }
43
43
  return requested === "lualatex" ? { name: "lualatex", latexmkOption: "-lualatex" } : { name: "pdflatex", latexmkOption: "-pdf" };
44
44
  }
45
+ async function texFingerprint(engine) {
46
+ const firstLine = (output, pattern) => output.split("\n").find((line) => pattern.test(line))?.trim() ?? "";
47
+ const banner = firstLine((await run(engine.name, ["--version"])).output, /\S/u);
48
+ const latexmk = firstLine((await run("latexmk", ["-v"])).output, /Version/u);
49
+ const kernelFile = (await run("kpsewhich", ["latex.ltx"])).output.trim();
50
+ const kernel = kernelFile ? await readFile(kernelFile, "utf8").catch(() => "") : "";
51
+ const date = /\\edef\\fmtversion\s*\{([^}]*)\}/u.exec(kernel)?.[1] ?? "";
52
+ const patch = /\\def\\patch@level\{([^}]*)\}/u.exec(kernel)?.[1] ?? "";
53
+ return [banner, latexmk, `LaTeX2e ${date} patch level ${patch}`].join("\n");
54
+ }
45
55
  function latexErrors(log) {
46
56
  const lines = log.split("\n");
47
57
  const found = [];
@@ -111,5 +121,6 @@ async function compileLatex(files, engine, options = {}) {
111
121
  export {
112
122
  compileLatex,
113
123
  findLatexEngine,
114
- missingCharacters
124
+ missingCharacters,
125
+ texFingerprint
115
126
  };