explico 0.6.0 → 0.7.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 (73) hide show
  1. package/CHANGELOG.md +180 -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 +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/exports/standalone-html.js +13 -0
  8. package/dist/compiler/logging.d.ts +109 -0
  9. package/dist/compiler/logging.js +107 -0
  10. package/dist/compiler/manifest.d.ts +7 -3
  11. package/dist/compiler/manifest.js +1 -1
  12. package/dist/reference/base-abbreviations.mjs +8 -2
  13. package/dist/reference/consistency.d.ts +8 -10
  14. package/dist/reference/consistency.js +3 -47
  15. package/dist/reference/delimiter-size.d.ts +39 -0
  16. package/dist/reference/delimiter-size.js +177 -0
  17. package/dist/reference/diagrams-validate.d.ts +1 -1
  18. package/dist/reference/equations-validate.d.ts +2 -2
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/figures-validate.d.ts +1 -1
  21. package/dist/reference/gate-math.d.ts +2 -0
  22. package/dist/reference/gate-math.js +28 -6
  23. package/dist/reference/keyed-float-validate.d.ts +2 -2
  24. package/dist/reference/keyed-float-validate.js +1 -1
  25. package/dist/reference/references.js +2 -1
  26. package/dist/reference/registry.js +1 -1
  27. package/dist/reference/rehype-base-links.d.mts +23 -0
  28. package/dist/reference/rehype-base-links.mjs +40 -0
  29. package/dist/reference/remark-abbreviations.mjs +10 -12
  30. package/dist/reference/remark-citation.mjs +0 -0
  31. package/dist/reference/render-lab-math.d.ts +18 -1
  32. package/dist/reference/render-lab-math.js +10 -3
  33. package/dist/reference/tables-validate.d.ts +1 -1
  34. package/dist/reference/types.d.ts +1 -1
  35. package/docs/VERSIONING.md +2 -2
  36. package/docs/authoring/README.md +12 -2
  37. package/docs/authoring/agents.md +7 -3
  38. package/docs/authoring/migrating.md +286 -0
  39. package/docs/authoring/notation.md +139 -7
  40. package/docs/authoring/prose.md +64 -0
  41. package/package.json +1 -1
  42. package/src/compiler/astro.ts +89 -0
  43. package/src/compiler/cli.ts +144 -12
  44. package/src/compiler/exports/standalone-html.ts +15 -0
  45. package/src/compiler/logging.ts +248 -0
  46. package/src/compiler/manifest.ts +7 -3
  47. package/src/components/memo/Memo.astro +2 -5
  48. package/src/components/memo/ShortNote.astro +2 -5
  49. package/src/components/notation/NotationFormula.astro +199 -10
  50. package/src/components/notation/NotationGlossary.astro +89 -58
  51. package/src/components/notation/NotationLayer.astro +83 -81
  52. package/src/components/scroll-animation.ts +160 -0
  53. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  54. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  55. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  56. package/src/components/starlight/LessonFooter.astro +2 -0
  57. package/src/reference/base-abbreviations.mjs +8 -2
  58. package/src/reference/consistency.ts +10 -66
  59. package/src/reference/delimiter-size.ts +301 -0
  60. package/src/reference/diagrams-validate.ts +1 -1
  61. package/src/reference/equations-validate.ts +3 -3
  62. package/src/reference/figures-validate.ts +1 -1
  63. package/src/reference/gate-math.ts +41 -6
  64. package/src/reference/keyed-float-validate.ts +3 -3
  65. package/src/reference/references.ts +2 -1
  66. package/src/reference/registry.ts +1 -1
  67. package/src/reference/rehype-base-links.mjs +40 -0
  68. package/src/reference/remark-abbreviations.mjs +10 -12
  69. package/src/reference/remark-citation.mjs +0 -0
  70. package/src/reference/render-lab-math.ts +24 -1
  71. package/src/reference/tables-validate.ts +1 -1
  72. package/src/reference/types.ts +1 -1
  73. package/src/styles/global.css +119 -14
@@ -33,6 +33,282 @@ required config block throws with the section of this file to read.
33
33
 
34
34
  ---
35
35
 
