explico 0.6.0 → 0.8.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 (95) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
package/CHANGELOG.md CHANGED
@@ -14,6 +14,353 @@ under **Breaking** below; a PATCH never breaks.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.8.0] - 2026-09-19
18
+
19
+ ### Breaking
20
+
21
+ - **Rule N17 is inverted: a nested delimiter is sized by the engine, never by
22
+ hand.** The engine now installs its own KaTeX delimiter sizing
23
+ (`src/reference/katex-delimiters.mjs`), changing one constant in
24
+ `makeLeftRightDelim` — `delimiterExtend`, TeX's `\delimitershortfall`, from
25
+ `5.0` to `-1.0` points.
26
+ Stock TeX and stock KaTeX let a `\left … \right` pair fall up to 5pt short of
27
+ its content, so two pairs at different depths around similar content round to
28
+ the same glyph and the nesting vanishes; N17 used to repair that by making
29
+ authors hand-pick `\bigl` → `\Bigl` → `\biggl` → `\Biggl` outward. Those are
30
+ *fixed* glyphs: they stop tracking the content they wrap, they run out after
31
+ four levels, and editing an inner expression silently invalidates every size
32
+ above it. With a negative shortfall a pair is always strictly taller than its
33
+ content, so each enclosing level grows on its own, at any depth, by exactly
34
+ what it needs. `\left … \right` at every level is now the rule, and
35
+ hand-sizing a pair inside a nest is the warning.
36
+
37
+ Consequences for a course:
38
+ - The Tier-3 diagnostic code is renamed `delimiter-size-hierarchy` →
39
+ **`delimiter-manual-size`**; a `lint-ignore.yml` entry or a script matching
40
+ the old code must be updated. Its diagnostic shape changed too: the
41
+ `outerSize`/`nestedSize` fields are replaced by a single `size` (the
42
+ hand-picked size found), and `suggestion` is now always present and always
43
+ a `\left`/`\right` pair — it no longer carries a `size` field.
44
+ - Math that was N17-compliant now warns. A ladder such as
45
+ `\Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr]` reports one
46
+ finding per hand-sized level; the repair is `\left`/`\right` at every level.
47
+ A *lone* hand-sized pair that is not part of a nest — `\bigl(x,\ y\bigr)`
48
+ for a tuple — is deliberately left alone.
49
+ - **A course needs no install step for this.** `delimiterExtend` is a
50
+ function-local `const`, so nothing KaTeX exports reaches it and the change
51
+ has to happen in the module's own source. Rather than a `patches/` file —
52
+ which pnpm records per project and so cannot travel inside the npm artifact
53
+ — the engine reads KaTeX's ESM bundle, rewrites that constant, and moves
54
+ the render entry points onto the shared `katex` object. `rehype-katex`
55
+ reads `katex.renderToString` at call time and KaTeX's default export is
56
+ mutable, so lesson-body math inherits it without the plugin being forked or
57
+ aliased. `astro.config.mjs` already imports
58
+ `explico/reference/katex-options.mjs`, which installs it, so an existing
59
+ course picks this up on upgrade with no edit. The installer verifies itself
60
+ — a KaTeX upgrade that moves the constant, or anything that renders first,
61
+ fails the build with an explicit message instead of silently reverting to
62
+ stock spacing. [`docs/katex-bracket-scaling.md`](docs/katex-bracket-scaling.md)
63
+ derives the change and documents the mechanism and its trade-offs.
64
+
65
+ ### Added
66
+
67
+ - **A math diagnostic now names the equation it belongs to.** Every finding
68
+ the math gate reports from a `$$…$$` block that carries a `\label{eq:…}`
69
+ gains an `equation` field — `{ key, href }`, where `href` is the anchor the
70
+ rendered page gives that equation (`/l1-ar/ar1-basics/#eq-ar1-variance`) —
71
+ and `diagnosticLine()` appends it, so the console line, the run log, and
72
+ `pnpm content diagnostics` all read
73
+ `[code] file:line:column: message (eq:<key> -> <href>)`. A size-only
74
+ `delimiter-manual-size` finding inside a six-line `aligned` block was
75
+ otherwise anonymous: the message names delimiters, never the result they
76
+ belong to, so an author had to reconstruct which equation was meant from a
77
+ line number alone. `CliDiagnostic` carries the same optional field, so a
78
+ course's own `scripts/content.ts` gets it without change. The manifest
79
+ schema is now **v16** (additive: v15 → v16); nothing existing changed shape.
80
+
81
+ - **The About panel publishes the identity it shows.** `AboutPanel.astro` now
82
+ stamps `data-knowledge-version`, `data-knowledge-date` (omitted when the
83
+ content changelog has no date) and `data-engine-version` on its
84
+ `details[data-about-panel]` element, and marks the rendered version line with
85
+ `data-knowledge-line`. The two versions the panel exists to publish were
86
+ readable only as prose — and as a `version · date` join — so a course's build
87
+ check had to match that text, which pins the check to one version string and
88
+ turns every routine bump into a test edit; this repo's own
89
+ `playground/tests/e2e/about.spec.ts` was asserting `0.4.0` against a
90
+ playground long since at `0.5.2`. A bare version substring is not scoped to
91
+ the panel either, so the engine version printed one row below can satisfy it.
92
+ The attributes are additive: nothing rendered changed, and the existing
93
+ `data-about-panel` hook is untouched.
94
+
95
+ ### Fixed
96
+
97
+ - **A glossary heading's accessible name is the term again.** The notation
98
+ glossary's per-entry anchor link moved *inside* its `<h2>` when the entry
99
+ header was rebuilt for smooth scrolling, so the `aria-label` on that link
100
+ ("Link to “Velocity”") joined the heading's accessible name — a screen reader
101
+ announced "Velocity Link to Velocity", and any check for the heading by name
102
+ stopped matching. `NotationGlossary.astro` now wraps the heading and its
103
+ anchor in a `.glossary-heading` flex row, the shape
104
+ `rehype-heading-anchors` already gives a lesson heading (`<h2>` and
105
+ `a.sl-anchor-link` as siblings). Nothing visual changed; the anchor still
106
+ reveals on hover and focus.
107
+
108
+ - **A diagnostic's `line` now addresses the file it names.** Every gate scans
109
+ `lesson.body`, which Astro's `parseFrontmatter` hands over with the
110
+ frontmatter already removed, but each finding is reported against
111
+ `lesson.source.file` — so every `file:line:column` the engine has ever
112
+ emitted for a lesson was short by the height of that lesson's frontmatter.
113
+ It failed silently in the worst way: the number stayed plausible, so an
114
+ editor, a VS Code problem matcher, or an author following
115
+ `pnpm content diagnostics` jumped confidently to the wrong line, and a
116
+ size-only N17 finding could not be located at all. `parseDocument` now
117
+ records the body's start line, `NotationLessonInput` carries it as an
118
+ optional `bodyLine`, and the new `lessonLine()` helper applies it — so
119
+ `gate-math` (including every `delimiter-manual-size` column),
120
+ `gate-prose-notation` (N9), `gate-abbreviations` (N14), and
121
+ `gate-heading-length` (S1) are all corrected at once. A hand-built lesson
122
+ input that omits `bodyLine` behaves exactly as before.
123
+
124
+ - **`equationAnchorId({ key })` type-checks without a `number`.** Its JSDoc
125
+ required both properties although `number` is read only when there is no
126
+ `key`, so a keyed caller had to pass a value the function ignores.
127
+
128
+ - **A notation `formula` can use a manual delimiter size, an accent, a box, or
129
+ a color without failing the build.** `render-notation-math.ts` sanitizes
130
+ KaTeX's output against an attribute allowlist that had only ever grown to
131
+ cover what the corpus happened to use, so ordinary math threw
132
+ `Unexpected KaTeX property` with no author-side workaround: `\Bigl`/`\biggl`/
133
+ `\Biggl` (`minsize`, `maxsize`), `\xrightarrow` (`minsize`), `\boxed`
134
+ (`notation`), and `\colorbox` (`voffset`, `mathbackground`). Rule N17 asks
135
+ authors for exactly those delimiter commands, so a card that followed the
136
+ engine's own advice could not build. The allowlist is now KaTeX's whole inert
137
+ presentation surface — `columnlines`, `depth`, `display`, `largeop`,
138
+ `linebreak`, `linethickness`, `mathbackground`, `mathcolor`, `mathsize`,
139
+ `maxsize`, `minsize`, `notation`, `rowlines`, `valign`, and `voffset` join the
140
+ set — with `alt`, `href`, `src`, and `title` explicitly denied, and a new test
141
+ scans KaTeX's own `setAttribute` calls so a KaTeX upgrade that adds an
142
+ attribute fails here instead of in a downstream course. Hex colors are
143
+ accepted in an attribute or style value; no attribute that can carry a URL is
144
+ allowed through.
145
+
146
+ - **`\vec` (and every other accent) renders again.** The style sanitizer
147
+ required each CSS declaration to end in `;`, but KaTeX does not always
148
+ terminate the last one — `\vec` emits `style="width:0.471em"` — and the
149
+ rejection then fell through to the misleading
150
+ `Unexpected KaTeX property: style`. Declarations are now validated one at a
151
+ time, and an unsafe `style` or `d` value says so instead of reporting an
152
+ unknown property.
153
+
154
+ - **A macro in a formula no longer invents a source span.** KaTeX gives a node
155
+ it produced by expanding a macro a `loc` from the lexer over the *macro
156
+ body*, so those offsets index that body and not the source being scanned —
157
+ and they are still valid indices into a longer source, so the glyph resolver
158
+ read them as a real span over unrelated text. `\vdots` expands to
159
+ `{\varvdots\rule{0pt}{15pt}}`, whose atom claims characters 1–10: in
160
+ `M=\begin{pmatrix}…` that is `"=\begin{p"`, and because no author can wrap
161
+ that slice in an `\explain`, a matrix showing a column ellipsis failed the
162
+ build with `Unresolved notation ""`. Where the stray span instead collided
163
+ with a glyph in scope, the binding was injected at the macro body's offsets
164
+ and split the source mid-command — `\varSigma=\operatorname{Cov}(X)` became
165
+ `\varSigm\explain{cov-matrix}{a=\ope}ratorname{Cov}(X)`, a corrupt formula no
166
+ gate rejected. `nodeSpan` now believes a `loc` only when it belongs to the
167
+ string this pass parsed. 79 of KaTeX's 282 macros emit such a node, among
168
+ them `\argmax`, `\argmin`, `\plim`, `\limsup`, `\liminf`, `\bmod`, `\pmod`,
169
+ `\R`, `\N`, `\Z` and every upright `\varGamma`-style capital. A macro's
170
+ *argument* keeps the author's own locs and still binds normally. One limit
171
+ remains: a macro-expanded glyph has no authored span, so canonical matching
172
+ and the completeness gate cannot see it — annotate it with an explicit
173
+ `\explain{key}{\varSigma}`, which works and is unaffected by this change.
174
+
175
+ - **A malformed `\explain` / `\slot` key fails the build instead of reaching
176
+ the reader.** KaTeX does not treat a denied `trust` callback as a parse
177
+ error: it renders the command name in `errorColor` and continues, so
178
+ `\explain{Not A Key}{\Omega}` produced red `\htmlData` text on the page with
179
+ the build green. (Until now this was masked by `mathcolor` — the attribute
180
+ KaTeX's error rendering carries — not being in the allowlist.)
181
+ `renderNotationMath` now checks both marker macros against
182
+ `trustNotationMarker` before KaTeX runs and throws naming the offending key.
183
+ `renderNotationFormula` still degrades to inert-but-correct math rather than
184
+ costing a reader the equation.
185
+
186
+ ## [0.7.0] - 2026-09-18
187
+
188
+ ### Breaking
189
+
190
+ - **The compiler manifest schema is now v15 and diagnostic severity has three
191
+ levels.** Public diagnostic severity unions now include `info`, whose findings
192
+ are optional advice rather than repair debt. `eq-key-unused`, every keyed-float
193
+ `*-key-unused`, `unused-definition`, `weak-local`, `gloss-wants-promoting`, and
194
+ `card-wants-demoting` now emit `info` instead of `warning`. Consumers must stop
195
+ treating every non-error as a warning and accept schema v15.
196
+
197
+ - **`explicoContentRefresh()` now fails the build when a manifest-driven
198
+ remark plugin is missing from `markdown.remarkPlugins`.** Its
199
+ `astro:config:setup` hook checks `manifest.abbreviations`,
200
+ `manifest.sources`, and `manifest.notation.raw` against the configured
201
+ plugins and throws if a non-empty table has no matching
202
+ `remarkAbbreviations`, `remarkCitation`, or `remarkNotation` registration.
203
+ Previously this "half-migrated" shape — content catalogued, plugin wiring
204
+ skipped — passed `pnpm validate:content` and `astro build` with no signal
205
+ anywhere, silently rendering triggers as plain text (see
206
+ [`docs/authoring/migrating.md`, rule N14](docs/authoring/migrating.md#4-rule-n14)).
207
+ A course with this exact gap will now fail its next build until the
208
+ missing plugin is registered.
209
+
210
+ - **Retired the `numerals-tagged` Tier-3 consistency diagnostic.** It counted
211
+ every numeral in an untagged math span or table row—including unit exponents,
212
+ transparent worked-example arithmetic, and repeated table values—while
213
+ reporting only a lesson-level aggregate. `ConsistencyCode` no longer includes
214
+ `numerals-tagged`; remove any consumer logic or `lint-ignore.yml` entry for
215
+ that code. Numerical provenance should be enforced only by a future rule with
216
+ an explicit authoring surface and occurrence-level diagnostics.
217
+
218
+ - **Removed the notation popup's "Copy LaTeX" button and the glossary's `#`
219
+ copy-link button** (`.notation-popup-copy`, `[data-popup-copy]`, and
220
+ `[data-glossary-anchor]` with its "Link copied" flash are gone from the
221
+ DOM). Copying a formula's LaTeX is now a click on the equation itself (see
222
+ **Added**); a glossary entry's title is now a link to its own
223
+ `#notation-<key>` anchor, with a hover-revealed link-icon anchor beside it
224
+ that navigates like any heading permalink instead of writing to the
225
+ clipboard. `NotationFormula`'s keyboard-focusable scroll region also moved
226
+ from `.notation-formula-math` to a new nested `.notation-formula-scroll`.
227
+ A course's own CSS or end-to-end tests targeting any of these selectors
228
+ need updating.
229
+
230
+ ### Added
231
+
232
+ - **Click any rendered equation to copy its LaTeX, and every glossary formula
233
+ is numbered.** `NotationLayer.astro` (loaded on every page through the
234
+ Footer override) now handles clicks on any `[data-notation-formula]` box —
235
+ a glossary formula, an `\explain{}` formula inside a popup or `Diagram`, or
236
+ an interactive lab equation — copying its `data-latex` and flashing
237
+ "Copied!"; a hover tooltip names the gesture, and a click on an explained
238
+ symbol or an equation number keeps its own meaning. A glossary entry's
239
+ formula gets a `G.<n>` number (page order) that links to its entry, plus a
240
+ copy button; `NotationFormula` gains optional `number` / `numberHref` props
241
+ and renders neither when they are omitted. For interactive labs,
242
+ `renderLabMath()` now also returns `plainLatexTemplate` (bare symbols, each
243
+ `\slot{name}{…}` replaced by `slotPlaceholder(name)`), and the new
244
+ `slotPlaceholder` export lets an island substitute each slot's live value
245
+ so `data-latex` tracks the sliders. A lab that sets `data-notation-formula`
246
+ and `data-latex` on its math root joins the gesture; one that does not is
247
+ unchanged.
248
+
249
+ - **Smooth, eased scrolling for in-page anchor links.** The new
250
+ `AnchorScrollEnhancer.astro`, mounted by `LessonFooter.astro`, catches every
251
+ same-page `<a href="#…">` — headings, equation/table/figure numbers,
252
+ footnote and memo markers, the glossary's links, the table of contents —
253
+ animates to the target with the shared `animatedScrollIntoView` helper, then
254
+ performs a real fragment navigation on arrival so `:target` styling and
255
+ `hashchange` behave as for a plain click. `prefers-reduced-motion` is
256
+ honored, and scrolling away mid-animation just updates the URL.
257
+ `HeadingAnchorEnhancer`, `KeyedFloatEnhancer`, `Memo`, and `ShortNote` now
258
+ share that helper. A course that overrides `Footer` with its own component
259
+ instead of `LessonFooter.astro` must render `<AnchorScrollEnhancer />` itself
260
+ to get it.
261
+
262
+ - **New review-only authoring rule P1 and a new `P` (prose) rule area: "or"
263
+ never introduces a second name for the same thing.** Write "i.e.,", "also
264
+ called", "also known as", or parentheses instead, because "or" also
265
+ separates real alternatives and a reader cannot tell which is meant ("the
266
+ residual or error term"). Nothing is checked at build time, so no course
267
+ needs to change to keep building. See `docs/authoring/prose.md#p1`.
268
+
269
+ - **The shipped abbreviation baseline (rule N14) now covers every common
270
+ time-of-day and "and so on" spelling.** `AM`, `PM`, `A.M.`, `P.M.`, and
271
+ `e.t.c.` join `a.m.`, `p.m.`, `e.g.`, `i.e.`, and `etc.`, so a lesson can
272
+ write "9 AM" or "e.g." without a `content/abbreviations/*.md` entry or an
273
+ `abbreviations.allow` line. This only loosens the gate — no course needs to
274
+ change to keep building; a course that wants one of them explained anyway
275
+ lists it in `abbreviations.deny`. See `docs/authoring/notation.md#n14`.
276
+
277
+ - **Run logs for `pnpm validate:content`, in Python's `logging` format.** The
278
+ new `explico/compiler/logging` export (`createRunLog()`) records every
279
+ diagnostic a validate run reports to `build/logs/<timestamp>/`, formatted as
280
+ `%(asctime)s %(levelname)s %(name)s: %(message)s` so the lines filter with
281
+ the same tooling a Python log does. Three cumulative files per run —
282
+ `error.log` (errors), `warning.log` (errors + warnings), and `info.log`
283
+ (everything) — let you open the narrowest one that still answers the
284
+ question, and `build/logs/latest` symlinks to the newest run. Logger names
285
+ are `explico.<subsystem>` (`explico.notation`, `explico.curriculum`,
286
+ `explico.raw-floats`, …), derived from the diagnostic category, so filtering
287
+ by name is filtering by subsystem. Nothing is pruned; `build/` is
288
+ git-ignored. `info.log` also captures the severity-`info` diagnostics the
289
+ engine has always produced but never printed, plus the run summary lines.
290
+ The terminal output is deliberately unchanged — the compact `[code] message`
291
+ lines stay exactly as they were, with one added pointer line naming the run's
292
+ log directory and its counts. Blocking diagnostics still throw and still exit
293
+ non-zero; logging is purely additive. A course picks this up by routing its
294
+ own `scripts/compile-manifest.ts` through `createRunLog()`.
295
+
296
+ - **New severity-filtered content diagnostics and VS Code actions.** `pnpm
297
+ content diagnostics [info|warning|error] [--json]` flattens manifest findings
298
+ into stable, source-aware `group:code` diagnostics at or above the requested
299
+ severity; the new `content warnings` is the warning-only view, and
300
+ `content status` now also counts `info` findings. Both commands are exported
301
+ from `explico/compiler/cli` (`contentDiagnostics`, `contentWarnings`), so a
302
+ course's own `scripts/content.ts` must route them to expose the commands. The
303
+ playground provides **Explico: Show content diagnostics (info+)**,
304
+ **(warning+)**, and **(errors)** tasks, which feed locatable findings into
305
+ VS Code's Problems panel.
306
+
307
+ - **New delimiter authoring rules N16 and N17, with warning-level size
308
+ linting.** Rule N16 tells AI authors to alternate `()`, `[]`, and `\{\}` for
309
+ purely stylistic nested grouping while preserving function calls,
310
+ expectations, intervals, sets, tuples, probability, indexing, norms, and
311
+ every other semantically meaningful conventional delimiter. It is
312
+ review-only. Rule N17 independently requires nested scalable delimiter pairs
313
+ to grow from inside to outside. The new stack-based
314
+ `lintDelimiterSizes` parser handles multiline and arbitrarily deep
315
+ `\left...\right` plus `\big`/`\Big`/`\bigg`/`\Bigg` pairs, emits the
316
+ non-blocking `delimiter-size-hierarchy` diagnostic at the outer pair's line
317
+ and column, and suggests a larger manual size without changing delimiter
318
+ shape. It deliberately does not lint type alternation or infer mathematical
319
+ semantics.
320
+
321
+ - **New `explico/reference/rehype-base-links.mjs`.** Wire `[rehypeBaseLinks, base]`
322
+ into `markdown.rehypePlugins` and it prefixes every root-relative
323
+ `<a href="/...">` a lesson's Markdown/MDX body produces — a lesson's own
324
+ prose links, `remark-notation.mjs`'s equation cross-references, and
325
+ `remark-citation.mjs`'s citation links — with the course's Astro `base`.
326
+ Starlight only applies `base` to its own generated navigation (sidebar,
327
+ pagination, breadcrumbs); it never touches anything the Markdown/MDX →
328
+ rehype pipeline produces, so on any course deployed under a subpath (a
329
+ GitHub Pages *project* site, e.g. `base: '/my-course'`) every one of those
330
+ links 404s without this. No-op when the course has no base, so it's safe
331
+ to wire in unconditionally. A lesson's Starlight frontmatter (e.g.
332
+ `hero.actions[].link`) never enters this pipeline either — write those as
333
+ paths relative to the current page instead.
334
+
335
+ - **The `↩` link at the end of each entry in a page's References section now
336
+ has a hover tooltip.** `remark-citation.mjs` already gave that back-link an
337
+ `aria-label` ("Back to the citation in the text"); it now sets the same text
338
+ as `title`, so a mouse user hovering the bare arrow sees what it does. No
339
+ course wiring or config change needed.
340
+
341
+ ### Changed
342
+
343
+ - **`remarkAbbreviations` (rule N14) now wraps every occurrence of a
344
+ catalogued abbreviation in a lesson, not just the first per page.** A
345
+ reader who deep-links into a mid-page section — the sidebar TOC, a heading
346
+ anchor, an external link — used to land on plain, unlinked text with no
347
+ visual cue that a hover/click glossary card existed elsewhere on the page,
348
+ because only the earliest occurrence in document order was ever wrapped.
349
+ `AbbreviationLayer.astro` already resolved every marker independently by
350
+ `data-abbr-key`, so no island-side change was needed. This changes rendered
351
+ output for every existing course using this plugin: abbreviation-dense
352
+ prose that repeats the same term many times in one passage will now show a
353
+ dotted-underline link on each instance.
354
+
355
+ ### Fixed
356
+
357
+ - **Standalone HTML export no longer runs every `<link>` through the asset
358
+ inliner.** Only a `<link>` whose `rel` is `stylesheet` or an icon type
359
+ (`icon`, `shortcut icon`, `apple-touch-icon`,
360
+ `apple-touch-icon-precomposed`, `mask-icon`) is inlined; any other `rel`
361
+ (`canonical`, `alternate`, `preload`, …) is left as authored. No course
362
+ change is needed.
363
+
17
364
  ## [0.6.0] - 2026-09-16
18
365
 
19
366
  ### Breaking
@@ -544,7 +891,9 @@ under **Breaking** below; a PATCH never breaks.
544
891
  system with its completeness gate, the curriculum-graph validator, the
545
892
  Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
546
893
 
547
- [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.6.0...HEAD
894
+ [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.8.0...HEAD
895
+ [0.8.0]: https://github.com/danieltuzes/explico/compare/v0.7.0...v0.8.0
896
+ [0.7.0]: https://github.com/danieltuzes/explico/compare/v0.6.0...v0.7.0
548
897
  [0.6.0]: https://github.com/danieltuzes/explico/compare/v0.5.0...v0.6.0
549
898
  [0.5.0]: https://github.com/danieltuzes/explico/compare/v0.4.0...v0.5.0
550
899
  [0.4.0]: https://github.com/danieltuzes/explico/compare/v0.3.0...v0.4.0
package/README.md CHANGED
@@ -41,8 +41,9 @@ What it gives a course:
41
41
  figure — nothing in the engine assumes one plotting approach.
42
42
  - **LMS / analytics / identity seams** — framework-free interfaces shipped as
43
43
  no-ops; a host supplies real implementations without touching a component.
44
- - **A `content` CLI** — `status`, `context <lesson>`, `check <lesson>` (compiles
45
- MDX → remark → KaTeX and fails loudly), `new lesson|term`.
44
+ - **A `content` CLI** — `status`, `diagnostics`, `warnings`, `context <lesson>`,
45
+ `check <lesson>` (compiles MDX → remark → KaTeX and fails loudly),
46
+ `new lesson|term`.
46
47
 
47
48
  The prerequisite graph itself is authored by the lecturer or an AI; the engine
48
49
  validates it (DAG, ordering, coverage, orphans) rather than inferring it.
@@ -192,6 +193,19 @@ URL likewise fails the standalone export audit.
192
193
  "scripts": { "validate:content": "tsx scripts/compile-manifest.ts" }
193
194
  ```
194
195
 
196
+ Diagnostics have three distinct severities: an `error` blocks validation because
197
+ content is incorrect, ambiguous, unexplainable, or cannot render reliably; a
198
+ `warning` is non-blocking but identifies a rule or usability problem that should
199
+ be repaired; `info` is optional advice that may be intentionally left as-is.
200
+
201
+ Run `pnpm --filter explico-playground content diagnostics info` to print every
202
+ diagnostic. Replace `info` with `warning` to show warnings and errors, or `error`
203
+ to show blockers only. `content warnings` remains the warning-only view, and
204
+ `--json` returns normalized data for tooling. VS Code exposes matching
205
+ **Explico: Show content diagnostics (info+)**, **(warning+)**, and **(errors)**
206
+ tasks; locatable findings populate the Problems panel at the reported line and
207
+ column.
208
+
195
209
  The reference course is
196
210
  [**credit-and-derivatives**](https://github.com/danieltuzes/credit-and-derivatives):
197
211
  discounting → bonds → credit/CDS → derivatives and options.
@@ -1,4 +1,4 @@
1
- import { AstroIntegration } from 'astro';
1
+ import { AstroConfig, AstroIntegration } from 'astro';
2
2
  import { CourseConfig } from './course-config.js';
3
3
  import { Manifest } from './manifest.js';
4
4
  import '../curriculum/validation.js';
@@ -25,6 +25,17 @@ declare function matchStandaloneDownload(pathname: string, base: string, lessonS
25
25
  declare function assertLocalNotationParity(lessonId: string, authoredKeys: readonly string[], renderedKeys: readonly string[]): void;
26
26
  /** Fail an Astro build if final rendered HTML contains an unusable trigger. */
27
27
  declare function assertNotationPayloadHtml(page: string, html: string): void;
28
+ type MarkdownConfig = AstroConfig['markdown'];
29
+ /**
30
+ * Fail the build when a manifest-driven remark plugin has catalogued content
31
+ * but was never registered in Astro's markdown config — the "half-migrated"
32
+ * failure mode `docs/authoring/migrating.md` documents but, until now, never
33
+ * checked: `pnpm validate:content` stays green because the content-side gate
34
+ * (e.g. `gate-abbreviations.ts`'s N14 check) only scans prose, not Astro
35
+ * config, so a skipped plugin registration renders every trigger as plain
36
+ * text with no signal anywhere.
37
+ */
38
+ declare function assertRemarkPluginsWired(manifest: Pick<Manifest, 'abbreviations' | 'sources' | 'notation'>, markdown: MarkdownConfig): void;
28
39
  /**
29
40
  * Restart Astro when an authored content file changes. Astro reloads MDX on
30
41
  * its own, but Explico's manifest is created by `astro.config.mjs`; a restart
@@ -35,4 +46,4 @@ declare function assertNotationPayloadHtml(page: string, html: string): void;
35
46
  */
36
47
  declare function explicoContentRefresh(courseConfig: CourseConfig, manifest: Manifest): AstroIntegration;
37
48
 
38
- export { assertLocalNotationParity, assertNotationPayloadHtml, explicoContentRefresh, matchStandaloneDownload };
49
+ export { assertLocalNotationParity, assertNotationPayloadHtml, assertRemarkPluginsWired, explicoContentRefresh, matchStandaloneDownload };
@@ -9,6 +9,9 @@ import {
9
9
  } from "./manifest.js";
10
10
  import { missingNotationPayloadKeys } from "../reference/page-notation.js";
11
11
  import { writeStandaloneLessonExports } from "./exports/standalone-html.js";
12
+ import remarkAbbreviations from "../reference/remark-abbreviations.mjs";
13
+ import remarkCitation from "../reference/remark-citation.mjs";
14
+ import remarkNotation from "../reference/remark-notation.mjs";
12
15
  const watchedServers = /* @__PURE__ */ new WeakSet();
13
16
  function normalizedBasePath(base) {
14
17
  const segment = base.replace(/^\/+|\/+$/gu, "");
@@ -94,6 +97,40 @@ function assertNotationPayloadHtml(page, html) {
94
97
  `Explico notation payload invariant failed for rendered page "${page}": ${missing.map((key) => `data-notation-key="${key}"`).join(", ")} ${missing.length === 1 ? "has" : "have"} no matching \`data-notation-entry\` popup definition. A nested Astro component that emits notation must register its formula dependencies; use Diagram, NotationFormula, or labMathScope(..., { locals: Astro.locals, context }).`
95
98
  );
96
99
  }
100
+ function configuredRemarkPlugins(markdown) {
101
+ const direct = markdown?.remarkPlugins ?? [];
102
+ const processorOptions = markdown?.processor?.options;
103
+ return [...direct, ...processorOptions?.remarkPlugins ?? []];
104
+ }
105
+ function remarkPluginIsWired(remarkPlugins, plugin) {
106
+ return remarkPlugins.some(
107
+ (entry) => entry === plugin || Array.isArray(entry) && entry[0] === plugin
108
+ );
109
+ }
110
+ function assertRemarkPluginsWired(manifest, markdown) {
111
+ const remarkPlugins = configuredRemarkPlugins(markdown);
112
+ const missing = [];
113
+ if (manifest.abbreviations.length > 0 && !remarkPluginIsWired(remarkPlugins, remarkAbbreviations)) {
114
+ missing.push(
115
+ `remarkAbbreviations is missing from markdown.remarkPlugins, but manifest.abbreviations has ${manifest.abbreviations.length} entr${manifest.abbreviations.length === 1 ? "y" : "ies"}. Import it from 'explico/reference/remark-abbreviations.mjs' and add [remarkAbbreviations, { abbreviations: manifest.abbreviations }] to markdown.remarkPlugins \u2014 see docs/authoring/migrating.md#4-rule-n14.`
116
+ );
117
+ }
118
+ if (manifest.sources.length > 0 && !remarkPluginIsWired(remarkPlugins, remarkCitation)) {
119
+ missing.push(
120
+ `remarkCitation is missing from markdown.remarkPlugins, but manifest.sources has ${manifest.sources.length} entr${manifest.sources.length === 1 ? "y" : "ies"}. Import it from 'explico/reference/remark-citation.mjs' and add [remarkCitation, { sources: manifest.sources }] to markdown.remarkPlugins.`
121
+ );
122
+ }
123
+ if (manifest.notation.raw.length > 0 && !remarkPluginIsWired(remarkPlugins, remarkNotation)) {
124
+ missing.push(
125
+ `remarkNotation is missing from markdown.remarkPlugins, but manifest.notation.raw has ${manifest.notation.raw.length} entr${manifest.notation.raw.length === 1 ? "y" : "ies"}. Import it from 'explico/reference/remark-notation.mjs' and add [remarkNotation, createRemarkNotationOptions(manifest)] to markdown.remarkPlugins.`
126
+ );
127
+ }
128
+ if (missing.length === 0) return;
129
+ throw new Error(
130
+ `Explico manifest-driven remark plugin${missing.length === 1 ? " is" : "s are"} not wired into Astro's markdown config:
131
+ ${missing.map((line) => ` - ${line}`).join("\n")}`
132
+ );
133
+ }
97
134
  async function validateGeneratedNotationPayloads(directory) {
98
135
  const root = fileURLToPath(directory);
99
136
  const walk = async (path) => {
@@ -136,6 +173,7 @@ function explicoContentRefresh(courseConfig, manifest) {
136
173
  name: "explico-content-refresh",
137
174
  hooks: {
138
175
  "astro:config:setup": ({ command, config }) => {
176
+ assertRemarkPluginsWired(manifest, config.markdown);
139
177
  if (command === "dev") {
140
178
  developmentConfig = { root: config.root, base: config.base };
141
179
  }
@@ -253,6 +291,7 @@ ${message}
253
291
  export {
254
292
  assertLocalNotationParity,
255
293
  assertNotationPayloadHtml,
294
+ assertRemarkPluginsWired,
256
295
  explicoContentRefresh,
257
296
  matchStandaloneDownload
258
297
  };
@@ -1,9 +1,10 @@
1
+ import { DiagnosticSeverity } from '../reference/types.js';
2
+ import { EquationLink } from '../reference/gate-math.js';
1
3
  import { Manifest, ManifestLesson } from './manifest.js';
2
4
  import '../curriculum/validation.js';
3
5
  import '../reference/consistency.js';
4
6
  import './course-config.js';
5
7
  import './collections.js';
6
- import '../reference/types.js';
7
8
  import '../reference/lint-ignore.js';
8
9
  import '../reference/equations-validate.js';
9
10
  import '../reference/tables-validate.js';
@@ -12,7 +13,6 @@ import '../reference/figures-validate.js';
12
13
  import '../reference/diagrams-validate.js';
13
14
  import '../reference/knowledge-checks-validate.js';
14
15
  import '../reference/raw-floats-validate.js';
15
- import '../reference/gate-math.js';
16
16
  import '../reference/mixed-content.js';
17
17
  import '../reference/gloss.js';
18
18
  import './knowledge-version.js';
@@ -38,6 +38,9 @@ import './lesson-derivation.js';
38
38
  * source metadata + locators, the section's domain signatures, and the
39
39
  * lesson's current diagnostics — and reports token counts against the doc
40
40
  * set + corpus so the size win is visible.
41
+ * - `content diagnostics [severity]` flattens every diagnostic category at
42
+ * or above a threshold into one stable, source-aware list for editors.
43
+ * - `content warnings` is the warning-only compatibility view.
41
44
  * - `content check` compiles the lesson MDX → remark → KaTeX → HTML and fails
42
45
  * on any renderability error (unparseable MDX, KaTeX error, unresolved
43
46
  * notation, unknown `[[key]]`, unknown citation), on top of the manifest's
@@ -53,10 +56,18 @@ declare class ContentCliError extends Error {
53
56
  /** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
54
57
  interface CliDiagnostic {
55
58
  readonly code: string;
56
- readonly severity: 'error' | 'warning';
59
+ readonly severity: DiagnosticSeverity;
57
60
  readonly message: string;
58
61
  readonly file?: string;
59
62
  readonly line?: number;
63
+ readonly column?: number;
64
+ /**
65
+ * The keyed equation the finding sits in, when the math carries a
66
+ * `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
67
+ * rendered page. A size-only finding inside a long `aligned` block is
68
+ * otherwise anonymous.
69
+ */
70
+ readonly equation?: EquationLink;
60
71
  }
61
72
  /**
62
73
  * Every manifest diagnostic that names this lesson (by id or file), normalized
@@ -121,9 +132,24 @@ interface StatusReport {
121
132
  readonly diagnostics: {
122
133
  readonly errors: number;
123
134
  readonly warnings: number;
135
+ readonly infos: number;
124
136
  };
125
137
  }
126
138
  declare function contentStatus(manifest: Manifest): StatusReport;
139
+ type DiagnosticThreshold = DiagnosticSeverity;
140
+ interface DiagnosticsReport {
141
+ readonly minimumSeverity: DiagnosticThreshold;
142
+ readonly count: number;
143
+ readonly diagnostics: readonly CliDiagnostic[];
144
+ }
145
+ interface WarningsReport {
146
+ readonly count: number;
147
+ readonly warnings: readonly CliDiagnostic[];
148
+ }
149
+ /** Manifest diagnostics at or above a severity, normalized for tooling. */
150
+ declare function contentDiagnostics(manifest: Manifest, minimumSeverity?: DiagnosticThreshold): DiagnosticsReport;
151
+ /** Every warning exactly; errors and informational advice are excluded. */
152
+ declare function contentWarnings(manifest: Manifest): WarningsReport;
127
153
  interface ContextReport {
128
154
  readonly lesson: {
129
155
  readonly id: string;
@@ -216,4 +242,4 @@ interface Scaffold {
216
242
  declare function scaffoldLesson(rawId: string): Scaffold;
217
243
  declare function scaffoldTerm(rawKey: string): Scaffold;
218
244
 
219
- export { type CheckLessonResult, type CheckReport, type CliDiagnostic, type ContentCliCode, ContentCliError, type ContextReport, type DomainFileSignatures, type RenderResult, type Scaffold, type StatusReport, type TokenCounts, changedLessonIds, contentCheck, contentContext, contentStatus, estimateTokens, findLesson, lessonDiagnostics, normalizeLessonId, renderLesson, scaffoldLesson, scaffoldTerm };
245
+ export { type CheckLessonResult, type CheckReport, type CliDiagnostic, type ContentCliCode, ContentCliError, type ContextReport, type DiagnosticThreshold, type DiagnosticsReport, type DomainFileSignatures, type RenderResult, type Scaffold, type StatusReport, type TokenCounts, type WarningsReport, changedLessonIds, contentCheck, contentContext, contentDiagnostics, contentStatus, contentWarnings, estimateTokens, findLesson, lessonDiagnostics, normalizeLessonId, renderLesson, scaffoldLesson, scaffoldTerm };