explico 0.11.1 → 0.13.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 (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -0,0 +1,329 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import type { Manifest, ManifestLesson, ManifestPart } from '../manifest';
6
+ import { courseFileStem } from './export-pages';
7
+ import {
8
+ consoleLogger,
9
+ diagramsOf,
10
+ figuresOf,
11
+ imageExtension,
12
+ imageFile,
13
+ imagesOf,
14
+ needsCapture,
15
+ prepareChapters,
16
+ readBuiltPage,
17
+ type ExportLogger,
18
+ type PreparedChapters,
19
+ } from './export-chapters';
20
+ import type { CapturedFigure } from './latex/capture';
21
+ import { checkItems } from './latex/document';
22
+ import { serializeMarkdownNodes, serializeMarkdownPage } from './markdown/serialize';
23
+ import type { StaticPage } from './static-tree';
24
+ import { zip, type ZipEntry } from './zip';
25
+
26
+ /**
27
+ * The Markdown export (`lessonExports.markdown`, decision D2): built with the
28
+ * site, from the same reading trees and figure capture as the LaTeX export.
29
+ *
30
+ * - `downloads/<slug>-markdown.zip`: `<name>-markdown/lesson.md` and its
31
+ * `assets/`.
32
+ * - `downloads/part/<dir>-markdown.zip`: `index.md` (the part's page and its
33
+ * contents), one file per chapter, and shared `assets/`.
34
+ * - `downloads/course-markdown.zip`: `index.md` (the course and its
35
+ * contents), each part's page, one file per chapter, and `assets/`.
36
+ *
37
+ * A figure a browser draws is a PNG at two device pixels per CSS pixel; an
38
+ * image is the site's own file; a `<Diagram>` is a PNG with its boxes and
39
+ * arrows listed as text. Links between files of a download are relative.
40
+ */
41
+
42
+ interface Picture {
43
+ readonly path: string;
44
+ readonly content: Uint8Array;
45
+ }
46
+
47
+ /** Every picture a page shows, by float anchor or image `src`, under `assetDir`. */
48
+ async function pagePictures(
49
+ outputRoot: string,
50
+ base: string,
51
+ page: StaticPage,
52
+ captured: ReadonlyMap<string, CapturedFigure> | undefined,
53
+ assetDir: string,
54
+ ): Promise<Map<string, Picture>> {
55
+ const pictures = new Map<string, Picture>();
56
+ const copy = async (key: string, src: string, name: string) => {
57
+ const file = imageFile(outputRoot, base, page.route, src);
58
+ pictures.set(key, { path: `${assetDir}/${name}${extname(file).toLowerCase()}`, content: await readFile(file) });
59
+ };
60
+ for (const figure of figuresOf(page.blocks)) {
61
+ // An image file stays itself, SVG included: Markdown shows it as it is.
62
+ if (figure.picture.kind === 'image' && (!needsCapture(figure) || imageExtension(figure.picture.src) === '.svg')) {
63
+ await copy(figure.anchor, figure.picture.src, figure.anchor);
64
+ continue;
65
+ }
66
+ const png = captured?.get(figure.anchor)?.png;
67
+ if (png) pictures.set(figure.anchor, { path: `${assetDir}/${figure.anchor}.png`, content: png });
68
+ }
69
+ for (const diagram of diagramsOf(page.blocks)) {
70
+ const png = captured?.get(diagram.anchor)?.png;
71
+ if (png) pictures.set(diagram.anchor, { path: `${assetDir}/${diagram.anchor}.png`, content: png });
72
+ }
73
+ for (const image of imagesOf(page.blocks)) {
74
+ if (pictures.has(image.src)) continue;
75
+ const name = basename(new URL(image.src, 'https://x.invalid/').pathname, extname(image.src)) || 'image';
76
+ await copy(image.src, image.src, name);
77
+ }
78
+ return pictures;
79
+ }
80
+
81
+ function twoDigits(value: number): string {
82
+ return String(value).padStart(2, '0');
83
+ }
84
+
85
+ /** A chapter's file in a part's or the course's download: `02-01-projectile-range.md`. */
86
+ function chapterFile(lesson: ManifestLesson): string {
87
+ return `${twoDigits(lesson.part)}-${twoDigits(lesson.chapter)}-${lesson.slug.split('/').at(-1) ?? lesson.slug}.md`;
88
+ }
89
+
90
+ /** A part's page in the course's download: `02-00-projectiles.md`. */
91
+ function partFile(part: ManifestPart): string {
92
+ return `${twoDigits(part.ordinal)}-00-${part.dir}.md`;
93
+ }
94
+
95
+ /** The pages of a download, by route, with the file each is written to. */
96
+ type DocumentFiles = ReadonlyMap<string, string>;
97
+
98
+ /** The same map, seen from one file of the download: its own route maps to `''`. */
99
+ function relativeTo(files: DocumentFiles, route: string): Map<string, string> {
100
+ return new Map([...files].map(([key, file]) => [key, key === route ? '' : file]));
101
+ }
102
+
103
+ export async function writeMarkdownExports(
104
+ directory: URL,
105
+ manifest: Manifest,
106
+ options: {
107
+ readonly logger?: ExportLogger;
108
+ readonly projectRoot?: string;
109
+ readonly prepared?: PreparedChapters;
110
+ } = {},
111
+ ): Promise<void> {
112
+ if (!manifest.lessonExports.markdown) return;
113
+ const logger = options.logger ?? consoleLogger;
114
+ const outputRoot = fileURLToPath(directory);
115
+ const { base, chapters, captures } =
116
+ options.prepared ?? (await prepareChapters(outputRoot, manifest, { logger, projectRoot: options.projectRoot }));
117
+ const canonicalBaseUrl = manifest.lessonExports.canonicalBaseUrl;
118
+ const course = manifest.about.material.name;
119
+ const version = manifest.about.knowledge.version;
120
+ const source = (route: string) => ({
121
+ course,
122
+ version,
123
+ ...(canonicalBaseUrl ? { url: `${canonicalBaseUrl}${route ? `${route}/` : ''}` } : {}),
124
+ });
125
+
126
+ /** One chapter's Markdown and pictures, seen from `documentFiles`. */
127
+ const chapterFiles = async (
128
+ chapter: (typeof chapters)[number],
129
+ file: string,
130
+ assetDir: string,
131
+ documentFiles: DocumentFiles,
132
+ ): Promise<ZipEntry[]> => {
133
+ const { lesson, page } = chapter;
134
+ const pictures = await pagePictures(outputRoot, base, page, captures.get(page.route), assetDir);
135
+ const markdown = serializeMarkdownPage(
136
+ {
137
+ page,
138
+ documentFiles: relativeTo(documentFiles, page.route),
139
+ ...(canonicalBaseUrl ? { canonicalBaseUrl } : {}),
140
+ pictures: new Map([...pictures].map(([key, picture]) => [key, picture.path])),
141
+ checkItems: checkItems(manifest, lesson),
142
+ source: source(page.route),
143
+ },
144
+ { number: `${lesson.part}.${lesson.chapter}` },
145
+ );
146
+ return [
147
+ { path: file, content: markdown },
148
+ ...[...pictures.values()].map((picture) => ({ path: picture.path, content: picture.content })),
149
+ ];
150
+ };
151
+
152
+ const pending: { target: string; content: Uint8Array }[] = [];
153
+ const pack = (target: string, folder: string, entries: readonly ZipEntry[]) => {
154
+ const unique = new Map(entries.map((entry) => [entry.path, entry]));
155
+ pending.push({
156
+ target: join(outputRoot, 'downloads', target),
157
+ content: zip([...unique.values()].map((entry) => ({ path: `${folder}/${entry.path}`, content: entry.content }))),
158
+ });
159
+ };
160
+
161
+ // Each chapter on its own.
162
+ for (const chapter of chapters) {
163
+ const name = chapter.lesson.slug.split('/').at(-1) ?? chapter.lesson.slug;
164
+ pack(
165
+ `${chapter.lesson.slug}-markdown.zip`,
166
+ `${name}-markdown`,
167
+ await chapterFiles(chapter, 'lesson.md', 'assets', new Map([[chapter.page.route, 'lesson.md']])),
168
+ );
169
+ }
170
+
171
+ const byId = new Map(chapters.map((chapter) => [chapter.lesson.id, chapter]));
172
+ const pageSlugs = new Set(manifest.pages.map((page) => page.slug));
173
+ const parts = manifest.parts
174
+ .map((part) => ({
175
+ part,
176
+ chapters: part.lessons.flatMap((id) => (byId.has(id) ? [byId.get(id)!] : [])),
177
+ overview: pageSlugs.has(`${part.dir}/index`) ? `${part.dir}/index` : undefined,
178
+ }))
179
+ .filter((entry) => entry.chapters.length > 0);
180
+
181
+ /** A part's page as Markdown, or its label alone when it has none. */
182
+ const overviewMarkdown = async (
183
+ entry: (typeof parts)[number],
184
+ documentFiles: DocumentFiles,
185
+ assetDir: string,
186
+ ): Promise<{ markdown: string; pictures: ZipEntry[] }> => {
187
+ if (!entry.overview) {
188
+ return { markdown: serializeMarkdownNodes([{ type: 'heading', depth: 1, children: [{ type: 'text', value: entry.part.label }] }]), pictures: [] };
189
+ }
190
+ const { page } = await readBuiltPage(outputRoot, entry.overview);
191
+ const pictures = await pagePictures(outputRoot, base, page, undefined, assetDir);
192
+ const markdown = serializeMarkdownPage(
193
+ {
194
+ page,
195
+ documentFiles: relativeTo(documentFiles, page.route),
196
+ ...(canonicalBaseUrl ? { canonicalBaseUrl } : {}),
197
+ pictures: new Map([...pictures].map(([key, picture]) => [key, picture.path])),
198
+ checkItems: new Map(),
199
+ source: source(page.route),
200
+ },
201
+ { number: `Part ${entry.part.ordinal}:` },
202
+ );
203
+ return { markdown, pictures: [...pictures.values()].map((picture) => ({ path: picture.path, content: picture.content })) };
204
+ };
205
+
206
+ const contents = (entry: (typeof parts)[number], files: DocumentFiles) =>
207
+ entry.chapters.map((chapter) => ({
208
+ type: 'listItem' as const,
209
+ spread: false,
210
+ children: [
211
+ {
212
+ type: 'paragraph' as const,
213
+ children: [
214
+ {
215
+ type: 'link' as const,
216
+ url: files.get(chapter.page.route) ?? '',
217
+ children: [{ type: 'text' as const, value: `${chapter.lesson.part}.${chapter.lesson.chapter} ${chapter.page.title}` }],
218
+ },
219
+ ],
220
+ },
221
+ ],
222
+ }));
223
+
224
+ // Each part: its page as index.md, then its chapters.
225
+ for (const entry of parts) {
226
+ const files: DocumentFiles = new Map([
227
+ ...(entry.overview ? [[entry.part.dir, 'index.md'] as const] : []),
228
+ ...entry.chapters.map((chapter) => [chapter.page.route, chapterFile(chapter.lesson)] as const),
229
+ ]);
230
+ const overview = await overviewMarkdown(entry, files, 'assets/index');
231
+ const index =
232
+ `${overview.markdown}\n` +
233
+ serializeMarkdownNodes([
234
+ { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Contents' }] },
235
+ { type: 'list', ordered: false, spread: false, children: contents(entry, files) },
236
+ ]);
237
+ const entries: ZipEntry[] = [{ path: 'index.md', content: index }, ...overview.pictures];
238
+ for (const chapter of entry.chapters) {
239
+ const file = chapterFile(chapter.lesson);
240
+ entries.push(...(await chapterFiles(chapter, file, `assets/${file.replace(/\.md$/u, '')}`, files)));
241
+ }
242
+ pack(`part/${entry.part.dir}-markdown.zip`, `${entry.part.dir}-markdown`, entries);
243
+ }
244
+
245
+ // The course: index.md, then each part's page and chapters.
246
+ if (parts.length > 0) {
247
+ const files: DocumentFiles = new Map([
248
+ ['', 'index.md'],
249
+ ...parts.flatMap((entry) => [
250
+ ...(entry.overview ? [[entry.part.dir, partFile(entry.part)] as const] : []),
251
+ ...entry.chapters.map((chapter) => [chapter.page.route, chapterFile(chapter.lesson)] as const),
252
+ ]),
253
+ ]);
254
+ const entries: ZipEntry[] = [];
255
+ for (const entry of parts) {
256
+ if (entry.overview) {
257
+ const file = partFile(entry.part);
258
+ const overview = await overviewMarkdown(entry, files, `assets/${file.replace(/\.md$/u, '')}`);
259
+ entries.push({ path: file, content: overview.markdown }, ...overview.pictures);
260
+ }
261
+ for (const chapter of entry.chapters) {
262
+ const file = chapterFile(chapter.lesson);
263
+ entries.push(...(await chapterFiles(chapter, file, `assets/${file.replace(/\.md$/u, '')}`, files)));
264
+ }
265
+ }
266
+ const index = serializeMarkdownNodes([
267
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: course }] },
268
+ {
269
+ type: 'paragraph',
270
+ children: [
271
+ { type: 'text', value: `Content version ${version}.` },
272
+ ...(canonicalBaseUrl
273
+ ? [
274
+ { type: 'text' as const, value: ' Interactive version: ' },
275
+ { type: 'link' as const, url: canonicalBaseUrl, children: [{ type: 'text' as const, value: canonicalBaseUrl }] },
276
+ { type: 'text' as const, value: '.' },
277
+ ]
278
+ : []),
279
+ ],
280
+ },
281
+ ...(manifest.about.material.summary
282
+ ? [{ type: 'paragraph' as const, children: [{ type: 'text' as const, value: manifest.about.material.summary }] }]
283
+ : []),
284
+ { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Contents' }] },
285
+ {
286
+ type: 'list',
287
+ ordered: false,
288
+ spread: false,
289
+ children: parts.map((entry) => ({
290
+ type: 'listItem' as const,
291
+ spread: false,
292
+ children: [
293
+ {
294
+ type: 'paragraph' as const,
295
+ children: entry.overview
296
+ ? [
297
+ {
298
+ type: 'link' as const,
299
+ url: partFile(entry.part),
300
+ children: [{ type: 'text' as const, value: `Part ${entry.part.ordinal}: ${entry.part.label}` }],
301
+ },
302
+ ]
303
+ : [{ type: 'text' as const, value: `Part ${entry.part.ordinal}: ${entry.part.label}` }],
304
+ },
305
+ { type: 'list' as const, ordered: false, spread: false, children: contents(entry, files) },
306
+ ],
307
+ })),
308
+ },
309
+ ]);
310
+ entries.push({ path: 'index.md', content: index });
311
+ pack('course-markdown.zip', `${courseFileStem(manifest)}-markdown`, entries);
312
+ }
313
+
314
+ // Nothing is published until every file is ready.
315
+ try {
316
+ for (const file of pending) {
317
+ await mkdir(dirname(file.target), { recursive: true });
318
+ await writeFile(`${file.target}.tmp-${process.pid}`, file.content);
319
+ }
320
+ for (const file of pending) await rename(`${file.target}.tmp-${process.pid}`, file.target);
321
+ } finally {
322
+ await Promise.all(pending.map((file) => rm(`${file.target}.tmp-${process.pid}`, { force: true })));
323
+ }
324
+ logger.info(
325
+ `Markdown export: ${chapters.length} chapter(s), ${parts.length} part(s)` +
326
+ (parts.length > 0 ? ' and the course' : '') +
327
+ '.',
328
+ );
329
+ }
@@ -9,19 +9,21 @@ import {
9
9
  import { Buffer } from 'node:buffer';
10
10
  import { dirname, extname, join, relative, resolve, sep } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import {
13
- parse,
14
- serialize,
15
- type DefaultTreeAdapterMap,
16
- } from 'parse5';
12
+ import { parse, serialize } from 'parse5';
17
13
 
18
14
  import type { Manifest } from '../manifest';
19
-
20
- type HtmlNode = DefaultTreeAdapterMap['node'];
21
- type HtmlParent = DefaultTreeAdapterMap['parentNode'];
22
- type HtmlChild = DefaultTreeAdapterMap['childNode'];
23
- type HtmlElement = DefaultTreeAdapterMap['element'];
24
- type HtmlText = DefaultTreeAdapterMap['textNode'];
15
+ import { exportPageRoute, standalonePageSlugs } from './export-pages';
16
+ import {
17
+ attribute,
18
+ elements,
19
+ hasClass,
20
+ inferBasePrefix,
21
+ type HtmlChild,
22
+ type HtmlElement,
23
+ type HtmlNode,
24
+ type HtmlParent,
25
+ type HtmlText,
26
+ } from './html-tree';
25
27
 
26
28
  const HTML_NAMESPACE =
27
29
  'http://www.w3.org/1999/xhtml' as HtmlElement['namespaceURI'];
@@ -115,11 +117,12 @@ const STANDALONE_THEME_SCRIPT = `(() => {
115
117
  apply('auto');
116
118
  })();`;
117
119
 
118
- /** Inputs for producing one standalone lesson from Astro's final HTML. */
120
+ /** Inputs for producing one standalone page from Astro's final HTML. */
119
121
  export interface StandaloneLessonInput {
120
122
  readonly html: string;
121
123
  readonly sourceFile: string;
122
124
  readonly outputRoot: string;
125
+ /** The page's docs slug: a chapter's, or `index`, `motion/index`, `glossary`. */
123
126
  readonly lessonSlug: string;
124
127
  readonly canonicalBaseUrl?: string;
125
128
  }
@@ -143,45 +146,12 @@ interface ExportContext {
143
146
  readonly assetData: Map<string, string>;
144
147
  }
145
148
 
146
- function isElement(node: HtmlNode): node is HtmlElement {
147
- return 'tagName' in node;
148
- }
149
-
150
- function childrenOf(node: HtmlNode): readonly HtmlChild[] {
151
- return 'childNodes' in node ? node.childNodes : [];
152
- }
153
-
154
- function walk(node: HtmlNode, visit: (node: HtmlNode) => void): void {
155
- visit(node);
156
- for (const child of childrenOf(node)) walk(child, visit);
157
- if (isElement(node) && node.tagName === 'template') {
158
- const template = node as DefaultTreeAdapterMap['template'];
159
- for (const child of template.content.childNodes) walk(child, visit);
160
- }
161
- }
162
-
163
- function elements(root: HtmlNode, predicate?: (node: HtmlElement) => boolean): HtmlElement[] {
164
- const found: HtmlElement[] = [];
165
- walk(root, (node) => {
166
- if (isElement(node) && (predicate?.(node) ?? true)) found.push(node);
167
- });
168
- return found;
169
- }
170
-
171
149
  function firstElement(root: HtmlNode, tagName: string): HtmlElement {
172
150
  const found = elements(root, (node) => node.tagName === tagName)[0];
173
151
  if (!found) throw new Error(`Standalone HTML source has no <${tagName}> element.`);
174
152
  return found;
175
153
  }
176
154
 
177
- function attribute(node: HtmlElement, name: string): string | undefined {
178
- return node.attrs.find((entry) => entry.name === name)?.value;
179
- }
180
-
181
- function hasClass(node: HtmlElement, name: string): boolean {
182
- return (attribute(node, 'class') ?? '').split(/\s+/u).includes(name);
183
- }
184
-
185
155
  function setAttribute(node: HtmlElement, name: string, value = ''): void {
186
156
  const existing = node.attrs.find((entry) => entry.name === name);
187
157
  if (existing) existing.value = value;
@@ -489,7 +459,7 @@ async function bundleBrowserGraph(
489
459
  });
490
460
  const javascript = result.outputFiles.find((file) => file.path.endsWith('.js'));
491
461
  if (!javascript) {
492
- throw new Error('Browser bundler produced no JavaScript for the standalone lesson.');
462
+ throw new Error('Browser bundler produced no JavaScript for the standalone page.');
493
463
  }
494
464
  const unsupported = result.outputFiles.filter(
495
465
  (file) => !file.path.endsWith('.js') && !file.path.endsWith('.css'),
@@ -509,18 +479,6 @@ async function bundleBrowserGraph(
509
479
  };
510
480
  }
511
481
 
512
- function inferBasePrefix(document: HtmlNode): string {
513
- for (const node of elements(document)) {
514
- for (const name of ['href', 'src', 'component-url', 'renderer-url']) {
515
- const value = attribute(node, name);
516
- if (!value?.startsWith('/')) continue;
517
- const marker = value.indexOf('/astro-assets/');
518
- if (marker !== -1) return `${value.slice(0, marker)}/`;
519
- }
520
- }
521
- return '/';
522
- }
523
-
524
482
  function normalizedPagePath(pathname: string): string {
525
483
  return pathname.replace(/\/index\.html$/iu, '/').replace(/\.html$/iu, '').replace(/\/+$/u, '');
526
484
  }
@@ -538,14 +496,17 @@ function rewriteCourseLink(
538
496
  throw new Error(`Standalone HTML cannot publish local navigation URL ${JSON.stringify(href)}.`);
539
497
  }
540
498
 
541
- const pagePath = `${basePrefix}${lessonSlug}/`.replace(/\/{2,}/gu, '/');
499
+ const pagePath = `${basePrefix}${exportPageRoute(lessonSlug)}/`.replace(
500
+ /\/{2,}/gu,
501
+ '/',
502
+ );
542
503
  const resolved = new URL(trimmed, `https://explico.invalid${pagePath}`);
543
504
  if (normalizedPagePath(resolved.pathname) === normalizedPagePath(pagePath)) {
544
505
  return `${resolved.search}${resolved.hash}` || '#_top';
545
506
  }
546
507
  if (!canonicalBaseUrl) {
547
508
  throw new Error(
548
- `Lesson ${lessonSlug} links outside itself (${JSON.stringify(href)}), so ` +
509
+ `Page ${lessonSlug} links outside itself (${JSON.stringify(href)}), so ` +
549
510
  '`lessonExports.canonicalBaseUrl` is required for standalone HTML.',
550
511
  );
551
512
  }
@@ -900,14 +861,14 @@ function auditStandaloneHtml(html: string, lessonSlug: string): void {
900
861
  }
901
862
  }