36
+ ## 0.6.0 → 0.7.0
37
+
38
+ ### 1. Manifest schema is now v15: diagnostic severity has three levels
39
+
40
+ `MANIFEST_SCHEMA_VERSION` is 15 (v14 → v15). Public diagnostic severity unions
41
+ now include `info`, so a client that treated "not an error" as "a warning"
42
+ needs three edits:
43
+
44
+ 1. A client asserting `MANIFEST_SCHEMA_VERSION` by exact equality expects 15.
45
+ 2. Widen any hand-written `'error' | 'warning'` type over manifest diagnostics
46
+ to `'error' | 'warning' | 'info'`. `ConsistencyDiagnostic.severity` is now
47
+ `'warning' | 'info'`.
48
+ 3. In `scripts/compile-manifest.ts` (and any other consumer), filter on
49
+ `severity === 'warning'` before printing or counting warnings. A script that
50
+ counts all of `manifest.diagnostics.consistency` as "Tier-3 findings" now
51
+ over-reports:
52
+
53
+ ```diff
54
+ -const consistencyByCode = consistencyCounts(consistency);
55
+ -const consistencyTotal = consistency.length;
56
+ +const consistencyWarnings = consistency.filter(
57
+ + (diagnostic) => diagnostic.severity === 'warning',
58
+ +);
59
+ +const consistencyByCode = consistencyCounts(consistencyWarnings);
60
+ +const consistencyTotal = consistencyWarnings.length;
61
+ ```
62
+
63
+ These codes moved from `warning` to `info`: `eq-key-unused`, every keyed-float
64
+ `*-key-unused`, `unused-definition`, `weak-local`, `gloss-wants-promoting`, and
65
+ `card-wants-demoting`. Their code strings and container fields did not change,
66
+ the `lint-ignore.yml` lines that already worked for `weak-local`,
67
+ `gloss-wants-promoting`, and `card-wants-demoting` still match, and nothing
68
+ blocks the build. They no longer appear in `pnpm content warnings`; read them
69
+ with `pnpm content diagnostics info`.
70
+
71
+ Run `pnpm validate:content` twice. The committed `build/resolution/*` files
72
+ (including `consistency-report.json`) change because those findings now carry
73
+ `info` severity and `numerals-tagged` findings are gone (item 2); commit them.
74
+
75
+ ### 2. Retired diagnostic: `numerals-tagged`
76
+
77
+ `ConsistencyCode` no longer contains `numerals-tagged`, and the engine never
78
+ emits it. Delete every `numerals-tagged` line from `lint-ignore.yml` (a stale
79
+ line is inert but misleads the next reader) and every course script, test, or
80
+ type narrowing that names the code — `tsc`/`astro check` flags the latter as a
81
+ comparison against a non-member of the union.
82
+
83
+ ### 3. `explicoContentRefresh()` fails the build on a missing remark plugin (rule N14 and its siblings)
84
+
85
+ This is the enforcement half of rule N14's Astro wiring (see item 4 of the
86
+ 0.4.x → 0.5.0 section). Its `astro:config:setup` hook now compares the manifest
87
+ against the configured remark plugins and throws, naming each gap, when a
88
+ non-empty table has no matching plugin registered:
89
+
90
+ | Manifest table is non-empty | Plugin that must be registered |
91
+ | --- | --- |
92
+ | `manifest.abbreviations` | `[remarkAbbreviations, { abbreviations: manifest.abbreviations }]` from `explico/reference/remark-abbreviations.mjs` |
93
+ | `manifest.sources` | `[remarkCitation, { sources: manifest.sources }]` from `explico/reference/remark-citation.mjs` |
94
+ | `manifest.notation.raw` | `[remarkNotation, createRemarkNotationOptions(manifest)]` from `explico/reference/remark-notation.mjs` |
95
+
96
+ Check `astro.config.mjs` against that table. The check reads both wiring
97
+ surfaces Astro offers — the flat `markdown.remarkPlugins` array and a
98
+ `markdown.processor` such as `unified({ remarkPlugins: [...] })` — and accepts
99
+ either the bare plugin or a `[plugin, options]` tuple. It compares by identity
100
+ with the module the `explico/reference/*.mjs` specifier resolves to, so register
101
+ the imported function itself rather than a local wrapper around it. A course
102
+ that was already fully wired needs no edit; one that skipped a plugin was
103
+ silently rendering triggers as plain text and now fails `astro build` /
104
+ `astro dev` until it adds the missing registration.
105
+
106
+ ### 4. Removed markup: popup "Copy LaTeX", glossary `#` copy-link, formula scroll region
107
+
108
+ None of these has a replacement selector; update course-owned CSS and
109
+ end-to-end tests that target them:
110
+
111
+ - `.notation-popup-copy` / `[data-popup-copy]` — the button in a notation
112
+ popup. Copying is now a click on the equation itself (item 8).
113
+ - `[data-glossary-anchor]` and its "Link copied" flash — a glossary entry's `#`
114
+ button that wrote its URL to the clipboard. The entry title is now a link to
115
+ `#notation-<key>` (`.glossary-term-link`), and a hover-revealed link-icon
116
+ anchor (`.glossary-anchor`) simply navigates; nothing is copied.
117
+ - `NotationFormula`'s `tabindex="0"` scroll region moved from
118
+ `.notation-formula-math` to a nested `.notation-formula-scroll`. A test that
119
+ focuses or scrolls `.notation-formula-math` must target the inner element.
120
+
121
+ No source or config change is needed beyond selectors.
122
+
123
+ ### 5. Rule N14: every occurrence of a catalogued abbreviation is now wrapped
124
+
125
+ Two changes ride under rule N14, neither of which needs a config edit:
126
+
127
+ - **Rendered output changed.** `remarkAbbreviations` used to wrap only the first
128
+ occurrence of each catalogued abbreviation per page; it now wraps every
129
+ occurrence (`a.abbreviation-term[data-abbr-key]`). A course-owned e2e test or
130
+ snapshot that expected exactly one such link per term per page, or CSS that
131
+ assumed a page's later occurrences were plain text, must be updated.
132
+ `AbbreviationLayer.astro` already resolved markers independently by
133
+ `data-abbr-key`, so no island change is needed.
134
+ - **The shipped baseline grew** (`base-abbreviations.mjs`): `AM`, `PM`, `A.M.`,
135
+ `P.M.`, and `e.t.c.` join `a.m.`, `p.m.`, `e.g.`, `i.e.`, and `etc.`. This only
136
+ loosens the gate. A now-redundant `abbreviations.allow` line or
137
+ `content/abbreviations/*.md` entry for one of them is harmless and can stay; a
138
+ course that wants one explained anyway lists it in `abbreviations.deny`.
139
+
140
+ ### 6. Rule N16 and rule N17: delimiter conventions and sizes
141
+
142
+ Rule N16 (alternate `()`, `[]`, `\{\}` for purely stylistic nested grouping,
143
+ never for conventional delimiters such as `f(x)`, `\mathbb{E}[X]`, or
144
+ intervals) is **review-only**: nothing checks it and nothing needs to change to
145
+ keep building. Rule N17 (every outer scalable pair in a nest grows strictly
146
+ larger than the pairs inside it: `\left…\right` → `\Bigl…\Bigr` → `\biggl…\biggr`
147
+ → `\Biggl…\Biggr`) is a **Tier-3 warning**, `delimiter-size-hierarchy`, reported
148
+ at the outer pair's line and column by the normal math gate for lesson math,
149
+ notation formulas, and assessment math. It never fails a build, so no course
150
+ needs to change to keep building. To clear the warnings, run
151
+ `pnpm content diagnostics warning`, and enlarge each named outer pair to the
152
+ next manual size without changing its delimiter shape. Rule N15's build-enforced
153
+ behavior is unchanged — its documentation now also accepts an N17 manual pair as
154
+ "sized". See [`notation.md#n16`](notation.md#n16) and
155
+ [`notation.md#n17`](notation.md#n17).
156
+
157
+ ### 7. Rule P1: "or" never introduces a second name for the same thing
158
+
159
+ Rule P1 opens the new `P` (prose) rule area and is **review-only**: nothing is
160
+ checked at build time and no course change is required. When editing lessons,
161
+ replace "the residual or error term" with "the residual (i.e., the error
162
+ term)"-style wording. See [`prose.md#p1`](prose.md#p1).
163
+
164
+ ### 8. Optional: click-to-copy equations, glossary equation numbers, and live lab LaTeX
165
+
166
+ Nothing to do for a course that wires `Footer` to `LessonFooter.astro`: a
167
+ click on any `[data-notation-formula]` box now copies its `data-latex`, and every
168
+ glossary formula gets a `G.<n>` number and copy button, automatically.
169
+
170
+ Only a course with an interactive lab island that renders math through
171
+ `renderLabMath()` can opt that equation into the gesture, and keep the copied
172
+ LaTeX in step with the sliders, with `plainLatexTemplate` and the new
173
+ `slotPlaceholder` export:
174
+
175
+ ```tsx
176
+ import {
177
+ slotPlaceholder,
178
+ type RenderedLabMath,
179
+ } from 'explico/reference/render-lab-math';
180
+
181
+ // On the math root element:
182
+ <div
183
+ className="lab-math"
184
+ data-notation-formula
185
+ data-latex={rendered.plainLatexTemplate}
186
+ /* … */
187
+ />
188
+
189
+ // Where the island writes each slot's live value (`values`: slot name → text):
190
+ let latex = rendered.plainLatexTemplate;
191
+ for (const [name, value] of Object.entries(values)) {
192
+ latex = latex.split(slotPlaceholder(name)).join(value);
193
+ }
194
+ root.dataset.latex = latex;
195
+ ```
196
+
197
+ Rebuild `latex` from the template on every update, not from the previous result.
198
+ A lab that does not set these attributes is unchanged. `RenderedLabMath` gained
199
+ the required `plainLatexTemplate` field; a course that constructs one by hand
200
+ (a test double, for instance) must add it.
201
+
202
+ ### 9. Only if you override `Footer` yourself: mount `AnchorScrollEnhancer`
203
+
204
+ The new smooth-scroll behavior for in-page anchor links ships in
205
+ `AnchorScrollEnhancer.astro`, which `LessonFooter.astro` mounts. A course whose
206
+ Starlight `components.Footer` is `explico/components/starlight/LessonFooter.astro`
207
+ gets it with no change. A course that swapped in its own footer component must
208
+ render it (`import AnchorScrollEnhancer from
209
+ 'explico/components/starlight/AnchorScrollEnhancer.astro'`, then
210
+ `<AnchorScrollEnhancer />`) or its anchor links keep the browser's native jump.
211
+
212
+ ### 10. Optional: wire `rehypeBaseLinks` for a course deployed under a subpath
213
+
214
+ Skip this for a course served from a domain root (`base` omitted or `'/'`); the
215
+ plugin is a no-op there. For a course under a subpath (a GitHub Pages *project*
216
+ site, `base: '/my-course'`), Starlight prefixes only its own navigation, so
217
+ every root-relative link the Markdown pipeline produces — prose links,
218
+ equation cross-references, citation links — 404s without this:
219
+
220
+ ```diff
221
+ + import rehypeBaseLinks from 'explico/reference/rehype-base-links.mjs';
222
+ …
223
+ rehypePlugins: [
224
+ [rehypeKatex, createNotationKatexOptions()],
225
+ rehypeFailKatexErrors,
226
+ + [rehypeBaseLinks, base],
227
+ ]
228
+ ```
229
+
230
+ `base` is the same value passed to `defineConfig({ base })`. **Undo any manual
231
+ workaround first:** the plugin prefixes every `href` beginning with a single
232
+ `/`, so a lesson link already hand-written as `/my-course/other/` would become
233
+ `/my-course/my-course/other/`. Write lesson links without the base. A lesson's
234
+ Starlight frontmatter (`hero.actions[].link`) never passes through this
235
+ pipeline; write those as paths relative to the current page.
236
+
237
+ ### 11. Optional: run logs for `pnpm validate:content`
238
+
239
+ Route the course's `scripts/compile-manifest.ts` through `createRunLog()` to
240
+ record every diagnostic to `build/logs/<timestamp>/{error,warning,info}.log`
241
+ (Python `logging` format, `build/logs/latest` symlinks the newest run):
242
+
243
+ ```diff
244
+ + import { createRunLog } from 'explico/compiler/logging';
245
+ …
246
+ + const log = createRunLog();
247
+ const manifest = await compileManifest(courseConfig);
248
+ …
249
+ - console.warn(`[${warning.code}] ${warning.message}`);
250
+ + console.warn(log.warning('notation', `[${warning.code}] ${warning.message}`));
251
+ …
252
+ + const summary = log.close();
253
+ + console.log(
254
+ + `Run log: ${summary.relativeDirectory} ` +
255
+ + `(${summary.counts.ERROR} error(s), ${summary.counts.WARNING} warning(s), ${summary.counts.INFO} info).`,
256
+ + );
257
+ ```
258
+
259
+ Each `log.error/warning/info(source, message)` call returns its message, so
260
+ printing and recording share one expression; `log.infoDiagnostics(manifest.diagnostics)`
261
+ records the severity-`info` findings the console never showed. Call
262
+ `log.close()` before any `throw` so a failing run's log is complete
263
+ (`playground/scripts/compile-manifest.ts` is a full worked example). Add
264
+ `build/logs/` to the course's `.gitignore` — a course commits
265
+ `build/resolution/*`, and nothing prunes old runs.
266
+
267
+ ### 12. Optional: the `content diagnostics` / `content warnings` commands and VS Code tasks
268
+
269
+ `content diagnostics [info|warning|error] [--json]` and `content warnings
270
+ [--json]` are new, and `content status` now reports an `info` count
271
+ (`report.diagnostics.infos`). The engine implements them in
272
+ `explico/compiler/cli`, but a course's own `scripts/content.ts` dispatches
273
+ commands, so it must route them:
274
+
275
+ ```diff
276
+ import {
277
+ ContentCliError,
278
+ contentCheck,
279
+ contentContext,
280
+ + contentDiagnostics,
281
+ contentStatus,
282
+ + contentWarnings,
283
+ …
284
+ } from 'explico/compiler/cli';
285
+ ```
286
+
287
+ Add a `case 'warnings'` (`contentWarnings(manifest)`) and a `case 'diagnostics'`
288
+ (validate the one positional as `info`/`warning`/`error`, default `info`, then
289
+ `contentDiagnostics(manifest, severity)`) beside `case 'status'`, printing
290
+ `file:line:column: severity code: message` lines — copy
291
+ `playground/scripts/content.ts`. To surface findings in VS Code's Problems
292
+ panel, copy the three **Explico: Show content diagnostics** tasks from the
293
+ engine repository's `.vscode/tasks.json`, replacing the `--filter` package name
294
+ and the problem matcher's `fileLocation` root with the course's own.
295
+
296
+ ### 13. No action: other changes in this release
297
+
298
+ - Every `<a>` back-link (`↩`) in a page's References section now also carries a
299
+ `title` tooltip; `remark-citation.mjs` does this itself.
300
+ - Standalone HTML export now inlines a `<link>` only when its `rel` is
301
+ `stylesheet` or an icon type, and leaves other `<link>`s (`canonical`,
302
+ `alternate`, `preload`, …) as authored.
303
+
304
+ ### Verification
305
+
306
+ `pnpm validate:content` (run it **twice** — the first run regenerates
307
+ `build/resolution/*`, item 1), then `pnpm content check --changed`, then
308
+ `astro check`, then `astro build`. `astro build` is the step that reports item 3.
309
+
310
+ ---
311
+
36
312
  ## 0.5.0 → 0.6.0
37
313
 
38
314
  ### 1. Rule S1: no heading or float title runs past 65 characters
@@ -215,6 +491,16 @@ abbreviation needs its remark plugin wired in too:
215
491
  ]
