explico 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -0,0 +1,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`. 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.3.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
+ }