explico 0.4.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 (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  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 +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  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/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -105,12 +105,14 @@ Part 2 Projectile motion
105
105
 
106
106
  ## Numbering
107
107
 
108
- A display equation, `<Table>`, or `<Figure>` is numbered **`‹part›.‹n›`**:
108
+ A display equation, `<Table>`, `<Figure>`, `<Diagram>`, or knowledge check is
109
+ numbered **`‹part›.‹n›`**:
109
110
 
110
111
  - **`part`** is the part's 1-based position in `courseConfig.parts`.
111
- - **`n`** counts that kind of float across the *whole part*, in document order,
112
- running unbroken from one chapter into the next. Each kind counts on its own,
113
- so a `<Table>` never advances the `<Figure>` sequence.
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.
114
116
  - **Sections and subsections do not participate.** Neither `##` nor `###`
115
117
  resets `n` or appears in the number.
116
118
  - **Keyed or not, everything counts.** An equation with no `\label{eq:…}` still
@@ -121,8 +123,8 @@ In the playground: part 1 runs `1.1` … `1.7` through chapter 1 and continues
121
123
  `1.8` in chapter 2; part 2 opens at `2.1`.
122
124
 
123
125
  `manifest.lessons[]` carries what the render path needs to reproduce this:
124
- `part`, `chapter`, and `numbering.{equations,tables,figures}` — the running
125
- count each chapter starts from.
126
+ `part`, `chapter`, and `numbering.{equations,tables,figures,diagrams,checks}` —
127
+ the running count each chapter starts from.
126
128
 
127
129
  ### The invariant, and why it is build-enforced
128
130
 
@@ -135,5 +137,115 @@ This exists because the disagreement is otherwise silent and shipped: a course
135
137
  config that passed the manifest number tables without the placement map
136
138
  numbered its pages by `##` section while every reference to them read the
137
139
  manifest, so one equation was `(2.2)` on its own page and `(4.5)` everywhere
138
- else. Wire the plugin by handing it the whole manifest — `[remarkNotation,
139
- { definitions: manifest.notation.raw, manifest }]` — and the two cannot drift.
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`. If
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
- _Enforcement._ Review-only.
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. See
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.4.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
  }
@@ -1,17 +1,101 @@
1
- import { resolve, sep } from 'node:path';
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';
2
6
 
3
- import type { AstroIntegration } from 'astro';
7
+ import type { AstroConfig, AstroIntegration } from 'astro';
4
8
  import type { CourseConfig } from './course-config';
5
9
  import {
6
10
  compileManifest,
7
11
  type Manifest,
8
12
  writeManifest,
9
13
  } from './manifest';
14
+ import { missingNotationPayloadKeys } from '../reference/page-notation';
15
+ import { writeStandaloneLessonExports } from './exports/standalone-html';
10
16
 
11
17
  // Vite keeps its server object across a restart. Do not accumulate watcher
12
18
  // callbacks each time Astro reloads its configuration.
13
19
  const watchedServers = new WeakSet<object>();
14
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
+
15
99
  /** Fail loudly instead of rendering math triggers whose local cards are stale. */
16
100
  export function assertLocalNotationParity(
17
101
  lessonId: string,
@@ -34,6 +118,35 @@ export function assertLocalNotationParity(
34
118
  );
35
119
  }
36
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
+
37
150
  function replaceInPlace(target: unknown, source: unknown): void {
38
151
  if (Array.isArray(target) && Array.isArray(source)) {
39
152
  target.splice(0, target.length, ...source);
@@ -82,15 +195,43 @@ export function explicoContentRefresh(
82
195
  courseConfig: CourseConfig,
83
196
  manifest: Manifest,
84
197
  ): AstroIntegration {
198
+ let developmentConfig: Pick<AstroConfig, 'root' | 'base'> | undefined;
85
199
  return {
86
200
  name: 'explico-content-refresh',
87
201
  hooks: {
88
- 'astro:server:setup': ({ server }) => {
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 }) => {
89
208
  if (watchedServers.has(server)) return;
90
209
  watchedServers.add(server);
91
210
  const contentRoot = `${resolve(process.cwd(), 'content')}${sep}`;
92
211
  let timer: ReturnType<typeof setTimeout> | undefined;
93
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
+ };
94
235
 
95
236
  const refresh = (path: string) => {
96
237
  const absolute = resolve(path);
@@ -121,6 +262,70 @@ export function explicoContentRefresh(
121
262
  server.watcher.on('add', refresh);
122
263
  server.watcher.on('change', refresh);
123
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);
124
329
  },
125
330
  },
126
331
  };
@@ -40,7 +40,11 @@ import remarkMdx from 'remark-mdx';
40
40
  import { createNotationKatexOptions } from '../reference/katex-options.mjs';
41
41
  import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
42
42
  import remarkCitation from '../reference/remark-citation.mjs';
43
- import remarkNotation from '../reference/remark-notation.mjs';
43
+ import remarkMemo from '../reference/remark-memo.mjs';
44
+ import remarkNotation, {
45
+ createRemarkNotationOptions,
46
+ } from '../reference/remark-notation.mjs';
47
+ import remarkShortNote from '../reference/remark-short-note.mjs';
44
48
  import type { Manifest, ManifestLesson } from './manifest';
45
49
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
46
50
 
@@ -173,6 +177,9 @@ export function lessonDiagnostics(
173
177
  for (const diagnostic of [
174
178
  ...manifest.diagnostics.tables,
175
179
  ...manifest.diagnostics.figures,
180
+ ...manifest.diagnostics.diagrams,
181
+ ...manifest.diagnostics.checks,
182
+ ...manifest.diagnostics.rawFloats,
176
183
  ]) {
177
184
  if (
178
185
  diagnostic.lessonId !== id &&
@@ -221,11 +228,18 @@ function processorFor(manifest: Manifest) {
221
228
  // shape here that it does there (D7/F-tables-figures parity).
222
229
  remarkMdx,
223
230
  remarkMath,
231
+ remarkShortNote,
232
+ remarkMemo,
224
233
  // `manifest` carries the number tables *and* the part/offset placement
225
234
  // together, so this check numbers a page exactly as the Astro build
226
235
  // does. Passing the tables alone silently numbered pages by `##` section
227
236
  // here while cross-page references read the part-scheme manifest.
228
- [remarkNotation, { definitions: manifest.notation.raw, manifest }],
237
+ // No `strict: true` here: `renderLesson()` below is also called
238
+ // directly with a synthetic path/body that has no manifest entry at all
239
+ // (fixture renderability checks — see its own doc comment), which
240
+ // strict mode would reject as "catalogued lesson with no numbering",
241
+ // even though it was never meant to be catalogued in the first place.
242
+ [remarkNotation, createRemarkNotationOptions(manifest)],
229
243
  [remarkCitation, { sources: manifest.sources }],
230
244
  ],