902
863
 
903
- /** Build one fully inlined lesson document without writing it. */
864
+ /** Build one fully inlined page document without writing it. */
904
865
  export async function buildStandaloneLessonHtml(
905
866
  input: StandaloneLessonInput,
906
867
  ): Promise<string> {
907
868
  const outputRoot = resolve(input.outputRoot);
908
869
  const sourceFile = resolve(input.sourceFile);
909
870
  if (!insideRoot(sourceFile, outputRoot)) {
910
- throw new Error('Standalone lesson source must be inside Astro build output.');
871
+ throw new Error('Standalone page source must be inside Astro build output.');
911
872
  }
912
873
  const context: ExportContext = {
913
874
  outputRoot,
@@ -936,8 +897,11 @@ export async function buildStandaloneLessonHtml(
936
897
  return html;
937
898
  }
938
899
 
939
- async function lessonSourceFile(outputRoot: string, slug: string): Promise<string> {
940
- const candidates = [join(outputRoot, slug, 'index.html'), join(outputRoot, `${slug}.html`)];
900
+ /** Astro's built HTML file of a docs slug. */
901
+ export async function pageSourceFile(outputRoot: string, slug: string): Promise<string> {
902
+ const route = exportPageRoute(slug);
903
+ const candidates = [join(outputRoot, route, 'index.html')];
904
+ if (route) candidates.push(join(outputRoot, `${route}.html`));
941
905
  for (const candidate of candidates) if (await isFile(candidate)) return candidate;
942
906
  throw new Error(
943
907
  `Standalone HTML could not find Astro's built page for ${slug}; tried ` +
@@ -945,7 +909,12 @@ async function lessonSourceFile(outputRoot: string, slug: string): Promise<strin
945
909
  );
946
910
  }
947
911
 
948
- /** Emit all opt-in lesson HTML artifacts from Astro's completed static output. */
912
+ /**
913
+ * Emit the opt-in standalone HTML artifact of every chapter
914
+ * (`standalonePageSlugs`) from Astro's completed static output, at
915
+ * `downloads/<docs slug>.html`. A page that is not a chapter is in its
916
+ * part's and the course's files (`combined-html.ts`).
917
+ */
949
918
  export async function writeStandaloneLessonExports(
950
919
  directory: URL,
951
920
  manifest: Manifest,
@@ -953,9 +922,9 @@ export async function writeStandaloneLessonExports(
953
922
  if (!manifest.lessonExports.standaloneHtml) return;
954
923
  const outputRoot = fileURLToPath(directory);
955
924
  const pending: { target: string; temporary: string; html: string }[] = [];
956
- for (const lesson of manifest.lessons) {
957
- const sourceFile = await lessonSourceFile(outputRoot, lesson.slug);
958
- const target = join(outputRoot, 'downloads', `${lesson.slug}.html`);
925
+ for (const slug of standalonePageSlugs(manifest)) {
926
+ const sourceFile = await pageSourceFile(outputRoot, slug);
927
+ const target = join(outputRoot, 'downloads', `${slug}.html`);
959
928
  pending.push({
960
929
  target,
961
930
  temporary: `${target}.tmp-${process.pid}`,
@@ -963,13 +932,13 @@ export async function writeStandaloneLessonExports(
963
932
  html: await readFile(sourceFile, 'utf8'),
964
933
  sourceFile,
965
934
  outputRoot,
966
- lessonSlug: lesson.slug,
935
+ lessonSlug: slug,
967
936
  canonicalBaseUrl: manifest.lessonExports.canonicalBaseUrl,
968
937
  }),
969
938
  });
970
939
  }
971
940
 
972
- // Nothing is published until every lesson has passed the asset audit.
941
+ // Nothing is published until every page has passed the asset audit.
973
942
  for (const artifact of pending) {
974
943
  await mkdir(dirname(artifact.target), { recursive: true });
975
944
  await writeFile(artifact.temporary, artifact.html, 'utf8');