explico 0.12.0 → 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 (279) hide show
  1. package/CHANGELOG.md +1122 -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 +663 -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 +300 -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
@@ -0,0 +1,435 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createServer } from "node:http";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import { extname, join, resolve, sep } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ async function courseChromium(projectRoot) {
8
+ const require2 = createRequire(join(projectRoot, "package.json"));
9
+ for (const name of ["playwright-core", "playwright", "@playwright/test"]) {
10
+ let path;
11
+ try {
12
+ path = require2.resolve(name);
13
+ } catch {
14
+ continue;
15
+ }
16
+ try {
17
+ const module = require2(path);
18
+ const chromium = module.chromium ?? module.default?.chromium;
19
+ if (chromium) return chromium;
20
+ } catch {
21
+ const module = await import(
22
+ /* @vite-ignore */
23
+ pathToFileURL(path).href
24
+ );
25
+ const chromium = module.chromium ?? module.default?.chromium;
26
+ if (chromium) return chromium;
27
+ }
28
+ }
29
+ return void 0;
30
+ }
31
+ const MIME_TYPES = {
32
+ ".css": "text/css; charset=utf-8",
33
+ ".html": "text/html; charset=utf-8",
34
+ ".js": "text/javascript; charset=utf-8",
35
+ ".mjs": "text/javascript; charset=utf-8",
36
+ ".json": "application/json",
37
+ ".svg": "image/svg+xml",
38
+ ".png": "image/png",
39
+ ".jpg": "image/jpeg",
40
+ ".jpeg": "image/jpeg",
41
+ ".gif": "image/gif",
42
+ ".webp": "image/webp",
43
+ ".avif": "image/avif",
44
+ ".ico": "image/x-icon",
45
+ ".woff": "font/woff",
46
+ ".woff2": "font/woff2",
47
+ ".ttf": "font/ttf",
48
+ ".otf": "font/otf",
49
+ ".txt": "text/plain; charset=utf-8",
50
+ ".xml": "application/xml",
51
+ ".wasm": "application/wasm"
52
+ };
53
+ const CAPTURE_PATH = "__explico_capture__";
54
+ async function existingFile(path) {
55
+ try {
56
+ const info = await stat(path);
57
+ if (info.isFile()) return path;
58
+ if (info.isDirectory()) return existingFile(join(path, "index.html"));
59
+ } catch {
60
+ return void 0;
61
+ }
62
+ return void 0;
63
+ }
64
+ async function serve(root, base) {
65
+ const rootPath = resolve(root);
66
+ const server = createServer((request, response) => {
67
+ void (async () => {
68
+ let pathname = "/";
69
+ try {
70
+ pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
71
+ } catch {
72
+ response.writeHead(400).end();
73
+ return;
74
+ }
75
+ if (pathname.startsWith(base)) pathname = `/${pathname.slice(base.length)}`;
76
+ if (pathname === `/${CAPTURE_PATH}`) {
77
+ response.writeHead(200, { "Content-Type": MIME_TYPES[".html"] });
78
+ response.end("<!doctype html><html><head></head><body></body></html>");
79
+ return;
80
+ }
81
+ const candidate = resolve(rootPath, `.${pathname}`);
82
+ if (candidate !== rootPath && !candidate.startsWith(`${rootPath}${sep}`)) {
83
+ response.writeHead(403).end();
84
+ return;
85
+ }
86
+ const file = await existingFile(candidate) ?? await existingFile(`${candidate}.html`);
87
+ if (!file) {
88
+ response.writeHead(404).end();
89
+ return;
90
+ }
91
+ response.writeHead(200, {
92
+ "Content-Type": MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
93
+ });
94
+ response.end(await readFile(file));
95
+ })().catch(() => {
96
+ if (!response.headersSent) response.writeHead(500);
97
+ response.end();
98
+ });
99
+ });
100
+ await new Promise((resolveListen, rejectListen) => {
101
+ server.once("error", rejectListen);
102
+ server.listen(0, "127.0.0.1", () => resolveListen());
103
+ });
104
+ const address = server.address();
105
+ if (!address || typeof address === "string") throw new Error("Capture server has no port.");
106
+ return { origin: `http://127.0.0.1:${address.port}`, server };
107
+ }
108
+ async function settleFigure(page, anchor, timeoutMs) {
109
+ const settled = await page.evaluate(
110
+ async ({ anchor: id, timeout }) => {
111
+ const figure = document.getElementById(id);
112
+ if (!figure) return `no element #${id}`;
113
+ const body = figure.querySelector(".keyed-figure__body, .diagram__body") ?? figure;
114
+ body.scrollIntoView({ block: "center" });
115
+ const frame = () => new Promise((done) => requestAnimationFrame(() => done()));
116
+ const started = performance.now();
117
+ let previous = "";
118
+ let stableSince = performance.now();
119
+ while (performance.now() - started < timeout) {
120
+ await frame();
121
+ const pending = body.querySelector("astro-island[ssr]");
122
+ const current = body.innerHTML;
123
+ if (current !== previous) {
124
+ previous = current;
125
+ stableSince = performance.now();
126
+ }
127
+ if (!pending && performance.now() - stableSince > 400) {
128
+ await document.fonts.ready;
129
+ return "";
130
+ }
131
+ await new Promise((done) => setTimeout(done, 50));
132
+ }
133
+ return body.querySelector("astro-island[ssr]") ? `#${id} never hydrated` : `#${id} kept changing`;
134
+ },
135
+ { anchor, timeout: timeoutMs }
136
+ );
137
+ if (settled) throw new Error(`Figure capture: ${settled} within ${timeoutMs} ms.`);
138
+ }
139
+ async function snapshotFigure(page, anchor) {
140
+ return page.evaluate((id) => {
141
+ const figure = document.getElementById(id);
142
+ const body = figure.querySelector(".keyed-figure__body, .diagram__body") ?? figure;
143
+ const box = body.getBoundingClientRect();
144
+ const scrolled = [...body.querySelectorAll("*")].map((element) => element.scrollWidth);
145
+ const clone = figure.cloneNode(true);
146
+ clone.querySelectorAll("script").forEach((node) => node.remove());
147
+ const live = figure.querySelector(".float-caption__state");
148
+ return {
149
+ html: clone.outerHTML,
150
+ width: Math.ceil(Math.max(box.width, body.scrollWidth, ...scrolled)),
151
+ height: Math.ceil(Math.max(box.height, body.scrollHeight)),
152
+ links: [...document.styleSheets].flatMap((sheet) => sheet.href ? [sheet.href] : []),
153
+ styles: [...document.querySelectorAll("style")].map((node) => node.textContent ?? "").join("\n"),
154
+ live: live?.innerHTML ?? ""
155
+ };
156
+ }, anchor);
157
+ }
158
+ const LABEL_FONT = "KaTeX_Main, KaTeX_Math, serif";
159
+ const LABEL_PT = 9;
160
+ const PT_PER_PX = 0.75;
161
+ const CAPTURE_STYLE = `
162
+ @page { margin: 0 }
163
+ html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; }
164
+ main, .sl-markdown-content { margin: 0 !important; padding: 0 !important; max-width: none !important; }
165
+ figure { margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; }
166
+ figure > :not(.keyed-figure__body, .diagram__body) { display: none !important; }
167
+ :is(.keyed-figure__body, .diagram__body) { font-family: ${LABEL_FONT}; }
168
+ :is(.keyed-figure__body, .diagram__body) :is(svg text, svg tspan) { font-family: ${LABEL_FONT} !important; }
169
+ :is(.keyed-figure__body, .diagram__body), :is(.keyed-figure__body, .diagram__body) :is(svg, .diagram-scroll) { overflow: visible !important; }
170
+ `;
171
+ function printedPtPerPx(box, measure) {
172
+ const fit = Math.min(1, measure.width / (box.width * PT_PER_PX), measure.maxHeight / (box.height * PT_PER_PX));
173
+ return PT_PER_PX * fit;
174
+ }
175
+ async function sizeLabels(page, box, measure) {
176
+ return page.evaluate(
177
+ async ({ labelPt, ptPerPx, width, height }) => {
178
+ const body = document.querySelector(".keyed-figure__body, .diagram__body");
179
+ if (!body) return { width, height };
180
+ const scaleOf = (element) => {
181
+ const matrix = element.getScreenCTM();
182
+ return matrix ? Math.hypot(matrix.a, matrix.b) : 1;
183
+ };
184
+ const elements = [...body.querySelectorAll("svg text, svg tspan")];
185
+ const own = new Map(elements.map((element) => [element, Number.parseFloat(getComputedStyle(element).fontSize)]));
186
+ const weights = /* @__PURE__ */ new Map();
187
+ for (const element of elements) {
188
+ const characters = [...element.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).reduce((count, node) => count + (node.textContent ?? "").trim().length, 0);
189
+ const printed = Math.round((own.get(element) ?? 0) * scaleOf(element) * ptPerPx * 10) / 10;
190
+ if (characters > 0 && printed > 0) weights.set(printed, (weights.get(printed) ?? 0) + characters);
191
+ }
192
+ const common = [...weights].sort((a, b) => b[1] - a[1] || b[0] - a[0])[0]?.[0];
193
+ if (!common) return { width, height };
194
+ const texts = [...body.querySelectorAll("svg text")];
195
+ const ink = (text) => {
196
+ const rect = text.getBoundingClientRect();
197
+ const size = Number.parseFloat(getComputedStyle(text).fontSize) * scaleOf(text);
198
+ const matrix = text.getScreenCTM();
199
+ if (!matrix || text.getNumberOfChars() === 0 || rect.height > 1.6 * size) {
200
+ return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
201
+ }
202
+ const start = text.getStartPositionOfChar(0);
203
+ const baseline = matrix.b * start.x + matrix.d * start.y + matrix.f;
204
+ return { left: rect.left, right: rect.right, top: baseline - 0.72 * size, bottom: baseline + 0.22 * size };
205
+ };
206
+ const touching = (a, b) => a.left < b.right - 0.5 && b.left < a.right - 0.5 && a.top < b.bottom - 0.5 && b.top < a.bottom - 0.5;
207
+ const contacts = () => {
208
+ const boxes = texts.map(ink);
209
+ const found = /* @__PURE__ */ new Set();
210
+ boxes.forEach((a, i) => boxes.slice(i + 1).forEach((b, j) => touching(a, b) && found.add(`${i}:${i + 1 + j}`)));
211
+ return found;
212
+ };
213
+ const before = contacts();
214
+ const transforms = new Map(texts.map((text) => [text, text.getAttribute("transform")]));
215
+ const titles = ["x", "y", "fx", "fy"].flatMap((axis) => {
216
+ const ticks = [...body.querySelectorAll(`g[aria-label="${axis}-axis tick label"] text`)];
217
+ return [...body.querySelectorAll(`g[aria-label="${axis}-axis label"] text`)].flatMap((title) => {
218
+ const box2 = ink(title);
219
+ const near = ticks.filter((tick) => {
220
+ const other = ink(tick);
221
+ return other.left < box2.right && box2.left < other.right;
222
+ });
223
+ if (near.length === 0) return [];
224
+ const boxes = near.map(ink);
225
+ const below = (box2.top + box2.bottom) / 2 > Math.max(...boxes.map((other) => (other.top + other.bottom) / 2));
226
+ const gap = below ? box2.top - Math.max(...boxes.map((other) => other.bottom)) : Math.min(...boxes.map((other) => other.top)) - box2.bottom;
227
+ return [{ title, near, below, gap: Math.max(1, gap) }];
228
+ });
229
+ });
230
+ const apply = (ratio2) => {
231
+ for (const element of elements) {
232
+ element.style.setProperty("font-size", `${(own.get(element) ?? 0) * ratio2}px`, "important");
233
+ }
234
+ for (const [text, transform] of transforms) {
235
+ if (transform === null) text.removeAttribute("transform");
236
+ else text.setAttribute("transform", transform);
237
+ }
238
+ for (const { title, near, below, gap } of titles) {
239
+ const box2 = ink(title);
240
+ const boxes = near.map(ink);
241
+ const shift = below ? Math.max(0, Math.max(...boxes.map((other) => other.bottom)) + gap - box2.top) : Math.min(0, Math.min(...boxes.map((other) => other.top)) - gap - box2.bottom);
242
+ if (shift === 0) continue;
243
+ const transform = transforms.get(title);
244
+ title.setAttribute("transform", `${transform ?? ""} translate(0 ${shift / scaleOf(title)})`.trim());
245
+ }
246
+ };
247
+ const collides = () => [...contacts()].some((pair) => !before.has(pair));
248
+ const goal = Math.min(2.5, Math.max(0.4, labelPt / common));
249
+ let ratio = goal;
250
+ apply(ratio);
251
+ if (goal > 1 && collides()) {
252
+ let [fits, fails] = [1, goal];
253
+ for (let step = 0; step < 8; step++) {
254
+ const middle = (fits + fails) / 2;
255
+ apply(middle);
256
+ if (collides()) fails = middle;
257
+ else fits = middle;
258
+ }
259
+ ratio = fits;
260
+ apply(ratio);
261
+ }
262
+ await document.fonts.ready;
263
+ for (const svg of body.querySelectorAll("svg")) {
264
+ for (let node = svg.parentElement; node && node !== body; node = node.parentElement) {
265
+ node.style.setProperty("overflow", "visible", "important");
266
+ }
267
+ }
268
+ const frame = body.getBoundingClientRect();
269
+ let [left, top, right, bottom] = [frame.left, frame.top, frame.left + width, frame.top + height];
270
+ for (const element of elements) {
271
+ const rect = element.getBoundingClientRect();
272
+ if (rect.width === 0 && rect.height === 0) continue;
273
+ [left, top] = [Math.min(left, rect.left), Math.min(top, rect.top)];
274
+ [right, bottom] = [Math.max(right, rect.right), Math.max(bottom, rect.bottom)];
275
+ }
276
+ const [shiftX, shiftY] = [Math.ceil(frame.left - left), Math.ceil(frame.top - top)];
277
+ if (shiftX > 0 || shiftY > 0) {
278
+ document.body.style.setProperty("padding", `${shiftY}px 0 0 ${shiftX}px`, "important");
279
+ }
280
+ return {
281
+ width: Math.ceil(right - left),
282
+ height: Math.ceil(bottom - top),
283
+ labelPt: Math.round(common * ratio * 10) / 10
284
+ };
285
+ },
286
+ { labelPt: LABEL_PT, ptPerPx: printedPtPerPx(box, measure), width: box.width, height: box.height }
287
+ );
288
+ }
289
+ async function machineFonts(context, page) {
290
+ if (!context.newCDPSession) return [];
291
+ await page.evaluate(() => {
292
+ for (const element of document.querySelectorAll(
293
+ ":is(.keyed-figure__body, .diagram__body), :is(.keyed-figure__body, .diagram__body) *"
294
+ )) {
295
+ if (element.closest(".katex-mathml")) continue;
296
+ const text = [...element.childNodes].some(
297
+ (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim() !== ""
298
+ );
299
+ if (text) element.setAttribute("data-explico-capture-text", "");
300
+ }
301
+ });
302
+ const session = await context.newCDPSession(page);
303
+ try {
304
+ await session.send("DOM.enable");
305
+ await session.send("CSS.enable");
306
+ const { root } = await session.send("DOM.getDocument", { depth: 0 });
307
+ const { nodeIds } = await session.send("DOM.querySelectorAll", {
308
+ nodeId: root.nodeId,
309
+ selector: "[data-explico-capture-text]"
310
+ });
311
+ const found = /* @__PURE__ */ new Map();
312
+ for (const nodeId of nodeIds) {
313
+ const { fonts } = await session.send("CSS.getPlatformFontsForNode", { nodeId });
314
+ for (const font of fonts) {
315
+ if (font.isCustomFont || found.has(font.familyName)) continue;
316
+ const { outerHTML } = await session.send("DOM.getOuterHTML", { nodeId });
317
+ const sample = outerHTML.replace(/<[^>]*>/gu, " ").replace(/\s+/gu, " ").trim().slice(0, 40);
318
+ found.set(font.familyName, sample);
319
+ }
320
+ }
321
+ return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([family, sample]) => `${family} ("${sample}")`);
322
+ } finally {
323
+ await session.detach();
324
+ }
325
+ }
326
+ async function printFigure(context, origin, snapshot, measure, withPng) {
327
+ const page = await context.newPage();
328
+ try {
329
+ await page.goto(`${origin}/${CAPTURE_PATH}`);
330
+ const links = snapshot.links.map((href) => `<link rel="stylesheet" href="${href.replace(/"/gu, "&quot;")}">`).join("");
331
+ await page.setContent(
332
+ `<!doctype html><html data-theme="light"><head><meta charset="utf-8">${links}<style>${snapshot.styles.replace(/<\/style/giu, "<\\/style")}</style><style>${CAPTURE_STYLE}</style></head><body><main><div class="sl-markdown-content" style="width:${snapshot.width}px">${snapshot.html}</div></main></body></html>`,
333
+ { waitUntil: "load" }
334
+ );
335
+ await page.evaluate(async () => {
336
+ await document.fonts.ready;
337
+ });
338
+ await page.emulateMedia({ media: "screen" });
339
+ const box = await sizeLabels(page, { width: snapshot.width, height: snapshot.height }, measure);
340
+ const fonts = await machineFonts(context, page);
341
+ const png = withPng ? await page.screenshot({
342
+ type: "png",
343
+ clip: { x: 0, y: 0, width: box.width, height: box.height },
344
+ animations: "disabled",
345
+ caret: "hide"
346
+ }) : void 0;
347
+ const pdf = await page.pdf({
348
+ width: `${box.width}px`,
349
+ height: `${box.height}px`,
350
+ printBackground: true,
351
+ pageRanges: "1"
352
+ });
353
+ return {
354
+ pdf,
355
+ ...png ? { png } : {},
356
+ machineFonts: fonts,
357
+ ...box.labelPt ? { labelPt: box.labelPt } : {}
358
+ };
359
+ } finally {
360
+ await page.close();
361
+ }
362
+ }
363
+ function figureMeasure(paper) {
364
+ const inch = 72;
365
+ const millimetre = 72 / 25.4;
366
+ return paper === "a4" ? { width: (210 - 50.8) * millimetre, maxHeight: 0.6 * (297 - 50.8) * millimetre } : { width: 6.5 * inch, maxHeight: 0.6 * 9 * inch };
367
+ }
368
+ function stampPdfDates(pdf, epoch) {
369
+ const date = new Date(epoch * 1e3).toISOString().replace(/\D/gu, "").slice(0, 14);
370
+ const text = Buffer.from(pdf).toString("latin1");
371
+ return Buffer.from(
372
+ text.replace(/(\/(?:CreationDate|ModDate)\s*\(D:)\d{14}/gu, (_match, key) => `${key}${date}`),
373
+ "latin1"
374
+ );
375
+ }
376
+ async function captureFigures(requests, options) {
377
+ const results = /* @__PURE__ */ new Map();
378
+ if (requests.length === 0) return results;
379
+ const { origin, server } = await serve(options.outputRoot, options.base);
380
+ const browser = await options.chromium.launch();
381
+ try {
382
+ for (const request of requests) {
383
+ const context = await browser.newContext({
384
+ viewport: { width: 1200, height: 900 },
385
+ // Two device pixels per CSS pixel: the PNG a Markdown export shows.
386
+ deviceScaleFactor: 2,
387
+ colorScheme: "light",
388
+ reducedMotion: "reduce",
389
+ locale: "en-US"
390
+ });
391
+ try {
392
+ const page = await context.newPage();
393
+ const path = `${options.base}${request.route ? `${request.route}/` : ""}`;
394
+ await page.goto(`${origin}${path}`, { waitUntil: "load" });
395
+ await page.evaluate(() => {
396
+ window.dispatchEvent(
397
+ new CustomEvent("explico:prepare-static-export", { detail: { mode: "markdown" } })
398
+ );
399
+ });
400
+ const figures = /* @__PURE__ */ new Map();
401
+ for (const anchor of request.anchors) {
402
+ await settleFigure(page, anchor, options.timeoutMs ?? 3e4);
403
+ const snapshot = await snapshotFigure(page, anchor);
404
+ const printed = await printFigure(
405
+ context,
406
+ origin,
407
+ snapshot,
408
+ options.measure ?? figureMeasure("letter"),
409
+ options.png ?? false
410
+ );
411
+ const pdf = options.epoch === void 0 ? printed.pdf : stampPdfDates(printed.pdf, options.epoch);
412
+ figures.set(anchor, { ...printed, pdf, liveHtml: snapshot.live });
413
+ }
414
+ results.set(request.route, figures);
415
+ } catch (error) {
416
+ throw new Error(
417
+ `Could not capture the figures of ${request.route || "the landing page"}: ${error instanceof Error ? error.message : String(error)}`
418
+ );
419
+ } finally {
420
+ await context.close();
421
+ }
422
+ }
423
+ } finally {
424
+ await browser.close();
425
+ await new Promise((done) => server.close(() => done()));
426
+ }
427
+ return results;
428
+ }
429
+ export {
430
+ LABEL_PT,
431
+ captureFigures,
432
+ courseChromium,
433
+ figureMeasure,
434
+ stampPdfDates
435
+ };
@@ -0,0 +1,73 @@
1
+ import { SourceFile } from './document.js';
2
+ import '../../manifest.js';
3
+ import '../../../curriculum/validation.js';
4
+ import '../../../reference/consistency.js';
5
+ import '../../../course-config-rRtME5UV.js';
6
+ import '../../../rules/catalog.js';
7
+ import '../../../rules/course-rules.js';
8
+ import '../../collections.js';
9
+ import '../../../reference/types.js';
10
+ import '../../../reference/gate-retired-keys.js';
11
+ import '../../../reference/lint-ignore.js';
12
+ import '../../../reference/equations-validate.js';
13
+ import '../../../reference/tables-validate.js';
14
+ import '../../../reference/keyed-float-validate.js';
15
+ import '../../../reference/figures-validate.js';
16
+ import '../../../reference/diagrams-validate.js';
17
+ import '../../../reference/examples-validate.js';
18
+ import '../../../reference/folds-validate.js';
19
+ import '../../../reference/knowledge-checks-validate.js';
20
+ import '../../../reference/raw-floats-validate.js';
21
+ import '../../../reference/lab-katex-validate.js';
22
+ import '../../../reference/gate-math.js';
23
+ import '../../../reference/mixed-content.js';
24
+ import '../../../reference/gloss.js';
25
+ import '../../../rules/waivers.js';
26
+ import '../../knowledge-version.js';
27
+ import '../../lesson-derivation.js';
28
+ import '../static-tree.js';
29
+ import '../html-tree.js';
30
+ import 'parse5';
31
+ import './serialize.js';
32
+
33
+ /**
34
+ * Compile a LaTeX folder to PDF with `latexmk`. LuaLaTeX is the engine: it
35
+ * is the one the LaTeX Project recommends for new documents and the only one
36
+ * that writes MathML into a tagged PDF. `EXPLICO_LATEX_ENGINE=pdflatex`
37
+ * switches to pdfLaTeX, for a machine whose TeX Live lacks LuaLaTeX's font
38
+ * loader.
39
+ */
40
+ interface LatexEngine {
41
+ readonly name: 'lualatex' | 'pdflatex';
42
+ readonly latexmkOption: '-lualatex' | '-pdf';
43
+ }
44
+ interface CompiledLatex {
45
+ readonly pdf: Buffer;
46
+ readonly aux: string;
47
+ readonly log: string;
48
+ }
49
+ /**
50
+ * The engine to compile with, or the reason there is none. LuaLaTeX also
51
+ * needs `luaotfload` (Debian ships it separately, in `texlive-luatex`).
52
+ */
53
+ declare function findLatexEngine(): Promise<LatexEngine | {
54
+ readonly missing: string;
55
+ }>;
56
+ /**
57
+ * The characters a compile could not draw, in first-seen order: LuaLaTeX
58
+ * leaves a blank where the font has no glyph and only logs `Missing
59
+ * character: There is no 中 (U+4E2D) in font …` — Greek, CJK or emoji in
60
+ * prose against Latin Modern. Each comes back once, as `中 (U+4E2D)`.
61
+ */
62
+ declare function missingCharacters(log: string): string[];
63
+ /**
64
+ * Compile `files` (a folder with `main.tex`) and return the PDF with the
65
+ * `.aux` LaTeX wrote, for the numbering check. `epoch` fixes the PDF's dates
66
+ * so the same source gives the same file.
67
+ */
68
+ declare function compileLatex(files: readonly SourceFile[], engine: LatexEngine, options?: {
69
+ readonly epoch?: number;
70
+ readonly name?: string;
71
+ }): Promise<CompiledLatex>;
72
+
73
+ export { type CompiledLatex, type LatexEngine, compileLatex, findLatexEngine, missingCharacters };
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ function run(command, args, options = {}) {
6
+ return new Promise((resolveRun) => {
7
+ let output = "";
8
+ let child;
9
+ try {
10
+ child = spawn(command, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] });
11
+ } catch {
12
+ resolveRun({ code: null, output: "" });
13
+ return;
14
+ }
15
+ const capture = (chunk) => {
16
+ if (output.length < 2e5) output += chunk.toString("utf8");
17
+ };
18
+ child.stdout.on("data", capture);
19
+ child.stderr.on("data", capture);
20
+ child.once("error", () => resolveRun({ code: null, output }));
21
+ child.once("close", (code) => resolveRun({ code, output }));
22
+ });
23
+ }
24
+ async function findLatexEngine() {
25
+ const requested = process.env.EXPLICO_LATEX_ENGINE ?? "lualatex";
26
+ if (requested !== "lualatex" && requested !== "pdflatex") {
27
+ return { missing: `EXPLICO_LATEX_ENGINE must be "lualatex" or "pdflatex", not ${JSON.stringify(requested)}.` };
28
+ }
29
+ if ((await run("latexmk", ["-v"])).code !== 0) {
30
+ return { missing: "latexmk is not installed (TeX Live 2025 or later provides it)." };
31
+ }
32
+ if ((await run(requested, ["--version"])).code !== 0) {
33
+ return { missing: `${requested} is not installed.` };
34
+ }
35
+ if (requested === "lualatex") {
36
+ const loader = await run("kpsewhich", ["luaotfload.sty"]);
37
+ if (loader.code !== 0 || !loader.output.trim()) {
38
+ return {
39
+ missing: "LuaLaTeX cannot load fonts: luaotfload is missing (on Debian or Ubuntu, install texlive-luatex). Set EXPLICO_LATEX_ENGINE=pdflatex to compile with pdfLaTeX instead."
40
+ };
41
+ }
42
+ }
43
+ return requested === "lualatex" ? { name: "lualatex", latexmkOption: "-lualatex" } : { name: "pdflatex", latexmkOption: "-pdf" };
44
+ }
45
+ function latexErrors(log) {
46
+ const lines = log.split("\n");
47
+ const found = [];
48
+ lines.forEach((line, index) => {
49
+ if (/^(?:\.\/[^:]+:\d+: |! |\[\\directlua\]:\d+: |\S+\.lua:\d+: )/u.test(line)) {
50
+ found.push([line, lines[index + 1] ?? ""].join(" ").trim());
51
+ }
52
+ });
53
+ return found.slice(0, 6).join("\n ");
54
+ }
55
+ function missingCharacters(log) {
56
+ const found = /* @__PURE__ */ new Set();
57
+ for (const match of log.matchAll(/Missing character: There is no (\S+) \((U\+[0-9A-F]+)\)/gu)) {
58
+ found.add(`${match[1]} (${match[2]})`);
59
+ }
60
+ return [...found];
61
+ }
62
+ async function compileLatex(files, engine, options = {}) {
63
+ const root = await mkdtemp(join(tmpdir(), "explico-latex-"));
64
+ try {
65
+ for (const file of files) {
66
+ const path = join(root, file.path);
67
+ await mkdir(dirname(path), { recursive: true });
68
+ await writeFile(path, file.content);
69
+ }
70
+ const result = await run(
71
+ "latexmk",
72
+ [
73
+ engine.latexmkOption,
74
+ "-interaction=nonstopmode",
75
+ "-halt-on-error",
76
+ "-file-line-error",
77
+ "-norc",
78
+ // luaotfload's caches are inputs latexmk tracks; the build's other
79
+ // compiles rewrite them meanwhile, and a compile that sees its inputs
80
+ // change on every run never settles ("needed too many passes").
81
+ "-e",
82
+ "$hash_calc_ignore_pattern{'luc'} = '^';",
83
+ "main.tex"
84
+ ],
85
+ {
86
+ cwd: root,
87
+ env: {
88
+ ...process.env,
89
+ SOURCE_DATE_EPOCH: String(options.epoch ?? 0),
90
+ FORCE_SOURCE_DATE: "1"
91
+ }
92
+ }
93
+ );
94
+ const log = await readFile(join(root, "main.log"), "utf8").catch(() => result.output);
95
+ if (result.code !== 0) {
96
+ const errors = latexErrors(log);
97
+ const said = errors === "" ? `
98
+ latexmk: ${result.output.trim().split("\n").slice(-12).join("\n ")}` : "";
99
+ throw new Error(`LaTeX could not compile ${options.name ?? "the document"}:
100
+ ${errors || log.slice(-1500)}${said}`);
101
+ }
102
+ return {
103
+ pdf: await readFile(join(root, "main.pdf")),
104
+ aux: await readFile(join(root, "main.aux"), "utf8"),
105
+ log
106
+ };
107
+ } finally {
108
+ await rm(root, { recursive: true, force: true });
109
+ }
110
+ }
111
+ export {
112
+ compileLatex,
113
+ findLatexEngine,
114
+ missingCharacters
115
+ };