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.
- package/CHANGELOG.md +1137 -1
- package/README.md +91 -13
- package/dist/compiler/astro.d.ts +4 -3
- package/dist/compiler/astro.js +45 -15
- package/dist/compiler/cli.d.ts +26 -12
- package/dist/compiler/cli.js +96 -25
- package/dist/compiler/collections.d.ts +9 -1
- package/dist/compiler/collections.js +55 -16
- package/dist/compiler/content-schema.d.ts +31 -0
- package/dist/compiler/content-schema.js +82 -0
- package/dist/compiler/content-schemas.d.ts +422 -0
- package/dist/compiler/content-schemas.js +229 -0
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +30 -2
- package/dist/compiler/exports/combined-html.d.ts +97 -0
- package/dist/compiler/exports/combined-html.js +492 -0
- package/dist/compiler/exports/download-index.d.ts +9 -0
- package/dist/compiler/exports/download-index.js +33 -0
- package/dist/compiler/exports/export-chapters.d.ts +98 -0
- package/dist/compiler/exports/export-chapters.js +164 -0
- package/dist/compiler/exports/export-pages.d.ts +48 -0
- package/dist/compiler/exports/export-pages.js +22 -0
- package/dist/compiler/exports/html-tree.d.ts +22 -0
- package/dist/compiler/exports/html-tree.js +47 -0
- package/dist/compiler/exports/latex/capture.d.ts +106 -0
- package/dist/compiler/exports/latex/capture.js +435 -0
- package/dist/compiler/exports/latex/compile.d.ts +73 -0
- package/dist/compiler/exports/latex/compile.js +115 -0
- package/dist/compiler/exports/latex/document.d.ts +102 -0
- package/dist/compiler/exports/latex/document.js +349 -0
- package/dist/compiler/exports/latex/explico.cls +415 -0
- package/dist/compiler/exports/latex/numbering.d.ts +18 -0
- package/dist/compiler/exports/latex/numbering.js +57 -0
- package/dist/compiler/exports/latex/serialize.d.ts +112 -0
- package/dist/compiler/exports/latex/serialize.js +606 -0
- package/dist/compiler/exports/latex-export.d.ts +44 -0
- package/dist/compiler/exports/latex-export.js +169 -0
- package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
- package/dist/compiler/exports/markdown/serialize.js +527 -0
- package/dist/compiler/exports/markdown-export.d.ts +39 -0
- package/dist/compiler/exports/markdown-export.js +240 -0
- package/dist/compiler/exports/standalone-html.d.ts +14 -5
- package/dist/compiler/exports/standalone-html.js +23 -51
- package/dist/compiler/exports/static-tree.d.ts +253 -0
- package/dist/compiler/exports/static-tree.js +625 -0
- package/dist/compiler/exports/zip.d.ts +16 -0
- package/dist/compiler/exports/zip.js +72 -0
- package/dist/compiler/lesson-derivation.d.ts +8 -2
- package/dist/compiler/lesson-derivation.js +6 -7
- package/dist/compiler/manifest.d.ts +87 -6
- package/dist/compiler/manifest.js +174 -31
- package/dist/compiler/render-diagnostics.d.ts +65 -0
- package/dist/compiler/render-diagnostics.js +56 -0
- package/dist/compiler/resolution-report.d.ts +2 -1
- package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
- package/dist/curriculum/validation.d.ts +9 -3
- package/dist/curriculum/validation.js +35 -17
- package/dist/reference/base-abbreviations.d.mts +2 -2
- package/dist/reference/base-abbreviations.mjs +2 -2
- package/dist/reference/base-operators.d.mts +2 -2
- package/dist/reference/base-operators.mjs +2 -2
- package/dist/reference/base-roman-labels.d.mts +4 -3
- package/dist/reference/base-roman-labels.mjs +4 -3
- package/dist/reference/citation-format.d.mts +35 -0
- package/dist/reference/citation-format.mjs +74 -0
- package/dist/reference/consistency.d.ts +5 -9
- package/dist/reference/consistency.js +34 -38
- package/dist/reference/curriculum-alignment.js +3 -24
- package/dist/reference/derived-entries.d.ts +45 -0
- package/dist/reference/derived-entries.js +50 -0
- package/dist/reference/derived-notation.d.mts +125 -0
- package/dist/reference/derived-notation.mjs +121 -0
- package/dist/reference/diagram-fit.d.ts +45 -0
- package/dist/reference/diagram-fit.js +54 -0
- package/dist/reference/diagram-layout.d.ts +59 -10
- package/dist/reference/diagram-layout.js +85 -24
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +1 -1
- package/dist/reference/equations-validate.js +26 -1
- package/dist/reference/equations.d.mts +27 -0
- package/dist/reference/equations.mjs +120 -22
- package/dist/reference/examples-validate.d.ts +1 -1
- package/dist/reference/figure-fit.d.ts +70 -0
- package/dist/reference/figure-fit.js +60 -0
- package/dist/reference/figure-pictures.d.ts +27 -0
- package/dist/reference/figure-pictures.js +49 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +1 -1
- package/dist/reference/gate-course-rules.d.ts +4 -3
- package/dist/reference/gate-course-rules.js +9 -4
- package/dist/reference/gate-heading-length.d.ts +2 -0
- package/dist/reference/gate-heading-length.js +7 -5
- package/dist/reference/gate-heading-structure.js +26 -0
- package/dist/reference/gate-indices.d.ts +35 -0
- package/dist/reference/gate-indices.js +299 -0
- package/dist/reference/gate-math.d.ts +32 -2
- package/dist/reference/gate-math.js +68 -12
- package/dist/reference/gate-mdx-expression.d.ts +17 -0
- package/dist/reference/gate-mdx-expression.js +29 -0
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/gate-prop-text.d.ts +28 -0
- package/dist/reference/gate-prop-text.js +106 -0
- package/dist/reference/gate-prose-notation.d.ts +3 -1
- package/dist/reference/gate-prose-notation.js +4 -2
- package/dist/reference/gate-retired-keys.d.ts +35 -0
- package/dist/reference/gate-retired-keys.js +58 -0
- package/dist/reference/gloss.d.ts +2 -0
- package/dist/reference/katex-measure.d.ts +17 -0
- package/dist/reference/katex-measure.js +223 -0
- package/dist/reference/keyed-float-validate.js +12 -0
- package/dist/reference/keyed-jsx-floats.d.mts +2 -2
- package/dist/reference/keyed-jsx-floats.mjs +11 -14
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-scope.js +13 -2
- package/dist/reference/math-glyphs.d.mts +94 -2
- package/dist/reference/math-glyphs.mjs +851 -46
- package/dist/reference/mixed-content.d.ts +8 -3
- package/dist/reference/mixed-content.js +4 -2
- package/dist/reference/page-notation.d.ts +34 -4
- package/dist/reference/page-notation.js +54 -2
- package/dist/reference/references.d.ts +11 -1
- package/dist/reference/references.js +6 -0
- package/dist/reference/registry.js +104 -10
- package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
- package/dist/reference/rehype-heading-anchors.mjs +11 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +84 -20
- package/dist/reference/render-lab-math.d.ts +2 -0
- package/dist/reference/render-lab-math.js +17 -5
- package/dist/reference/sections-validate.d.ts +28 -3
- package/dist/reference/sections-validate.js +31 -4
- package/dist/reference/sections.d.mts +24 -6
- package/dist/reference/sections.mjs +92 -17
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/tables-validate.js +24 -1
- package/dist/reference/text-measure.d.ts +10 -0
- package/dist/reference/text-measure.js +41 -0
- package/dist/reference/text-widths.d.ts +10 -0
- package/dist/reference/text-widths.js +1238 -0
- package/dist/reference/types.d.ts +64 -8
- package/dist/rules/catalog.js +191 -30
- package/dist/rules/in-effect.d.ts +1 -1
- package/dist/rules/rules.schema.json +79 -24
- package/dist/rules/settings.d.ts +1 -1
- package/dist/rules/settings.js +7 -0
- package/dist/session/PreferenceStore.d.ts +18 -1
- package/dist/session/PreferenceStore.js +29 -1
- package/docs/authoring/README.md +32 -21
- package/docs/authoring/agents.md +38 -7
- package/docs/authoring/components.md +111 -6
- package/docs/authoring/migrating.md +707 -0
- package/docs/authoring/notation.md +281 -29
- package/docs/authoring/retired.md +1 -0
- package/docs/authoring/structure-rules.md +10 -41
- package/docs/authoring/structure.md +53 -2
- package/docs/authoring/visualizations.md +144 -18
- package/package.json +12 -7
- package/src/compiler/astro.ts +57 -17
- package/src/compiler/cli.ts +143 -35
- package/src/compiler/collections.ts +92 -19
- package/src/compiler/content-schema.ts +108 -0
- package/src/compiler/content-schemas.ts +391 -0
- package/src/compiler/course-config.ts +86 -15
- package/src/compiler/exports/combined-html.ts +669 -0
- package/src/compiler/exports/download-index.ts +36 -0
- package/src/compiler/exports/export-chapters.ts +233 -0
- package/src/compiler/exports/export-pages.ts +44 -0
- package/src/compiler/exports/html-tree.ts +62 -0
- package/src/compiler/exports/latex/capture.ts +651 -0
- package/src/compiler/exports/latex/compile.ts +168 -0
- package/src/compiler/exports/latex/document.ts +479 -0
- package/src/compiler/exports/latex/explico.cls +415 -0
- package/src/compiler/exports/latex/numbering.ts +74 -0
- package/src/compiler/exports/latex/serialize.ts +787 -0
- package/src/compiler/exports/latex/texlive-packages.txt +76 -0
- package/src/compiler/exports/latex-export.ts +255 -0
- package/src/compiler/exports/markdown/serialize.ts +632 -0
- package/src/compiler/exports/markdown-export.ts +329 -0
- package/src/compiler/exports/standalone-html.ts +39 -70
- package/src/compiler/exports/static-tree.ts +944 -0
- package/src/compiler/exports/zip.ts +88 -0
- package/src/compiler/lesson-derivation.ts +13 -8
- package/src/compiler/manifest.ts +299 -28
- package/src/compiler/render-diagnostics.ts +111 -0
- package/src/components/HoverPanel.astro +204 -6
- package/src/components/PartOverview.astro +12 -9
- package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
- package/src/components/assessments/AssessmentRunner.tsx +7 -1
- package/src/components/assessments/AssessmentSet.astro +14 -1
- package/src/components/block-heading.ts +38 -0
- package/src/components/card-arrow.ts +18 -4
- package/src/components/card-carry.ts +406 -0
- package/src/components/card-drag.ts +1 -30
- package/src/components/card-fetch.ts +87 -0
- package/src/components/card-manager.ts +1222 -38
- package/src/components/card-memory.ts +5 -1
- package/src/components/card-placement.ts +211 -13
- package/src/components/card-side.ts +281 -31
- package/src/components/citation/CitationLayer.astro +45 -40
- package/src/components/diagram/Diagram.astro +123 -44
- package/src/components/figure/Figure.astro +11 -0
- package/src/components/figure/FloatCaption.astro +4 -17
- package/src/components/float-toolbar.ts +274 -0
- package/src/components/memo/Memo.astro +21 -10
- package/src/components/memo/ShortNote.astro +4 -0
- package/src/components/notation/NotationGlossary.astro +22 -17
- package/src/components/notation/NotationLayer.astro +321 -79
- package/src/components/notation/NotationMath.astro +8 -1
- package/src/components/notation/render-notation-math.ts +51 -5
- package/src/components/readable-text.ts +25 -0
- package/src/components/reference/FloatRefLayer.astro +162 -89
- package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
- package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
- package/src/components/starlight/LayoutPageSidebar.astro +17 -3
- package/src/components/starlight/LayoutSocialIcons.astro +79 -29
- package/src/components/starlight/LessonExportActions.astro +384 -310
- package/src/components/starlight/LessonFooter.astro +47 -9
- package/src/components/starlight/PrintController.astro +28 -0
- package/src/components/starlight/TextSizeControl.astro +137 -0
- package/src/components/starlight/print-controller.ts +380 -0
- package/src/components/starlight/section-summary.ts +13 -5
- package/src/components/table/Table.astro +13 -21
- package/src/components/table/table-truncation.ts +15 -0
- package/src/content-config.ts +20 -312
- package/src/curriculum/validation.ts +72 -24
- package/src/reference/base-abbreviations.mjs +2 -2
- package/src/reference/base-operators.mjs +2 -2
- package/src/reference/base-roman-labels.mjs +4 -3
- package/src/reference/citation-format.mjs +74 -0
- package/src/reference/consistency.ts +47 -61
- package/src/reference/curriculum-alignment.ts +4 -41
- package/src/reference/derived-entries.ts +68 -0
- package/src/reference/derived-notation.mjs +121 -0
- package/src/reference/diagram-fit.ts +104 -0
- package/src/reference/diagram-layout.ts +182 -51
- package/src/reference/diagrams-validate.ts +2 -1
- package/src/reference/equations-validate.ts +41 -2
- package/src/reference/equations.mjs +120 -22
- package/src/reference/examples-validate.ts +1 -0
- package/src/reference/figure-fit.ts +139 -0
- package/src/reference/figure-pictures.ts +96 -0
- package/src/reference/figures-validate.ts +2 -1
- package/src/reference/gate-abbreviations.ts +1 -1
- package/src/reference/gate-course-rules.ts +22 -7
- package/src/reference/gate-heading-length.ts +25 -7
- package/src/reference/gate-heading-structure.ts +40 -1
- package/src/reference/gate-indices.ts +470 -0
- package/src/reference/gate-math.ts +102 -13
- package/src/reference/gate-mdx-expression.ts +45 -0
- package/src/reference/gate-prop-text.ts +145 -0
- package/src/reference/gate-prose-notation.ts +19 -5
- package/src/reference/gate-retired-keys.ts +101 -0
- package/src/reference/gloss.ts +2 -0
- package/src/reference/katex-measure.ts +384 -0
- package/src/reference/keyed-float-validate.ts +22 -0
- package/src/reference/keyed-jsx-floats.mjs +11 -14
- package/src/reference/lab-math-scope.ts +15 -1
- package/src/reference/math-glyphs.mjs +851 -46
- package/src/reference/mixed-content.ts +11 -2
- package/src/reference/page-notation.ts +100 -3
- package/src/reference/references.ts +14 -0
- package/src/reference/registry.ts +154 -8
- package/src/reference/rehype-fail-katex-errors.mjs +19 -0
- package/src/reference/rehype-heading-anchors.mjs +11 -1
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +84 -20
- package/src/reference/render-lab-math.ts +29 -5
- package/src/reference/sections-validate.ts +67 -3
- package/src/reference/sections.mjs +92 -17
- package/src/reference/tables-validate.ts +40 -2
- package/src/reference/text-measure.ts +63 -0
- package/src/reference/text-widths.ts +133 -0
- package/src/reference/types.ts +75 -8
- package/src/rules/catalog.ts +205 -29
- package/src/rules/settings.ts +10 -0
- package/src/session/PreferenceStore.ts +53 -0
- package/src/styles/global.css +291 -75
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse, serialize } from 'parse5';
|
|
5
|
+
|
|
6
|
+
import type { Manifest } from '../manifest';
|
|
7
|
+
import { exportPageRoute } from './export-pages';
|
|
8
|
+
import {
|
|
9
|
+
attribute,
|
|
10
|
+
childrenOf,
|
|
11
|
+
elements,
|
|
12
|
+
hasClass,
|
|
13
|
+
inferBasePrefix,
|
|
14
|
+
type HtmlChild,
|
|
15
|
+
type HtmlElement,
|
|
16
|
+
type HtmlNode,
|
|
17
|
+
type HtmlParent,
|
|
18
|
+
} from './html-tree';
|
|
19
|
+
import { buildStandaloneLessonHtml, pageSourceFile } from './standalone-html';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One offline HTML file of several built pages (decision D7): a part — its
|
|
23
|
+
* overview page, then its chapters — or the whole course.
|
|
24
|
+
*
|
|
25
|
+
* It is assembled from the pages Astro already built, not rendered again:
|
|
26
|
+
* each page keeps everything explico rendered for it, its body and the card
|
|
27
|
+
* layers at its foot (notation, citations, abbreviations, float references),
|
|
28
|
+
* so every number, card and check is the website's. Each page becomes one
|
|
29
|
+
* `<section>`, and so that ids stay unique, every id in it gains the page's
|
|
30
|
+
* suffix (`eq-range` → `eq-range--projectiles-projectile-range`), with every
|
|
31
|
+
* reference to it: fragment links, ARIA id lists, `for`, `popovertarget`,
|
|
32
|
+
* SVG `href` and `url(#…)`. A suffix, not a prefix, keeps the kind an id
|
|
33
|
+
* starts with (`eq-`, `fig-`, `cite-`), which the card layers read. A link
|
|
34
|
+
* to another page of the document becomes a link into it, so its hover
|
|
35
|
+
* preview works offline. The standalone exporter then inlines the document
|
|
36
|
+
* as it does one page.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface CombinedPage {
|
|
40
|
+
/** The page's docs slug: `projectiles/index`, `projectiles/projectile-range`. */
|
|
41
|
+
readonly slug: string;
|
|
42
|
+
/** Astro's built HTML of the page. */
|
|
43
|
+
readonly html: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A line of the document's contents; its title is the page's own `<h1>`. */
|
|
47
|
+
export interface ContentsEntry {
|
|
48
|
+
readonly slug: string;
|
|
49
|
+
/** `Part 2`, `2.1`; none for a page outside the numbered parts. */
|
|
50
|
+
readonly number?: string;
|
|
51
|
+
readonly children?: readonly ContentsEntry[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CombinedDocumentInput {
|
|
55
|
+
/** The document's title: the part's label or the course's name. */
|
|
56
|
+
readonly title: string;
|
|
57
|
+
/** The pages in reading order. */
|
|
58
|
+
readonly pages: readonly CombinedPage[];
|
|
59
|
+
/** The contents, set after the first page; none for a single page. */
|
|
60
|
+
readonly contents?: readonly ContentsEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Attributes holding one id. */
|
|
64
|
+
const ID_ATTRIBUTES = [
|
|
65
|
+
'for',
|
|
66
|
+
'list',
|
|
67
|
+
'form',
|
|
68
|
+
'popovertarget',
|
|
69
|
+
'commandfor',
|
|
70
|
+
'aria-activedescendant',
|
|
71
|
+
'data-notation-layer-id',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Attributes holding a space-separated list of ids. */
|
|
75
|
+
const ID_LIST_ATTRIBUTES = [
|
|
76
|
+
'aria-controls',
|
|
77
|
+
'aria-describedby',
|
|
78
|
+
'aria-details',
|
|
79
|
+
'aria-errormessage',
|
|
80
|
+
'aria-flowto',
|
|
81
|
+
'aria-labelledby',
|
|
82
|
+
'aria-owns',
|
|
83
|
+
'headers',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Attributes holding a URL, whose fragment may name an id. */
|
|
87
|
+
const URL_ATTRIBUTES = ['href', 'xlink:href', 'data-notation-href'];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The card shells every page repeats, looked up by these ids: kept once,
|
|
91
|
+
* unchanged. (A notation layer's own shell is found inside the layer, so
|
|
92
|
+
* each page keeps its own.)
|
|
93
|
+
*/
|
|
94
|
+
const SHARED_TEMPLATE_IDS = new Set(['citation-panel', 'abbreviation-panel', 'float-ref-panel']);
|
|
95
|
+
|
|
96
|
+
const COMBINED_STYLE = `
|
|
97
|
+
.explico-combined-page {
|
|
98
|
+
content-visibility: auto;
|
|
99
|
+
contain-intrinsic-size: auto 1200px;
|
|
100
|
+
}
|
|
101
|
+
.explico-combined-page + .explico-combined-page,
|
|
102
|
+
.explico-combined-contents + .explico-combined-page {
|
|
103
|
+
border-top: 1px solid var(--sl-color-hairline);
|
|
104
|
+
}
|
|
105
|
+
.explico-combined-contents ol {
|
|
106
|
+
list-style: none;
|
|
107
|
+
padding-inline-start: 0;
|
|
108
|
+
}
|
|
109
|
+
.explico-combined-contents ol ol {
|
|
110
|
+
padding-inline-start: 1.5rem;
|
|
111
|
+
}
|
|
112
|
+
.explico-combined-contents li {
|
|
113
|
+
margin-block: 0.25rem;
|
|
114
|
+
}
|
|
115
|
+
.explico-combined-contents li:has(> ol) {
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
}
|
|
118
|
+
.explico-combined-contents li li {
|
|
119
|
+
font-weight: normal;
|
|
120
|
+
}
|
|
121
|
+
.explico-combined-contents__number {
|
|
122
|
+
display: inline-block;
|
|
123
|
+
min-width: 3.5em;
|
|
124
|
+
font-variant-numeric: tabular-nums;
|
|
125
|
+
}
|
|
126
|
+
@media print {
|
|
127
|
+
.explico-combined-page {
|
|
128
|
+
content-visibility: visible;
|
|
129
|
+
}
|
|
130
|
+
.explico-combined-page + .explico-combined-page,
|
|
131
|
+
.explico-combined-contents + .explico-combined-page {
|
|
132
|
+
border-top: 0;
|
|
133
|
+
break-before: page;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
/** The suffix a page's ids gain: its route, `/` as `-`; the landing page is `home`. */
|
|
139
|
+
export function pageKey(slug: string): string {
|
|
140
|
+
return exportPageRoute(slug).replace(/\//gu, '-') || 'home';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The id of a page's section in the combined document. */
|
|
144
|
+
export function pageSectionId(slug: string): string {
|
|
145
|
+
return `page--${pageKey(slug)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function setAttribute(node: HtmlElement, name: string, value: string): void {
|
|
149
|
+
const existing = node.attrs.find((entry) => entry.name === name);
|
|
150
|
+
if (existing) existing.value = value;
|
|
151
|
+
else node.attrs.push({ name, value });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function firstElement(root: HtmlNode, tagName: string): HtmlElement {
|
|
155
|
+
const found = elements(root, (node) => node.tagName === tagName)[0];
|
|
156
|
+
if (!found) throw new Error(`Combined HTML source has no <${tagName}> element.`);
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function detach(node: HtmlChild): void {
|
|
161
|
+
const parent = node.parentNode as HtmlParent | null;
|
|
162
|
+
if (!parent) return;
|
|
163
|
+
const index = parent.childNodes.indexOf(node);
|
|
164
|
+
if (index !== -1) parent.childNodes.splice(index, 1);
|
|
165
|
+
node.parentNode = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function appendChild(parent: HtmlParent, child: HtmlChild): void {
|
|
169
|
+
detach(child);
|
|
170
|
+
parent.childNodes.push(child);
|
|
171
|
+
child.parentNode = parent;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function element(tagName: string, attrs: Record<string, string>, children: HtmlChild[] = []): HtmlElement {
|
|
175
|
+
const node: HtmlElement = {
|
|
176
|
+
nodeName: tagName,
|
|
177
|
+
tagName,
|
|
178
|
+
attrs: Object.entries(attrs).map(([name, value]) => ({ name, value })),
|
|
179
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml' as HtmlElement['namespaceURI'],
|
|
180
|
+
parentNode: null,
|
|
181
|
+
childNodes: [],
|
|
182
|
+
};
|
|
183
|
+
for (const child of children) appendChild(node, child);
|
|
184
|
+
return node;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function text(value: string): HtmlChild {
|
|
188
|
+
return { nodeName: '#text', value, parentNode: null } as HtmlChild;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function textOf(node: HtmlNode): string {
|
|
192
|
+
if ('value' in node && node.nodeName === '#text') return String(node.value);
|
|
193
|
+
return childrenOf(node)
|
|
194
|
+
.map((child) => textOf(child))
|
|
195
|
+
.join('');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A path below the base, without `index.html` or slashes at either end: `projectiles/projectile-range`. */
|
|
199
|
+
function routeOfPath(pathname: string, base: string): string | undefined {
|
|
200
|
+
if (!pathname.startsWith(base)) return undefined;
|
|
201
|
+
let route: string;
|
|
202
|
+
try {
|
|
203
|
+
route = decodeURIComponent(pathname.slice(base.length));
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
return route
|
|
208
|
+
.replace(/(?:^|\/)index\.html$/u, '')
|
|
209
|
+
.replace(/\.html$/u, '')
|
|
210
|
+
.replace(/^\/+|\/+$/gu, '');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface PageParts {
|
|
214
|
+
readonly slug: string;
|
|
215
|
+
readonly route: string;
|
|
216
|
+
readonly key: string;
|
|
217
|
+
readonly document: HtmlNode;
|
|
218
|
+
readonly main: HtmlElement;
|
|
219
|
+
/** The page's own ids, to their ids in the combined document. */
|
|
220
|
+
readonly ids: Map<string, string>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Make every relative URL in a page absolute, so it means the same thing
|
|
225
|
+
* from the combined document's location.
|
|
226
|
+
*/
|
|
227
|
+
function absolutizeUrls(page: PageParts, base: string): void {
|
|
228
|
+
const location = new URL(`https://explico.invalid${base}${page.route ? `${page.route}/` : ''}`);
|
|
229
|
+
const absolute = (value: string) => {
|
|
230
|
+
const trimmed = value.trim();
|
|
231
|
+
if (!trimmed || /^(?:[a-z][a-z\d+.-]*:|\/|#)/iu.test(trimmed)) return value;
|
|
232
|
+
const url = new URL(trimmed, location);
|
|
233
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
234
|
+
};
|
|
235
|
+
for (const node of elements(page.document)) {
|
|
236
|
+
for (const name of ['href', 'src', 'poster', 'data', 'component-url', 'renderer-url', 'before-hydration-url']) {
|
|
237
|
+
const value = attribute(node, name);
|
|
238
|
+
if (value) setAttribute(node, name, absolute(value));
|
|
239
|
+
}
|
|
240
|
+
for (const name of ['srcset', 'imagesrcset']) {
|
|
241
|
+
const value = attribute(node, name);
|
|
242
|
+
if (!value) continue;
|
|
243
|
+
setAttribute(
|
|
244
|
+
node,
|
|
245
|
+
name,
|
|
246
|
+
value
|
|
247
|
+
.split(',')
|
|
248
|
+
.map((candidate) => {
|
|
249
|
+
const [url = '', ...rest] = candidate.trim().split(/\s+/u);
|
|
250
|
+
return [absolute(url), ...rest].join(' ');
|
|
251
|
+
})
|
|
252
|
+
.join(', '),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `url(#id)` references in CSS text or an SVG presentation attribute. */
|
|
259
|
+
function rewriteCssFragments(value: string, ids: ReadonlyMap<string, string>): string {
|
|
260
|
+
return value.replace(/url\(\s*(["']?)#([^"')\s]+)\1\s*\)/gu, (match, quote: string, id: string) => {
|
|
261
|
+
const next = ids.get(id);
|
|
262
|
+
return next ? `url(${quote}#${next}${quote})` : match;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Rewrite a URL that names a page of the document: its own fragment, or a
|
|
268
|
+
* path to a page in it. Anything else is left for the standalone exporter.
|
|
269
|
+
*/
|
|
270
|
+
function rewriteUrl(
|
|
271
|
+
value: string,
|
|
272
|
+
page: PageParts,
|
|
273
|
+
pages: ReadonlyMap<string, PageParts>,
|
|
274
|
+
base: string,
|
|
275
|
+
): string {
|
|
276
|
+
const trimmed = value.trim();
|
|
277
|
+
if (trimmed.startsWith('#')) {
|
|
278
|
+
let id: string;
|
|
279
|
+
try {
|
|
280
|
+
id = decodeURIComponent(trimmed.slice(1));
|
|
281
|
+
} catch {
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
const next = page.ids.get(id);
|
|
285
|
+
return next ? `#${next}` : value;
|
|
286
|
+
}
|
|
287
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return value;
|
|
288
|
+
const url = new URL(trimmed, 'https://explico.invalid/');
|
|
289
|
+
const route = routeOfPath(url.pathname, base);
|
|
290
|
+
const target = route === undefined ? undefined : pages.get(route);
|
|
291
|
+
if (!target) return value;
|
|
292
|
+
if (!url.hash) return `#${pageSectionId(target.slug)}`;
|
|
293
|
+
let id: string;
|
|
294
|
+
try {
|
|
295
|
+
id = decodeURIComponent(url.hash.slice(1));
|
|
296
|
+
} catch {
|
|
297
|
+
return `#${pageSectionId(target.slug)}`;
|
|
298
|
+
}
|
|
299
|
+
const next = target.ids.get(id);
|
|
300
|
+
return next ? `#${next}` : `#${pageSectionId(target.slug)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function rewriteReferences(page: PageParts, pages: ReadonlyMap<string, PageParts>, base: string): void {
|
|
304
|
+
for (const node of elements(page.main)) {
|
|
305
|
+
for (const name of URL_ATTRIBUTES) {
|
|
306
|
+
const value = attribute(node, name);
|
|
307
|
+
if (value) setAttribute(node, name, rewriteUrl(value, page, pages, base));
|
|
308
|
+
}
|
|
309
|
+
for (const name of ID_ATTRIBUTES) {
|
|
310
|
+
const value = attribute(node, name);
|
|
311
|
+
const next = value ? page.ids.get(value) : undefined;
|
|
312
|
+
if (next) setAttribute(node, name, next);
|
|
313
|
+
}
|
|
314
|
+
for (const name of ID_LIST_ATTRIBUTES) {
|
|
315
|
+
const value = attribute(node, name);
|
|
316
|
+
if (!value) continue;
|
|
317
|
+
setAttribute(
|
|
318
|
+
node,
|
|
319
|
+
name,
|
|
320
|
+
value
|
|
321
|
+
.split(/\s+/u)
|
|
322
|
+
.filter(Boolean)
|
|
323
|
+
.map((id) => page.ids.get(id) ?? id)
|
|
324
|
+
.join(' '),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
for (const entry of node.attrs) {
|
|
328
|
+
if (entry.value.includes('url(')) entry.value = rewriteCssFragments(entry.value, page.ids);
|
|
329
|
+
}
|
|
330
|
+
if (node.tagName === 'style') {
|
|
331
|
+
for (const child of childrenOf(node)) {
|
|
332
|
+
if ('value' in child) child.value = rewriteCssFragments(String(child.value), page.ids);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every element inside an island: what its framework rendered and will hydrate. */
|
|
339
|
+
function islandContents(root: HtmlNode): Set<HtmlElement> {
|
|
340
|
+
const inside = new Set<HtmlElement>();
|
|
341
|
+
for (const island of elements(root, (node) => node.tagName === 'astro-island')) {
|
|
342
|
+
for (const node of elements(island)) if (node !== island) inside.add(node);
|
|
343
|
+
}
|
|
344
|
+
return inside;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every `id` in the page's `<main>`, with its id in the combined document.
|
|
349
|
+
* An island's own ids stay: its framework renders them again when it
|
|
350
|
+
* hydrates (see `uniqueIslandPrefixes`). The first page keeps `_top`, the
|
|
351
|
+
* document's top.
|
|
352
|
+
*/
|
|
353
|
+
function collectIds(page: Omit<PageParts, 'ids'>, first: boolean): Map<string, string> {
|
|
354
|
+
const ids = new Map<string, string>();
|
|
355
|
+
const islands = islandContents(page.main);
|
|
356
|
+
for (const node of elements(page.main)) {
|
|
357
|
+
const id = attribute(node, 'id');
|
|
358
|
+
if (!id || ids.has(id) || islands.has(node)) continue;
|
|
359
|
+
if (node.tagName === 'template' && SHARED_TEMPLATE_IDS.has(id)) continue;
|
|
360
|
+
ids.set(id, first && id === '_top' ? id : `${id}--${page.key}`);
|
|
361
|
+
}
|
|
362
|
+
return ids;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Astro numbers a page's islands (`prefix="r5"`), and React builds every
|
|
367
|
+
* id an island renders from that prefix (`useId`: `_r5R_d_`), so two pages'
|
|
368
|
+
* islands repeat each other's ids. A prefix an earlier page used gets the
|
|
369
|
+
* page's index (`r5p2`), and every attribute in the island that holds one
|
|
370
|
+
* of its ids is rewritten to match what React renders when it hydrates: a
|
|
371
|
+
* server-rendered React id has the prefix followed by `R`.
|
|
372
|
+
*/
|
|
373
|
+
function uniqueIslandPrefixes(pages: readonly PageParts[]): void {
|
|
374
|
+
const used = new Set<string>();
|
|
375
|
+
pages.forEach((page, index) => {
|
|
376
|
+
for (const island of elements(page.main, (node) => node.tagName === 'astro-island')) {
|
|
377
|
+
const prefix = attribute(island, 'prefix');
|
|
378
|
+
if (!prefix) continue;
|
|
379
|
+
if (!used.has(prefix)) {
|
|
380
|
+
used.add(prefix);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const next = `${prefix}p${index}`;
|
|
384
|
+
used.add(next);
|
|
385
|
+
setAttribute(island, 'prefix', next);
|
|
386
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
387
|
+
const reactId = new RegExp(`(^|[^A-Za-z0-9])${escaped}R`, 'gu');
|
|
388
|
+
for (const node of elements(island)) {
|
|
389
|
+
if (node === island) continue;
|
|
390
|
+
for (const entry of node.attrs) {
|
|
391
|
+
if (entry.name === 'id' || ID_ATTRIBUTES.includes(entry.name) || ID_LIST_ATTRIBUTES.includes(entry.name)) {
|
|
392
|
+
entry.value = entry.value.replace(reactId, `$1${next}R`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Whether a script runs: a classic or module script, not a data block. */
|
|
401
|
+
function isExecutable(node: HtmlElement): boolean {
|
|
402
|
+
const type = (attribute(node, 'type') ?? '').trim().toLowerCase();
|
|
403
|
+
return type === '' || type === 'module' || type === 'text/javascript' || type === 'application/javascript';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The content panels a page's own `<main>` uses, so a generated panel looks like them. */
|
|
407
|
+
function panelClasses(main: HtmlElement): { panel: string; container: string } {
|
|
408
|
+
const panel = elements(main, (node) => hasClass(node, 'content-panel'))[0];
|
|
409
|
+
const container = elements(main, (node) => hasClass(node, 'sl-container'))[0];
|
|
410
|
+
return {
|
|
411
|
+
panel: attribute(panel ?? main, 'class') ?? 'content-panel',
|
|
412
|
+
container: attribute(container ?? main, 'class') ?? 'sl-container',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function contentsList(
|
|
417
|
+
entries: readonly ContentsEntry[],
|
|
418
|
+
titles: ReadonlyMap<string, string>,
|
|
419
|
+
): HtmlElement {
|
|
420
|
+
return element(
|
|
421
|
+
'ol',
|
|
422
|
+
{},
|
|
423
|
+
entries.map((entry) =>
|
|
424
|
+
element('li', {}, [
|
|
425
|
+
...(entry.number
|
|
426
|
+
? [element('span', { class: 'explico-combined-contents__number' }, [text(entry.number)]), text(' ')]
|
|
427
|
+
: []),
|
|
428
|
+
element('a', { href: `#${pageSectionId(entry.slug)}` }, [text(titles.get(entry.slug) ?? entry.slug)]),
|
|
429
|
+
...(entry.children && entry.children.length > 0 ? [contentsList(entry.children, titles)] : []),
|
|
430
|
+
]),
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The combined document's HTML: the first page's `<head>` and shell, each
|
|
437
|
+
* page's `<main>` content in a section of its own, the head's stylesheets
|
|
438
|
+
* and scripts the union of every page's.
|
|
439
|
+
*/
|
|
440
|
+
export function combinePages(input: CombinedDocumentInput): string {
|
|
441
|
+
if (input.pages.length === 0) throw new Error('A combined document needs at least one page.');
|
|
442
|
+
const documents = input.pages.map((page) => parse(page.html));
|
|
443
|
+
const shell = documents[0]!;
|
|
444
|
+
const base = inferBasePrefix(shell);
|
|
445
|
+
|
|
446
|
+
const parts: PageParts[] = input.pages.map((page, index) => {
|
|
447
|
+
const document = documents[index]!;
|
|
448
|
+
const partial = {
|
|
449
|
+
slug: page.slug,
|
|
450
|
+
route: exportPageRoute(page.slug),
|
|
451
|
+
key: pageKey(page.slug),
|
|
452
|
+
document,
|
|
453
|
+
main: firstElement(document, 'main'),
|
|
454
|
+
};
|
|
455
|
+
return { ...partial, ids: collectIds(partial, index === 0) };
|
|
456
|
+
});
|
|
457
|
+
const titles = new Map(
|
|
458
|
+
parts.map((page) => {
|
|
459
|
+
const heading = elements(page.main, (node) => node.tagName === 'h1')[0];
|
|
460
|
+
return [page.slug, heading ? textOf(heading).replace(/\s+/gu, ' ').trim() : page.slug];
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
const byRoute = new Map(parts.map((page) => [page.route, page]));
|
|
464
|
+
uniqueIslandPrefixes(parts);
|
|
465
|
+
for (const page of parts) absolutizeUrls(page, base);
|
|
466
|
+
for (const page of parts) {
|
|
467
|
+
for (const node of elements(page.main)) {
|
|
468
|
+
const id = attribute(node, 'id');
|
|
469
|
+
const next = id ? page.ids.get(id) : undefined;
|
|
470
|
+
if (next) setAttribute(node, 'id', next);
|
|
471
|
+
}
|
|
472
|
+
rewriteReferences(page, byRoute, base);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The head: the first page's, with any stylesheet or module script
|
|
476
|
+
// another page adds.
|
|
477
|
+
const head = firstElement(shell, 'head');
|
|
478
|
+
const headKeys = new Set(
|
|
479
|
+
elements(head).map((node) => `${node.tagName} ${attribute(node, 'href') ?? attribute(node, 'src') ?? ''}`),
|
|
480
|
+
);
|
|
481
|
+
for (const page of parts.slice(1)) {
|
|
482
|
+
for (const node of elements(firstElement(page.document, 'head'))) {
|
|
483
|
+
const isStylesheet = node.tagName === 'link' && (attribute(node, 'rel') ?? '').split(/\s+/u).includes('stylesheet');
|
|
484
|
+
const isModule = node.tagName === 'script' && !!attribute(node, 'src');
|
|
485
|
+
if (!isStylesheet && !isModule) continue;
|
|
486
|
+
const key = `${node.tagName} ${attribute(node, 'href') ?? attribute(node, 'src') ?? ''}`;
|
|
487
|
+
if (headKeys.has(key)) continue;
|
|
488
|
+
headKeys.add(key);
|
|
489
|
+
appendChild(head, node);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const title = elements(head, (node) => node.tagName === 'title')[0];
|
|
493
|
+
if (title) {
|
|
494
|
+
title.childNodes.splice(0, title.childNodes.length);
|
|
495
|
+
appendChild(title, text(input.title));
|
|
496
|
+
}
|
|
497
|
+
appendChild(head, element('style', { 'data-explico-combined-style': '' }, [text(COMBINED_STYLE)]));
|
|
498
|
+
|
|
499
|
+
// The body: one section per page. A script every page carries runs once,
|
|
500
|
+
// and a card template every page repeats is kept once.
|
|
501
|
+
const main = firstElement(shell, 'main');
|
|
502
|
+
const classes = panelClasses(main);
|
|
503
|
+
const seen = new Set<string>();
|
|
504
|
+
const sections = parts.map((page) => {
|
|
505
|
+
const heading = page.ids.get('_top');
|
|
506
|
+
const section = element('section', {
|
|
507
|
+
class: 'explico-combined-page',
|
|
508
|
+
id: pageSectionId(page.slug),
|
|
509
|
+
'data-explico-page': page.slug,
|
|
510
|
+
...(heading ? { 'aria-labelledby': heading } : {}),
|
|
511
|
+
});
|
|
512
|
+
for (const child of [...page.main.childNodes]) appendChild(section, child);
|
|
513
|
+
for (const node of elements(section)) {
|
|
514
|
+
const id = attribute(node, 'id');
|
|
515
|
+
let key: string | undefined;
|
|
516
|
+
if (node.tagName === 'script' && !id && isExecutable(node)) {
|
|
517
|
+
key = `script ${attribute(node, 'src') ?? textOf(node)}`;
|
|
518
|
+
} else if (node.tagName === 'style' && !id) {
|
|
519
|
+
key = `style ${textOf(node)}`;
|
|
520
|
+
} else if (node.tagName === 'template' && id && SHARED_TEMPLATE_IDS.has(id)) {
|
|
521
|
+
key = `template ${id}`;
|
|
522
|
+
}
|
|
523
|
+
if (key === undefined) continue;
|
|
524
|
+
if (seen.has(key)) detach(node);
|
|
525
|
+
else seen.add(key);
|
|
526
|
+
}
|
|
527
|
+
return section;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
main.childNodes.splice(0, main.childNodes.length);
|
|
531
|
+
sections.forEach((section, index) => {
|
|
532
|
+
appendChild(main, section);
|
|
533
|
+
if (index !== 0 || !input.contents || input.contents.length === 0) return;
|
|
534
|
+
appendChild(
|
|
535
|
+
main,
|
|
536
|
+
element('div', { class: `${classes.panel} explico-combined-contents` }, [
|
|
537
|
+
element('div', { class: classes.container }, [
|
|
538
|
+
element('nav', { class: 'sl-markdown-content', 'aria-labelledby': 'explico-contents' }, [
|
|
539
|
+
element('h2', { id: 'explico-contents' }, [text('Contents')]),
|
|
540
|
+
contentsList(input.contents, titles),
|
|
541
|
+
]),
|
|
542
|
+
]),
|
|
543
|
+
]),
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
setAttribute(main, 'data-explico-combined', '');
|
|
547
|
+
|
|
548
|
+
return serialize(shell);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** One combined download: where it goes, its title, its pages and contents. */
|
|
552
|
+
export interface CombinedDownload {
|
|
553
|
+
/** Below `downloads/`: `part/projectiles.html`, `course.html`. */
|
|
554
|
+
readonly path: string;
|
|
555
|
+
readonly title: string;
|
|
556
|
+
readonly slugs: readonly string[];
|
|
557
|
+
readonly contents: readonly ContentsEntry[];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The part and course downloads of a course: each part is its overview page
|
|
562
|
+
* (when it has one), then its chapters; the course is the landing page, the
|
|
563
|
+
* front matter (the curriculum map, the figures and tables), every part, and
|
|
564
|
+
* the glossary last, as a book ends with its glossary.
|
|
565
|
+
*/
|
|
566
|
+
export function combinedDownloads(manifest: Pick<Manifest, 'lessons' | 'pages' | 'parts' | 'about'>): CombinedDownload[] {
|
|
567
|
+
const pageSlugs = new Set(manifest.pages.map((page) => page.slug));
|
|
568
|
+
const lessonsById = new Map(manifest.lessons.map((lesson) => [lesson.id, lesson]));
|
|
569
|
+
const partPages = manifest.parts.map((part) => {
|
|
570
|
+
const overview = pageSlugs.has(`${part.dir}/index`) ? `${part.dir}/index` : undefined;
|
|
571
|
+
const lessons = part.lessons.flatMap((id) => {
|
|
572
|
+
const lesson = lessonsById.get(id);
|
|
573
|
+
return lesson ? [lesson] : [];
|
|
574
|
+
});
|
|
575
|
+
return { part, overview, lessons };
|
|
576
|
+
});
|
|
577
|
+
const downloads: CombinedDownload[] = partPages.flatMap(({ part, overview, lessons }) => {
|
|
578
|
+
if (lessons.length === 0) return [];
|
|
579
|
+
return [
|
|
580
|
+
{
|
|
581
|
+
path: `part/${part.dir}.html`,
|
|
582
|
+
title: part.label,
|
|
583
|
+
slugs: [...(overview ? [overview] : []), ...lessons.map((lesson) => lesson.slug)],
|
|
584
|
+
contents: lessons.map((lesson) => ({ slug: lesson.slug, number: `${lesson.part}.${lesson.chapter}` })),
|
|
585
|
+
},
|
|
586
|
+
];
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const partSlugs = new Set(partPages.flatMap(({ overview }) => (overview ? [overview] : [])));
|
|
590
|
+
const front = ['curriculum-map', 'figures-and-tables'].filter((slug) => pageSlugs.has(slug));
|
|
591
|
+
const back = manifest.pages
|
|
592
|
+
.map((page) => page.slug)
|
|
593
|
+
.filter((slug) => slug !== 'index' && slug !== 'glossary' && !partSlugs.has(slug) && !front.includes(slug))
|
|
594
|
+
.concat(pageSlugs.has('glossary') ? ['glossary'] : []);
|
|
595
|
+
const parts = partPages.filter(({ lessons }) => lessons.length > 0);
|
|
596
|
+
const slugs = [
|
|
597
|
+
...(pageSlugs.has('index') ? ['index'] : []),
|
|
598
|
+
...front,
|
|
599
|
+
...parts.flatMap(({ overview, lessons }) => [...(overview ? [overview] : []), ...lessons.map((lesson) => lesson.slug)]),
|
|
600
|
+
...back,
|
|
601
|
+
];
|
|
602
|
+
if (slugs.length > 0 && parts.length > 0) {
|
|
603
|
+
downloads.push({
|
|
604
|
+
path: 'course.html',
|
|
605
|
+
title: manifest.about.material.name,
|
|
606
|
+
slugs,
|
|
607
|
+
contents: [
|
|
608
|
+
...front.map((slug) => ({ slug })),
|
|
609
|
+
...parts.map(({ part, overview, lessons }) => ({
|
|
610
|
+
slug: overview ?? lessons[0]!.slug,
|
|
611
|
+
number: `Part ${part.ordinal}`,
|
|
612
|
+
children: lessons.map((lesson) => ({ slug: lesson.slug, number: `${lesson.part}.${lesson.chapter}` })),
|
|
613
|
+
})),
|
|
614
|
+
...back.map((slug) => ({ slug })),
|
|
615
|
+
],
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return downloads;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Write every part's and the course's offline HTML file under `downloads/`
|
|
623
|
+
* (`lessonExports.standaloneHtml`), from Astro's finished output.
|
|
624
|
+
*/
|
|
625
|
+
export async function writeCombinedExports(directory: URL, manifest: Manifest): Promise<void> {
|
|
626
|
+
if (!manifest.lessonExports.standaloneHtml) return;
|
|
627
|
+
const outputRoot = fileURLToPath(directory);
|
|
628
|
+
const pending: { target: string; temporary: string; html: string }[] = [];
|
|
629
|
+
const sources = new Map<string, Promise<{ file: string; html: string }>>();
|
|
630
|
+
const source = (slug: string) => {
|
|
631
|
+
let read = sources.get(slug);
|
|
632
|
+
if (!read) {
|
|
633
|
+
read = pageSourceFile(outputRoot, slug).then(async (file) => ({ file, html: await readFile(file, 'utf8') }));
|
|
634
|
+
sources.set(slug, read);
|
|
635
|
+
}
|
|
636
|
+
return read;
|
|
637
|
+
};
|
|
638
|
+
for (const download of combinedDownloads(manifest)) {
|
|
639
|
+
const pages = await Promise.all(download.slugs.map(async (slug) => ({ slug, ...(await source(slug)) })));
|
|
640
|
+
const html = combinePages({
|
|
641
|
+
title: download.title,
|
|
642
|
+
pages: pages.map(({ slug, html: page }) => ({ slug, html: page })),
|
|
643
|
+
contents: download.contents,
|
|
644
|
+
});
|
|
645
|
+
const target = join(outputRoot, 'downloads', download.path);
|
|
646
|
+
pending.push({
|
|
647
|
+
target,
|
|
648
|
+
temporary: `${target}.tmp-${process.pid}`,
|
|
649
|
+
html: await buildStandaloneLessonHtml({
|
|
650
|
+
html,
|
|
651
|
+
sourceFile: pages[0]!.file,
|
|
652
|
+
outputRoot,
|
|
653
|
+
lessonSlug: pages[0]!.slug,
|
|
654
|
+
canonicalBaseUrl: manifest.lessonExports.canonicalBaseUrl,
|
|
655
|
+
}),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Nothing is published until every file has passed the asset audit.
|
|
660
|
+
try {
|
|
661
|
+
for (const file of pending) {
|
|
662
|
+
await mkdir(dirname(file.target), { recursive: true });
|
|
663
|
+
await writeFile(file.temporary, file.html, 'utf8');
|
|
664
|
+
}
|
|
665
|
+
for (const file of pending) await rename(file.temporary, file.target);
|
|
666
|
+
} finally {
|
|
667
|
+
await Promise.all(pending.map((file) => rm(file.temporary, { force: true })));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `downloads/index.json`: every file the exports wrote, by its path below
|
|
7
|
+
* `downloads/`, with its size in bytes. The Download menu
|
|
8
|
+
* (`LessonExportActions.astro`) reads it to show sizes and to hide a download
|
|
9
|
+
* the build did not write — a PDF on a machine without TeX, say.
|
|
10
|
+
*/
|
|
11
|
+
export async function writeDownloadIndex(directory: URL): Promise<void> {
|
|
12
|
+
const root = join(fileURLToPath(directory), 'downloads');
|
|
13
|
+
const files: [string, number][] = [];
|
|
14
|
+
const walk = async (folder: string): Promise<void> => {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(folder, { withFileTypes: true });
|
|
18
|
+
} catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const path = join(folder, entry.name);
|
|
23
|
+
if (entry.isDirectory()) await walk(path);
|
|
24
|
+
else if (entry.isFile() && !(folder === root && entry.name === 'index.json')) {
|
|
25
|
+
files.push([relative(root, path).split(sep).join('/'), (await stat(path)).size]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
await walk(root);
|
|
30
|
+
if (files.length === 0) return;
|
|
31
|
+
files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(root, 'index.json'),
|
|
34
|
+
`${JSON.stringify({ files: Object.fromEntries(files) }, null, 2)}\n`,
|
|
35
|
+
);
|
|
36
|
+
}
|