explico 0.3.0 → 0.5.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 (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -0,0 +1,963 @@
1
+ import { build } from 'esbuild';
2
+ import {
3
+ mkdir,
4
+ readFile,
5
+ rename,
6
+ stat,
7
+ writeFile,
8
+ } from 'node:fs/promises';
9
+ import { Buffer } from 'node:buffer';
10
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ parse,
14
+ serialize,
15
+ type DefaultTreeAdapterMap,
16
+ } from 'parse5';
17
+
18
+ import type { Manifest } from '../manifest';
19
+
20
+ type HtmlNode = DefaultTreeAdapterMap['node'];
21
+ type HtmlParent = DefaultTreeAdapterMap['parentNode'];
22
+ type HtmlChild = DefaultTreeAdapterMap['childNode'];
23
+ type HtmlElement = DefaultTreeAdapterMap['element'];
24
+ type HtmlText = DefaultTreeAdapterMap['textNode'];
25
+
26
+ const HTML_NAMESPACE =
27
+ 'http://www.w3.org/1999/xhtml' as HtmlElement['namespaceURI'];
28
+ const CSS_URL = /url\(\s*(?:(["'])(.*?)\1|([^)]*?))\s*\)/giu;
29
+ const CSS_IMPORT =
30
+ /@import\s+(?:url\(\s*(?:(["'])(.*?)\1|([^)]*?))\s*\)|(["'])(.*?)\4)\s*([^;]*);/giu;
31
+ const LOCAL_IMPORT =
32
+ /(?:\bimport\s*\(\s*|\bfrom\s+)(["'])(?!data:)([^"']+)\1/gu;
33
+
34
+ const MIME_TYPES: Readonly<Record<string, string>> = {
35
+ '.avif': 'image/avif',
36
+ '.gif': 'image/gif',
37
+ '.jpeg': 'image/jpeg',
38
+ '.jpg': 'image/jpeg',
39
+ '.mp3': 'audio/mpeg',
40
+ '.mp4': 'video/mp4',
41
+ '.ogg': 'audio/ogg',
42
+ '.otf': 'font/otf',
43
+ '.png': 'image/png',
44
+ '.svg': 'image/svg+xml',
45
+ '.ttf': 'font/ttf',
46
+ '.wasm': 'application/wasm',
47
+ '.wav': 'audio/wav',
48
+ '.webm': 'video/webm',
49
+ '.webp': 'image/webp',
50
+ '.woff': 'font/woff',
51
+ '.woff2': 'font/woff2',
52
+ };
53
+
54
+ const STANDALONE_STYLE = `
55
+ html[data-explico-standalone] {
56
+ min-width: 0;
57
+ }
58
+ html[data-explico-standalone] body {
59
+ display: block;
60
+ margin: 0;
61
+ min-width: 0;
62
+ }
63
+ html[data-explico-standalone] main {
64
+ margin-inline: auto;
65
+ max-width: calc(var(--sl-content-width, 45rem) + 3rem);
66
+ min-width: 0;
67
+ width: 100%;
68
+ }
69
+ html[data-explico-standalone] main > .content-panel {
70
+ padding-inline: 1.5rem;
71
+ }
72
+ html[data-explico-standalone] .sl-skip-link:focus {
73
+ z-index: 10000;
74
+ }
75
+ html[data-explico-standalone] .explico-standalone-theme {
76
+ align-items: center;
77
+ background: var(--sl-color-bg-nav, var(--sl-color-bg));
78
+ border: 1px solid var(--sl-color-gray-5);
79
+ border-radius: 0.5rem;
80
+ box-shadow: var(--sl-shadow-md);
81
+ display: flex;
82
+ inset-block-start: 0.75rem;
83
+ inset-inline-end: 0.75rem;
84
+ padding: 0.3rem 0.45rem;
85
+ position: fixed;
86
+ z-index: 100;
87
+ }
88
+ @media print {
89
+ html[data-explico-standalone] .explico-standalone-theme {
90
+ display: none;
91
+ }
92
+ }
93
+ @media (max-width: 30rem) {
94
+ html[data-explico-standalone] main > .content-panel {
95
+ padding-inline: 1rem;
96
+ }
97
+ }
98
+ `;
99
+
100
+ const STANDALONE_THEME_SCRIPT = `(() => {
101
+ const root = document.documentElement;
102
+ const picker = document.querySelector('[data-explico-standalone-theme] select');
103
+ if (!(picker instanceof HTMLSelectElement)) return;
104
+ const media = window.matchMedia('(prefers-color-scheme: light)');
105
+ const parse = (value) => value === 'light' || value === 'dark' ? value : 'auto';
106
+ const apply = (theme) => {
107
+ picker.value = theme;
108
+ window.StarlightThemeProvider?.updatePickers(theme);
109
+ root.dataset.theme = theme === 'auto' ? (media.matches ? 'light' : 'dark') : theme;
110
+ };
111
+ picker.addEventListener('change', () => apply(parse(picker.value)));
112
+ media.addEventListener('change', () => {
113
+ if (picker.value === 'auto') apply('auto');
114
+ });
115
+ apply('auto');
116
+ })();`;
117
+
118
+ /** Inputs for producing one standalone lesson from Astro's final HTML. */
119
+ export interface StandaloneLessonInput {
120
+ readonly html: string;
121
+ readonly sourceFile: string;
122
+ readonly outputRoot: string;
123
+ readonly lessonSlug: string;
124
+ readonly canonicalBaseUrl?: string;
125
+ }
126
+
127
+ interface BrowserBundle {
128
+ readonly code: string;
129
+ readonly css: readonly string[];
130
+ }
131
+
132
+ interface RegistryRequest {
133
+ readonly path: string;
134
+ readonly exportPath: readonly string[];
135
+ readonly key: string;
136
+ readonly island: HtmlElement;
137
+ readonly attributeName: string;
138
+ }
139
+
140
+ interface ExportContext {
141
+ readonly outputRoot: string;
142
+ readonly sourceFile: string;
143
+ readonly assetData: Map<string, string>;
144
+ }
145
+
146
+ function isElement(node: HtmlNode): node is HtmlElement {
147
+ return 'tagName' in node;
148
+ }
149
+
150
+ function childrenOf(node: HtmlNode): readonly HtmlChild[] {
151
+ return 'childNodes' in node ? node.childNodes : [];
152
+ }
153
+
154
+ function walk(node: HtmlNode, visit: (node: HtmlNode) => void): void {
155
+ visit(node);
156
+ for (const child of childrenOf(node)) walk(child, visit);
157
+ if (isElement(node) && node.tagName === 'template') {
158
+ const template = node as DefaultTreeAdapterMap['template'];
159
+ for (const child of template.content.childNodes) walk(child, visit);
160
+ }
161
+ }
162
+
163
+ function elements(root: HtmlNode, predicate?: (node: HtmlElement) => boolean): HtmlElement[] {
164
+ const found: HtmlElement[] = [];
165
+ walk(root, (node) => {
166
+ if (isElement(node) && (predicate?.(node) ?? true)) found.push(node);
167
+ });
168
+ return found;
169
+ }
170
+
171
+ function firstElement(root: HtmlNode, tagName: string): HtmlElement {
172
+ const found = elements(root, (node) => node.tagName === tagName)[0];
173
+ if (!found) throw new Error(`Standalone HTML source has no <${tagName}> element.`);
174
+ return found;
175
+ }
176
+
177
+ function attribute(node: HtmlElement, name: string): string | undefined {
178
+ return node.attrs.find((entry) => entry.name === name)?.value;
179
+ }
180
+
181
+ function hasClass(node: HtmlElement, name: string): boolean {
182
+ return (attribute(node, 'class') ?? '').split(/\s+/u).includes(name);
183
+ }
184
+
185
+ function setAttribute(node: HtmlElement, name: string, value = ''): void {
186
+ const existing = node.attrs.find((entry) => entry.name === name);
187
+ if (existing) existing.value = value;
188
+ else node.attrs.push({ name, value });
189
+ }
190
+
191
+ function removeAttribute(node: HtmlElement, name: string): void {
192
+ const index = node.attrs.findIndex((entry) => entry.name === name);
193
+ if (index !== -1) node.attrs.splice(index, 1);
194
+ }
195
+
196
+ function removeNode(node: HtmlElement): void {
197
+ const parent = node.parentNode;
198
+ if (!parent || !('childNodes' in parent)) return;
199
+ const index = parent.childNodes.indexOf(node);
200
+ if (index !== -1) parent.childNodes.splice(index, 1);
201
+ node.parentNode = null;
202
+ }
203
+
204
+ function textElement(tagName: string, text: string): HtmlElement {
205
+ const element: HtmlElement = {
206
+ nodeName: tagName,
207
+ tagName,
208
+ attrs: [],
209
+ namespaceURI: HTML_NAMESPACE,
210
+ parentNode: null,
211
+ childNodes: [],
212
+ };
213
+ const textNode: HtmlText = {
214
+ nodeName: '#text',
215
+ value: text,
216
+ parentNode: element,
217
+ };
218
+ element.childNodes.push(textNode);
219
+ return element;
220
+ }
221
+
222
+ function append(parent: HtmlParent, child: HtmlChild): void {
223
+ parent.childNodes.push(child);
224
+ if ('parentNode' in child) child.parentNode = parent;
225
+ }
226
+
227
+ function replaceNode(node: HtmlElement, replacement: HtmlElement): void {
228
+ const parent = node.parentNode;
229
+ if (!parent || !('childNodes' in parent)) {
230
+ throw new Error(`Cannot replace detached <${node.tagName}>.`);
231
+ }
232
+ const index = parent.childNodes.indexOf(node);
233
+ if (index === -1) throw new Error(`Cannot find <${node.tagName}> in its parent.`);
234
+ replacement.parentNode = parent;
235
+ parent.childNodes[index] = replacement;
236
+ node.parentNode = null;
237
+ }
238
+
239
+ function textContent(node: HtmlElement): string {
240
+ return node.childNodes
241
+ .filter((child): child is HtmlText => child.nodeName === '#text')
242
+ .map((child) => child.value)
243
+ .join('');
244
+ }
245
+
246
+ function setTextContent(node: HtmlElement, text: string): void {
247
+ node.childNodes.splice(0, node.childNodes.length);
248
+ const child: HtmlText = { nodeName: '#text', value: text, parentNode: node };
249
+ node.childNodes.push(child);
250
+ }
251
+
252
+ function insideRoot(path: string, root: string): boolean {
253
+ const rel = relative(root, path);
254
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !rel.startsWith(sep));
255
+ }
256
+
257
+ async function isFile(path: string): Promise<boolean> {
258
+ try {
259
+ return (await stat(path)).isFile();
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ function cleanReference(reference: string): string {
266
+ return reference.split(/[?#]/u, 1)[0] ?? '';
267
+ }
268
+
269
+ function isEmbeddedReference(reference: string): boolean {
270
+ return /^(?:data:|#)/iu.test(reference.trim());
271
+ }
272
+
273
+ function hasScheme(reference: string): boolean {
274
+ return /^[a-z][a-z\d+.-]*:/iu.test(reference.trim());
275
+ }
276
+
277
+ async function resolveLocalFile(
278
+ reference: string,
279
+ declaringFile: string,
280
+ outputRoot: string,
281
+ ): Promise<string> {
282
+ const trimmed = reference.trim();
283
+ if (!trimmed || isEmbeddedReference(trimmed)) {
284
+ throw new Error(`Expected a local file reference, received ${JSON.stringify(reference)}.`);
285
+ }
286
+ if (trimmed.startsWith('//') || hasScheme(trimmed)) {
287
+ throw new Error(
288
+ `Standalone HTML cannot load remote render dependency ${JSON.stringify(reference)}.`,
289
+ );
290
+ }
291
+
292
+ let decoded: string;
293
+ try {
294
+ decoded = decodeURIComponent(cleanReference(trimmed));
295
+ } catch {
296
+ throw new Error(`Malformed asset URL in standalone HTML: ${JSON.stringify(reference)}.`);
297
+ }
298
+
299
+ const candidates: string[] = [];
300
+ if (decoded.startsWith('/')) {
301
+ const segments = decoded.split('/').filter(Boolean);
302
+ for (let index = 0; index < segments.length; index += 1) {
303
+ candidates.push(resolve(outputRoot, ...segments.slice(index)));
304
+ }
305
+ } else {
306
+ candidates.push(resolve(dirname(declaringFile), decoded));
307
+ }
308
+
309
+ for (const candidate of candidates) {
310
+ if (insideRoot(candidate, outputRoot) && (await isFile(candidate))) return candidate;
311
+ }
312
+ throw new Error(
313
+ `Standalone HTML could not resolve render dependency ${JSON.stringify(reference)} ` +
314
+ `from ${relative(outputRoot, declaringFile)}.`,
315
+ );
316
+ }
317
+
318
+ function mimeType(path: string): string {
319
+ const type = MIME_TYPES[extname(path).toLowerCase()];
320
+ if (!type) {
321
+ throw new Error(
322
+ `Standalone HTML does not know the MIME type for ${JSON.stringify(path)}.`,
323
+ );
324
+ }
325
+ return type;
326
+ }
327
+
328
+ async function assetDataUrl(path: string, context: ExportContext): Promise<string> {
329
+ const cached = context.assetData.get(path);
330
+ if (cached) return cached;
331
+ const data = `data:${mimeType(path)};base64,${(await readFile(path)).toString('base64')}`;
332
+ context.assetData.set(path, data);
333
+ return data;
334
+ }
335
+
336
+ function splitCssSources(value: string): string[] {
337
+ const sources: string[] = [];
338
+ let start = 0;
339
+ let depth = 0;
340
+ let quote = '';
341
+ for (let index = 0; index < value.length; index += 1) {
342
+ const character = value[index] ?? '';
343
+ if (quote) {
344
+ if (character === quote && value[index - 1] !== '\\') quote = '';
345
+ } else if (character === '"' || character === "'") {
346
+ quote = character;
347
+ } else if (character === '(') {
348
+ depth += 1;
349
+ } else if (character === ')') {
350
+ depth = Math.max(0, depth - 1);
351
+ } else if (character === ',' && depth === 0) {
352
+ sources.push(value.slice(start, index).trim());
353
+ start = index + 1;
354
+ }
355
+ }
356
+ sources.push(value.slice(start).trim());
357
+ return sources.filter(Boolean);
358
+ }
359
+
360
+ /** Prefer one modern font payload instead of embedding every fallback format. */
361
+ function modernFontSources(css: string): string {
362
+ return css.replace(/@font-face\s*\{[\s\S]*?\}/giu, (block) =>
363
+ block.replace(/\bsrc\s*:\s*([^;]+);/iu, (declaration, value: string) => {
364
+ const sources = splitCssSources(value);
365
+ const modern = sources.find(
366
+ (source) => /format\(\s*["']?woff2["']?\s*\)/iu.test(source) || /\.woff2(?:[?#"')]|$)/iu.test(source),
367
+ );
368
+ return modern ? `src: ${modern};` : declaration;
369
+ }),
370
+ );
371
+ }
372
+
373
+ async function inlineCssUrls(
374
+ css: string,
375
+ declaringFile: string,
376
+ context: ExportContext,
377
+ importStack: ReadonlySet<string> = new Set(),
378
+ ): Promise<string> {
379
+ let imported = '';
380
+ let importIndex = 0;
381
+ const importPattern = new RegExp(CSS_IMPORT.source, CSS_IMPORT.flags);
382
+ for (
383
+ let match = importPattern.exec(css);
384
+ match;
385
+ match = importPattern.exec(css)
386
+ ) {
387
+ imported += css.slice(importIndex, match.index);
388
+ const reference = (match[2] ?? match[3] ?? match[5] ?? '').trim();
389
+ if (reference.startsWith('data:')) {
390
+ imported += match[0];
391
+ } else {
392
+ const path = await resolveLocalFile(reference, declaringFile, context.outputRoot);
393
+ if (importStack.has(path)) {
394
+ throw new Error(
395
+ `Standalone HTML found a circular CSS @import through ${relative(context.outputRoot, path)}.`,
396
+ );
397
+ }
398
+ const nested = await inlineCssUrls(await readFile(path, 'utf8'), path, context, new Set([...importStack, path]));
399
+ const media = (match[6] ?? '').trim();
400
+ imported += media ? `@media ${media}{${nested}}` : nested;
401
+ }
402
+ importIndex = match.index + match[0].length;
403
+ }
404
+ imported += css.slice(importIndex);
405
+
406
+ const source = modernFontSources(imported);
407
+ let rewritten = '';
408
+ let lastIndex = 0;
409
+ CSS_URL.lastIndex = 0;
410
+ for (let match = CSS_URL.exec(source); match; match = CSS_URL.exec(source)) {
411
+ rewritten += source.slice(lastIndex, match.index);
412
+ const reference = (match[2] ?? match[3] ?? '').trim();
413
+ if (!reference || isEmbeddedReference(reference)) {
414
+ rewritten += match[0];
415
+ } else {
416
+ const file = await resolveLocalFile(reference, declaringFile, context.outputRoot);
417
+ rewritten += `url("${await assetDataUrl(file, context)}")`;
418
+ }
419
+ lastIndex = match.index + match[0].length;
420
+ }
421
+ return rewritten + source.slice(lastIndex);
422
+ }
423
+
424
+ async function bundleBrowserGraph(
425
+ entryPaths: readonly string[],
426
+ requests: readonly RegistryRequest[],
427
+ context: ExportContext,
428
+ ): Promise<BrowserBundle> {
429
+ const moduleIds = new Map<string, string>();
430
+ for (const request of requests) {
431
+ if (!moduleIds.has(request.path)) {
432
+ moduleIds.set(request.path, `standaloneModule${moduleIds.size}`);
433
+ }
434
+ }
435
+ const entryImports = entryPaths.map((path) => `import ${JSON.stringify(path)};`);
436
+ const moduleImports = [...moduleIds].map(
437
+ ([path, identifier]) => `import * as ${identifier} from ${JSON.stringify(path)};`,
438
+ );
439
+ const assignments = requests.map((request) => {
440
+ const identifier = moduleIds.get(request.path)!;
441
+ const selected = request.exportPath.reduce(
442
+ (expression, part) => `${expression}[${JSON.stringify(part)}]`,
443
+ identifier,
444
+ );
445
+ return `standaloneRegistry[${JSON.stringify(request.key)}] = ${selected};`;
446
+ });
447
+ const source = [
448
+ ...entryImports,
449
+ ...moduleImports,
450
+ `const standaloneRegistry = globalThis[Symbol.for('explico.standalone.modules')] = Object.create(null);`,
451
+ ...assignments,
452
+ ].join('\n');
453
+
454
+ const result = await build({
455
+ absWorkingDir: context.outputRoot,
456
+ bundle: true,
457
+ format: 'esm',
458
+ stdin: {
459
+ contents: source,
460
+ loader: 'js',
461
+ resolveDir: context.outputRoot,
462
+ sourcefile: 'explico-standalone-browser-entry.js',
463
+ },
464
+ legalComments: 'none',
465
+ loader: {
466
+ '.avif': 'dataurl',
467
+ '.gif': 'dataurl',
468
+ '.jpeg': 'dataurl',
469
+ '.jpg': 'dataurl',
470
+ '.mp3': 'dataurl',
471
+ '.mp4': 'dataurl',
472
+ '.ogg': 'dataurl',
473
+ '.otf': 'dataurl',
474
+ '.png': 'dataurl',
475
+ '.svg': 'dataurl',
476
+ '.ttf': 'dataurl',
477
+ '.wasm': 'dataurl',
478
+ '.wav': 'dataurl',
479
+ '.webm': 'dataurl',
480
+ '.webp': 'dataurl',
481
+ '.woff': 'dataurl',
482
+ '.woff2': 'dataurl',
483
+ },
484
+ logLevel: 'silent',
485
+ outdir: join(context.outputRoot, '.explico-browser-bundle'),
486
+ platform: 'browser',
487
+ target: 'es2022',
488
+ write: false,
489
+ });
490
+ const javascript = result.outputFiles.find((file) => file.path.endsWith('.js'));
491
+ if (!javascript) {
492
+ throw new Error('Browser bundler produced no JavaScript for the standalone lesson.');
493
+ }
494
+ const unsupported = result.outputFiles.filter(
495
+ (file) => !file.path.endsWith('.js') && !file.path.endsWith('.css'),
496
+ );
497
+ if (unsupported.length > 0) {
498
+ throw new Error(
499
+ `Browser bundler emitted unsupported standalone assets for ` +
500
+ `the lesson: ${unsupported.map((file) => file.path).join(', ')}.`,
501
+ );
502
+ }
503
+ const code = javascript.text.replace(/<\/script/giu, '<\\/script');
504
+ return {
505
+ code,
506
+ css: result.outputFiles
507
+ .filter((file) => file.path.endsWith('.css'))
508
+ .map((file) => file.text),
509
+ };
510
+ }
511
+
512
+ function inferBasePrefix(document: HtmlNode): string {
513
+ for (const node of elements(document)) {
514
+ for (const name of ['href', 'src', 'component-url', 'renderer-url']) {
515
+ const value = attribute(node, name);
516
+ if (!value?.startsWith('/')) continue;
517
+ const marker = value.indexOf('/astro-assets/');
518
+ if (marker !== -1) return `${value.slice(0, marker)}/`;
519
+ }
520
+ }
521
+ return '/';
522
+ }
523
+
524
+ function normalizedPagePath(pathname: string): string {
525
+ return pathname.replace(/\/index\.html$/iu, '/').replace(/\.html$/iu, '').replace(/\/+$/u, '');
526
+ }
527
+
528
+ function rewriteCourseLink(
529
+ href: string,
530
+ lessonSlug: string,
531
+ basePrefix: string,
532
+ canonicalBaseUrl: string | undefined,
533
+ ): string {
534
+ const trimmed = href.trim();
535
+ if (!trimmed || trimmed.startsWith('#')) return href;
536
+ if (/^(?:https?:|mailto:|tel:)/iu.test(trimmed) || trimmed.startsWith('//')) return href;
537
+ if (hasScheme(trimmed)) {
538
+ throw new Error(`Standalone HTML cannot publish local navigation URL ${JSON.stringify(href)}.`);
539
+ }
540
+
541
+ const pagePath = `${basePrefix}${lessonSlug}/`.replace(/\/{2,}/gu, '/');
542
+ const resolved = new URL(trimmed, `https://explico.invalid${pagePath}`);
543
+ if (normalizedPagePath(resolved.pathname) === normalizedPagePath(pagePath)) {
544
+ return `${resolved.search}${resolved.hash}` || '#_top';
545
+ }
546
+ if (!canonicalBaseUrl) {
547
+ throw new Error(
548
+ `Lesson ${lessonSlug} links outside itself (${JSON.stringify(href)}), so ` +
549
+ '`lessonExports.canonicalBaseUrl` is required for standalone HTML.',
550
+ );
551
+ }
552
+ let coursePath = resolved.pathname;
553
+ if (coursePath.startsWith(basePrefix)) coursePath = coursePath.slice(basePrefix.length);
554
+ const canonical = new URL(coursePath.replace(/^\/+/, ''), canonicalBaseUrl);
555
+ canonical.search = resolved.search;
556
+ canonical.hash = resolved.hash;
557
+ return canonical.toString();
558
+ }
559
+
560
+ function reduceToLessonShell(document: HtmlNode): void {
561
+ const html = firstElement(document, 'html');
562
+ const body = firstElement(document, 'body');
563
+ const main = firstElement(body, 'main');
564
+ const skipLink = elements(body, (node) => hasClass(node, 'sl-skip-link'))[0];
565
+ const themeSelect = elements(
566
+ body,
567
+ (node) => node.tagName === 'starlight-theme-select',
568
+ )[0];
569
+
570
+ for (const chrome of elements(main, (node) => attribute(node, 'data-explico-export-chrome') !== undefined)) {
571
+ removeNode(chrome);
572
+ }
573
+ removeAttribute(main, 'data-pagefind-body');
574
+ setAttribute(html, 'data-explico-standalone');
575
+ removeAttribute(html, 'data-has-sidebar');
576
+ removeAttribute(html, 'data-has-toc');
577
+
578
+ body.childNodes.splice(0, body.childNodes.length);
579
+ if (skipLink) append(body, skipLink);
580
+ if (themeSelect) {
581
+ const themeControl = textElement('div', '');
582
+ themeControl.childNodes.splice(0, themeControl.childNodes.length);
583
+ setAttribute(themeControl, 'class', 'explico-standalone-theme');
584
+ setAttribute(themeControl, 'data-explico-standalone-theme');
585
+ setAttribute(themeControl, 'role', 'group');
586
+ setAttribute(themeControl, 'aria-label', 'Appearance');
587
+ append(themeControl, themeSelect);
588
+ append(body, themeControl);
589
+ const themeScript = textElement('script', STANDALONE_THEME_SCRIPT);
590
+ setAttribute(themeScript, 'data-explico-standalone-theme-script');
591
+ append(body, themeScript);
592
+ }
593
+ append(body, main);
594
+ }
595
+
596
+ async function inlineStyles(document: HtmlNode, context: ExportContext): Promise<void> {
597
+ for (const link of elements(document, (node) => node.tagName === 'link')) {
598
+ const rel = (attribute(link, 'rel') ?? '').toLowerCase().split(/\s+/u);
599
+ if (rel.includes('stylesheet')) {
600
+ const href = attribute(link, 'href');
601
+ if (!href) throw new Error('Standalone HTML found a stylesheet without href.');
602
+ const path = await resolveLocalFile(href, context.sourceFile, context.outputRoot);
603
+ const style = textElement(
604
+ 'style',
605
+ (await inlineCssUrls(await readFile(path, 'utf8'), path, context)).replace(
606
+ /<\/style/giu,
607
+ '<\\/style',
608
+ ),
609
+ );
610
+ const media = attribute(link, 'media');
611
+ if (media) setAttribute(style, 'media', media);
612
+ replaceNode(link, style);
613
+ } else if (
614
+ rel.some((value) =>
615
+ ['icon', 'modulepreload', 'preload', 'prefetch'].includes(value),
616
+ )
617
+ ) {
618
+ removeNode(link);
619
+ }
620
+ }
621
+
622
+ for (const style of elements(document, (node) => node.tagName === 'style')) {
623
+ setTextContent(
624
+ style,
625
+ (await inlineCssUrls(textContent(style), context.sourceFile, context)).replace(
626
+ /<\/style/giu,
627
+ '<\\/style',
628
+ ),
629
+ );
630
+ }
631
+ }
632
+
633
+ async function inlineScripts(document: HtmlNode, context: ExportContext): Promise<void> {
634
+ const head = firstElement(document, 'head');
635
+ const entryPaths: string[] = [];
636
+ let insertionPoint: HtmlElement | undefined;
637
+ for (const script of elements(document, (node) => node.tagName === 'script')) {
638
+ const src = attribute(script, 'src');
639
+ if (!src) continue;
640
+ const path = await resolveLocalFile(src, context.sourceFile, context.outputRoot);
641
+ const source = await readFile(path, 'utf8');
642
+ // Astro's page-prefetch entry belongs to navigation that the lesson shell
643
+ // intentionally removed. Keeping it would turn link hover into networking.
644
+ if (source.includes('dataset.astroPrefetch') && source.includes('prefetch')) {
645
+ removeNode(script);
646
+ continue;
647
+ }
648
+ entryPaths.push(path);
649
+ if (!insertionPoint) insertionPoint = script;
650
+ else removeNode(script);
651
+ }
652
+
653
+ const requests: RegistryRequest[] = [];
654
+ let requestNumber = 0;
655
+ for (const island of elements(document, (node) => node.tagName === 'astro-island')) {
656
+ for (const name of ['component-url', 'renderer-url', 'before-hydration-url']) {
657
+ const reference = attribute(island, name);
658
+ if (!reference) continue;
659
+ const path = await resolveLocalFile(reference, context.sourceFile, context.outputRoot);
660
+ const key = `module-${requestNumber++}`;
661
+ requests.push({
662
+ path,
663
+ exportPath:
664
+ name === 'component-url'
665
+ ? (attribute(island, 'component-export') ?? 'default').split('.')
666
+ : name === 'renderer-url'
667
+ ? ['default']
668
+ : [],
669
+ key,
670
+ island,
671
+ attributeName: name,
672
+ });
673
+ if (name === 'component-url') setAttribute(island, 'component-export', 'default');
674
+ }
675
+ }
676
+
677
+ if (entryPaths.length === 0 && requests.length === 0) return;
678
+ const bundle = await bundleBrowserGraph(entryPaths, requests, context);
679
+ for (const css of bundle.css) {
680
+ append(
681
+ head,
682
+ textElement(
683
+ 'style',
684
+ (await inlineCssUrls(css, context.sourceFile, context)).replace(
685
+ /<\/style/giu,
686
+ '<\\/style',
687
+ ),
688
+ ),
689
+ );
690
+ }
691
+
692
+ const graphScript = insertionPoint ?? textElement('script', '');
693
+ removeAttribute(graphScript, 'src');
694
+ removeAttribute(graphScript, 'integrity');
695
+ removeAttribute(graphScript, 'crossorigin');
696
+ setAttribute(graphScript, 'type', 'module');
697
+ setAttribute(graphScript, 'data-explico-standalone-browser-graph');
698
+ setTextContent(graphScript, bundle.code);
699
+ if (!insertionPoint) append(head, graphScript);
700
+
701
+ for (const request of requests) {
702
+ const adapter =
703
+ `const registry=globalThis[Symbol.for('explico.standalone.modules')];` +
704
+ `if(!registry)throw new Error('Explico standalone module registry is unavailable');` +
705
+ `const value=registry[${JSON.stringify(request.key)}];export{value as default};`;
706
+ setAttribute(
707
+ request.island,
708
+ request.attributeName,
709
+ `data:text/javascript;base64,${Buffer.from(adapter).toString('base64')}`,
710
+ );
711
+ }
712
+ }
713
+
714
+ async function inlineResourceAttribute(
715
+ node: HtmlElement,
716
+ name: string,
717
+ context: ExportContext,
718
+ ): Promise<void> {
719
+ const reference = attribute(node, name);
720
+ if (!reference || isEmbeddedReference(reference)) return;
721
+ const path = await resolveLocalFile(reference, context.sourceFile, context.outputRoot);
722
+ setAttribute(node, name, await assetDataUrl(path, context));
723
+ }
724
+
725
+ function parseSourceSet(value: string): readonly { url: string; descriptor: string }[] {
726
+ const candidates = value.match(/(?:[^\s,]+)(?:\s+(?:\d+w|\d+(?:\.\d+)?x))?/gu) ?? [];
727
+ return candidates.map((candidate) => {
728
+ const [url = '', descriptor = ''] = candidate.trim().split(/\s+/, 2);
729
+ return { url, descriptor };
730
+ });
731
+ }
732
+
733
+ async function inlineMedia(document: HtmlNode, context: ExportContext): Promise<void> {
734
+ const resources: Readonly<Record<string, readonly string[]>> = {
735
+ audio: ['src'],
736
+ embed: ['src'],
737
+ img: ['src'],
738
+ input: ['src'],
739
+ link: ['href'],
740
+ object: ['data'],
741
+ source: ['src'],
742
+ track: ['src'],
743
+ video: ['src', 'poster'],
744
+ };
745
+ for (const node of elements(document)) {
746
+ if (node.tagName === 'iframe' && attribute(node, 'src')) {
747
+ throw new Error(
748
+ 'Standalone HTML does not support an iframe src; use self-contained semantic DOM instead.',
749
+ );
750
+ }
751
+ const names = resources[node.tagName] ?? [];
752
+ for (const name of names) await inlineResourceAttribute(node, name, context);
753
+ if (node.tagName === 'use' || node.tagName === 'image') {
754
+ for (const name of ['href', 'xlink:href']) {
755
+ const reference = attribute(node, name);
756
+ if (!reference || isEmbeddedReference(reference)) continue;
757
+ const hash = reference.includes('#') ? `#${reference.split('#').slice(1).join('#')}` : '';
758
+ const path = await resolveLocalFile(
759
+ reference,
760
+ context.sourceFile,
761
+ context.outputRoot,
762
+ );
763
+ setAttribute(node, name, `${await assetDataUrl(path, context)}${hash}`);
764
+ }
765
+ }
766
+ for (const name of ['srcset', 'imagesrcset']) {
767
+ const value = attribute(node, name);
768
+ if (!value) continue;
769
+ const inlined: string[] = [];
770
+ for (const candidate of parseSourceSet(value)) {
771
+ const path = await resolveLocalFile(
772
+ candidate.url,
773
+ context.sourceFile,
774
+ context.outputRoot,
775
+ );
776
+ inlined.push(
777
+ `${await assetDataUrl(path, context)}${candidate.descriptor ? ` ${candidate.descriptor}` : ''}`,
778
+ );
779
+ }
780
+ setAttribute(node, name, inlined.join(', '));
781
+ }
782
+ const style = attribute(node, 'style');
783
+ if (style) setAttribute(node, 'style', await inlineCssUrls(style, context.sourceFile, context));
784
+ }
785
+ }
786
+
787
+ function rewriteNavigation(
788
+ document: HtmlNode,
789
+ lessonSlug: string,
790
+ basePrefix: string,
791
+ canonicalBaseUrl: string | undefined,
792
+ ): void {
793
+ for (const anchor of elements(document, (node) => node.tagName === 'a')) {
794
+ const href = attribute(anchor, 'href');
795
+ if (href) {
796
+ setAttribute(
797
+ anchor,
798
+ 'href',
799
+ rewriteCourseLink(href, lessonSlug, basePrefix, canonicalBaseUrl),
800
+ );
801
+ }
802
+ }
803
+ for (const node of elements(document)) {
804
+ const href = attribute(node, 'data-notation-href');
805
+ if (href) {
806
+ setAttribute(
807
+ node,
808
+ 'data-notation-href',
809
+ rewriteCourseLink(href, lessonSlug, basePrefix, canonicalBaseUrl),
810
+ );
811
+ }
812
+ }
813
+ }
814
+
815
+ function auditStandaloneHtml(html: string, lessonSlug: string): void {
816
+ const document = parse(html);
817
+ const failures: string[] = [];
818
+ for (const node of elements(document)) {
819
+ if (node.tagName === 'script' && attribute(node, 'src')) {
820
+ failures.push(`<script src=${JSON.stringify(attribute(node, 'src'))}>`);
821
+ }
822
+ if (node.tagName === 'link') {
823
+ const rel = (attribute(node, 'rel') ?? '').toLowerCase();
824
+ if (/\b(?:stylesheet|modulepreload|preload|prefetch)\b/u.test(rel)) {
825
+ failures.push(`<link rel=${JSON.stringify(rel)}>`);
826
+ }
827
+ }
828
+ if (node.tagName === 'astro-island') {
829
+ for (const name of ['component-url', 'renderer-url', 'before-hydration-url']) {
830
+ const value = attribute(node, name);
831
+ if (value && !value.startsWith('data:text/javascript;base64,')) {
832
+ failures.push(`<astro-island ${name}=${JSON.stringify(value)}>`);
833
+ }
834
+ }
835
+ }
836
+ for (const name of ['src', 'poster', 'data', 'srcset', 'imagesrcset']) {
837
+ const value = attribute(node, name);
838
+ if (value && !value.startsWith('data:') && !value.startsWith('#')) {
839
+ failures.push(`<${node.tagName} ${name}=${JSON.stringify(value)}>`);
840
+ }
841
+ }
842
+ if (node.tagName === 'use' || node.tagName === 'image') {
843
+ for (const name of ['href', 'xlink:href']) {
844
+ const value = attribute(node, name);
845
+ if (value && !value.startsWith('data:') && !value.startsWith('#')) {
846
+ failures.push(`<${node.tagName} ${name}=${JSON.stringify(value)}>`);
847
+ }
848
+ }
849
+ }
850
+ const css =
851
+ node.tagName === 'style' ? textContent(node) : (attribute(node, 'style') ?? '');
852
+ CSS_IMPORT.lastIndex = 0;
853
+ for (let match = CSS_IMPORT.exec(css); match; match = CSS_IMPORT.exec(css)) {
854
+ const value = (match[2] ?? match[3] ?? match[5] ?? '').trim();
855
+ if (value && !value.startsWith('data:')) failures.push(`CSS @import ${value}`);
856
+ }
857
+ CSS_URL.lastIndex = 0;
858
+ for (let match = CSS_URL.exec(css); match; match = CSS_URL.exec(css)) {
859
+ const value = (match[2] ?? match[3] ?? '').trim();
860
+ if (value && !isEmbeddedReference(value)) failures.push(`CSS url(${value})`);
861
+ }
862
+ if (node.tagName === 'script') {
863
+ const code = textContent(node);
864
+ LOCAL_IMPORT.lastIndex = 0;
865
+ const imported = LOCAL_IMPORT.exec(code)?.[2];
866
+ if (imported) failures.push(`JavaScript import ${JSON.stringify(imported)}`);
867
+ if (
868
+ attribute(node, 'data-explico-standalone-browser-graph') !== undefined &&
869
+ /\bimport\s*\(/u.test(code)
870
+ ) {
871
+ failures.push('unbundled dynamic import in browser graph');
872
+ }
873
+ if (/new\s+(?:Shared)?Worker\s*\(\s*["']/u.test(code)) {
874
+ failures.push('literal worker URL');
875
+ }
876
+ if (/new\s+URL\s*\(\s*["'][^"']+["']\s*,\s*import\.meta\.url/u.test(code)) {
877
+ failures.push('import.meta.url asset');
878
+ }
879
+ }
880
+ }
881
+ if (failures.length > 0) {
882
+ throw new Error(
883
+ `Standalone HTML audit failed for ${lessonSlug}: ${[...new Set(failures)].join(', ')}.`,
884
+ );
885
+ }
886
+ }
887
+
888
+ /** Build one fully inlined lesson document without writing it. */
889
+ export async function buildStandaloneLessonHtml(
890
+ input: StandaloneLessonInput,
891
+ ): Promise<string> {
892
+ const outputRoot = resolve(input.outputRoot);
893
+ const sourceFile = resolve(input.sourceFile);
894
+ if (!insideRoot(sourceFile, outputRoot)) {
895
+ throw new Error('Standalone lesson source must be inside Astro build output.');
896
+ }
897
+ const context: ExportContext = {
898
+ outputRoot,
899
+ sourceFile,
900
+ assetData: new Map(),
901
+ };
902
+ const document = parse(input.html);
903
+ const basePrefix = inferBasePrefix(document);
904
+ reduceToLessonShell(document);
905
+ const head = firstElement(document, 'head');
906
+ const exportStyle = textElement('style', STANDALONE_STYLE);
907
+ setAttribute(exportStyle, 'data-explico-standalone-style');
908
+ append(head, exportStyle);
909
+ rewriteNavigation(
910
+ document,
911
+ input.lessonSlug,
912
+ basePrefix,
913
+ input.canonicalBaseUrl,
914
+ );
915
+ await inlineStyles(document, context);
916
+ await inlineScripts(document, context);
917
+ await inlineMedia(document, context);
918
+
919
+ const html = serialize(document);
920
+ auditStandaloneHtml(html, input.lessonSlug);
921
+ return html;
922
+ }
923
+
924
+ async function lessonSourceFile(outputRoot: string, slug: string): Promise<string> {
925
+ const candidates = [join(outputRoot, slug, 'index.html'), join(outputRoot, `${slug}.html`)];
926
+ for (const candidate of candidates) if (await isFile(candidate)) return candidate;
927
+ throw new Error(
928
+ `Standalone HTML could not find Astro's built page for ${slug}; tried ` +
929
+ candidates.map((path) => relative(outputRoot, path)).join(', '),
930
+ );
931
+ }
932
+
933
+ /** Emit all opt-in lesson HTML artifacts from Astro's completed static output. */
934
+ export async function writeStandaloneLessonExports(
935
+ directory: URL,
936
+ manifest: Manifest,
937
+ ): Promise<void> {
938
+ if (!manifest.lessonExports.standaloneHtml) return;
939
+ const outputRoot = fileURLToPath(directory);
940
+ const pending: { target: string; temporary: string; html: string }[] = [];
941
+ for (const lesson of manifest.lessons) {
942
+ const sourceFile = await lessonSourceFile(outputRoot, lesson.slug);
943
+ const target = join(outputRoot, 'downloads', `${lesson.slug}.html`);
944
+ pending.push({
945
+ target,
946
+ temporary: `${target}.tmp-${process.pid}`,
947
+ html: await buildStandaloneLessonHtml({
948
+ html: await readFile(sourceFile, 'utf8'),
949
+ sourceFile,
950
+ outputRoot,
951
+ lessonSlug: lesson.slug,
952
+ canonicalBaseUrl: manifest.lessonExports.canonicalBaseUrl,
953
+ }),
954
+ });
955
+ }
956
+
957
+ // Nothing is published until every lesson has passed the asset audit.
958
+ for (const artifact of pending) {
959
+ await mkdir(dirname(artifact.target), { recursive: true });
960
+ await writeFile(artifact.temporary, artifact.html, 'utf8');
961
+ }
962
+ for (const artifact of pending) await rename(artifact.temporary, artifact.target);
963
+ }