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
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * A page that is not a chapter (rule S10: the landing page, a part's overview
9
9
  * page) gets the card layers too — notation, citation, abbreviation and float
10
- * reference — from its own manifest bundle. The layers that serve a chapter's
11
- * numbered floats and outline stay on chapters.
10
+ * reference — from its own manifest bundle. Every layer registers on every
11
+ * course page, with nothing of its own to show or not, so a card carried in
12
+ * from another page can be built there (rule K5, `card-carry.ts`). The layers
13
+ * that serve a chapter's numbered floats and outline stay on chapters.
12
14
  *
13
15
  * The resolved notation bundle comes from the one compiler manifest (Phase
14
16
  * D5); this component never rebuilds the registry.
@@ -19,6 +21,7 @@ import { loadManifest } from '../../compiler/manifest';
19
21
  import {
20
22
  isLessonSlug,
21
23
  lessonIdFromSlug,
24
+ docsSlugsForRoute,
22
25
  } from '../../compiler/lesson-derivation';
23
26
  import NotationLayer, {
24
27
  type NotationLayerDefinition,
@@ -32,7 +35,9 @@ import AnchorScrollEnhancer from './AnchorScrollEnhancer.astro';
32
35
  import CollapsibleSections from './CollapsibleSections.astro';
33
36
  import TableOverflowExpander from '../table/TableOverflowExpander.astro';
34
37
  import DiagramEnhancer from './DiagramEnhancer.astro';
38
+ import PrintController from './PrintController.astro';
35
39
  import {
40
+ derivedNotationForPage,
36
41
  notationDefinitionsForRenderedPage,
37
42
  pageNotationDependencies,
38
43
  } from '../../reference/page-notation';
@@ -45,13 +50,9 @@ const lessonId = isLessonSlug(routeSlug)
45
50
  : undefined;
46
51
  const manifest = await loadManifest();
47
52
  // A chapter, or a non-chapter page the manifest knows (not the 404 route).
48
- // The glob loader shortens `<dir>/index.mdx` to the id `<dir>`, and may give
49
- // the landing page an empty one, so match the page's docs slug either way.
50
- const routeSlugs = new Set(
51
- routeSlug === '' ? ['index'] : [routeSlug, `${routeSlug}/index`],
52
- );
53
+ const routeSlugs = docsSlugsForRoute(routeSlug);
53
54
  const pageId =
54
- lessonId ?? manifest.pages.find((page) => routeSlugs.has(page.slug))?.id;
55
+ lessonId ?? manifest.pages.find((page) => routeSlugs.includes(page.slug))?.id;
55
56
 
56
57
  const glossaryBase = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`;
57
58
 
@@ -109,8 +110,8 @@ if (pageId) {
109
110
  formulaScope: definition.formulaScope,
110
111
  glosses: definition.glosses,
111
112
  units: definition.units,
113
+ range: definition.range,
112
114
  seeAlso: definition.seeAlso,
113
- editorialStatus: definition.editorialStatus,
114
115
  scope: definition.scope,
115
116
  href:
116
117
  definition.scope === 'shared'
@@ -118,13 +119,42 @@ if (pageId) {
118
119
  : `#notation-${definition.key}`,
119
120
  });
120
121
  }
122
+ // Rule N23: a derived card goes to its index entry's full definition.
123
+ for (const derived of derivedNotationForPage(
124
+ manifest,
125
+ pageId,
126
+ lessonId ? Astro.locals : undefined,
127
+ )) {
128
+ const index = definitions.find((definition) => definition.key === derived.index);
129
+ definitions.push({
130
+ key: derived.key,
131
+ notation: derived.notation,
132
+ label: derived.label,
133
+ summary: derived.summary,
134
+ formula: derived.formula,
135
+ formulaScope: derived.formulaScope,
136
+ glosses: derived.glosses,
137
+ units: derived.units,
138
+ scope: 'derived',
139
+ ...(index?.href === undefined ? {} : { href: index.href }),
140
+ });
141
+ }
121
142
  }