231
245
  rehypePlugins: [
@@ -476,6 +490,7 @@ export interface StatusReport {
476
490
  readonly keyedEquations: number;
477
491
  readonly keyedTables: number;
478
492
  readonly keyedFigures: number;
493
+ readonly keyedDiagrams: number;
479
494
  };
480
495
  readonly draftDebt: {
481
496
  readonly lessons: readonly string[];
@@ -521,6 +536,9 @@ export function contentStatus(manifest: Manifest): StatusReport {
521
536
  ...manifest.diagnostics.equations.map((d) => d.severity),
522
537
  ...manifest.diagnostics.tables.map((d) => d.severity),
523
538
  ...manifest.diagnostics.figures.map((d) => d.severity),
539
+ ...manifest.diagnostics.diagrams.map((d) => d.severity),
540
+ ...manifest.diagnostics.checks.map((d) => d.severity),
541
+ ...manifest.diagnostics.rawFloats.map((d) => d.severity),
524
542
  ...manifest.diagnostics.consistency.map(() => 'warning' as const),
525
543
  ...manifest.diagnostics.knowledge.map((d) => d.severity),
526
544
  ];
@@ -537,6 +555,10 @@ export function contentStatus(manifest: Manifest): StatusReport {
537
555
  (count, lesson) => count + lesson.figures.length,
538
556
  0,
539
557
  );
558
+ const keyedDiagrams = manifest.lessons.reduce(
559
+ (count, lesson) => count + lesson.diagrams.length,
560
+ 0,
561
+ );
540
562
  const assessments = new Set(
541
563
  manifest.lessons.flatMap((lesson) => lesson.assessments),
542
564
  ).size;
@@ -553,6 +575,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
553
575
  keyedEquations,
554
576
  keyedTables,
555
577
  keyedFigures,
578
+ keyedDiagrams,
556
579
  },
557
580
  draftDebt: { lessons: draftLessons, notation: draftNotation },
558
581
  orphans,
@@ -593,6 +616,8 @@ export interface ContextReport {
593
616
  readonly equations: readonly { key: string; number: string }[];
594
617
  readonly tables: readonly { key: string; number: string }[];
595
618
  readonly figures: readonly { key: string; number: string }[];
619
+ readonly diagrams: readonly { key: string; number: string }[];
620
+ readonly knowledgeChecks: readonly { key: string; number: string }[];
596
621
  readonly sources: readonly {
597
622
  id: string;
598
623
  title: string;
@@ -663,6 +688,8 @@ export function contentContext(
663
688
  equations: lesson.equations.map((equation) => ({ ...equation })),
664
689
  tables: lesson.tables.map((table) => ({ ...table })),
665
690
  figures: lesson.figures.map((figure) => ({ ...figure })),
691
+ diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
692
+ knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
666
693
  sources: lesson.sources.map((id) => {
667
694
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
668
695
  return {
@@ -24,6 +24,7 @@ import type {
24
24
  TrackDefinition,
25
25
  } from '../curriculum/validation';
26
26
  import type {
27
+ AbbreviationDefinitionInput,
27
28
  EditorialStatus,
28
29
  LocalNotationDefinitionInput,
29
30
  NotationAlignment,
@@ -37,6 +38,8 @@ import type {
37
38
  import { isSubstantiveMeaning } from '../reference/prose';
38
39
  import { parseEquationRef } from '../reference/equations.mjs';
39
40
  import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
41
+ import { parseKnowledgeCheckRef } from '../reference/knowledge-checks.mjs';
42
+ import { parseMemoRef } from '../reference/remark-memo.mjs';
40
43
  import { resolveLabel } from '../reference/label';
41
44
  import { resolveGlosses } from '../reference/gloss';
42
45
  import {
@@ -231,13 +234,17 @@ export function extractNotationReferences(
231
234
  for (const match of searchable.matchAll(pattern)) {
232
235
  const key = (match[1] ?? '').trim();
233
236
  // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
234
- // `[[tab-key]]`/`[[fig-key]]` (and their cross-page forms) are
235
- // `<Table>`/`<Figure>` references (F-tables-figures) — all resolved by
236
- // the remark pass and the manifest, not notation keys.
237
+ // `[[tab-key]]`/`[[fig-key]]`/`[[dia-key]]` (and their cross-page forms)
238
+ // are keyed-float references, `[[check-key]]` targets a lesson's
239
+ // assessment list, and `[[memo-key]]` is a page-local memo reference —
240
+ // all resolved by the remark pass and the manifest, not notation keys.
237
241
  if (
238
242
  parseEquationRef(key) ||
239
243
  parseKeyedFloatRef(key, { prefix: 'tab' }) ||
240
- parseKeyedFloatRef(key, { prefix: 'fig' })
244
+ parseKeyedFloatRef(key, { prefix: 'fig' }) ||
245
+ parseKeyedFloatRef(key, { prefix: 'dia' }) ||
246
+ parseKnowledgeCheckRef(key) ||
247
+ parseMemoRef(key)
241
248
  ) {
242
249
  continue;
243
250
  }
@@ -438,6 +445,40 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
438
445
  });
439
446
  }
440
447
 
448
+ /**
449
+ * Abbreviations table (rule N14) — a lighter tier than a notation card, read
450
+ * straight off disk the same way `sharedNotationEntries` reads `notation/`.
451
+ * The directory is optional: a course that never uses this feature need not
452
+ * create an empty `content/abbreviations/`.
453
+ */
454
+ function abbreviationEntries(): AbbreviationDefinitionInput[] {
455
+ const directory = join(CONTENT_ROOT, 'abbreviations');
456
+ if (!existsSync(directory)) return [];
457
+ return filesBelow(directory, ['.md']).map((path) => {
458
+ const { file, data } = parseDocument(path);
459
+ const key = string(data.key, `${file} key`);
460
+ const filenameKey = basename(path, '.md');
461
+ if (key !== filenameKey) {
462
+ throw new Error(`${file} declares key ${key}; expected ${filenameKey}`);
463
+ }
464
+ const domain = optionalString(data.domain, `${file} domain`);
465
+ return {
466
+ key,
467
+ abbr: string(data.abbr, `${file} abbr`),
468
+ expansion: meaningText(data.expansion, `${file} expansion`),
469
+ aliases: strings(data.aliases, `${file} aliases`),
470
+ source: { file },
471
+ ...(domain === undefined ? {} : { domain }),
472
+ };
473
+ });
474
+ }
475
+
476
+ export async function loadAbbreviationRegistryInput(): Promise<
477
+ readonly AbbreviationDefinitionInput[]
478
+ > {
479
+ return abbreviationEntries();
480
+ }
481
+
441
482
  function notationLessonEntries(): NotationLessonInput[] {
442
483
  const lessons: NotationLessonInput[] = [];
443
484
  for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {