explico 0.12.0 → 0.13.1

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 (279) hide show
  1. package/CHANGELOG.md +1137 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +4 -3
  4. package/dist/compiler/astro.js +45 -15
  5. package/dist/compiler/cli.d.ts +26 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +9 -1
  8. package/dist/compiler/collections.js +55 -16
  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 +14 -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 +87 -6
  51. package/dist/compiler/manifest.js +174 -31
  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 +2 -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 +2 -2
  61. package/dist/reference/base-operators.mjs +2 -2
  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/figures-validate.d.ts +1 -1
  88. package/dist/reference/gate-abbreviations.d.ts +1 -1
  89. package/dist/reference/gate-abbreviations.js +1 -1
  90. package/dist/reference/gate-course-rules.d.ts +4 -3
  91. package/dist/reference/gate-course-rules.js +9 -4
  92. package/dist/reference/gate-heading-length.d.ts +2 -0
  93. package/dist/reference/gate-heading-length.js +7 -5
  94. package/dist/reference/gate-heading-structure.js +26 -0
  95. package/dist/reference/gate-indices.d.ts +35 -0
  96. package/dist/reference/gate-indices.js +299 -0
  97. package/dist/reference/gate-math.d.ts +32 -2
  98. package/dist/reference/gate-math.js +68 -12
  99. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  100. package/dist/reference/gate-mdx-expression.js +29 -0
  101. package/dist/reference/gate-part-pages.d.ts +1 -1
  102. package/dist/reference/gate-prop-text.d.ts +28 -0
  103. package/dist/reference/gate-prop-text.js +106 -0
  104. package/dist/reference/gate-prose-notation.d.ts +3 -1
  105. package/dist/reference/gate-prose-notation.js +4 -2
  106. package/dist/reference/gate-retired-keys.d.ts +35 -0
  107. package/dist/reference/gate-retired-keys.js +58 -0
  108. package/dist/reference/gloss.d.ts +2 -0
  109. package/dist/reference/katex-measure.d.ts +17 -0
  110. package/dist/reference/katex-measure.js +223 -0
  111. package/dist/reference/keyed-float-validate.js +12 -0
  112. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  113. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  114. package/dist/reference/lab-math-scope.d.ts +1 -0
  115. package/dist/reference/lab-math-scope.js +13 -2
  116. package/dist/reference/math-glyphs.d.mts +94 -2
  117. package/dist/reference/math-glyphs.mjs +851 -46
  118. package/dist/reference/mixed-content.d.ts +8 -3
  119. package/dist/reference/mixed-content.js +4 -2
  120. package/dist/reference/page-notation.d.ts +34 -4
  121. package/dist/reference/page-notation.js +54 -2
  122. package/dist/reference/references.d.ts +11 -1
  123. package/dist/reference/references.js +6 -0
  124. package/dist/reference/registry.js +104 -10
  125. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  126. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  127. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  128. package/dist/reference/remark-citation.mjs +0 -0
  129. package/dist/reference/remark-notation.mjs +84 -20
  130. package/dist/reference/render-lab-math.d.ts +2 -0
  131. package/dist/reference/render-lab-math.js +17 -5
  132. package/dist/reference/sections-validate.d.ts +28 -3
  133. package/dist/reference/sections-validate.js +31 -4
  134. package/dist/reference/sections.d.mts +24 -6
  135. package/dist/reference/sections.mjs +92 -17
  136. package/dist/reference/tables-validate.d.ts +1 -1
  137. package/dist/reference/tables-validate.js +24 -1
  138. package/dist/reference/text-measure.d.ts +10 -0
  139. package/dist/reference/text-measure.js +41 -0
  140. package/dist/reference/text-widths.d.ts +10 -0
  141. package/dist/reference/text-widths.js +1238 -0
  142. package/dist/reference/types.d.ts +64 -8
  143. package/dist/rules/catalog.js +191 -30
  144. package/dist/rules/in-effect.d.ts +1 -1
  145. package/dist/rules/rules.schema.json +79 -24
  146. package/dist/rules/settings.d.ts +1 -1
  147. package/dist/rules/settings.js +7 -0
  148. package/dist/session/PreferenceStore.d.ts +18 -1
  149. package/dist/session/PreferenceStore.js +29 -1
  150. package/docs/authoring/README.md +32 -21
  151. package/docs/authoring/agents.md +38 -7
  152. package/docs/authoring/components.md +111 -6
  153. package/docs/authoring/migrating.md +707 -0
  154. package/docs/authoring/notation.md +281 -29
  155. package/docs/authoring/retired.md +1 -0
  156. package/docs/authoring/structure-rules.md +10 -41
  157. package/docs/authoring/structure.md +53 -2
  158. package/docs/authoring/visualizations.md +144 -18
  159. package/package.json +12 -7
  160. package/src/compiler/astro.ts +57 -17
  161. package/src/compiler/cli.ts +143 -35
  162. package/src/compiler/collections.ts +92 -19
  163. package/src/compiler/content-schema.ts +108 -0
  164. package/src/compiler/content-schemas.ts +391 -0
  165. package/src/compiler/course-config.ts +86 -15
  166. package/src/compiler/exports/combined-html.ts +669 -0
  167. package/src/compiler/exports/download-index.ts +36 -0
  168. package/src/compiler/exports/export-chapters.ts +233 -0
  169. package/src/compiler/exports/export-pages.ts +44 -0
  170. package/src/compiler/exports/html-tree.ts +62 -0
  171. package/src/compiler/exports/latex/capture.ts +651 -0
  172. package/src/compiler/exports/latex/compile.ts +168 -0
  173. package/src/compiler/exports/latex/document.ts +479 -0
  174. package/src/compiler/exports/latex/explico.cls +415 -0
  175. package/src/compiler/exports/latex/numbering.ts +74 -0
  176. package/src/compiler/exports/latex/serialize.ts +787 -0
  177. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  178. package/src/compiler/exports/latex-export.ts +255 -0
  179. package/src/compiler/exports/markdown/serialize.ts +632 -0
  180. package/src/compiler/exports/markdown-export.ts +329 -0
  181. package/src/compiler/exports/standalone-html.ts +39 -70
  182. package/src/compiler/exports/static-tree.ts +944 -0
  183. package/src/compiler/exports/zip.ts +88 -0
  184. package/src/compiler/lesson-derivation.ts +13 -8
  185. package/src/compiler/manifest.ts +299 -28
  186. package/src/compiler/render-diagnostics.ts +111 -0
  187. package/src/components/HoverPanel.astro +204 -6
  188. package/src/components/PartOverview.astro +12 -9
  189. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  190. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  191. package/src/components/assessments/AssessmentSet.astro +14 -1
  192. package/src/components/block-heading.ts +38 -0
  193. package/src/components/card-arrow.ts +18 -4
  194. package/src/components/card-carry.ts +406 -0
  195. package/src/components/card-drag.ts +1 -30
  196. package/src/components/card-fetch.ts +87 -0
  197. package/src/components/card-manager.ts +1222 -38
  198. package/src/components/card-memory.ts +5 -1
  199. package/src/components/card-placement.ts +211 -13
  200. package/src/components/card-side.ts +281 -31
  201. package/src/components/citation/CitationLayer.astro +45 -40
  202. package/src/components/diagram/Diagram.astro +123 -44
  203. package/src/components/figure/Figure.astro +11 -0
  204. package/src/components/figure/FloatCaption.astro +4 -17
  205. package/src/components/float-toolbar.ts +274 -0
  206. package/src/components/memo/Memo.astro +21 -10
  207. package/src/components/memo/ShortNote.astro +4 -0
  208. package/src/components/notation/NotationGlossary.astro +22 -17
  209. package/src/components/notation/NotationLayer.astro +321 -79
  210. package/src/components/notation/NotationMath.astro +8 -1
  211. package/src/components/notation/render-notation-math.ts +51 -5
  212. package/src/components/readable-text.ts +25 -0
  213. package/src/components/reference/FloatRefLayer.astro +162 -89
  214. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  215. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  216. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  217. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  218. package/src/components/starlight/LessonExportActions.astro +384 -310
  219. package/src/components/starlight/LessonFooter.astro +47 -9
  220. package/src/components/starlight/PrintController.astro +28 -0
  221. package/src/components/starlight/TextSizeControl.astro +137 -0
  222. package/src/components/starlight/print-controller.ts +380 -0
  223. package/src/components/starlight/section-summary.ts +13 -5
  224. package/src/components/table/Table.astro +13 -21
  225. package/src/components/table/table-truncation.ts +15 -0
  226. package/src/content-config.ts +20 -312
  227. package/src/curriculum/validation.ts +72 -24
  228. package/src/reference/base-abbreviations.mjs +2 -2
  229. package/src/reference/base-operators.mjs +2 -2
  230. package/src/reference/base-roman-labels.mjs +4 -3
  231. package/src/reference/citation-format.mjs +74 -0
  232. package/src/reference/consistency.ts +47 -61
  233. package/src/reference/curriculum-alignment.ts +4 -41
  234. package/src/reference/derived-entries.ts +68 -0
  235. package/src/reference/derived-notation.mjs +121 -0
  236. package/src/reference/diagram-fit.ts +104 -0
  237. package/src/reference/diagram-layout.ts +182 -51
  238. package/src/reference/diagrams-validate.ts +2 -1
  239. package/src/reference/equations-validate.ts +41 -2
  240. package/src/reference/equations.mjs +120 -22
  241. package/src/reference/examples-validate.ts +1 -0
  242. package/src/reference/figure-fit.ts +139 -0
  243. package/src/reference/figure-pictures.ts +96 -0
  244. package/src/reference/figures-validate.ts +2 -1
  245. package/src/reference/gate-abbreviations.ts +1 -1
  246. package/src/reference/gate-course-rules.ts +22 -7
  247. package/src/reference/gate-heading-length.ts +25 -7
  248. package/src/reference/gate-heading-structure.ts +40 -1
  249. package/src/reference/gate-indices.ts +470 -0
  250. package/src/reference/gate-math.ts +102 -13
  251. package/src/reference/gate-mdx-expression.ts +45 -0
  252. package/src/reference/gate-prop-text.ts +145 -0
  253. package/src/reference/gate-prose-notation.ts +19 -5
  254. package/src/reference/gate-retired-keys.ts +101 -0
  255. package/src/reference/gloss.ts +2 -0
  256. package/src/reference/katex-measure.ts +384 -0
  257. package/src/reference/keyed-float-validate.ts +22 -0
  258. package/src/reference/keyed-jsx-floats.mjs +11 -14
  259. package/src/reference/lab-math-scope.ts +15 -1
  260. package/src/reference/math-glyphs.mjs +851 -46
  261. package/src/reference/mixed-content.ts +11 -2
  262. package/src/reference/page-notation.ts +100 -3
  263. package/src/reference/references.ts +14 -0
  264. package/src/reference/registry.ts +154 -8
  265. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  266. package/src/reference/rehype-heading-anchors.mjs +11 -1
  267. package/src/reference/remark-citation.mjs +0 -0
  268. package/src/reference/remark-notation.mjs +84 -20
  269. package/src/reference/render-lab-math.ts +29 -5
  270. package/src/reference/sections-validate.ts +67 -3
  271. package/src/reference/sections.mjs +92 -17
  272. package/src/reference/tables-validate.ts +40 -2
  273. package/src/reference/text-measure.ts +63 -0
  274. package/src/reference/text-widths.ts +133 -0
  275. package/src/reference/types.ts +75 -8
  276. package/src/rules/catalog.ts +205 -29
  277. package/src/rules/settings.ts +10 -0
  278. package/src/session/PreferenceStore.ts +53 -0
  279. package/src/styles/global.css +291 -75