122
143
  ---
123
144
 
145
+ {/* What the course says about its cards (`card-carry.ts`): carrying (K1),
146
+ and the base its carried set is stored under (K5). */}
147
+ <div
148
+ hidden
149
+ data-explico-card-config
150
+ data-carry={String(manifest.cards.carry)}
151
+ data-base={import.meta.env.BASE_URL}
152
+ ></div>
124
153
  <NotationLayer
125
154
  definitions={definitions}
126
155
  defaultOpen={notationDefaultOpen}
127
156
  suspended={suspended}
157
+ page={routeSlug}
128
158
  />
129
159
  <AnchorScrollEnhancer />
130
160
  {pageId && <CitationLayer />}
@@ -135,6 +165,14 @@ if (pageId) {
135
165
  {lessonId && <TableOverflowExpander />}
136
166
  <DiagramEnhancer />
137
167
  {lessonId && <HeadingAnchorEnhancer />}
168
+ {
169
+ pageId && manifest.lessonExports.print && (
170
+ <PrintController
171
+ canonicalBaseUrl={manifest.lessonExports.canonicalBaseUrl}
172
+ siteBaseUrl={import.meta.env.BASE_URL}
173
+ />
174
+ )
175
+ }
138
176
  <div class="explico-site-footer" data-explico-export-chrome="site-footer">
139
177
  <DefaultFooter />
140
178
  </div>
@@ -0,0 +1,28 @@
1
+ ---
2
+ /**
3
+ * The page's print controller (`print-controller.ts`): what the Print
4
+ * button, Ctrl+P and Cmd+P run, and the site's URLs it moves links onto
5
+ * when printing. It sits in the page's footer, inside `<main>`, because a
6
+ * downloaded page keeps only `<main>` and must print the same way.
7
+ */
8
+ interface Props {
9
+ canonicalBaseUrl?: string;
10
+ siteBaseUrl: string;
11
+ }
12
+
13
+ const { canonicalBaseUrl, siteBaseUrl } = Astro.props;
14
+ ---
15
+
16
+ <div
17
+ hidden
18
+ data-explico-print-controller
19
+ data-canonical-base-url={canonicalBaseUrl}
20
+ data-site-base-url={siteBaseUrl}
21
+ >
22
+ </div>
23
+
24
+ <script>
25
+ import { installPrintController } from './print-controller';
26
+
27
+ installPrintController();
28
+ </script>
@@ -0,0 +1,137 @@
1
+ ---
2
+ /**
3
+ * The reader's text size on a touch screen: 100%, 90%, 80% or 70% of the
4
+ * site's own. A PC has the browser's zoom for this; a phone's text-size
5
+ * settings are buried in its browser's menus, so the page offers its own.
6
+ * It sits beside the theme switch (in the phone menu, and in the header
7
+ * of a tablet held wide) and shows only where there is no hover.
8
+ *
9
+ * The choice scales the root font size (`data-text-size` on `<html>`,
10
+ * `global.css`), so the text, the math, the spacing and the cards all
11
+ * follow. It is remembered per reader and copied onto the root before the
12
+ * page first paints by the inline script below, so the page never jumps
13
+ * from one size to another. `LayoutSocialIcons.astro` renders this in the
14
+ * header and in the phone menu; both copies stay in step.
15
+ */
16
+ import { Icon } from '@astrojs/starlight/components';
17
+ import { inlineRootAttributeScript } from '../../session/PreferenceStore';
18
+
19
+ const TEXT_SIZE_KEY = 'explico:display:text-size';
20
+ const SIZES = ['100', '90', '80', '70'] as const;
21
+ const boot = inlineRootAttributeScript(TEXT_SIZE_KEY, 'data-text-size', SIZES.slice(1));
22
+ ---
23
+
24
+ <script is:inline set:html={boot} />
25
+ <label class="explico-text-size" data-text-size-control>
26
+ <span class="sr-only">Text size</span>
27
+ <span class="label-icon" aria-hidden="true">Aa</span>
28
+ <select autocomplete="off" data-text-size-select>
29
+ {SIZES.map((size) => <option value={size} selected={size === '100'}>{size}%</option>)}
30
+ </select>
31
+ <Icon name="down-caret" class="caret" />
32
+ </label>
33
+
34
+ <script>
35
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
36
+
37
+ const KEY = 'explico:display:text-size';
38
+ const SIZES = new Set(['100', '90', '80', '70']);
39
+ const preferences = createBrowserPreferenceStore();
40
+ const root = document.documentElement;
41
+ const selects = () =>
42
+ Array.from(document.querySelectorAll<HTMLSelectElement>('[data-text-size-select]'));
43
+
44
+ const current = (): string => {
45
+ const value = root.getAttribute('data-text-size') ?? '100';
46
+ return SIZES.has(value) ? value : '100';
47
+ };
48
+
49
+ const show = (value: string) => {
50
+ for (const select of selects()) select.value = value;
51
+ };
52
+
53
+ const choose = (value: string) => {
54
+ if (!SIZES.has(value)) return;
55
+ if (value === '100') {
56
+ root.removeAttribute('data-text-size');
57
+ preferences.remove?.(KEY);
58
+ } else {
59
+ root.setAttribute('data-text-size', value);
60
+ preferences.set(KEY, value);
61
+ }
62
+ show(value);
63
+ };
64
+
65
+ show(current());
66
+ for (const select of selects()) {
67
+ select.addEventListener('change', () => choose(select.value));
68
+ }
69
+ // A page restored from the back/forward cache keeps its old selects' values.
70
+ window.addEventListener('pageshow', () => show(current()));
71
+ </script>
72
+
73
+ <style>
74
+ @layer starlight.components {
75
+ /* Starlight's own select (`ThemeSelect`), with "Aa" for its icon. */
76
+ .explico-text-size {
77
+ --sl-label-icon-size: 0.875rem;
78
+ --sl-caret-size: 1.25rem;
79
+ --sl-inline-padding: 0.5rem;
80
+ --sl-select-width: 5.5em;
81
+ position: relative;
82
+ display: none;
83
+ align-items: center;
84
+ gap: 0.25rem;
85
+ color: var(--sl-color-gray-2);
86
+ }
87
+
88
+ @media (hover: none) {
89
+ .explico-text-size {
90
+ display: flex;
91
+ }
92
+ }
93
+
94
+ .explico-text-size:hover {
95
+ color: var(--sl-color-white);
96
+ }
97
+
98
+ .label-icon,
99
+ .explico-text-size :global(.caret) {
100
+ position: absolute;
101
+ top: 50%;
102
+ transform: translateY(-50%);
103
+ pointer-events: none;
104
+ }
105
+
106
+ .label-icon {
107
+ inset-inline-start: 0;
108
+ font-size: var(--sl-label-icon-size);
109
+ font-weight: 600;
110
+ letter-spacing: -0.02em;
111
+ }
112
+
113
+ .explico-text-size :global(.caret) {
114
+ inset-inline-end: 0;
115
+ font-size: var(--sl-caret-size);
116
+ }
117
+
118
+ /* As tall as the icon buttons beside it, so the row keeps its height. */
119
+ select {
120
+ border: 0;
121
+ padding-block: 0.25rem;
122
+ padding-inline: calc(var(--sl-label-icon-size) + var(--sl-inline-padding) + 0.5rem)
123
+ calc(var(--sl-caret-size) + var(--sl-inline-padding) + 0.25rem);
124
+ margin-inline: calc(var(--sl-inline-padding) * -1);
125
+ width: calc(var(--sl-select-width) + var(--sl-inline-padding) * 2);
126
+ background-color: transparent;
127
+ color: inherit;
128
+ cursor: pointer;
129
+ appearance: none;
130
+ }
131
+
132
+ option {
133
+ background-color: var(--sl-color-bg-nav);
134
+ color: var(--sl-color-gray-1);
135
+ }
136
+ }
137
+ </style>
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The page's print controller, shared by the header's Print button and the
3
+ * page itself. The button, Ctrl+P and Cmd+P prepare the page as shown
4
+ * (`../disclosure/print-as-shown`), move its links onto the deployed site,
5
+ * load every lab that will be on paper and then open the print dialog; the
6
+ * browser's own File → Print gets the same preparation without loading
7
+ * labs. The figure capture fires `explico:prepare-static-export` in
8
+ * `markdown` mode, which opens everything.
9
+ *
10
+ * `PrintController.astro` installs it from the page's footer, inside
11
+ * `<main>`, with the `[data-explico-print-controller]` element that holds
12
+ * the site's URLs: a downloaded page keeps only `<main>`, so it prints the
13
+ * same way.
14
+ */
15
+ import {
16
+ prepareDisclosures,
17
+ type PreparedDisclosures,
18
+ } from '../disclosure/print-as-shown';
19
+
20
+ type SavedLink = {
21
+ readonly element: HTMLAnchorElement;
22
+ readonly href: string | null;
23
+ };
24
+
25
+ type SavedHidden = {
26
+ readonly element: HTMLElement;
27
+ readonly hidden: HTMLElement['hidden'];
28
+ };
29
+
30
+ type StaticExportMode = 'print' | 'markdown';
31
+
32
+ type PreparedStaticExport = {
33
+ readonly mode: StaticExportMode;
34
+ readonly disclosures: PreparedDisclosures;
35
+ readonly hidden: readonly SavedHidden[];
36
+ readonly links: readonly SavedLink[];
37
+ };
38
+
39
+ const root = document.documentElement;
40
+ let prepared: PreparedStaticExport | null = null;
41
+ let pendingMarkerCleanup: (() => void) | null = null;
42
+
43
+ function controller(): HTMLElement | null {
44
+ return document.querySelector<HTMLElement>('[data-explico-print-controller]');
45
+ }
46
+
47
+ function canonicalPrintHref(
48
+ link: HTMLAnchorElement,
49
+ canonicalBaseUrl: string | undefined,
50
+ siteBaseUrl: string,
51
+ ): string | undefined {
52
+ const rawHref = link.getAttribute('href');
53
+ if (!rawHref || rawHref.startsWith('#') || link.hasAttribute('download')) {
54
+ return undefined;
55
+ }
56
+
57
+ let target: URL;
58
+ try {
59
+ target = new URL(rawHref, location.href);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') return undefined;
64
+
65
+ const current = new URL(location.href);
66
+ if (
67
+ target.origin === current.origin &&
68
+ target.pathname === current.pathname &&
69
+ target.search === current.search
70
+ ) {
71
+ return undefined;
72
+ }
73
+
74
+ return canonicalUrl(target, canonicalBaseUrl, siteBaseUrl);
75
+ }
76
+
77
+ // A same-site URL moved onto the deployed site, so a printed or saved
78
+ // lesson's links don't point at whatever host the reader printed from.
79
+ function canonicalUrl(
80
+ target: URL,
81
+ canonicalBaseUrl: string | undefined,
82
+ siteBaseUrl: string,
83
+ ): string {
84
+ if (!canonicalBaseUrl || target.origin !== location.origin) return target.href;
85
+ const normalizedSiteBase = `/${siteBaseUrl.replace(/^\/+|\/+$/gu, '')}/`;
86
+ const relativePath = target.pathname.startsWith(normalizedSiteBase)
87
+ ? target.pathname.slice(normalizedSiteBase.length)
88
+ : target.pathname.replace(/^\/+/, '');
89
+ return new URL(
90
+ `${relativePath}${target.search}${target.hash}`,
91
+ canonicalBaseUrl,
92
+ ).href;
93
+ }
94
+
95
+ function prepareStaticExport(mode: StaticExportMode): void {
96
+ if (prepared) return;
97
+ const printController = controller();
98
+ if (!printController) return;
99
+ pendingMarkerCleanup?.();
100
+
101
+ root.dataset.explicoStaticExport = mode;
102
+
103
+ const canonicalBaseUrl = printController.dataset.canonicalBaseUrl;
104
+ const siteBaseUrl = printController.dataset.siteBaseUrl ?? '/';
105
+
106
+ // Print follows the screen (see `../disclosure/print-as-shown`); a
107
+ // Markdown export needs every word whatever the reader collapsed.
108
+ const disclosures = prepareDisclosures(
109
+ mode === 'print' ? 'as-shown' : 'expanded',
110
+ {
111
+ noteHref: (anchorId) => {
112
+ const page = new URL(location.href);
113
+ page.hash = anchorId ? `#${anchorId}` : '';
114
+ return canonicalUrl(page, canonicalBaseUrl, siteBaseUrl);
115
+ },
116
+ },
117
+ );
118
+
119
+ // Rows beyond a long table's fold and an open example set's inactive
120
+ // tabs are hidden only to save screen space, so they are exposed for
121
+ // print too. A closed short note or question is a reader's disclosure:
122
+ // print leaves it closed (with its note), only Markdown export opens it.
123
+ const hiddenSelector =
124
+ mode === 'print'
125
+ ? 'main :is(.compact-example, .keyed-table__body tr:not(.keyed-table__more-row))[hidden]'
126
+ : 'main :is(.assessment-panel, .short-note, .compact-example, .keyed-table__body tr:not(.keyed-table__more-row))[hidden]';
127
+ const hidden = Array.from(
128
+ document.querySelectorAll<HTMLElement>(hiddenSelector),
129
+ (element): SavedHidden => {
130
+ const saved = { element, hidden: element.hidden };
131
+ element.hidden = false;
132
+ return saved;
133
+ },
134
+ );
135
+
136
+ const links =
137
+ mode === 'print'
138
+ ? Array.from(
139
+ document.querySelectorAll<HTMLAnchorElement>('main a[href]'),
140
+ ).flatMap((element): SavedLink[] => {
141
+ const printHref = canonicalPrintHref(
142
+ element,
143
+ canonicalBaseUrl,
144
+ siteBaseUrl,
145
+ );
146
+ if (!printHref) return [];
147
+ const saved = {
148
+ element,
149
+ href: element.getAttribute('href'),
150
+ };
151
+ // PDF viewers retain anchors as clickable annotations. Point the
152
+ // anchor itself at the deployed lesson while printing; no visible
153
+ // URL suffix is needed in the document text.
154
+ element.setAttribute('href', printHref);
155
+ return [saved];
156
+ })
157
+ : [];
158
+
159
+ prepared = { mode, disclosures, hidden, links };
160
+ if (mode === 'print') root.dataset.explicoPrintReady = '';
161
+ }
162
+
163
+ function restoreStaticExport(): void {
164
+ const saved = prepared;
165
+ if (!saved) return;
166
+ prepared = null;
167
+
168
+ saved.disclosures.restore();
169
+ for (const state of saved.hidden) state.element.hidden = state.hidden;
170
+ for (const state of saved.links) {
171
+ if (state.href === null) state.element.removeAttribute('href');
172
+ else state.element.setAttribute('href', state.href);
173
+ }
174
+ delete root.dataset.explicoPrintReady;
175
+
176
+ // Native `toggle` is queued after changing `.open`. Keep the temporary
177
+ // marker through that task so disclosure persistence ignores both the
178
+ // prepare and restore mutations.
179
+ const cleanupMarkers = () => {
180
+ if (pendingMarkerCleanup !== cleanupMarkers) return;
181
+ saved.disclosures.releaseMarkers();
182
+ delete root.dataset.explicoStaticExport;
183
+ pendingMarkerCleanup = null;
184
+ };
185
+ pendingMarkerCleanup = cleanupMarkers;
186
+ window.setTimeout(cleanupMarkers, 0);
187
+ }
188
+
189
+ function afterNextPaint(): Promise<void> {
190
+ return new Promise((resolve) => {
191
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
192
+ });
193
+ }
194
+
195
+ /** `promise`, or nothing after `ms` — a slow resource never holds the dialog back. */
196
+ function atMost(promise: Promise<unknown>, ms: number): Promise<void> {
197
+ return new Promise((resolve) => {
198
+ const timer = window.setTimeout(resolve, ms);
199
+ void promise.then(
200
+ () => {
201
+ window.clearTimeout(timer);
202
+ resolve();
203
+ },
204
+ () => {
205
+ window.clearTimeout(timer);
206
+ resolve();
207
+ },
208
+ );
209
+ });
210
+ }
211
+
212
+ function waitForIslandHydration(island: HTMLElement): Promise<void> {
213
+ if (!island.hasAttribute('ssr')) return Promise.resolve();
214
+
215
+ return new Promise((resolve) => {
216
+ let timeout = 0;
217
+ const finish = () => {
218
+ window.clearTimeout(timeout);
219
+ island.removeEventListener('astro:hydrate', finish);
220
+ island.removeEventListener('astro:hydration-error', finish);
221
+ resolve();
222
+ };
223
+ island.addEventListener('astro:hydrate', finish, { once: true });
224
+ island.addEventListener('astro:hydration-error', finish, { once: true });
225
+ timeout = window.setTimeout(finish, 10_000);
226
+ requestAnimationFrame(() => {
227
+ if (!island.hasAttribute('ssr')) finish();
228
+ });
229
+ });
230
+ }
231
+
232
+ async function hydrateVisibleIslandsForPrint(): Promise<void> {
233
+ // Only islands that will be on paper: one inside a disclosure the reader
234
+ // left closed (print follows the screen) never gets a layout box, so it
235
+ // can never become visible, and waiting on it would stall the dialog for
236
+ // `waitForIslandHydration`'s full timeout. A lab's tools never print.
237
+ const islands = Array.from(
238
+ document.querySelectorAll<HTMLElement>('main astro-island[ssr][client="visible"]'),
239
+ ).filter((island) => !island.closest('details:not([open]), [hidden], [data-print-omit]'));
240
+ if (islands.length === 0) return;
241
+
242
+ const scrollX = window.scrollX;
243
+ const scrollY = window.scrollY;
244
+ const scrollBehavior = root.style.scrollBehavior;
245
+ root.style.scrollBehavior = 'auto';
246
+ try {
247
+ // `client:visible` deliberately does not hydrate an off-screen plot.
248
+ // Visit each island briefly so its normal Astro directive runs before
249
+ // the browser snapshots the document for printing.
250
+ for (const island of islands) {
251
+ if (!island.isConnected || !island.hasAttribute('ssr')) continue;
252
+ const hydrated = waitForIslandHydration(island);
253
+ // Astro's `client:visible` directive observes an island's children,
254
+ // while the custom-element wrapper itself may have no layout box.
255
+ (island.firstElementChild ?? island).scrollIntoView({
256
+ block: 'center',
257
+ inline: 'nearest',
258
+ });
259
+ await hydrated;
260
+ }
261
+ } finally {
262
+ window.scrollTo({ left: scrollX, top: scrollY, behavior: 'auto' });
263
+ root.style.scrollBehavior = scrollBehavior;
264
+ }
265
+ }
266
+
267
+ async function waitForPrintVisuals(): Promise<void> {
268
+ await hydrateVisibleIslandsForPrint();
269
+ await document.fonts?.ready;
270
+ // A lazy image the reader never scrolled to would never load, and the
271
+ // wait below would never end: load it now, and give up after a while.
272
+ const images = Array.from(document.querySelectorAll<HTMLImageElement>('main img'));
273
+ for (const image of images) {
274
+ if (image.loading === 'lazy') image.loading = 'eager';
275
+ }
276
+ await atMost(
277
+ Promise.all(
278
+ images.map((image) => {
279
+ if (!image.complete) {
280
+ return new Promise<void>((resolve) => {
281
+ image.addEventListener('load', () => resolve(), { once: true });
282
+ image.addEventListener('error', () => resolve(), { once: true });
283
+ });
284
+ }
285
+ return typeof image.decode === 'function'
286
+ ? image.decode().catch(() => undefined)
287
+ : Promise.resolve();
288
+ }),
289
+ ),
290
+ 5_000,
291
+ );
292
+ // React chart effects and SVG layout settle after island hydration.
293
+ await afterNextPaint();
294
+ }
295
+
296
+ let printing = false;
297
+
298
+ /** Prepare the page, load what will be on paper, then open the print dialog. */
299
+ export async function printPage(button?: HTMLButtonElement): Promise<void> {
300
+ if (printing) return;
301
+ printing = true;
302
+ if (button) {
303
+ button.disabled = true;
304
+ button.setAttribute('aria-busy', 'true');
305
+ }
306
+ window.dispatchEvent(
307
+ new CustomEvent('explico:prepare-static-export', {
308
+ detail: { mode: 'print' },
309
+ }),
310
+ );
311
+ try {
312
+ await waitForPrintVisuals();
313
+ window.print();
314
+ } finally {
315
+ printing = false;
316
+ if (button) {
317
+ button.disabled = false;
318
+ button.removeAttribute('aria-busy');
319
+ }
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Wire the page once: the prepare and restore events, the browser's own
325
+ * print, and Ctrl+P / Cmd+P, which run the Print button's steps.
326
+ */
327
+ export function installPrintController(): void {
328
+ if (!root.hasAttribute('data-explico-print-controller-wired')) {
329
+ root.dataset.explicoPrintControllerWired = '';
330
+ const printMedia = window.matchMedia('print');
331
+ window.addEventListener('explico:prepare-static-export', (event) => {
332
+ const mode = (event as CustomEvent<{ mode?: unknown }>).detail?.mode;
333
+ prepareStaticExport(mode === 'markdown' ? 'markdown' : 'print');
334
+ });
335
+ window.addEventListener('explico:restore-static-export', restoreStaticExport);
336
+ const requestPrintPreparation = () => {
337
+ window.dispatchEvent(
338
+ new CustomEvent('explico:prepare-static-export', {
339
+ detail: { mode: 'print' },
340
+ }),
341
+ );
342
+ };
343
+ const requestPrintRestore = () => {
344
+ window.dispatchEvent(
345
+ new CustomEvent('explico:restore-static-export', {
346
+ detail: { mode: 'print' },
347
+ }),
348
+ );
349
+ };
350
+ window.addEventListener('beforeprint', requestPrintPreparation);
351
+ window.addEventListener('afterprint', requestPrintRestore);
352
+ printMedia.addEventListener('change', (event) => {
353
+ if (event.matches) requestPrintPreparation();
354
+ else requestPrintRestore();
355
+ });
356
+ if (printMedia.matches) requestPrintPreparation();
357
+
358
+ // Ctrl+P / Cmd+P run the Print button's steps: the browser's own dialog
359
+ // cannot wait for an off-screen lab to load.
360
+ window.addEventListener(
361
+ 'keydown',
362
+ (event) => {
363
+ if (
364
+ !(event.ctrlKey || event.metaKey) ||
365
+ event.altKey ||
366
+ event.shiftKey ||
367
+ event.key.toLowerCase() !== 'p'
368
+ ) {
369
+ return;
370
+ }
371
+ event.preventDefault();
372
+ event.stopImmediatePropagation();
373
+ void printPage(
374
+ document.querySelector<HTMLButtonElement>('[data-explico-print-action]') ?? undefined,
375
+ );
376
+ },
377
+ { capture: true },
378
+ );
379
+ }
380
+ }