216
492
  ```
217
493
 
494
+ Skipping this second half is now **build-enforced**, not just documented:
495
+ `explicoContentRefresh()`'s `astro:config:setup` hook checks
496
+ `markdown.remarkPlugins` against the manifest and throws if a catalogued
497
+ manifest table (`manifest.abbreviations`, `manifest.sources`, or
498
+ `manifest.notation.raw`) has no matching plugin (`remarkAbbreviations`,
499
+ `remarkCitation`, `remarkNotation` respectively) registered — so a
500
+ half-completed migration like this one fails `astro build`/`astro dev`
501
+ instead of silently rendering plain text with `pnpm validate:content` still
502
+ green.
503
+
218
504
  ### 5. Optional: rule N10 warns on an unexplained `\mathrm{…}` label
219
505
 
220
506
  A `\mathrm{…}` multi-letter label (`\mathrm{KE}`, `\mathrm{PV}`) not on the
@@ -837,14 +837,15 @@ change to that file.
837
837
  ## N15
838
838
 
839
839
  **A nested visible delimiter — `(`, `[`, `\{`, or `|` inside another one of
840
- them — must use its `\left`/`\right` sizing form, not the bare character.**
840
+ them — must use a scalable sizing form, not the bare character.**
841
841
 
842
842
  `(`, `[`, `\{` (an escaped, visible brace — bare `{`/`}` is invisible LaTeX
843
843
  grouping and out of scope here), and `|` may be written bare only when they
844
844
  are not nested — not inside one another, and not inside a second instance of
845
845
  themselves. The moment one of them sits inside another, **every level of that
846
- nest** switches to the auto-sizing form: `\left(`/`\right)`, `\left[`/`\right]`,
847
- `\left\{`/`\right\}`, `\left|`/`\right|`.
846
+ nest** uses either the auto-sizing form (`\left(`/`\right)`,
847
+ `\left[`/`\right]`, `\left\{`/`\right\}`, `\left|`/`\right|`) or an explicit
848
+ manual pair from [N17](#n17).
848
849
 
849
850
  ```text
850
851
  \sin(2(90\text{°}-\theta)) ✗ two bare ( nested
@@ -873,10 +874,10 @@ author on a case that can render invisibly wrong.
873
874
  _How to apply._
874
875
 
875
876
  - Reach for `\left(`/`\right)` (or the bracket/brace/pipe equivalents) the
876
- moment a second one of these four glyphs would end up inside the first —
877
- regardless of which of the four it is, and regardless of whether the outer
878
- one is already sized. Sizing the outer pair does not exempt an inner bare
879
- one; both levels of a two-level nest need their own `\left`/`\right`.
877
+ moment a second one of these four glyphs would end up inside the first, then
878
+ apply [N17](#n17)'s manual sizes where necessary. Sizing the outer pair does
879
+ not exempt an inner bare one; both levels of a two-level nest need a sizing
880
+ command.
880
881
  - Mixing types counts as nesting too: `(a + [b])`, `\{a \mid (b)\}`, and
881
882
  `(|a|)` all need every level sized, exactly like nesting the same character
882
883
  twice.
@@ -909,3 +910,134 @@ _Exceptions._ None. A quoted source nesting bare delimiters still renders
909
910
  sized in this course, the same way N13 reparenthesizes a quoted `\sin x` —
910
911
  the sizing is a typesetting choice, not a claim about the source's own
911
912
  notation.
913
+
914
+ ---
915
+
916
+ ## N16
917
+
918
+ **In AI-generated mathematical content, alternate delimiter types for nested
919
+ grouping when their shape is stylistic; preserve every conventional or
920
+ semantic delimiter shape.**
921
+
922
+ For a nest whose delimiters do nothing except group an expression, prefer this
923
+ cycle from the inside out:
924
+
925
+ 1. `( ... )`
926
+ 2. `[ ... ]`
927
+ 3. `\{ ... \}`
928
+ 4. repeat the cycle if another grouping level is genuinely necessary.
929
+
930
+ This is a preference for generated notation, not a mechanical rewrite. Do not
931
+ change or alternate a delimiter whose type is part of the mathematical
932
+ notation. Preserve, among other conventional forms:
933
+
934
+ - function application such as `f(x)`;
935
+ - expectation notation such as `\mathbb{E}[X]`;
936
+ - intervals such as `[a,b]` and `(a,b]`;
937
+ - sets and set-builder notation;
938
+ - tuples and coordinates;
939
+ - commutators and probability notation;
940
+ - matrices, vectors, indexing, norms, and absolute values.
941
+
942
+ When there is doubt about whether the delimiter is semantic, keep the
943
+ conventional notation. Do not alternate merely to complete the cycle.
944
+
945
+ _Rationale._ Repeating the same shape at every purely stylistic grouping level
946
+ makes the boundaries harder to scan. Alternation can expose the grouping
947
+ structure, but only mathematical context can distinguish a disposable grouping
948
+ mark from notation whose shape carries meaning. That judgment belongs to the
949
+ authoring agent or reviewer, not a syntax linter.
950
+
951
+ _How to apply._ Decide whether each pair is stylistic before choosing its
952
+ shape. Alternate only the pairs that pass that test. Apply [N17](#n17)
953
+ separately so the visual size still increases from the inside out; shape and
954
+ size are independent decisions.
955
+
956
+ ```text
957
+ \Biggl[
958
+ a+
959
+ \biggl(
960
+ b+
961
+ \Bigl[
962
+ c+
963
+ \left(d+e\right)
964
+ \Bigr]
965
+ \biggr)
966
+ \Biggr] ✓
967
+ ```
968
+
969
+ Semantic notation stays intact even when stylistic grouping surrounds it:
970
+
971
+ ```text
972
+ \Biggl[
973
+ \mathbb{E}\!\Bigl[
974
+ f\!\left(X\right)
975
+ \Bigr]
976
+ +
977
+ \biggl(
978
+ a+b
979
+ \biggr)
980
+ \Biggr] ✓
981
+ ```
982
+
983
+ In particular, never turn `\mathbb{E}[X]` into another delimiter type merely
984
+ to alternate shapes.
985
+
986
+ _Enforcement._ Review-only. Delimiter semantics cannot be inferred reliably
987
+ from LaTeX syntax, so no automated check enforces alternation. The N17 linter
988
+ checks sizes only and never recommends changing a delimiter type.
989
+
990
+ _Exceptions._ Every conventional or semantically meaningful delimiter is
991
+ outside the alternation preference. Preserving established mathematical
992
+ notation takes precedence over the stylistic cycle.
993
+
994
+ ---
995
+
996
+ ## N17
997
+
998
+ **Every outer pair in a nest of scalable `()`, `[]`, or `\{\}` delimiters must
999
+ be visibly larger than every pair inside it.**
1000
+
1001
+ Use progressively larger standard manual sizes to make grouping depth visible
1002
+ without producing unnecessarily oversized marks:
1003
+
1004
+ 1. innermost baseline: `\left ... \right` (or `\bigl ... \bigr`);
1005
+ 2. next: `\Bigl ... \Bigr`;
1006
+ 3. next: `\biggl ... \biggr`;
1007
+ 4. outermost: `\Biggl ... \Biggr`.
1008
+
1009
+ The opening and closing commands change size only. Preserve each delimiter's
1010
+ shape: `[` remains `[`, `(` remains `(`, and `\{` remains `\{`.
1011
+
1012
+ _Rationale._ Two nested `\left...\right` pairs around similarly short content
1013
+ can render at the same apparent size, hiding the nesting that scalable
1014
+ delimiters were meant to clarify. A strict inside-to-outside hierarchy makes
1015
+ the depth legible and has an objective syntax-level repair through the four
1016
+ standard manual size levels.
1017
+
1018
+ _How to apply._ Start at the innermost scalable pair and assign each enclosing
1019
+ pair the next larger level. Four or fewer levels must have a strict increase at
1020
+ every level. If a valid expression needs more levels than the standard sizes
1021
+ can distinguish, restructure the expression rather than inventing a fifth
1022
+ size. This rule is independent of N16: semantic shapes stay semantic, but their
1023
+ sizes still increase when their scalable pairs are nested.
1024
+
1025
+ _Enforcement._ Tier-3 warning `delimiter-size-hierarchy`, emitted by
1026
+ `lintDelimiterSizes` (`src/reference/delimiter-size.ts`) through the normal
1027
+ math gate for lesson math, notation formulas, and assessment math. The linter
1028
+ uses a stack to parse arbitrarily deep `\left...\right`,
1029
+ `\bigl...\bigr`, `\Bigl...\Bigr`, `\biggl...\biggr`, and
1030
+ `\Biggl...\Biggr` pairs for `()`, `[]`, and `\{\}`. It reports the outer
1031
+ pair's source line and column and suggests the required larger manual size when
1032
+ one exists. Malformed pairs remain KaTeX syntax errors; the size linter does not
1033
+ guess at their structure.
1034
+
1035
+ The automated check deliberately does **not** infer mathematical semantics or
1036
+ lint delimiter-type alternation. Its suggestion repeats the exact opening and
1037
+ closing delimiter shapes it found.
1038
+
1039
+ _Exceptions._ None for a valid nest of scalable pairs with four or fewer
1040
+ levels. Beyond four levels, the warning remains but has no automatic size
1041
+ replacement; restructure the expression. Bare, non-nested semantic notation
1042
+ such as `f(x)` or `\mathbb{E}[X]` is outside this linter and remains governed by
1043
+ the mathematical convention and [N15](#n15)'s separate bare-nesting rule.
@@ -0,0 +1,64 @@
1
+ # Prose rules
2
+
3
+ `P` rules govern how a lesson's sentences are worded — choices a reader
4
+ meets in running text rather than in a symbol, a figure, or a heading.
5
+
6
+ See [README.md](README.md) for what "strong recommendation", "Build-enforced",
7
+ and "Review-only" mean here, and how to amend a rule.
8
+
9
+ ---
10
+
11
+ ## P1
12
+
13
+ **Never use "or" to give a thing a second name.**
14
+
15
+ When a sentence introduces an alternative name, synonym, or equivalent
16
+ expression for something, do not join the two with "or". Use "i.e.,", "also
17
+ called", "also known as", or parentheses, whichever fits the sentence.
18
+
19
+ ```md
20
+ <!-- "or" reads as a choice between two different things. -->
21
+
22
+ The residual or error term is what the model leaves unexplained.
23
+ Fit the model by ordinary least squares or OLS.
24
+
25
+ <!-- Each names one thing, and says so. -->
26
+
27
+ The residual (also called the error term) is what the model leaves unexplained.
28
+ Fit the model by ordinary least squares (OLS).
29
+ ```
30
+
31
+ _Rationale._ "Or" has two jobs in English: it separates alternatives ("a
32
+ sample or a population"), and it introduces a second name for the same thing.
33
+ Nothing in the sentence says which job it is doing, so a reader can take "the
34
+ residual or error term" as two different quantities to choose between. In a
35
+ statistics course that misreading is expensive, because many pairs that look
36
+ like synonyms are not ("variance or standard deviation", "estimate or
37
+ estimator"). A reader who takes a real distinction for a synonym, or the
38
+ reverse, learns the wrong thing without noticing.
39
+
40
+ _How to apply._ Decide whether the two expressions name the same thing or
41
+ different things.
42
+
43
+ - **Same thing** — rewrite with one of:
44
+ - _i.e.,_ when the second expression restates or defines the first: "the
45
+ estimator is unbiased, i.e., its expected value equals the parameter".
46
+ - _also called_ / _also known as_ when it is a second name in use: "the
47
+ coefficient of determination, also known as $R^2$".
48
+ - _parentheses_ when the second name is short and the sentence carries
49
+ on: "the mean squared error (MSE)".
50
+ - **Different things** — "or" is correct, and stays. If the sentence could
51
+ still be read as a synonym, name the choice: "either a sample or a
52
+ population", "whether the variance or the standard deviation is reported".
53
+
54
+ An abbreviation introduced in parentheses is still subject to
55
+ [N14](notation.md#n14): it has to be catalogued or explained.
56
+
57
+ _Enforcement._ Review-only. Whether "or" is a logical alternative or a
58
+ second name depends on what the sentence means, so no gate can decide it; a
59
+ reviewer does. An automated "or" scan would flag every legitimate
60
+ alternative.
61
+
62
+ _Exceptions._ A quotation, a source title, or a code identifier that contains
63
+ "or" is left as written. So is "or" inside an assessment answer choice that
64
+ really offers alternatives.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -13,6 +13,9 @@ import {
13
13
  } from './manifest';
14
14
  import { missingNotationPayloadKeys } from '../reference/page-notation';
15
15
  import { writeStandaloneLessonExports } from './exports/standalone-html';
16
+ import remarkAbbreviations from '../reference/remark-abbreviations.mjs';
17
+ import remarkCitation from '../reference/remark-citation.mjs';
18
+ import remarkNotation from '../reference/remark-notation.mjs';
16
19
 
17
20
  // Vite keeps its server object across a restart. Do not accumulate watcher
18
21
  // callbacks each time Astro reloads its configuration.
@@ -132,6 +135,91 @@ export function assertNotationPayloadHtml(page: string, html: string): void {
132
135
  );
133
136
  }
134
137
 
138
+ type MarkdownConfig = AstroConfig['markdown'];
139
+
140
+ /**
141
+ * Every configured remark plugin, regardless of which of Astro's two
142
+ * mutually-exclusive wiring surfaces a course uses: the deprecated flat
143
+ * `markdown.remarkPlugins` array, or a `markdown.processor` (e.g.
144
+ * `unified({ remarkPlugins })`) whose own `options.remarkPlugins` a
145
+ * non-`unified` processor (e.g. `satteri()`) may not have at all.
146
+ */
147
+ function configuredRemarkPlugins(markdown: MarkdownConfig): readonly unknown[] {
148
+ const direct = markdown?.remarkPlugins ?? [];
149
+ const processorOptions = markdown?.processor?.options as
150
+ | { remarkPlugins?: unknown[] }
151
+ | undefined;
152
+ return [...direct, ...(processorOptions?.remarkPlugins ?? [])];
153
+ }
154
+
155
+ function remarkPluginIsWired(remarkPlugins: readonly unknown[], plugin: unknown): boolean {
156
+ return remarkPlugins.some(
157
+ (entry) => entry === plugin || (Array.isArray(entry) && entry[0] === plugin),
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Fail the build when a manifest-driven remark plugin has catalogued content
163
+ * but was never registered in Astro's markdown config — the "half-migrated"
164
+ * failure mode `docs/authoring/migrating.md` documents but, until now, never
165
+ * checked: `pnpm validate:content` stays green because the content-side gate
166
+ * (e.g. `gate-abbreviations.ts`'s N14 check) only scans prose, not Astro
167
+ * config, so a skipped plugin registration renders every trigger as plain
168
+ * text with no signal anywhere.
169
+ */
170
+ export function assertRemarkPluginsWired(
171
+ manifest: Pick<Manifest, 'abbreviations' | 'sources' | 'notation'>,
172
+ markdown: MarkdownConfig,
173
+ ): void {
174
+ const remarkPlugins = configuredRemarkPlugins(markdown);
175
+ const missing: string[] = [];
176
+
177
+ if (
178
+ manifest.abbreviations.length > 0 &&
179
+ !remarkPluginIsWired(remarkPlugins, remarkAbbreviations)
180
+ ) {
181
+ missing.push(
182
+ `remarkAbbreviations is missing from markdown.remarkPlugins, but ` +
183
+ `manifest.abbreviations has ${manifest.abbreviations.length} ` +
184
+ `entr${manifest.abbreviations.length === 1 ? 'y' : 'ies'}. Import it from ` +
185
+ `'explico/reference/remark-abbreviations.mjs' and add ` +
186
+ `[remarkAbbreviations, { abbreviations: manifest.abbreviations }] to ` +
187
+ `markdown.remarkPlugins — see docs/authoring/migrating.md#4-rule-n14.`,
188
+ );
189
+ }
190
+
191
+ if (manifest.sources.length > 0 && !remarkPluginIsWired(remarkPlugins, remarkCitation)) {
192
+ missing.push(
193
+ `remarkCitation is missing from markdown.remarkPlugins, but ` +
194
+ `manifest.sources has ${manifest.sources.length} ` +
195
+ `entr${manifest.sources.length === 1 ? 'y' : 'ies'}. Import it from ` +
196
+ `'explico/reference/remark-citation.mjs' and add ` +
197
+ `[remarkCitation, { sources: manifest.sources }] to markdown.remarkPlugins.`,
198
+ );
199
+ }
200
+
201
+ if (
202
+ manifest.notation.raw.length > 0 &&
203
+ !remarkPluginIsWired(remarkPlugins, remarkNotation)
204
+ ) {
205
+ missing.push(
206
+ `remarkNotation is missing from markdown.remarkPlugins, but ` +
207
+ `manifest.notation.raw has ${manifest.notation.raw.length} ` +
208
+ `entr${manifest.notation.raw.length === 1 ? 'y' : 'ies'}. Import it from ` +
209
+ `'explico/reference/remark-notation.mjs' and add ` +
210
+ `[remarkNotation, createRemarkNotationOptions(manifest)] to markdown.remarkPlugins.`,
211
+ );
212
+ }
213
+
214
+ if (missing.length === 0) return;
215
+ throw new Error(
216
+ `Explico manifest-driven remark plugin${missing.length === 1 ? ' is' : 's are'} ` +
217
+ `not wired into Astro's markdown config:\n${missing
218
+ .map((line) => ` - ${line}`)
219
+ .join('\n')}`,
220
+ );
221
+ }
222
+
135
223
  async function validateGeneratedNotationPayloads(directory: URL): Promise<void> {
136
224
  const root = fileURLToPath(directory);
137
225
  const walk = async (path: string): Promise<void> => {
@@ -200,6 +288,7 @@ export function explicoContentRefresh(
200
288
  name: 'explico-content-refresh',
201
289
  hooks: {
202
290
  'astro:config:setup': ({ command, config }) => {
291
+ assertRemarkPluginsWired(manifest, config.markdown);
203
292
  if (command === 'dev') {
204
293
  developmentConfig = { root: config.root, base: config.base };
205
294
  }