@@ -1,42 +1,107 @@
1
1
  ---
2
+ /**
3
+ * The page's export actions in the header's right cluster: a Download button
4
+ * that opens a menu of the page's downloads, and a Print button.
5
+ *
6
+ * **Download.** The menu is a native popover (Escape and an outside click
7
+ * close it; it sits in the top layer, above explico's cards). Its rows are
8
+ * the scopes that contain the page, its columns the formats, and each cell a
9
+ * plain link to a file the build wrote. The build lists what it wrote, with
10
+ * sizes, in `downloads/index.json`; the menu reads it the first time it
11
+ * opens, shows each size and hides a cell whose file is not there. During
12
+ * `astro dev` only a chapter's HTML builds on request; the others say they
13
+ * are built when the site is deployed.
14
+ *
15
+ * **Print.** The button loads every lab that will be on paper before the
16
+ * print dialog opens — the browser's own `beforeprint` cannot wait for that —
17
+ * and Ctrl+P / Cmd+P do the same. The browser's own File → Print still gets
18
+ * the print-as-shown preparation, without loading labs.
19
+ *
20
+ * A downloaded page carries none of this markup (`data-explico-export-chrome`);
21
+ * the print controller itself (`print-controller.ts`) is installed from the
22
+ * page's footer too (`PrintController.astro`), so Ctrl+P prepares a
23
+ * downloaded page the same way.
24
+ */
25
+ export type DownloadFormat = 'html' | 'pdf' | 'latex' | 'markdown';
26
+
27
+ export interface DownloadCell {
28
+ readonly format: DownloadFormat;
29
+ /** Path below `downloads/`, as `downloads/index.json` lists it. */
30
+ readonly path: string;
31
+ /** The name the file is saved under. */
32
+ readonly filename: string;
33
+ }
34
+
35
+ export type DownloadScope = 'chapter' | 'part' | 'course';
36
+
37
+ export interface DownloadRow {
38
+ readonly scope: DownloadScope;
39
+ /** "This chapter", "This part", "Whole course". */
40
+ readonly label: string;
41
+ /** The chapter's title, the part's label or the course's name. */
42
+ readonly title: string;
43
+ readonly cells: readonly DownloadCell[];
44
+ }
45
+
2
46
  interface Props {
3
- standaloneDownload?: {
4
- readonly href: string;
5
- readonly filename: string;
6
- };
47
+ downloads: readonly DownloadRow[];
7
48
  print: boolean;
8
- canonicalBaseUrl?: string;
9
49
  siteBaseUrl: string;
10
50
  }
