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.
- package/CHANGELOG.md +344 -1
- package/README.md +117 -3
- package/dist/compiler/astro.d.ts +38 -0
- package/dist/compiler/astro.js +258 -0
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +32 -12
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +86 -4
- package/dist/compiler/course-config.js +39 -0
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +112 -7
- package/dist/compiler/manifest.js +205 -40
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations-validate.d.ts +8 -3
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +66 -35
- package/dist/reference/equations.mjs +142 -40
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-math.d.ts +28 -5
- package/dist/reference/gate-math.js +5 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +14 -8
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/keyed-jsx-floats.d.mts +24 -21
- package/dist/reference/keyed-jsx-floats.mjs +90 -41
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +30 -0
- package/dist/reference/math-glyphs.mjs +367 -0
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +102 -5
- package/dist/reference/remark-notation.mjs +506 -50
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +33 -4
- package/docs/authoring/README.md +12 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +135 -0
- package/docs/authoring/migrating.md +154 -7
- package/docs/authoring/notation.md +568 -8
- package/docs/authoring/structure.md +251 -0
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +332 -0
- package/src/compiler/cli.ts +33 -10
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +131 -3
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +367 -49
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +444 -83
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +116 -8
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +38 -2
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +296 -9
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +20 -0
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations-validate.ts +6 -4
- package/src/reference/equations.mjs +142 -40
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-math.ts +27 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +12 -9
- package/src/reference/keyed-jsx-floats.mjs +90 -41
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +367 -0
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +506 -50
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +1125 -50
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Course structure — the taxonomy
|
|
2
|
+
|
|
3
|
+
> **Status: adopted.** The engine implements these names and this numbering.
|
|
4
|
+
> `courseConfig.parts` is the current field; `sections` still works and is
|
|
5
|
+
> removed in the next MINOR release.
|
|
6
|
+
|
|
7
|
+
A course built on explico has five nested structural levels. Two of them are
|
|
8
|
+
currently called by more than one name, and two different levels are both
|
|
9
|
+
called "section". This file fixes one name per level so the words stop
|
|
10
|
+
overlapping.
|
|
11
|
+
|
|
12
|
+
## The five levels
|
|
13
|
+
|
|
14
|
+
| Level | What it physically is | Where it is declared | Names it used to carry |
|
|
15
|
+
| --- | --- | --- | --- |
|
|
16
|
+
| 1 | The whole material | `courseConfig.title` | course |
|
|
17
|
+
| 2 | **Part** — a collapsible group in the left sidebar; one content directory | `courseConfig.parts[] = { dir, label }` | `CourseSection`, **"section"**, **"chapter"** (sidebar UI) |
|
|
18
|
+
| 3 | **Chapter** — one `.mdx` file = one page = one URL | `content/docs/<dir>/<name>.mdx` | `lesson`, **"subchapter"** (sidebar UI) |
|
|
19
|
+
| 4 | **Section** — an `##` heading inside a lesson | the `.mdx` body | — |
|
|
20
|
+
| 5 | **Subsection** — a `###` heading inside a lesson | the `.mdx` body | *(unnamed)* |
|
|
21
|
+
|
|
22
|
+
A **track** (`content/tracks/*.json`) is not one of these levels. It is an
|
|
23
|
+
ordered learning path over lesson ids; it feeds the order lessons appear in
|
|
24
|
+
inside their sidebar group, and it may span groups. It is never displayed as a
|
|
25
|
+
container.
|
|
26
|
+
|
|
27
|
+
### The two collisions this resolved
|
|
28
|
+
|
|
29
|
+
**"Section" meant both level 2 and level 4.** `courseConfig.sections` was a
|
|
30
|
+
sidebar group; the `section` counter in `src/reference/equations.mjs` and
|
|
31
|
+
`src/reference/keyed-jsx-floats.mjs` counts `##` headings inside one lesson.
|
|
32
|
+
They are two levels apart and nothing in the names says so.
|
|
33
|
+
|
|
34
|
+
**"Chapter" meant both level 2 and level 3.** The numbering code said a chapter
|
|
35
|
+
was a lesson — `manifest.lessons[].chapter` was the lesson's 1-based ordinal read
|
|
36
|
+
straight down the sidebar, and it was the number before the dot in `(4.5)`. The
|
|
37
|
+
sidebar UI said a chapter is a *group*: from the engine changelog, "Chapter
|
|
38
|
+
groups in the left sidebar now start collapsed, retain compact **subchapter**
|
|
39
|
+
icons … and expand to the full lesson names when the chapter title is opened."
|
|
40
|
+
So the reader was shown "chapter = group, subchapter = lesson" while the numbers
|
|
41
|
+
were built on "chapter = lesson". A number could not be read without knowing
|
|
42
|
+
which of the two vocabularies the writer had in mind.
|
|
43
|
+
|
|
44
|
+
## The names
|
|
45
|
+
|
|
46
|
+
One name per level, borrowed from the book metaphor a reader already has:
|
|
47
|
+
|
|
48
|
+
| Level | Proposed name | Playground example | time-series example |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| 1 | **Course** | Explico Playground | MTH 9893 Time Series Analysis |
|
|
51
|
+
| 2 | **Part** | "Motion in the plane" (`motion/`) | "L1 · Foundations & evidence" |
|
|
52
|
+
| 3 | **Chapter** — one lesson, one page, one URL | "Position and Coordinate Systems" | "Dependence: ACF and PACF" |
|
|
53
|
+
| 4 | **Section** — an `##` heading | "Two ways to name the same point" | — |
|
|
54
|
+
| 5 | **Subsection** — a `###` heading | "From Cartesian to polar" | — |
|
|
55
|
+
|
|
56
|
+
Consequences:
|
|
57
|
+
|
|
58
|
+
- `courseConfig.sections` is renamed `courseConfig.parts`. "Section" now only
|
|
59
|
+
ever means an `##` heading. `sections` is still accepted, and is removed in
|
|
60
|
+
the next MINOR release.
|
|
61
|
+
- The sidebar UI no longer says "chapter" for a group or "subchapter" for a
|
|
62
|
+
lesson; a group is a **part**, and the thing inside it is a **chapter**.
|
|
63
|
+
- "Lesson" and "chapter" stay as two words for level 3 on purpose: *lesson* is
|
|
64
|
+
the pedagogical unit (it teaches competencies, carries assessments), *chapter*
|
|
65
|
+
is that same unit's position in the reading order. A file is a lesson; its
|
|
66
|
+
ordinal is its chapter number.
|
|
67
|
+
|
|
68
|
+
## What the playground demonstrates
|
|
69
|
+
|
|
70
|
+
The playground is the worked example for every level. It was deliberately
|
|
71
|
+
extended for this: until now it had a single sidebar group, which made levels 2
|
|
72
|
+
and 3 indistinguishable — one group with four lessons numbers identically
|
|
73
|
+
whether you count groups or lessons, which is why a numbering bug and this
|
|
74
|
+
naming collision both stayed invisible.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Course Explico Playground
|
|
78
|
+
├── Part 1 Motion in the plane content/docs/motion/
|
|
79
|
+
│ ├── Ch 1 Position and Coordinate Systems position-and-coordinates.mdx
|
|
80
|
+
│ │ ├── § Locating a point in the plane ##
|
|
81
|
+
│ │ ├── § Two ways to name the same point ##
|
|
82
|
+
│ │ │ ├── §§ From Cartesian to polar ###
|
|
83
|
+
│ │ │ └── §§ From polar to Cartesian ###
|
|
84
|
+
│ │ ├── § Velocity and acceleration as derivatives ##
|
|
85
|
+
│ │ └── § The special case: constant acceleration ##
|
|
86
|
+
│ │ ├── §§ Integrating once: velocity ###
|
|
87
|
+
│ │ └── §§ Integrating twice: position ###
|
|
88
|
+
│ └── Ch 2 Velocity Under Constant Acceleration constant-acceleration.mdx
|
|
89
|
+
└── Part 2 Projectile motion content/docs/projectiles/
|
|
90
|
+
├── Ch 1 Projectile Range projectile-range.mdx
|
|
91
|
+
└── Ch 2 Optimal Launch Angle optimal-launch-angle.mdx
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Which numbers every float like this — the part supplies the prefix, and the
|
|
95
|
+
index carries on across the part's chapters:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Part 1 Motion in the plane
|
|
99
|
+
Ch 1 Position and Coordinate Systems (1.1) (1.2) (1.3) (1.4) (1.5) (1.6) (1.7)
|
|
100
|
+
Ch 2 Velocity Under Constant Accel. (1.8)
|
|
101
|
+
Part 2 Projectile motion
|
|
102
|
+
Ch 1 Projectile Range (2.1) (2.2)
|
|
103
|
+
Ch 2 Optimal Launch Angle (2.3) (2.4) (2.5) (2.6)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Numbering
|
|
107
|
+
|
|
108
|
+
A display equation, `<Table>`, `<Figure>`, `<Diagram>`, or knowledge check is
|
|
109
|
+
numbered **`‹part›.‹n›`**:
|
|
110
|
+
|
|
111
|
+
- **`part`** is the part's 1-based position in `courseConfig.parts`.
|
|
112
|
+
- **`n`** counts that kind of numbered item across the *whole part*, in document
|
|
113
|
+
order, running unbroken from one chapter into the next. Each kind counts on
|
|
114
|
+
its own, so a `<Table>` never advances the `<Figure>`, `<Diagram>`, or
|
|
115
|
+
knowledge-check sequence.
|
|
116
|
+
- **Sections and subsections do not participate.** Neither `##` nor `###`
|
|
117
|
+
resets `n` or appears in the number.
|
|
118
|
+
- **Keyed or not, everything counts.** An equation with no `\label{eq:…}` still
|
|
119
|
+
takes a number and a positional anchor (`#eq-2-4`); it just cannot be
|
|
120
|
+
referenced by name.
|
|
121
|
+
|
|
122
|
+
In the playground: part 1 runs `1.1` … `1.7` through chapter 1 and continues
|
|
123
|
+
`1.8` in chapter 2; part 2 opens at `2.1`.
|
|
124
|
+
|
|
125
|
+
`manifest.lessons[]` carries what the render path needs to reproduce this:
|
|
126
|
+
`part`, `chapter`, and `numbering.{equations,tables,figures,diagrams,checks}` —
|
|
127
|
+
the running count each chapter starts from.
|
|
128
|
+
|
|
129
|
+
### The invariant, and why it is build-enforced
|
|
130
|
+
|
|
131
|
+
The number a page prints beside an equation and the number a cross-page
|
|
132
|
+
`[[slug#eq-key]]` prints for it are computed by two code paths from the same
|
|
133
|
+
content. `remarkNotation` cross-checks them for every keyed float and fails the
|
|
134
|
+
build on any disagreement, naming the key and both numbers.
|
|
135
|
+
|
|
136
|
+
This exists because the disagreement is otherwise silent and shipped: a course
|
|
137
|
+
config that passed the manifest number tables without the placement map
|
|
138
|
+
numbered its pages by `##` section while every reference to them read the
|
|
139
|
+
manifest, so one equation was `(2.2)` on its own page and `(4.5)` everywhere
|
|
140
|
+
else. Wire the plugin with `createRemarkNotationOptions`, which reads
|
|
141
|
+
`definitions`, `manifest`, and every numbering input off one manifest in one
|
|
142
|
+
call so they cannot drift apart:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import remarkNotation, {
|
|
146
|
+
createRemarkNotationOptions,
|
|
147
|
+
} from 'explico/reference/remark-notation.mjs';
|
|
148
|
+
|
|
149
|
+
// [remarkNotation, createRemarkNotationOptions(manifest, { strict: true })]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
That cross-check only ever caught a disagreement for a *keyed* equation or
|
|
153
|
+
float — an unkeyed one still took a number, but nothing compared it against
|
|
154
|
+
the manifest, so a lesson whose only equations/tables/figures were unkeyed
|
|
155
|
+
could still silently fall back to `##`-section numbering with no error at all.
|
|
156
|
+
`strict: true` closes that: it fails the build when a catalogued lesson
|
|
157
|
+
contains any equation, `<Table>`, or `<Figure>` — keyed or not — but has no
|
|
158
|
+
`numbering` placement or no cross-page number table entry for its slug.
|
|
159
|
+
`<Diagram>` is exempt, since `Diagram.astro` already renders one without a
|
|
160
|
+
number by design when it has none. `strict` defaults to `false`, so existing
|
|
161
|
+
non-strict wiring keeps building unchanged; turn it on once a course's
|
|
162
|
+
manifest covers every catalogued lesson.
|
|
163
|
+
|
|
164
|
+
### Raw and unwrapped floats
|
|
165
|
+
|
|
166
|
+
A raw Markdown pipe table or a raw HTML `<table>` written directly into a
|
|
167
|
+
lesson — never wrapped in `<Table>` — renders fine but never enters keyed-float
|
|
168
|
+
validation: it has no number and `[[tab-…]]` cannot reach it. The same is true
|
|
169
|
+
of an `<Diagram>` with no `id`, and of a `<Diagram>` nested inside anything but
|
|
170
|
+
`<Memo>`/`<ShortNote>`/`<Table>`/`<Figure>` — `keyed-jsx-floats.mjs` only scans
|
|
171
|
+
top-level (and `<Memo>`/`<ShortNote>`-nested) occurrences, so a diagram buried
|
|
172
|
+
inside e.g. `<CompactExample>` never reaches diagram numbering at all.
|
|
173
|
+
|
|
174
|
+
The compiled manifest flags all four as warnings in `diagnostics.rawFloats`
|
|
175
|
+
(`raw-table-markdown`, `raw-table-html`, `diagram-unkeyed`, `diagram-nested`),
|
|
176
|
+
and `remarkNotation` reports the same at render time as a defense-in-depth
|
|
177
|
+
check for content added since the manifest was last compiled. None of these
|
|
178
|
+
fail the build — they are advisory, independent of `strict` — but a course
|
|
179
|
+
that wants them addressed should wrap the table in `<Table id="…">`/
|
|
180
|
+
`<Figure id="…">`, or give the diagram an `id` and move it to the top level (or
|
|
181
|
+
inside `<Memo>`/`<ShortNote>`), or wrap it in `<Figure>`.
|
|
182
|
+
|
|
183
|
+
### Knowledge checks
|
|
184
|
+
|
|
185
|
+
An assessment title is authored once, in `content/assessments/<id>.json`. Put
|
|
186
|
+
that id in the lesson's colocated `.checks.yml`, then render `<AssessmentSet />`;
|
|
187
|
+
do not add a separate “Check yourself” heading. The component renders
|
|
188
|
+
`Knowledge check ‹number› — ‹title›` as a permalinked, collapsible section at
|
|
189
|
+
`#check-<id>`. Cite it on the same page with `[[check-<id>]]`, or from another
|
|
190
|
+
lesson with `[[lesson/slug#check-<id>]]`. Single-choice options are shuffled
|
|
191
|
+
when the browser views the check; option ids, correctness, and authored content
|
|
192
|
+
remain unchanged.
|
|
193
|
+
|
|
194
|
+
## Detailed memos and inline short notes
|
|
195
|
+
|
|
196
|
+
Headings define the page's conceptual hierarchy: they belong in the outline and
|
|
197
|
+
should help every reader navigate the main argument. A `<Memo>` instead holds
|
|
198
|
+
optional depth — a long derivation, proof, robustness check, edge case, or
|
|
199
|
+
alternative method — without pretending that material is another section of
|
|
200
|
+
the lecture. It keeps the subsection-like card, background, and border, but is
|
|
201
|
+
collapsed and independently numbered.
|
|
202
|
+
|
|
203
|
+
Register `remarkShortNote` and then `remarkMemo` after `remarkMath` in
|
|
204
|
+
`astro.config.mjs`. Import `explico/components/memo/Memo.astro`, cite a memo with
|
|
205
|
+
`[[memo-id]]` wherever it is relevant, and place the flow block later:
|
|
206
|
+
|
|
207
|
+
```mdx
|
|
208
|
+
The full calculation is available in [[memo-supporting-check]].
|
|
209
|
+
|
|
210
|
+
Several paragraphs may intervene before the detailed material.
|
|
211
|
+
|
|
212
|
+
<Memo id="supporting-check" title="Supporting check" description="The detailed calculation behind the claim.">
|
|
213
|
+
|
|
214
|
+
The body can contain ordinary Markdown, display equations, `<Table>`, and
|
|
215
|
+
`<Figure>` components.
|
|
216
|
+
|
|
217
|
+
</Memo>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`[[memo-supporting-check]]` renders as `[1]`. Clicking it opens and scrolls to
|
|
221
|
+
the block and updates the URL to `#memo-supporting-check`; the `[1]` shown on
|
|
222
|
+
the memo itself remains a numbered permalink, and the chain icon beside its
|
|
223
|
+
title provides the same explicit anchor affordance used by headings and
|
|
224
|
+
figures. `id`, `title`, and `description` are required literal strings. The
|
|
225
|
+
block is collapsed on a reader's first visit and remembers later open/closed
|
|
226
|
+
choices unless `rememberState={false}` is set. Memo numbering is independent
|
|
227
|
+
of equation, table, figure, and diagram numbering. Those numbered items remain
|
|
228
|
+
in their normal page/part streams inside a memo.
|
|
229
|
+
|
|
230
|
+
For print and PDF export, the memo opens at its reading-order position. It does
|
|
231
|
+
not move to the bottom of the page: browser support for CSS footnote floats is
|
|
232
|
+
not reliable enough to make that layout portable.
|
|
233
|
+
|
|
234
|
+
Use `<ShortNote>` for the smaller, genuinely inline interaction. Import
|
|
235
|
+
`explico/components/memo/ShortNote.astro` and put it exactly where its collapsed
|
|
236
|
+
link belongs:
|
|
237
|
+
|
|
238
|
+
```mdx
|
|
239
|
+
The derivative agrees <ShortNote id="derivative" title="(show how)" description="Show the full calculus calculation.">
|
|
240
|
+
|
|
241
|
+
Take the derivative, set it to zero, and solve. The body may also contain
|
|
242
|
+
display equations, figures, or other block Markdown.
|
|
243
|
+
|
|
244
|
+
</ShortNote>. But symmetry is quicker.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
While collapsed, the surrounding sentence remains one paragraph and only the
|
|
248
|
+
`title` text is visible. Activating it hides that link, inserts the body at that
|
|
249
|
+
point in the reading flow, and exposes a “Show less” control that restores the
|
|
250
|
+
original sentence. Short notes are for a locally optional explanation, not for
|
|
251
|
+
material that needs a number or citations elsewhere; use a memo for that.
|
|
@@ -31,11 +31,40 @@ identically in print and with JS off.
|
|
|
31
31
|
_How to apply._ Pass `nodes` / `edges` and, if any node has a `formula`, a
|
|
32
32
|
`scope` from `labMathScope` — see
|
|
33
33
|
`playground/content/labs/DerivationDiagram.astro`. A `formula` node's symbols
|
|
34
|
-
must resolve against the page glyph table, same as `NotationFormula`.
|
|
34
|
+
must resolve against the page glyph table, same as `NotationFormula`. Pass the
|
|
35
|
+
component's formula source to `labMathScope`; when it names a shared key that
|
|
36
|
+
the lesson MDX does not, also pass
|
|
37
|
+
`{ locals: Astro.locals, context: '<ComponentName> <formula context>' }` so the
|
|
38
|
+
key joins that page's notation payload. `Diagram` independently registers the
|
|
39
|
+
keys it ultimately emits, which protects wrapper components and supplies a
|
|
40
|
+
node-specific build diagnostic. If
|
|
35
41
|
`Diagram` genuinely cannot express the picture, raise it as an engine gap
|
|
36
42
|
rather than reaching for another library.
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
A top-level lesson diagram must expose an authored `id` on an MDX element named
|
|
45
|
+
`Diagram` (a wrapper component may be imported under that name). It then receives
|
|
46
|
+
an independent diagram number, a `#dia-<id>` anchor, and references of the form
|
|
47
|
+
`[[dia-<id>]]` or `[[lesson/slug#dia-<id>]]`. Diagram numbers do not advance the
|
|
48
|
+
equation, table, or figure sequences.
|
|
49
|
+
|
|
50
|
+
The component preserves the build-time canvas size and scrolls horizontally
|
|
51
|
+
when the work area is narrower. A compact control on the title line can expand
|
|
52
|
+
the original diagram toward the full in-page region and shrink it back; it is
|
|
53
|
+
not modal, follows the diagram's document position while the page scrolls, and
|
|
54
|
+
adds internal vertical scrolling when the viewport is too short. The shrink
|
|
55
|
+
control or Escape restores its original width. Clicking the title minimizes/
|
|
56
|
+
restores the diagram and its text description; this state is remembered per
|
|
57
|
+
page by default (`defaultOpen` / `rememberState` configure it).
|
|
58
|
+
The title also carries the same chain-link permalink treatment as headings.
|
|
59
|
+
|
|
60
|
+
By default a laid-out width above `800px` emits a compile-time warning. Treat
|
|
61
|
+
that warning as a prompt to try
|
|
62
|
+
`direction="TB"`, shorter labels, or explicit node sizes before accepting the
|
|
63
|
+
wide layout. Set `warnAboveWidth={false}` only when horizontal overflow is
|
|
64
|
+
intentional, or set a positive pixel value when the course needs a different
|
|
65
|
+
review threshold.
|
|
66
|
+
|
|
67
|
+
_Enforcement._ Review-only, with a compile-time width warning from `Diagram`.
|
|
39
68
|
|
|
40
69
|
_Exceptions._ A one-off illustrative schematic that is not a graph (a labelled
|
|
41
70
|
photo, a geometric figure) is a [V2](#v2) figure, not a `Diagram`.
|
|
@@ -83,7 +112,11 @@ An interactive figure is a React island, hydrated `client:visible` (or
|
|
|
83
112
|
- **Gate-checked math.** Any equation shown in the figure goes through the
|
|
84
113
|
`labMathScope` / `renderLabMath` / `\slot{}` seam so its symbols are resolved
|
|
85
114
|
against the page's real notation at build time and the island only patches
|
|
86
|
-
numbers into pre-rendered KaTeX.
|
|
115
|
+
numbers into pre-rendered KaTeX. A wrapper whose template names notation not
|
|
116
|
+
otherwise present in lesson MDX passes
|
|
117
|
+
`{ locals: Astro.locals, context: '<ComponentName> <template>' }` as the third
|
|
118
|
+
`labMathScope` argument; that is the declaration that ships those definitions
|
|
119
|
+
in this page's popup payload. See
|
|
87
120
|
`playground/content/labs/ProjectileRangeExplorer.tsx` +
|
|
88
121
|
`ProjectileRangeMath.astro`.
|
|
89
122
|
- **An accessible name and description** — `ariaLabel` / `ariaDescription` on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explico",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"docs/authoring"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@dagrejs/dagre": "3.1.1"
|
|
36
|
+
"@dagrejs/dagre": "3.1.1",
|
|
37
|
+
"esbuild": "0.27.7",
|
|
38
|
+
"parse5": "7.3.0"
|
|
37
39
|
},
|
|
38
40
|
"peerDependencies": {
|
|
39
41
|
"@astrojs/markdown-remark": "7.2.4",
|
|
@@ -62,6 +64,7 @@
|
|
|
62
64
|
},
|
|
63
65
|
"scripts": {
|
|
64
66
|
"build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json",
|
|
67
|
+
"check:release-migration": "node scripts/check-release-migration.mjs",
|
|
65
68
|
"test": "vitest run",
|
|
66
69
|
"test:watch": "vitest"
|
|
67
70
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import type { AstroConfig, AstroIntegration } from 'astro';
|
|
8
|
+
import type { CourseConfig } from './course-config';
|
|
9
|
+
import {
|
|
10
|
+
compileManifest,
|
|
11
|
+
type Manifest,
|
|
12
|
+
writeManifest,
|
|
13
|
+
} from './manifest';
|
|
14
|
+
import { missingNotationPayloadKeys } from '../reference/page-notation';
|
|
15
|
+
import { writeStandaloneLessonExports } from './exports/standalone-html';
|
|
16
|
+
|
|
17
|
+
// Vite keeps its server object across a restart. Do not accumulate watcher
|
|
18
|
+
// callbacks each time Astro reloads its configuration.
|
|
19
|
+
const watchedServers = new WeakSet<object>();
|
|
20
|
+
|
|
21
|
+
function normalizedBasePath(base: string): string {
|
|
22
|
+
const segment = base.replace(/^\/+|\/+$/gu, '');
|
|
23
|
+
return segment ? `/${segment}/` : '/';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Match only a configured lesson artifact below Astro's deployment base. */
|
|
27
|
+
export function matchStandaloneDownload(
|
|
28
|
+
pathname: string,
|
|
29
|
+
base: string,
|
|
30
|
+
lessonSlugs: readonly string[],
|
|
31
|
+
): string | undefined {
|
|
32
|
+
const prefix = `${normalizedBasePath(base)}downloads/`;
|
|
33
|
+
if (!pathname.startsWith(prefix) || !pathname.endsWith('.html')) return undefined;
|
|
34
|
+
const slug = pathname.slice(prefix.length, -'.html'.length);
|
|
35
|
+
return lessonSlugs.includes(slug) ? slug : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function buildDevelopmentDownloads(
|
|
39
|
+
config: Pick<AstroConfig, 'root'>,
|
|
40
|
+
lessonSlugs: readonly string[],
|
|
41
|
+
): Promise<ReadonlyMap<string, Buffer>> {
|
|
42
|
+
const temporaryRoot = await mkdtemp(join(tmpdir(), 'explico-standalone-'));
|
|
43
|
+
const outputRoot = join(temporaryRoot, 'dist');
|
|
44
|
+
try {
|
|
45
|
+
const projectRoot = fileURLToPath(config.root);
|
|
46
|
+
const astroEntry = fileURLToPath(import.meta.resolve('astro'));
|
|
47
|
+
const astroCli = resolve(dirname(astroEntry), '..', 'bin', 'astro.mjs');
|
|
48
|
+
await new Promise<void>((resolveBuild, rejectBuild) => {
|
|
49
|
+
const child = spawn(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[
|
|
52
|
+
astroCli,
|
|
53
|
+
'build',
|
|
54
|
+
'--root',
|
|
55
|
+
projectRoot,
|
|
56
|
+
'--outDir',
|
|
57
|
+
outputRoot,
|
|
58
|
+
'--silent',
|
|
59
|
+
],
|
|
60
|
+
{
|
|
61
|
+
cwd: projectRoot,
|
|
62
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
let diagnostic = '';
|
|
67
|
+
const capture = (chunk: Buffer) => {
|
|
68
|
+
if (diagnostic.length < 16_000) diagnostic += chunk.toString('utf8');
|
|
69
|
+
};
|
|
70
|
+
child.stdout.on('data', capture);
|
|
71
|
+
child.stderr.on('data', capture);
|
|
72
|
+
child.once('error', rejectBuild);
|
|
73
|
+
child.once('close', (code) => {
|
|
74
|
+
if (code === 0) {
|
|
75
|
+
resolveBuild();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
rejectBuild(
|
|
79
|
+
new Error(
|
|
80
|
+
`Astro's standalone development build exited with code ${code}.` +
|
|
81
|
+
(diagnostic.trim() ? `\n${diagnostic.trim()}` : ''),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
return new Map(
|
|
87
|
+
await Promise.all(
|
|
88
|
+
lessonSlugs.map(async (slug) => [
|
|
89
|
+
slug,
|
|
90
|
+
await readFile(join(outputRoot, 'downloads', `${slug}.html`)),
|
|
91
|
+
] as const),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fail loudly instead of rendering math triggers whose local cards are stale. */
|
|
100
|
+
export function assertLocalNotationParity(
|
|
101
|
+
lessonId: string,
|
|
102
|
+
authoredKeys: readonly string[],
|
|
103
|
+
renderedKeys: readonly string[],
|
|
104
|
+
): void {
|
|
105
|
+
const authored = [...new Set(authoredKeys)].sort();
|
|
106
|
+
const rendered = [...new Set(renderedKeys)].sort();
|
|
107
|
+
if (
|
|
108
|
+
authored.length === rendered.length &&
|
|
109
|
+
authored.every((key, index) => key === rendered[index])
|
|
110
|
+
) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Stale Explico manifest for ${lessonId}: lesson notation.local has ` +
|
|
115
|
+
`[${authored.join(', ')}], but the page notation bundle has ` +
|
|
116
|
+
`[${rendered.join(', ')}]. Pass courseConfig to loadManifest() and add ` +
|
|
117
|
+
`explicoContentRefresh() to the Astro integrations.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fail an Astro build if final rendered HTML contains an unusable trigger. */
|
|
122
|
+
export function assertNotationPayloadHtml(page: string, html: string): void {
|
|
123
|
+
const missing = missingNotationPayloadKeys(html);
|
|
124
|
+
if (missing.length === 0) return;
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Explico notation payload invariant failed for rendered page "${page}": ` +
|
|
127
|
+
`${missing.map((key) => `data-notation-key="${key}"`).join(', ')} ` +
|
|
128
|
+
`${missing.length === 1 ? 'has' : 'have'} no matching ` +
|
|
129
|
+
'`data-notation-entry` popup definition. A nested Astro component that ' +
|
|
130
|
+
'emits notation must register its formula dependencies; use Diagram, ' +
|
|
131
|
+
'NotationFormula, or labMathScope(..., { locals: Astro.locals, context }).',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function validateGeneratedNotationPayloads(directory: URL): Promise<void> {
|
|
136
|
+
const root = fileURLToPath(directory);
|
|
137
|
+
const walk = async (path: string): Promise<void> => {
|
|
138
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
139
|
+
const child = join(path, entry.name);
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
await walk(child);
|
|
142
|
+
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
143
|
+
assertNotationPayloadHtml(relative(root, child), await readFile(child, 'utf8'));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
await walk(root);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function replaceInPlace(target: unknown, source: unknown): void {
|
|
151
|
+
if (Array.isArray(target) && Array.isArray(source)) {
|
|
152
|
+
target.splice(0, target.length, ...source);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
!target ||
|
|
157
|
+
!source ||
|
|
158
|
+
typeof target !== 'object' ||
|
|
159
|
+
typeof source !== 'object'
|
|
160
|
+
) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const mutable = target as Record<string, unknown>;
|
|
164
|
+
const fresh = source as Record<string, unknown>;
|
|
165
|
+
for (const key of Object.keys(mutable)) {
|
|
166
|
+
if (!(key in fresh)) delete mutable[key];
|
|
167
|
+
}
|
|
168
|
+
for (const [key, value] of Object.entries(fresh)) {
|
|
169
|
+
const current = mutable[key];
|
|
170
|
+
if (
|
|
171
|
+
(Array.isArray(current) && Array.isArray(value)) ||
|
|
172
|
+
(current &&
|
|
173
|
+
value &&
|
|
174
|
+
typeof current === 'object' &&
|
|
175
|
+
typeof value === 'object' &&
|
|
176
|
+
!Array.isArray(current) &&
|
|
177
|
+
!Array.isArray(value))
|
|
178
|
+
) {
|
|
179
|
+
replaceInPlace(current, value);
|
|
180
|
+
} else {
|
|
181
|
+
mutable[key] = value;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Restart Astro when an authored content file changes. Astro reloads MDX on
|
|
188
|
+
* its own, but Explico's manifest is created by `astro.config.mjs`; a restart
|
|
189
|
+
* is what makes the remark plugins and page UI consume the same fresh model.
|
|
190
|
+
*
|
|
191
|
+
* Add this integration once to `integrations` in `astro.config.mjs`. The
|
|
192
|
+
* restart is automatic and editor-agnostic (change/add/unlink all work).
|
|
193
|
+
*/
|
|
194
|
+
export function explicoContentRefresh(
|
|
195
|
+
courseConfig: CourseConfig,
|
|
196
|
+
manifest: Manifest,
|
|
197
|
+
): AstroIntegration {
|
|
198
|
+
let developmentConfig: Pick<AstroConfig, 'root' | 'base'> | undefined;
|
|
199
|
+
return {
|
|
200
|
+
name: 'explico-content-refresh',
|
|
201
|
+
hooks: {
|
|
202
|
+
'astro:config:setup': ({ command, config }) => {
|
|
203
|
+
if (command === 'dev') {
|
|
204
|
+
developmentConfig = { root: config.root, base: config.base };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
'astro:server:setup': ({ server, logger }) => {
|
|
208
|
+
if (watchedServers.has(server)) return;
|
|
209
|
+
watchedServers.add(server);
|
|
210
|
+
const contentRoot = `${resolve(process.cwd(), 'content')}${sep}`;
|
|
211
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
212
|
+
let restarting = false;
|
|
213
|
+
let developmentDownloads:
|
|
214
|
+
| Promise<ReadonlyMap<string, Buffer>>
|
|
215
|
+
| undefined;
|
|
216
|
+
const developmentRoot = developmentConfig
|
|
217
|
+
? fileURLToPath(developmentConfig.root)
|
|
218
|
+
: process.cwd();
|
|
219
|
+
const generatedDevelopmentRoots = [
|
|
220
|
+
'.astro',
|
|
221
|
+
'build',
|
|
222
|
+
'dist',
|
|
223
|
+
'node_modules',
|
|
224
|
+
].map((directory) => `${resolve(developmentRoot, directory)}${sep}`);
|
|
225
|
+
|
|
226
|
+
const invalidateDevelopmentDownloads = (path: string) => {
|
|
227
|
+
const absolute = resolve(path);
|
|
228
|
+
if (
|
|
229
|
+
generatedDevelopmentRoots.some((root) => absolute.startsWith(root))
|
|
230
|
+
) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
developmentDownloads = undefined;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const refresh = (path: string) => {
|
|
237
|
+
const absolute = resolve(path);
|
|
238
|
+
if (!absolute.startsWith(contentRoot) || restarting) return;
|
|
239
|
+
if (timer) clearTimeout(timer);
|
|
240
|
+
timer = setTimeout(async () => {
|
|
241
|
+
restarting = true;
|
|
242
|
+
try {
|
|
243
|
+
const fresh = await compileManifest(courseConfig);
|
|
244
|
+
writeManifest(fresh);
|
|
245
|
+
// Astro/Vite retains the configuration object during a server
|
|
246
|
+
// restart. Update it recursively so remark plugin option arrays
|
|
247
|
+
// and maps captured from `manifest` also see the new model.
|
|
248
|
+
replaceInPlace(manifest, fresh);
|
|
249
|
+
await server.restart();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
server.config.logger.error(
|
|
252
|
+
`Explico could not refresh the content manifest: ${
|
|
253
|
+
error instanceof Error ? error.message : String(error)
|
|
254
|
+
}`,
|
|
255
|
+
);
|
|
256
|
+
} finally {
|
|
257
|
+
restarting = false;
|
|
258
|
+
}
|
|
259
|
+
}, 50);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
server.watcher.on('add', refresh);
|
|
263
|
+
server.watcher.on('change', refresh);
|
|
264
|
+
server.watcher.on('unlink', refresh);
|
|
265
|
+
|
|
266
|
+
const configuredDevelopment = developmentConfig;
|
|
267
|
+
if (manifest.lessonExports?.standaloneHtml && configuredDevelopment) {
|
|
268
|
+
server.watcher.on('add', invalidateDevelopmentDownloads);
|
|
269
|
+
server.watcher.on('change', invalidateDevelopmentDownloads);
|
|
270
|
+
server.watcher.on('unlink', invalidateDevelopmentDownloads);
|
|
271
|
+
server.middlewares.use(async (request, response, next) => {
|
|
272
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return next();
|
|
273
|
+
let pathname: string;
|
|
274
|
+
try {
|
|
275
|
+
pathname = decodeURIComponent(
|
|
276
|
+
new URL(request.url ?? '/', 'http://localhost').pathname,
|
|
277
|
+
);
|
|
278
|
+
} catch {
|
|
279
|
+
return next();
|
|
280
|
+
}
|
|
281
|
+
const slug = matchStandaloneDownload(
|
|
282
|
+
pathname,
|
|
283
|
+
configuredDevelopment.base,
|
|
284
|
+
manifest.lessons.map((lesson) => lesson.slug),
|
|
285
|
+
);
|
|
286
|
+
if (!slug) return next();
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
logger.info(
|
|
290
|
+
developmentDownloads
|
|
291
|
+
? `Serving cached standalone lesson ${slug}.html`
|
|
292
|
+
: 'Building standalone lesson downloads for local development…',
|
|
293
|
+
);
|
|
294
|
+
developmentDownloads ??= buildDevelopmentDownloads(
|
|
295
|
+
configuredDevelopment,
|
|
296
|
+
manifest.lessons.map((lesson) => lesson.slug),
|
|
297
|
+
);
|
|
298
|
+
const currentDownloads = developmentDownloads;
|
|
299
|
+
currentDownloads.catch(() => {
|
|
300
|
+
if (developmentDownloads === currentDownloads) {
|
|
301
|
+
developmentDownloads = undefined;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const artifact = (await currentDownloads).get(slug);
|
|
305
|
+
if (!artifact) return next();
|
|
306
|
+
const filename = `${slug.split('/').at(-1) ?? 'lesson'}.html`;
|
|
307
|
+
response.statusCode = 200;
|
|
308
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
309
|
+
response.setHeader('Content-Length', artifact.byteLength);
|
|
310
|
+
response.setHeader(
|
|
311
|
+
'Content-Disposition',
|
|
312
|
+
`attachment; filename="${filename}"`,
|
|
313
|
+
);
|
|
314
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
315
|
+
response.end(request.method === 'HEAD' ? undefined : artifact);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
318
|
+
logger.error(`Could not build standalone lesson downloads: ${message}`);
|
|
319
|
+
response.statusCode = 500;
|
|
320
|
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
321
|
+
response.end(`Could not build standalone lesson download.\n${message}\n`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
'astro:build:done': async ({ dir }) => {
|
|
327
|
+
await validateGeneratedNotationPayloads(dir);
|
|
328
|
+
await writeStandaloneLessonExports(dir, manifest);
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|