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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,184 @@ under **Breaking** below; a PATCH never breaks.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.7.0] - 2026-09-18
18
+
19
+ ### Breaking
20
+
21
+ - **The compiler manifest schema is now v15 and diagnostic severity has three
22
+ levels.** Public diagnostic severity unions now include `info`, whose findings
23
+ are optional advice rather than repair debt. `eq-key-unused`, every keyed-float
24
+ `*-key-unused`, `unused-definition`, `weak-local`, `gloss-wants-promoting`, and
25
+ `card-wants-demoting` now emit `info` instead of `warning`. Consumers must stop
26
+ treating every non-error as a warning and accept schema v15.
27
+
28
+ - **`explicoContentRefresh()` now fails the build when a manifest-driven
29
+ remark plugin is missing from `markdown.remarkPlugins`.** Its
30
+ `astro:config:setup` hook checks `manifest.abbreviations`,
31
+ `manifest.sources`, and `manifest.notation.raw` against the configured
32
+ plugins and throws if a non-empty table has no matching
33
+ `remarkAbbreviations`, `remarkCitation`, or `remarkNotation` registration.
34
+ Previously this "half-migrated" shape — content catalogued, plugin wiring
35
+ skipped — passed `pnpm validate:content` and `astro build` with no signal
36
+ anywhere, silently rendering triggers as plain text (see
37
+ [`docs/authoring/migrating.md`, rule N14](docs/authoring/migrating.md#4-rule-n14)).
38
+ A course with this exact gap will now fail its next build until the
39
+ missing plugin is registered.
40
+
41
+ - **Retired the `numerals-tagged` Tier-3 consistency diagnostic.** It counted
42
+ every numeral in an untagged math span or table row—including unit exponents,
43
+ transparent worked-example arithmetic, and repeated table values—while
44
+ reporting only a lesson-level aggregate. `ConsistencyCode` no longer includes
45
+ `numerals-tagged`; remove any consumer logic or `lint-ignore.yml` entry for
46
+ that code. Numerical provenance should be enforced only by a future rule with
47
+ an explicit authoring surface and occurrence-level diagnostics.
48
+
49
+ - **Removed the notation popup's "Copy LaTeX" button and the glossary's `#`
50
+ copy-link button** (`.notation-popup-copy`, `[data-popup-copy]`, and
51
+ `[data-glossary-anchor]` with its "Link copied" flash are gone from the
52
+ DOM). Copying a formula's LaTeX is now a click on the equation itself (see
53
+ **Added**); a glossary entry's title is now a link to its own
54
+ `#notation-<key>` anchor, with a hover-revealed link-icon anchor beside it
55
+ that navigates like any heading permalink instead of writing to the
56
+ clipboard. `NotationFormula`'s keyboard-focusable scroll region also moved
57
+ from `.notation-formula-math` to a new nested `.notation-formula-scroll`.
58
+ A course's own CSS or end-to-end tests targeting any of these selectors
59
+ need updating.
60
+
61
+ ### Added
62
+
63
+ - **Click any rendered equation to copy its LaTeX, and every glossary formula
64
+ is numbered.** `NotationLayer.astro` (loaded on every page through the
65
+ Footer override) now handles clicks on any `[data-notation-formula]` box —
66
+ a glossary formula, an `\explain{}` formula inside a popup or `Diagram`, or
67
+ an interactive lab equation — copying its `data-latex` and flashing
68
+ "Copied!"; a hover tooltip names the gesture, and a click on an explained
69
+ symbol or an equation number keeps its own meaning. A glossary entry's
70
+ formula gets a `G.<n>` number (page order) that links to its entry, plus a
71
+ copy button; `NotationFormula` gains optional `number` / `numberHref` props
72
+ and renders neither when they are omitted. For interactive labs,
73
+ `renderLabMath()` now also returns `plainLatexTemplate` (bare symbols, each
74
+ `\slot{name}{…}` replaced by `slotPlaceholder(name)`), and the new
75
+ `slotPlaceholder` export lets an island substitute each slot's live value
76
+ so `data-latex` tracks the sliders. A lab that sets `data-notation-formula`
77
+ and `data-latex` on its math root joins the gesture; one that does not is
78
+ unchanged.
79
+
80
+ - **Smooth, eased scrolling for in-page anchor links.** The new
81
+ `AnchorScrollEnhancer.astro`, mounted by `LessonFooter.astro`, catches every
82
+ same-page `<a href="#…">` — headings, equation/table/figure numbers,
83
+ footnote and memo markers, the glossary's links, the table of contents —
84
+ animates to the target with the shared `animatedScrollIntoView` helper, then
85
+ performs a real fragment navigation on arrival so `:target` styling and
86
+ `hashchange` behave as for a plain click. `prefers-reduced-motion` is
87
+ honored, and scrolling away mid-animation just updates the URL.
88
+ `HeadingAnchorEnhancer`, `KeyedFloatEnhancer`, `Memo`, and `ShortNote` now
89
+ share that helper. A course that overrides `Footer` with its own component
90
+ instead of `LessonFooter.astro` must render `<AnchorScrollEnhancer />` itself
91
+ to get it.
92
+
93
+ - **New review-only authoring rule P1 and a new `P` (prose) rule area: "or"
94
+ never introduces a second name for the same thing.** Write "i.e.,", "also
95
+ called", "also known as", or parentheses instead, because "or" also
96
+ separates real alternatives and a reader cannot tell which is meant ("the
97
+ residual or error term"). Nothing is checked at build time, so no course
98
+ needs to change to keep building. See `docs/authoring/prose.md#p1`.
99
+
100
+ - **The shipped abbreviation baseline (rule N14) now covers every common
101
+ time-of-day and "and so on" spelling.** `AM`, `PM`, `A.M.`, `P.M.`, and
102
+ `e.t.c.` join `a.m.`, `p.m.`, `e.g.`, `i.e.`, and `etc.`, so a lesson can
103
+ write "9 AM" or "e.g." without a `content/abbreviations/*.md` entry or an
104
+ `abbreviations.allow` line. This only loosens the gate — no course needs to
105
+ change to keep building; a course that wants one of them explained anyway
106
+ lists it in `abbreviations.deny`. See `docs/authoring/notation.md#n14`.
107
+
108
+ - **Run logs for `pnpm validate:content`, in Python's `logging` format.** The
109
+ new `explico/compiler/logging` export (`createRunLog()`) records every
110
+ diagnostic a validate run reports to `build/logs/<timestamp>/`, formatted as
111
+ `%(asctime)s %(levelname)s %(name)s: %(message)s` so the lines filter with
112
+ the same tooling a Python log does. Three cumulative files per run —
113
+ `error.log` (errors), `warning.log` (errors + warnings), and `info.log`
114
+ (everything) — let you open the narrowest one that still answers the
115
+ question, and `build/logs/latest` symlinks to the newest run. Logger names
116
+ are `explico.<subsystem>` (`explico.notation`, `explico.curriculum`,
117
+ `explico.raw-floats`, …), derived from the diagnostic category, so filtering
118
+ by name is filtering by subsystem. Nothing is pruned; `build/` is
119
+ git-ignored. `info.log` also captures the severity-`info` diagnostics the
120
+ engine has always produced but never printed, plus the run summary lines.
121
+ The terminal output is deliberately unchanged — the compact `[code] message`
122
+ lines stay exactly as they were, with one added pointer line naming the run's
123
+ log directory and its counts. Blocking diagnostics still throw and still exit
124
+ non-zero; logging is purely additive. A course picks this up by routing its
125
+ own `scripts/compile-manifest.ts` through `createRunLog()`.
126
+
127
+ - **New severity-filtered content diagnostics and VS Code actions.** `pnpm
128
+ content diagnostics [info|warning|error] [--json]` flattens manifest findings
129
+ into stable, source-aware `group:code` diagnostics at or above the requested
130
+ severity; the new `content warnings` is the warning-only view, and
131
+ `content status` now also counts `info` findings. Both commands are exported
132
+ from `explico/compiler/cli` (`contentDiagnostics`, `contentWarnings`), so a
133
+ course's own `scripts/content.ts` must route them to expose the commands. The
134
+ playground provides **Explico: Show content diagnostics (info+)**,
135
+ **(warning+)**, and **(errors)** tasks, which feed locatable findings into
136
+ VS Code's Problems panel.
137
+
138
+ - **New delimiter authoring rules N16 and N17, with warning-level size
139
+ linting.** Rule N16 tells AI authors to alternate `()`, `[]`, and `\{\}` for
140
+ purely stylistic nested grouping while preserving function calls,
141
+ expectations, intervals, sets, tuples, probability, indexing, norms, and
142
+ every other semantically meaningful conventional delimiter. It is
143
+ review-only. Rule N17 independently requires nested scalable delimiter pairs
144
+ to grow from inside to outside. The new stack-based
145
+ `lintDelimiterSizes` parser handles multiline and arbitrarily deep
146
+ `\left...\right` plus `\big`/`\Big`/`\bigg`/`\Bigg` pairs, emits the
147
+ non-blocking `delimiter-size-hierarchy` diagnostic at the outer pair's line
148
+ and column, and suggests a larger manual size without changing delimiter
149
+ shape. It deliberately does not lint type alternation or infer mathematical
150
+ semantics.
151
+
152
+ - **New `explico/reference/rehype-base-links.mjs`.** Wire `[rehypeBaseLinks, base]`
153
+ into `markdown.rehypePlugins` and it prefixes every root-relative
154
+ `<a href="/...">` a lesson's Markdown/MDX body produces — a lesson's own
155
+ prose links, `remark-notation.mjs`'s equation cross-references, and
156
+ `remark-citation.mjs`'s citation links — with the course's Astro `base`.
157
+ Starlight only applies `base` to its own generated navigation (sidebar,
158
+ pagination, breadcrumbs); it never touches anything the Markdown/MDX →
159
+ rehype pipeline produces, so on any course deployed under a subpath (a
160
+ GitHub Pages *project* site, e.g. `base: '/my-course'`) every one of those
161
+ links 404s without this. No-op when the course has no base, so it's safe
162
+ to wire in unconditionally. A lesson's Starlight frontmatter (e.g.
163
+ `hero.actions[].link`) never enters this pipeline either — write those as
164
+ paths relative to the current page instead.
165
+
166
+ - **The `↩` link at the end of each entry in a page's References section now
167
+ has a hover tooltip.** `remark-citation.mjs` already gave that back-link an
168
+ `aria-label` ("Back to the citation in the text"); it now sets the same text
169
+ as `title`, so a mouse user hovering the bare arrow sees what it does. No
170
+ course wiring or config change needed.
171
+
172
+ ### Changed
173
+
174
+ - **`remarkAbbreviations` (rule N14) now wraps every occurrence of a
175
+ catalogued abbreviation in a lesson, not just the first per page.** A
176
+ reader who deep-links into a mid-page section — the sidebar TOC, a heading
177
+ anchor, an external link — used to land on plain, unlinked text with no
178
+ visual cue that a hover/click glossary card existed elsewhere on the page,
179
+ because only the earliest occurrence in document order was ever wrapped.
180
+ `AbbreviationLayer.astro` already resolved every marker independently by
181
+ `data-abbr-key`, so no island-side change was needed. This changes rendered
182
+ output for every existing course using this plugin: abbreviation-dense
183
+ prose that repeats the same term many times in one passage will now show a
184
+ dotted-underline link on each instance.
185
+
186
+ ### Fixed
187
+
188
+ - **Standalone HTML export no longer runs every `<link>` through the asset
189
+ inliner.** Only a `<link>` whose `rel` is `stylesheet` or an icon type
190
+ (`icon`, `shortcut icon`, `apple-touch-icon`,
191
+ `apple-touch-icon-precomposed`, `mask-icon`) is inlined; any other `rel`
192
+ (`canonical`, `alternate`, `preload`, …) is left as authored. No course
193
+ change is needed.
194
+
17
195
  ## [0.6.0] - 2026-09-16
18
196
 
19
197
  ### Breaking
@@ -544,7 +722,8 @@ under **Breaking** below; a PATCH never breaks.
544
722
  system with its completeness gate, the curriculum-graph validator, the
545
723
  Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
546
724
 
547
- [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.6.0...HEAD
725
+ [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.7.0...HEAD
726
+ [0.7.0]: https://github.com/danieltuzes/explico/compare/v0.6.0...v0.7.0
548
727
  [0.6.0]: https://github.com/danieltuzes/explico/compare/v0.5.0...v0.6.0
549
728
  [0.5.0]: https://github.com/danieltuzes/explico/compare/v0.4.0...v0.5.0
550
729
  [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,9 @@
1
+ import { DiagnosticSeverity } from '../reference/types.js';
1
2
  import { Manifest, ManifestLesson } from './manifest.js';
2
3
  import '../curriculum/validation.js';
3
4
  import '../reference/consistency.js';
4
5
  import './course-config.js';
5
6
  import './collections.js';
6
- import '../reference/types.js';
7
7
  import '../reference/lint-ignore.js';
8
8
  import '../reference/equations-validate.js';
9
9
  import '../reference/tables-validate.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,11 @@ 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;
60
64
  }
61
65
  /**
62
66
  * Every manifest diagnostic that names this lesson (by id or file), normalized
@@ -121,9 +125,24 @@ interface StatusReport {
121
125
  readonly diagnostics: {
122
126
  readonly errors: number;
123
127
  readonly warnings: number;
128
+ readonly infos: number;
124
129
  };
125
130
  }
126
131
  declare function contentStatus(manifest: Manifest): StatusReport;
132
+ type DiagnosticThreshold = DiagnosticSeverity;
133
+ interface DiagnosticsReport {
134
+ readonly minimumSeverity: DiagnosticThreshold;
135
+ readonly count: number;
136
+ readonly diagnostics: readonly CliDiagnostic[];
137
+ }
138
+ interface WarningsReport {
139
+ readonly count: number;
140
+ readonly warnings: readonly CliDiagnostic[];
141
+ }
142
+ /** Manifest diagnostics at or above a severity, normalized for tooling. */
143
+ declare function contentDiagnostics(manifest: Manifest, minimumSeverity?: DiagnosticThreshold): DiagnosticsReport;
144
+ /** Every warning exactly; errors and informational advice are excluded. */
145
+ declare function contentWarnings(manifest: Manifest): WarningsReport;
127
146
  interface ContextReport {
128
147
  readonly lesson: {
129
148
  readonly id: string;
@@ -216,4 +235,4 @@ interface Scaffold {
216
235
  declare function scaffoldLesson(rawId: string): Scaffold;
217
236
  declare function scaffoldTerm(rawKey: string): Scaffold;
218
237
 
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 };
238
+ 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 };
@@ -30,6 +30,9 @@ class ContentCliError extends Error {
30
30
  this.code = code;
31
31
  }
32
32
  }
33
+ function diagnosticSeverity(value) {
34
+ return value === "info" || value === "warning" ? value : "error";
35
+ }
33
36
  function mentions(message, lessonId, file) {
34
37
  return message.includes(lessonId) || message.includes(file) || message.includes(file.replace(/^content\/docs\//, ""));
35
38
  }
@@ -42,7 +45,7 @@ function lessonDiagnostics(manifest, lesson) {
42
45
  if (!mentions(issue.message, id, file)) continue;
43
46
  out.push({
44
47
  code: `curriculum:${issue.kind}`,
45
- severity: issue.severity === "warning" ? "warning" : "error",
48
+ severity: diagnosticSeverity(issue.severity),
46
49
  message: issue.message
47
50
  });
48
51
  }
@@ -57,7 +60,7 @@ function lessonDiagnostics(manifest, lesson) {
57
60
  }
58
61
  out.push({
59
62
  code: `${group}:${entry.code}`,
60
- severity: entry.severity === "warning" ? "warning" : "error",
63
+ severity: diagnosticSeverity(entry.severity),
61
64
  message: entry.message,
62
65
  ...withFile(entryFile)
63
66
  });
@@ -68,11 +71,12 @@ function lessonDiagnostics(manifest, lesson) {
68
71
  );
69
72
  if (!forLesson) continue;
70
73
  out.push({
71
- code: `math:${diagnostic.context}`,
74
+ code: `math:${diagnostic.code ?? diagnostic.context}`,
72
75
  severity: diagnostic.severity,
73
76
  message: diagnostic.message,
74
77
  file: diagnostic.file,
75
- ...diagnostic.line === void 0 ? {} : { line: diagnostic.line }
78
+ ...diagnostic.line === void 0 ? {} : { line: diagnostic.line },
79
+ ...diagnostic.column === void 0 ? {} : { column: diagnostic.column }
76
80
  });
77
81
  }
78
82
  for (const diagnostic of manifest.diagnostics.equations) {
@@ -81,7 +85,7 @@ function lessonDiagnostics(manifest, lesson) {
81
85
  }
82
86
  out.push({
83
87
  code: `equations:${diagnostic.code}`,
84
- severity: diagnostic.severity === "warning" ? "warning" : "error",
88
+ severity: diagnosticSeverity(diagnostic.severity),
85
89
  message: diagnostic.message,
86
90
  ...withFile(diagnostic.file)
87
91
  });
@@ -98,7 +102,7 @@ function lessonDiagnostics(manifest, lesson) {
98
102
  }
99
103
  out.push({
100
104
  code: diagnostic.code,
101
- severity: diagnostic.severity === "warning" ? "warning" : "error",
105
+ severity: diagnosticSeverity(diagnostic.severity),
102
106
  message: diagnostic.message,
103
107
  ...withFile(diagnostic.file)
104
108
  });
@@ -107,7 +111,7 @@ function lessonDiagnostics(manifest, lesson) {
107
111
  if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
108
112
  out.push({
109
113
  code: `consistency:${diagnostic.code}`,
110
- severity: "warning",
114
+ severity: diagnostic.severity,
111
115
  message: diagnostic.message,
112
116
  ...withFile(diagnostic.file)
113
117
  });
@@ -301,7 +305,7 @@ function contentStatus(manifest) {
301
305
  const draftNotation = manifest.notation.definitions.filter((definition) => definition.status === "draft").map((definition) => definition.id).sort();
302
306
  const orphans = manifest.notation.diagnostics.filter((diagnostic) => diagnostic.code === "unused-definition").map((diagnostic) => ({
303
307
  code: `notation:${diagnostic.code}`,
304
- severity: diagnostic.severity === "warning" ? "warning" : "error",
308
+ severity: diagnosticSeverity(diagnostic.severity),
305
309
  message: diagnostic.message,
306
310
  ...diagnostic.source?.file === void 0 ? {} : { file: diagnostic.source.file }
307
311
  })).sort((a, b) => a.message.localeCompare(b.message));
@@ -316,7 +320,7 @@ function contentStatus(manifest) {
316
320
  ...manifest.diagnostics.diagrams.map((d) => d.severity),
317
321
  ...manifest.diagnostics.checks.map((d) => d.severity),
318
322
  ...manifest.diagnostics.rawFloats.map((d) => d.severity),
319
- ...manifest.diagnostics.consistency.map(() => "warning"),
323
+ ...manifest.diagnostics.consistency.map((d) => d.severity),
320
324
  ...manifest.diagnostics.knowledge.map((d) => d.severity)
321
325
  ];
322
326
  const keyedEquations = manifest.lessons.reduce(
@@ -356,10 +360,65 @@ function contentStatus(manifest) {
356
360
  orphans,
357
361
  diagnostics: {
358
362
  errors: allDiagnostics.filter((severity) => severity === "error").length,
359
- warnings: allDiagnostics.filter((severity) => severity === "warning").length
363
+ warnings: allDiagnostics.filter((severity) => severity === "warning").length,
364
+ infos: allDiagnostics.filter((severity) => severity === "info").length
365
+ }
366
+ };
367
+ }
368
+ function numericLocation(value) {
369
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
370
+ }
371
+ const SEVERITY_RANK = {
372
+ info: 0,
373
+ warning: 1,
374
+ error: 2
375
+ };
376
+ function contentDiagnostics(manifest, minimumSeverity = "info") {
377
+ const lessonFileByHint = /* @__PURE__ */ new Map();
378
+ for (const lesson of manifest.lessons) {
379
+ lessonFileByHint.set(lesson.id, lesson.file);
380
+ lessonFileByHint.set(lesson.slug, lesson.file);
381
+ lessonFileByHint.set(lesson.file, lesson.file);
382
+ }
383
+ const groups = Object.entries(manifest.diagnostics);
384
+ const diagnostics = [];
385
+ for (const [group, values] of groups) {
386
+ for (const value of values) {
387
+ const diagnostic = value;
388
+ const severity = diagnosticSeverity(diagnostic.severity);
389
+ if (SEVERITY_RANK[severity] < SEVERITY_RANK[minimumSeverity]) continue;
390
+ const source = diagnostic.source !== null && typeof diagnostic.source === "object" ? diagnostic.source : void 0;
391
+ const rawFile = typeof diagnostic.file === "string" ? diagnostic.file : typeof source?.file === "string" ? source.file : void 0;
392
+ const lessonId = typeof diagnostic.lessonId === "string" ? diagnostic.lessonId : void 0;
393
+ const file = (rawFile === void 0 ? void 0 : lessonFileByHint.get(rawFile)) ?? (lessonId === void 0 ? void 0 : lessonFileByHint.get(lessonId)) ?? rawFile;
394
+ const line = numericLocation(diagnostic.line) ?? numericLocation(source?.line);
395
+ const column = numericLocation(diagnostic.column) ?? numericLocation(source?.column);
396
+ const detail = typeof diagnostic.code === "string" ? diagnostic.code : typeof diagnostic.kind === "string" ? diagnostic.kind : "diagnostic";
397
+ diagnostics.push({
398
+ code: `${group}:${detail}`,
399
+ severity,
400
+ message: String(diagnostic.message ?? detail),
401
+ ...file === void 0 ? {} : { file },
402
+ ...line === void 0 ? {} : { line },
403
+ ...column === void 0 ? {} : { column }
404
+ });
360
405
  }
406
+ }
407
+ diagnostics.sort(
408
+ (left, right) => (left.file ?? "").localeCompare(right.file ?? "") || (left.line ?? 0) - (right.line ?? 0) || (left.column ?? 0) - (right.column ?? 0) || left.code.localeCompare(right.code) || left.message.localeCompare(right.message)
409
+ );
410
+ return {
411
+ minimumSeverity,
412
+ count: diagnostics.length,
413
+ diagnostics
361
414
  };
362
415
  }
416
+ function contentWarnings(manifest) {
417
+ const warnings = contentDiagnostics(manifest, "warning").diagnostics.filter(
418
+ (diagnostic) => diagnostic.severity === "warning"
419
+ );
420
+ return { count: warnings.length, warnings };
421
+ }
363
422
  function contentContext(manifest, rawId) {
364
423
  const lesson = findLesson(manifest, rawId);
365
424
  const { frontmatter, body } = lessonRaw(lesson);
@@ -599,7 +658,9 @@ export {
599
658
  changedLessonIds,
600
659
  contentCheck,
601
660
  contentContext,
661
+ contentDiagnostics,
602
662
  contentStatus,
663
+ contentWarnings,
603
664
  estimateTokens,
604
665
  findLesson,
605
666
  lessonDiagnostics,
@@ -584,6 +584,18 @@ async function inlineResourceAttribute(node, name, context) {
584
584
  const path = await resolveLocalFile(reference, context.sourceFile, context.outputRoot);
585
585
  setAttribute(node, name, await assetDataUrl(path, context));
586
586
  }
587
+ const INLINEABLE_LINK_RELS = /* @__PURE__ */ new Set([
588
+ "stylesheet",
589
+ "icon",
590
+ "shortcut icon",
591
+ "apple-touch-icon",
592
+ "apple-touch-icon-precomposed",
593
+ "mask-icon"
594
+ ]);
595
+ function isInlineableLinkRel(node) {
596
+ const rel = attribute(node, "rel");
597
+ return rel !== void 0 && INLINEABLE_LINK_RELS.has(rel.trim().toLowerCase());
598
+ }
587
599
  function parseSourceSet(value) {
588
600
  const candidates = value.match(/(?:[^\s,]+)(?:\s+(?:\d+w|\d+(?:\.\d+)?x))?/gu) ?? [];
589
601
  return candidates.map((candidate) => {
@@ -609,6 +621,7 @@ async function inlineMedia(document, context) {
609
621
  "Standalone HTML does not support an iframe src; use self-contained semantic DOM instead."
610
622
  );
611
623
  }
624
+ if (node.tagName === "link" && !isInlineableLinkRel(node)) continue;
612
625
  const names = resources[node.tagName] ?? [];
613
626
  for (const name of names) await inlineResourceAttribute(node, name, context);
614
627
  if (node.tagName === "use" || node.tagName === "image") {