11
51
 
12
- const {
13
- standaloneDownload,
14
- print,
15
- canonicalBaseUrl,
16
- siteBaseUrl,
17
- } = Astro.props;
52
+ const { downloads, print, siteBaseUrl } = Astro.props;
53
+
54
+ // Starlight renders the header's icon cluster twice (the header and the
55
+ // mobile menu), so each menu needs an id of its own for `popovertarget`.
56
+ const locals = Astro.locals as { explicoDownloadMenus?: number };
57
+ locals.explicoDownloadMenus = (locals.explicoDownloadMenus ?? 0) + 1;
58
+ const menuId = `explico-downloads-${locals.explicoDownloadMenus}`;
59
+
60
+ const base = siteBaseUrl.replace(/\/$/u, '');
61
+ const development = import.meta.env.DEV;
62
+ /** What a download's accessible name calls each scope. */
63
+ const SCOPE_NAMES: Readonly<Record<DownloadScope, string>> = {
64
+ chapter: 'this chapter',
65
+ part: 'this part',
66
+ course: 'the whole course',
67
+ };
68
+ const FORMATS: readonly { readonly format: DownloadFormat; readonly label: string; readonly name: string }[] = [
69
+ { format: 'html', label: 'HTML', name: 'as standalone HTML' },
70
+ { format: 'pdf', label: 'PDF', name: 'as PDF' },
71
+ { format: 'latex', label: 'LaTeX', name: 'as LaTeX source' },
72
+ { format: 'markdown', label: 'Markdown', name: 'as Markdown' },
73
+ ];
74
+ const columns = FORMATS.filter(({ format }) =>
75
+ downloads.some((row) => row.cells.some((cell) => cell.format === format)),
76
+ );
77
+ const notes = [
78
+ columns.some((column) => column.format === 'html') && 'HTML opens offline with working figures.',
79
+ columns.some((column) => column.format === 'pdf') &&
80
+ (columns.some((column) => column.format === 'latex')
81
+ ? 'PDF is typeset for print, and LaTeX is its source.'
82
+ : 'PDF is typeset for print.'),
83
+ columns.some((column) => column.format === 'markdown') && 'Markdown is plain text with images.',
84
+ ].filter(Boolean);
18
85
  ---
19
86
 
20
87
  <nav
21
88
  class="lesson-export-actions not-content"
22
- aria-label="Lesson exports"
89
+ aria-label="Page exports"
23
90
  data-explico-export-chrome="lesson-actions"
24
- data-explico-print-controller={print ? '' : undefined}
25
- data-canonical-base-url={canonicalBaseUrl}
26
- data-site-base-url={siteBaseUrl}
27
91
  >
28
92
  {
29
- standaloneDownload && (
30
- <a
31
- href={standaloneDownload.href}
32
- download={standaloneDownload.filename}
33
- aria-label="Download this lesson as standalone HTML"
34
- title="Download this lesson as standalone HTML"
93
+ downloads.length > 0 && (
94
+ <button
95
+ type="button"
96
+ class="lesson-export-actions__download"
97
+ popovertarget={menuId}
98
+ aria-label="Download this page"
99
+ title="Download this page"
35
100
  >
36
101
  <svg viewBox="0 0 24 24" aria-hidden="true">
37
102
  <path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14" />
38
103
  </svg>
39
- </a>
104
+ </button>
40
105
  )
41
106
  }
42
107
  {
@@ -44,8 +109,8 @@ const {
44
109
  <button
45
110
  type="button"
46
111
  data-explico-print-action
47
- aria-label="Print or save this lesson as PDF"
48
- title="Print or save this lesson as PDF"
112
+ aria-label="Print or save this page as PDF"
113
+ title="Print or save this page as PDF"
49
114
  >
50
115
  <svg viewBox="0 0 24 24" aria-hidden="true">
51
116
  <path d="M7 9V3h10v6M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2M7 14h10v7H7z" />
@@ -53,15 +118,87 @@ const {
53
118
  </button>
54
119
  )
55
120
  }
121
+ {
122
+ downloads.length > 0 && (
123
+ <div
124
+ id={menuId}
125
+ class="explico-downloads"
126
+ popover
127
+ role="dialog"
128
+ aria-label="Downloads"
129
+ data-explico-downloads
130
+ data-index-href={`${base}/downloads/index.json`}
131
+ >
132
+ <p class="explico-downloads__title">Download</p>
133
+ <table class="explico-downloads__grid">
134
+ <thead>
135
+ <tr>
136
+ <th scope="col"><span class="sr-only">Scope</span></th>
137
+ {columns.map((column) => <th scope="col">{column.label}</th>)}
138
+ </tr>
139
+ </thead>
140
+ <tbody>
141
+ {downloads.map((row) => (
142
+ <tr>
143
+ <th scope="row">
144
+ {row.label}
145
+ <span class="explico-downloads__scope">{row.title}</span>
146
+ </th>
147
+ {columns.map((column) => {
148
+ const cell = row.cells.find((entry) => entry.format === column.format);
149
+ if (!cell) return <td><span class="explico-downloads__none" aria-hidden="true">—</span></td>;
150
+ const name = `Download ${SCOPE_NAMES[row.scope]} ${column.name}`;
151
+ // `astro dev` builds a chapter's HTML on request; the rest
152
+ // exists only once the site is built.
153
+ if (development && !(row.scope === 'chapter' && column.format === 'html')) {
154
+ return (
155
+ <td>
156
+ <span
157
+ class="explico-downloads__cell explico-downloads__cell--pending"
158
+ title="Built when the site is deployed"
159
+ >
160
+ <span class="explico-downloads__format">{column.label}</span>
161
+ <span class="explico-downloads__size">not in dev</span>
162
+ </span>
163
+ </td>
164
+ );
165
+ }
166
+ return (
167
+ <td>
168
+ <a
169
+ class="explico-downloads__cell"
170
+ href={`${base}/downloads/${cell.path}`}
171
+ download={cell.filename}
172
+ aria-label={name}
173
+ title={name}
174
+ data-download-path={cell.path}
175
+ >
176
+ <span class="explico-downloads__format">{column.label}</span>
177
+ <span class="explico-downloads__size" data-download-size>
178
+ <span aria-hidden="true">↓</span>
179
+ </span>
180
+ </a>
181
+ </td>
182
+ );
183
+ })}
184
+ </tr>
185
+ ))}
186
+ </tbody>
187
+ </table>
188
+ {notes.length > 0 && <p class="explico-downloads__note">{notes.join(' ')}</p>}
189
+ </div>
190
+ )
191
+ }
56
192
  </nav>
57
193
 
58
194
  <style>
59
195
  .lesson-export-actions {
60
196
  display: inline-flex;
197
+ gap: 0.4rem;
61
198
  }
62
199
 
63
- .lesson-export-actions a,
64
- .lesson-export-actions button {
200
+ .lesson-export-actions > a,
201
+ .lesson-export-actions > button {
65
202
  align-items: center;
66
203
  background: transparent;
67
204
  border: 1px solid var(--sl-color-gray-5);
@@ -87,8 +224,9 @@ const {
87
224
  width: 1rem;
88
225
  }
89
226
 
90
- .lesson-export-actions :is(a, button):hover,
91
- .lesson-export-actions :is(a, button):focus-visible {
227
+ .lesson-export-actions > :is(a, button):hover,
228
+ .lesson-export-actions > :is(a, button):focus-visible,
229
+ .lesson-export-actions__download[aria-expanded='true'] {
92
230
  border-color: var(--sl-color-accent);
93
231
  color: var(--sl-color-text-accent);
94
232
  }
@@ -97,264 +235,152 @@ const {
97
235
  outline: 2px solid var(--sl-color-accent);
98
236
  outline-offset: 2px;
99
237
  }
100
- </style>
101
238
 
102
- <script>
103
- import {
104
- prepareDisclosures,
105
- type PreparedDisclosures,
106
- } from '../disclosure/print-as-shown';
107
-
108
- type SavedLink = {
109
- readonly element: HTMLAnchorElement;
110
- readonly href: string | null;
111
- };
112
-
113
- type SavedHidden = {
114
- readonly element: HTMLElement;
115
- readonly hidden: boolean;
116
- };
117
-
118
- type StaticExportMode = 'print' | 'markdown';
119
-
120
- type PreparedStaticExport = {
121
- readonly mode: StaticExportMode;
122
- readonly disclosures: PreparedDisclosures;
123
- readonly hidden: readonly SavedHidden[];
124
- readonly links: readonly SavedLink[];
125
- };
126
-
127
- const root = document.documentElement;
128
- let prepared: PreparedStaticExport | null = null;
129
- let pendingMarkerCleanup: (() => void) | null = null;
130
-
131
- function controller(): HTMLElement | null {
132
- return document.querySelector<HTMLElement>('[data-explico-print-controller]');
239
+ /* The popover is placed under its button by the script; these undo the
240
+ user agent's centred placement. */
241
+ .explico-downloads {
242
+ background: var(--sl-color-bg-nav, var(--sl-color-bg));
243
+ border: 1px solid var(--sl-color-gray-5);
244
+ border-radius: 0.6rem;
245
+ box-shadow: var(--sl-shadow-md);
246
+ box-sizing: border-box;
247
+ color: var(--sl-color-text);
248
+ inset: auto;
249
+ margin: 0;
250
+ max-width: calc(100vw - 1rem);
251
+ overflow-y: auto;
252
+ overscroll-behavior: contain;
253
+ padding: 0.85rem 0.85rem 0.75rem;
254
+ position: fixed;
255
+ width: max-content;
133
256
  }
134
257
 
135
- function canonicalPrintHref(
136
- link: HTMLAnchorElement,
137
- canonicalBaseUrl: string | undefined,
138
- siteBaseUrl: string,
139
- ): string | undefined {
140
- const rawHref = link.getAttribute('href');
141
- if (!rawHref || rawHref.startsWith('#') || link.hasAttribute('download')) {
142
- return undefined;
143
- }
258
+ .explico-downloads__title {
259
+ font-size: var(--sl-text-sm);
260
+ font-weight: 600;
261
+ margin: 0 0 0.5rem;
262
+ }
144
263
 
145
- let target: URL;
146
- try {
147
- target = new URL(rawHref, location.href);
148
- } catch {
149
- return undefined;
150
- }
151
- if (target.protocol !== 'http:' && target.protocol !== 'https:') return undefined;
152
-
153
- const current = new URL(location.href);
154
- if (
155
- target.origin === current.origin &&
156
- target.pathname === current.pathname &&
157
- target.search === current.search
158
- ) {
159
- return undefined;
160
- }
264
+ .explico-downloads__grid {
265
+ border-collapse: collapse;
266
+ font-size: var(--sl-text-xs);
267
+ }
161
268
 
162
- return canonicalUrl(target, canonicalBaseUrl, siteBaseUrl);
269
+ .explico-downloads__grid th,
270
+ .explico-downloads__grid td {
271
+ padding: 0.35rem 0.25rem;
272
+ text-align: left;
273
+ vertical-align: middle;
163
274
  }
164
275
 
165
- // A same-site URL moved onto the deployed site, so a printed or saved
166
- // lesson's links don't point at whatever host the reader printed from.
167
- function canonicalUrl(
168
- target: URL,
169
- canonicalBaseUrl: string | undefined,
170
- siteBaseUrl: string,
171
- ): string {
172
- if (!canonicalBaseUrl || target.origin !== location.origin) return target.href;
173
- const normalizedSiteBase = `/${siteBaseUrl.replace(/^\/+|\/+$/gu, '')}/`;
174
- const relativePath = target.pathname.startsWith(normalizedSiteBase)
175
- ? target.pathname.slice(normalizedSiteBase.length)
176
- : target.pathname.replace(/^\/+/, '');
177
- return new URL(
178
- `${relativePath}${target.search}${target.hash}`,
179
- canonicalBaseUrl,
180
- ).href;
276
+ /* Normal case: an uppercased heading would print "LaTeX" as "LATEX". */
277
+ .explico-downloads__grid thead th {
278
+ color: var(--sl-color-gray-3);
279
+ font-size: 0.72rem;
280
+ font-weight: 600;
181
281
  }
182
282
 
183
- function prepareStaticExport(mode: StaticExportMode): void {
184
- if (prepared) return;
185
- const printController = controller();
186
- if (!printController) return;
187
- pendingMarkerCleanup?.();
188
-
189
- root.dataset.explicoStaticExport = mode;
190
-
191
- const canonicalBaseUrl = printController.dataset.canonicalBaseUrl;
192
- const siteBaseUrl = printController.dataset.siteBaseUrl ?? '/';
193
-
194
- // Print follows the screen (see `../disclosure/print-as-shown`); a
195
- // Markdown export needs every word whatever the reader collapsed.
196
- const disclosures = prepareDisclosures(
197
- mode === 'print' ? 'as-shown' : 'expanded',
198
- {
199
- noteHref: (anchorId) => {
200
- const page = new URL(location.href);
201
- page.hash = anchorId ? `#${anchorId}` : '';
202
- return canonicalUrl(page, canonicalBaseUrl, siteBaseUrl);
203
- },
204
- },
205
- );
206
-
207
- // Rows beyond a long table's fold and an open example set's inactive
208
- // tabs are hidden only to save screen space, so they are exposed for
209
- // print too. A closed short note or question is a reader's disclosure:
210
- // print leaves it closed (with its note), only Markdown export opens it.
211
- const hiddenSelector =
212
- mode === 'print'
213
- ? 'main :is(.compact-example, .keyed-table__body tr:not(.keyed-table__more-row))[hidden]'
214
- : 'main :is(.assessment-panel, .short-note, .compact-example, .keyed-table__body tr:not(.keyed-table__more-row))[hidden]';
215
- const hidden = Array.from(
216
- document.querySelectorAll<HTMLElement>(hiddenSelector),
217
- (element): SavedHidden => {
218
- const saved = { element, hidden: element.hidden };
219
- element.hidden = false;
220
- return saved;
221
- },
222
- );
223
-
224
- const links =
225
- mode === 'print'
226
- ? Array.from(
227
- document.querySelectorAll<HTMLAnchorElement>('main a[href]'),
228
- ).flatMap((element): SavedLink[] => {
229
- const printHref = canonicalPrintHref(
230
- element,
231
- canonicalBaseUrl,
232
- siteBaseUrl,
233
- );
234
- if (!printHref) return [];
235
- const saved = {
236
- element,
237
- href: element.getAttribute('href'),
238
- };
239
- // PDF viewers retain anchors as clickable annotations. Point the
240
- // anchor itself at the deployed lesson while printing; no visible
241
- // URL suffix is needed in the document text.
242
- element.setAttribute('href', printHref);
243
- return [saved];
244
- })
245
- : [];
246
-
247
- prepared = { mode, disclosures, hidden, links };
248
- if (mode === 'print') root.dataset.explicoPrintReady = '';
283
+ .explico-downloads__grid tbody th {
284
+ font-weight: 600;
285
+ line-height: 1.25;
286
+ max-width: 11rem;
287
+ padding-right: 0.75rem;
249
288
  }
250
289
 
251
- function restoreStaticExport(): void {
252
- const saved = prepared;
253
- if (!saved) return;
254
- prepared = null;
290
+ .explico-downloads__scope {
291
+ color: var(--sl-color-gray-3);
292
+ display: block;
293
+ font-weight: 400;
294
+ }
255
295
 
256
- saved.disclosures.restore();
257
- for (const state of saved.hidden) state.element.hidden = state.hidden;
258
- for (const state of saved.links) {
259
- if (state.href === null) state.element.removeAttribute('href');
260
- else state.element.setAttribute('href', state.href);
261
- }
262
- delete root.dataset.explicoPrintReady;
263
-
264
- // Native `toggle` is queued after changing `.open`. Keep the temporary
265
- // marker through that task so disclosure persistence ignores both the
266
- // prepare and restore mutations.
267
- const cleanupMarkers = () => {
268
- if (pendingMarkerCleanup !== cleanupMarkers) return;
269
- saved.disclosures.releaseMarkers();
270
- delete root.dataset.explicoStaticExport;
271
- pendingMarkerCleanup = null;
272
- };
273
- pendingMarkerCleanup = cleanupMarkers;
274
- window.setTimeout(cleanupMarkers, 0);
296
+ .explico-downloads__cell {
297
+ align-items: center;
298
+ border: 1px solid var(--sl-color-gray-5);
299
+ border-radius: 0.4rem;
300
+ color: var(--sl-color-text-accent);
301
+ display: inline-flex;
302
+ font-variant-numeric: tabular-nums;
303
+ gap: 0.35rem;
304
+ padding: 0.2rem 0.45rem;
305
+ text-decoration: none;
306
+ white-space: nowrap;
275
307
  }
276
308
 
277
- function afterNextPaint(): Promise<void> {
278
- return new Promise((resolve) => {
279
- requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
280
- });
309
+ a.explico-downloads__cell:hover,
310
+ a.explico-downloads__cell:focus-visible {
311
+ border-color: var(--sl-color-accent);
281
312
  }
282
313
 
283
- function waitForIslandHydration(island: HTMLElement): Promise<void> {
284
- if (!island.hasAttribute('ssr')) return Promise.resolve();
285
-
286
- return new Promise((resolve) => {
287
- let timeout = 0;
288
- const finish = () => {
289
- window.clearTimeout(timeout);
290
- island.removeEventListener('astro:hydrate', finish);
291
- island.removeEventListener('astro:hydration-error', finish);
292
- resolve();
293
- };
294
- island.addEventListener('astro:hydrate', finish, { once: true });
295
- island.addEventListener('astro:hydration-error', finish, { once: true });
296
- timeout = window.setTimeout(finish, 10_000);
297
- requestAnimationFrame(() => {
298
- if (!island.hasAttribute('ssr')) finish();
299
- });
300
- });
314
+ .explico-downloads__cell--pending {
315
+ color: var(--sl-color-gray-3);
316
+ cursor: default;
301
317
  }
302
318
 
303
- async function hydrateVisibleIslandsForPrint(): Promise<void> {
304
- // Only islands that will be on paper: one inside a disclosure the reader
305
- // left closed (print follows the screen) never gets a layout box, so it
306
- // can never become visible, and waiting on it would stall the dialog for
307
- // `waitForIslandHydration`'s full timeout. A lab's tools never print.
308
- const islands = Array.from(
309
- document.querySelectorAll<HTMLElement>('main astro-island[ssr][client="visible"]'),
310
- ).filter((island) => !island.closest('details:not([open]), [hidden], [data-print-omit]'));
311
- if (islands.length === 0) return;
312
-
313
- const scrollX = window.scrollX;
314
- const scrollY = window.scrollY;
315
- const scrollBehavior = root.style.scrollBehavior;
316
- root.style.scrollBehavior = 'auto';
317
- try {
318
- // `client:visible` deliberately does not hydrate an off-screen plot.
319
- // Visit each island briefly so its normal Astro directive runs before
320
- // the browser snapshots the document for printing.
321
- for (const island of islands) {
322
- if (!island.isConnected || !island.hasAttribute('ssr')) continue;
323
- const hydrated = waitForIslandHydration(island);
324
- // Astro's `client:visible` directive observes an island's children,
325
- // while the custom-element wrapper itself may have no layout box.
326
- (island.firstElementChild ?? island).scrollIntoView({
327
- block: 'center',
328
- inline: 'nearest',
329
- });
330
- await hydrated;
331
- }
332
- } finally {
333
- window.scrollTo({ left: scrollX, top: scrollY, behavior: 'auto' });
334
- root.style.scrollBehavior = scrollBehavior;
335
- }
319
+ /* Column headings name the format on a wide menu; the cell itself does
320
+ on a narrow one, where the grid turns into rows of labelled buttons. */
321
+ .explico-downloads__format {
322
+ display: none;
323
+ color: var(--sl-color-text);
324
+ font-weight: 600;
336
325
  }
337
326
 
338
- async function waitForPrintVisuals(): Promise<void> {
339
- await hydrateVisibleIslandsForPrint();
340
- await document.fonts?.ready;
341
- await Promise.all(
342
- Array.from(document.querySelectorAll<HTMLImageElement>('main img'), (image) => {
343
- if (!image.complete) {
344
- return new Promise<void>((resolve) => {
345
- image.addEventListener('load', () => resolve(), { once: true });
346
- image.addEventListener('error', () => resolve(), { once: true });
347
- });
348
- }
349
- return typeof image.decode === 'function'
350
- ? image.decode().catch(() => undefined)
351
- : Promise.resolve();
352
- }),
353
- );
354
- // React chart effects and SVG layout settle after island hydration.
355
- await afterNextPaint();
327
+ .explico-downloads__none {
328
+ color: var(--sl-color-gray-4);
356
329
  }
357
330
 
331
+ .explico-downloads__note {
332
+ color: var(--sl-color-gray-3);
333
+ font-size: 0.7rem;
334
+ line-height: 1.4;
335
+ margin: 0.55rem 0 0;
336
+ max-width: 24rem;
337
+ }
338
+
339
+ @media (max-width: 30rem) {
340
+ .explico-downloads {
341
+ width: auto;
342
+ }
343
+
344
+ /* Without columns, a format a scope lacks is simply not listed. */
345
+ .explico-downloads__grid td:has(> .explico-downloads__none) {
346
+ display: none;
347
+ }
348
+
349
+ .explico-downloads__grid thead {
350
+ display: none;
351
+ }
352
+
353
+ .explico-downloads__grid tbody tr {
354
+ display: grid;
355
+ gap: 0.35rem;
356
+ grid-template-columns: 1fr 1fr;
357
+ padding: 0.4rem 0;
358
+ }
359
+
360
+ .explico-downloads__grid tbody th {
361
+ grid-column: 1 / -1;
362
+ max-width: none;
363
+ }
364
+
365
+ .explico-downloads__grid td {
366
+ padding: 0;
367
+ }
368
+
369
+ .explico-downloads__cell {
370
+ box-sizing: border-box;
371
+ justify-content: space-between;
372
+ width: 100%;
373
+ }
374
+
375
+ .explico-downloads__format {
376
+ display: inline;
377
+ }
378
+ }
379
+ </style>
380
+
381
+ <script>
382
+ import { installPrintController, printPage } from './print-controller';
383
+
358
384
  function wirePrintActions(): void {
359
385
  document
360
386
  .querySelectorAll<HTMLButtonElement>(
@@ -362,56 +388,104 @@ const {
362
388
  )
363
389
  .forEach((button) => {
364
390
  button.dataset.explicoPrintWired = '';
365
- button.addEventListener('click', async () => {
366
- button.disabled = true;
367
- button.setAttribute('aria-busy', 'true');
368
- window.dispatchEvent(
369
- new CustomEvent('explico:prepare-static-export', {
370
- detail: { mode: 'print' },
371
- }),
372
- );
373
- try {
374
- await waitForPrintVisuals();
375
- window.print();
376
- } finally {
377
- button.disabled = false;
378
- button.removeAttribute('aria-busy');
379
- }
380
- });
391
+ button.addEventListener('click', () => void printPage(button));
381
392
  });
382
393
  }
383
394
 
384
- wirePrintActions();
385
- document.addEventListener('astro:page-load', wirePrintActions);
386
-
387
- if (!root.hasAttribute('data-explico-print-controller-wired')) {
388
- root.dataset.explicoPrintControllerWired = '';
389
- const printMedia = window.matchMedia('print');
390
- window.addEventListener('explico:prepare-static-export', (event) => {
391
- const mode = (event as CustomEvent<{ mode?: unknown }>).detail?.mode;
392
- prepareStaticExport(mode === 'markdown' ? 'markdown' : 'print');
395
+ /** "1.9 MB" — what the menu shows beside a download. */
396
+ function formatSize(bytes: number): string {
397
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
398
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
399
+ }
400
+
401
+ const downloadIndexes = new Map<string, Promise<Record<string, number> | null>>();
402
+
403
+ function downloadIndex(href: string): Promise<Record<string, number> | null> {
404
+ let index = downloadIndexes.get(href);
405
+ if (!index) {
406
+ index = fetch(href)
407
+ .then((response) => (response.ok ? response.json() : null))
408
+ .then((body: { files?: Record<string, number> } | null) => body?.files ?? null)
409
+ .catch(() => null);
410
+ downloadIndexes.set(href, index);
411
+ }
412
+ return index;
413
+ }
414
+
415
+ /** Sizes beside each download; a download the build did not write disappears. */
416
+ async function fillDownloadSizes(menu: HTMLElement): Promise<void> {
417
+ if (menu.dataset.explicoSizes !== undefined) return;
418
+ menu.dataset.explicoSizes = '';
419
+ const href = menu.dataset.indexHref;
420
+ const files = href ? await downloadIndex(href) : null;
421
+ if (!files) return;
422
+ menu.querySelectorAll<HTMLAnchorElement>('a[data-download-path]').forEach((link) => {
423
+ const size = files[link.dataset.downloadPath ?? ''];
424
+ if (size === undefined) {
425
+ const none = document.createElement('span');
426
+ none.className = 'explico-downloads__none';
427
+ none.setAttribute('aria-hidden', 'true');
428
+ none.textContent = '—';
429
+ link.replaceWith(none);
430
+ return;
431
+ }
432
+ const label = link.querySelector<HTMLElement>('[data-download-size]');
433
+ if (label) label.textContent = `↓ ${formatSize(size)}`;
434
+ link.setAttribute('aria-label', `${link.getAttribute('aria-label') ?? ''} (${formatSize(size)})`);
393
435
  });
394
- window.addEventListener('explico:restore-static-export', restoreStaticExport);
395
- const requestPrintPreparation = () => {
396
- window.dispatchEvent(
397
- new CustomEvent('explico:prepare-static-export', {
398
- detail: { mode: 'print' },
399
- }),
400
- );
401
- };
402
- const requestPrintRestore = () => {
403
- window.dispatchEvent(
404
- new CustomEvent('explico:restore-static-export', {
405
- detail: { mode: 'print' },
406
- }),
407
- );
408
- };
409
- window.addEventListener('beforeprint', requestPrintPreparation);
410
- window.addEventListener('afterprint', requestPrintRestore);
411
- printMedia.addEventListener('change', (event) => {
412
- if (event.matches) requestPrintPreparation();
413
- else requestPrintRestore();
436
+ }
437
+
438
+ /** Under its button, right-aligned, scrolling in a short window; a bottom sheet on a phone. */
439
+ function placeDownloadMenu(menu: HTMLElement): void {
440
+ const toggle = document.querySelector<HTMLElement>(`[popovertarget="${menu.id}"]`);
441
+ if (!toggle) return;
442
+ // On a phone the button sits low in Starlight's mobile menu, with little
443
+ // room under it: the menu is a sheet along the bottom of the screen.
444
+ if (window.innerWidth <= 480) {
445
+ Object.assign(menu.style, {
446
+ top: 'auto',
447
+ bottom: '0.5rem',
448
+ left: '0.5rem',
449
+ right: '0.5rem',
450
+ maxHeight: 'calc(100dvh - 1rem)',
451
+ });
452
+ return;
453
+ }
454
+ const box = toggle.getBoundingClientRect();
455
+ const top = Math.round(box.bottom + 8);
456
+ Object.assign(menu.style, {
457
+ top: `${top}px`,
458
+ bottom: 'auto',
459
+ left: 'auto',
460
+ right: `${Math.max(8, Math.round(window.innerWidth - box.right))}px`,
461
+ maxHeight: `calc(100dvh - ${top + 8}px)`,
414
462
  });
415
- if (printMedia.matches) requestPrintPreparation();
416
463
  }
464
+
465
+ function wireDownloadMenus(): void {
466
+ document
467
+ .querySelectorAll<HTMLElement>('[data-explico-downloads]:not([data-explico-downloads-wired])')
468
+ .forEach((menu) => {
469
+ menu.dataset.explicoDownloadsWired = '';
470
+ menu.addEventListener('beforetoggle', (event) => {
471
+ if ((event as ToggleEvent).newState !== 'open') return;
472
+ placeDownloadMenu(menu);
473
+ void fillDownloadSizes(menu);
474
+ });
475
+ menu.addEventListener('toggle', (event) => {
476
+ document
477
+ .querySelector(`[popovertarget="${menu.id}"]`)
478
+ ?.setAttribute('aria-expanded', String((event as ToggleEvent).newState === 'open'));
479
+ });
480
+ });
481
+ }
482
+
483
+ wirePrintActions();
484
+ wireDownloadMenus();
485
+ document.addEventListener('astro:page-load', () => {
486
+ wirePrintActions();
487
+ wireDownloadMenus();
488
+ });
489
+
490
+ installPrintController();
417
491
  </script>