explico 0.1.1 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +142 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -0,0 +1,195 @@
1
+ # Visualization rules
2
+
3
+ `V` rules govern anything a lesson draws: diagrams, static charts, and hydrated
4
+ interactive figures. The engine assumes no single plotting approach — these
5
+ rules make the choice per figure predictable and keep every figure accessible
6
+ and deterministic.
7
+
8
+ See [README.md](README.md) for what "strong recommendation", "Build-enforced",
9
+ and "Review-only" mean here, and how to amend a rule.
10
+
11
+ ---
12
+
13
+ ## V1
14
+
15
+ **Graphs and flowcharts use the `Diagram` component.**
16
+
17
+ Any node-and-edge picture — a derivation pipeline, a dependency graph, a state
18
+ machine, a decision tree — is built with
19
+ `explico/components/diagram/Diagram.astro`. It does build-time layout with
20
+ `@dagrejs/dagre` and renders KaTeX-labelled nodes as static SVG through the same
21
+ trust boundary as lesson math.
22
+
23
+ Do not add Mermaid, cytoscape, react-flow, elkjs, or any other runtime graph or
24
+ layout library. Nothing client-side lays out a graph.
25
+
26
+ _Rationale._ One diagram primitive means node math is gate-checked and
27
+ hover-explained exactly like prose math (an unresolved glyph fails the build
28
+ with the node id), zero runtime layout code ships, and diagrams render
29
+ identically in print and with JS off.
30
+
31
+ _How to apply._ Pass `nodes` / `edges` and, if any node has a `formula`, a
32
+ `scope` from `labMathScope` — see
33
+ `playground/content/labs/DerivationDiagram.astro`. A `formula` node's symbols
34
+ must resolve against the page glyph table, same as `NotationFormula`. If
35
+ `Diagram` genuinely cannot express the picture, raise it as an engine gap
36
+ rather than reaching for another library.
37
+
38
+ _Enforcement._ Review-only.
39
+
40
+ _Exceptions._ A one-off illustrative schematic that is not a graph (a labelled
41
+ photo, a geometric figure) is a [V2](#v2) figure, not a `Diagram`.
42
+
43
+ ---
44
+
45
+ ## V2
46
+
47
+ **Static charts: hand-rolled SVG first, then D3, then Observable Plot — chosen
48
+ per figure.**
49
+
50
+ - **Hand-rolled inline SVG** is the default: zero dependency, covers a single
51
+ bespoke figure.
52
+ - **D3** once a figure needs real tick-labelled axes, log scales, or several
53
+ series sharing a scale.
54
+ - **Observable Plot** (`@observablehq/plot`) once a course is authoring many
55
+ charts per lecture and the per-chart boilerplate of D3 is the bottleneck.
56
+
57
+ Decide per figure. A lesson may hold an SVG sketch and a Plot chart side by
58
+ side. Whatever the choice, the chart is produced at build time or inside a
59
+ `client:*` island — never a CDN `<script>`.
60
+
61
+ _Rationale._ The cost curve is real: SVG is free but unscalable, D3 is powerful
62
+ but verbose, Plot is concise but a dependency and a bundle. Matching the tool to
63
+ the figure keeps both the bundle and the authoring effort down.
64
+
65
+ _How to apply._ For a static (non-interactive) chart, prefer rendering in an
66
+ `.astro` component so nothing hydrates. Reach for an island only when the figure
67
+ responds to input — then it is a [V3](#v3) figure.
68
+
69
+ _Enforcement._ Review-only.
70
+
71
+ _Exceptions._ None — this rule _is_ the exception process.
72
+
73
+ ---
74
+
75
+ ## V3
76
+
77
+ **Interactive figures: a hydrated island, the `renderLabMath` seam for
78
+ equations, and a text alternative.**
79
+
80
+ An interactive figure is a React island, hydrated `client:visible` (or
81
+ `client:idle`). It carries:
82
+
83
+ - **Gate-checked math.** Any equation shown in the figure goes through the
84
+ `labMathScope` / `renderLabMath` / `\slot{}` seam so its symbols are resolved
85
+ against the page's real notation at build time and the island only patches
86
+ numbers into pre-rendered KaTeX. See
87
+ `playground/content/labs/ProjectileRangeExplorer.tsx` +
88
+ `ProjectileRangeMath.astro`.
89
+ - **An accessible name and description** — `ariaLabel` / `ariaDescription` on
90
+ the plot, marks that are decorative set `ariaHidden`.
91
+ - **A text alternative.** A `<details>` block with the model's assumptions and a
92
+ small table of representative values, so the figure is usable with no JS and
93
+ by a screen reader.
94
+ - **Labelled controls.** Every control is a labelled `<input>`; where precision
95
+ matters, pair a `range` slider with a `number` field (see `RangeField` in
96
+ `ProjectileRangeExplorer.tsx`).
97
+
98
+ _Rationale._ The seam is the only reason interactive math is not a silent
99
+ exception to the completeness gate. The text alternative and aria wiring are the
100
+ difference between a figure and an inaccessible one.
101
+
102
+ _How to apply._ Keep the island's job to "patch numbers and redraw". Domain
103
+ math lives in a plain module (`playground/content/domain/motion.ts`), not in the
104
+ component.
105
+
106
+ _Enforcement._ Review-only.
107
+
108
+ _Exceptions._ None.
109
+
110
+ ---
111
+
112
+ ## V4
113
+
114
+ **A randomised figure exposes an adjustable, deterministic seed.**
115
+
116
+ Any figure that draws random numbers has a visible **seed** control — an
117
+ integer, default `0` — shown near the figure. The same seed always produces the
118
+ same figure.
119
+
120
+ _Rationale._ A figure that reshuffles on every render can't be pointed at ("look
121
+ at the outlier near x = 3"), can't be reproduced in a question, and can't be
122
+ regression-tested. A seed makes randomness a parameter the reader controls.
123
+
124
+ _How to apply._
125
+
126
+ - Thread the seed through a real PRNG (e.g. a small `mulberry32` /
127
+ `splitmix32`), never `Math.random()`.
128
+ - The seed is a control like any other — a `number` field, or a slider over a
129
+ small range plus a field.
130
+ - Label it plainly ("Random seed") and show its current value.
131
+
132
+ _Enforcement._ Review-only.
133
+
134
+ _Exceptions._ None. If the figure is deterministic, there is no seed to show.
135
+
136
+ ---
137
+
138
+ ## V5
139
+
140
+ **Axis ranges span the first 10 seeds, evaluated at the current values of every
141
+ other control.**
142
+
143
+ Fix each axis `domain` explicitly. Compute it as the min and max of the plotted
144
+ quantity over **seeds 0–9**, with every non-seed control at its current value.
145
+ Then:
146
+
147
+ - **Some points, for some seeds, will fall outside the drawn range.** That is
148
+ expected and fine — clip them. Do **not** autoscale the axis per seed: a
149
+ domain that jumps as the seed changes defeats the comparison the seed control
150
+ exists for.
151
+ - **Recompute the domain when any non-seed control changes** (a slider, a
152
+ toggle). Its old range no longer describes the new family of curves.
153
+ - **Do not recompute the domain when only the seed changes.** The range is a
154
+ property of the control setting, not of the current draw.
155
+ - Pad the computed range by a small fixed fraction (≈ 5%) so extremes don't sit
156
+ on the frame.
157
+ - Keep the sample count (`10`) a named constant so it can be tuned in one place.
158
+
159
+ _Rationale._ A reader steps through seeds to see how much the outcome varies. A
160
+ fixed frame makes that variation legible — points move, the axes don't. Ten
161
+ seeds is enough to size the frame for the typical spread without chasing every
162
+ tail.
163
+
164
+ _How to apply._ A `useMemo` keyed on all non-seed inputs (not the seed) builds
165
+ the domain:
166
+
167
+ ```ts
168
+ const SEED_SAMPLE_COUNT = 10;
169
+ const RANGE_PAD = 0.05;
170
+
171
+ const yDomain = useMemo(() => {
172
+ let lo = Infinity;
173
+ let hi = -Infinity;
174
+ for (let seed = 0; seed < SEED_SAMPLE_COUNT; seed++) {
175
+ for (const point of series({ ...controls, seed })) {
176
+ if (point.y < lo) lo = point.y;
177
+ if (point.y > hi) hi = point.y;
178
+ }
179
+ }
180
+ const pad = (hi - lo) * RANGE_PAD || 1;
181
+ return [lo - pad, hi + pad] as const;
182
+ }, [controls.a, controls.b /* every non-seed control, not seed */]);
183
+
184
+ // Plot.plot({ y: { domain: yDomain, ... }, ... })
185
+ ```
186
+
187
+ Both axes get this treatment if both carry random spread. If one axis is a
188
+ deterministic sweep (angle 0–90°), give it its natural domain and apply the rule
189
+ only to the random axis.
190
+
191
+ _Enforcement._ Review-only.
192
+
193
+ _Exceptions._ A figure where the meaningful range is a known physical bound
194
+ (a probability on `[0, 1]`, an angle on `[0°, 360°]`) uses that bound instead —
195
+ state it in a comment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
@@ -27,8 +27,14 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "src",
30
- "README.md"
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "docs/VERSIONING.md",
33
+ "docs/authoring"
31
34
  ],
35
+ "dependencies": {
36
+ "@dagrejs/dagre": "3.1.1"
37
+ },
32
38
  "peerDependencies": {
33
39
  "@astrojs/markdown-remark": "7.2.4",
34
40
  "@astrojs/react": "6.0.4",
@@ -38,16 +44,25 @@
38
44
  "react": "19.2.8",
39
45
  "react-dom": "19.2.8",
40
46
  "rehype-katex": "7.0.1",
41
- "remark-math": "6.0.0"
47
+ "remark-math": "6.0.0",
48
+ "remark-mdx": "3.1.1"
42
49
  },
43
50
  "devDependencies": {
51
+ "@astrojs/markdown-remark": "7.2.4",
44
52
  "@types/node": "24.13.3",
45
53
  "@types/react": "19.2.18",
46
54
  "@types/react-dom": "19.2.5",
55
+ "katex": "0.16.47",
56
+ "rehype-katex": "7.0.1",
57
+ "remark-math": "6.0.0",
58
+ "remark-mdx": "3.1.1",
47
59
  "tsup": "8.5.1",
48
- "typescript": "6.0.2"
60
+ "typescript": "6.0.2",
61
+ "vitest": "4.1.11"
49
62
  },
50
63
  "scripts": {
51
- "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json"
64
+ "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
52
67
  }
53
68
  }
@@ -35,6 +35,7 @@ import {
35
35
  } from '@astrojs/markdown-remark';
36
36
  import rehypeKatex from 'rehype-katex';
37
37
  import remarkMath from 'remark-math';
38
+ import remarkMdx from 'remark-mdx';
38
39
 
39
40
  import { createNotationKatexOptions } from '../reference/katex-options.mjs';
40
41
  import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
@@ -169,6 +170,25 @@ export function lessonDiagnostics(
169
170
  });
170
171
  }
171
172
 
173
+ for (const diagnostic of [
174
+ ...manifest.diagnostics.tables,
175
+ ...manifest.diagnostics.figures,
176
+ ]) {
177
+ if (
178
+ diagnostic.lessonId !== id &&
179
+ diagnostic.file !== file &&
180
+ !mentions(diagnostic.message, id, file)
181
+ ) {
182
+ continue;
183
+ }
184
+ out.push({
185
+ code: diagnostic.code,
186
+ severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
187
+ message: diagnostic.message,
188
+ ...withFile(diagnostic.file),
189
+ });
190
+ }
191
+
172
192
  for (const diagnostic of manifest.diagnostics.consistency) {
173
193
  if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
174
194
  out.push({
@@ -195,12 +215,19 @@ function processorFor(manifest: Manifest) {
195
215
  syntaxHighlight: false,
196
216
  smartypants: false,
197
217
  remarkPlugins: [
218
+ // Syntax only (no JSX evaluation) — teaches the parser `<Table>` /
219
+ // `<Figure>` / `<CompactExample>` are `mdxJsxFlowElement` nodes, same
220
+ // as the real Astro/MDX build, so `remarkNotation` sees the same tree
221
+ // shape here that it does there (D7/F-tables-figures parity).
222
+ remarkMdx,
198
223
  remarkMath,
199
224
  [
200
225
  remarkNotation,
201
226
  {
202
227
  definitions: manifest.notation.raw,
203
228
  equations: manifest.equations.numbersBySlug,
229
+ tables: manifest.tables.numbersBySlug,
230
+ figures: manifest.figures.numbersBySlug,
204
231
  },
205
232
  ],
206
233
  [remarkCitation, { sources: manifest.sources }],
@@ -441,6 +468,8 @@ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
441
468
  // --- content status ------------------------------------------------------
442
469
 
443
470
  export interface StatusReport {
471
+ /** The course's published knowledge version (`about.version`). */
472
+ readonly knowledgeVersion: string;
444
473
  readonly counts: {
445
474
  readonly lessons: number;
446
475
  readonly competencies: number;
@@ -449,6 +478,8 @@ export interface StatusReport {
449
478
  readonly tracks: number;
450
479
  readonly notationDefinitions: number;
451
480
  readonly keyedEquations: number;
481
+ readonly keyedTables: number;
482
+ readonly keyedFigures: number;
452
483
  };
453
484
  readonly draftDebt: {
454
485
  readonly lessons: readonly string[];
@@ -492,18 +523,30 @@ export function contentStatus(manifest: Manifest): StatusReport {
492
523
  ...manifest.diagnostics.alignment.map((d) => d.severity),
493
524
  ...manifest.diagnostics.math.map((d) => d.severity),
494
525
  ...manifest.diagnostics.equations.map((d) => d.severity),
526
+ ...manifest.diagnostics.tables.map((d) => d.severity),
527
+ ...manifest.diagnostics.figures.map((d) => d.severity),
495
528
  ...manifest.diagnostics.consistency.map(() => 'warning' as const),
529
+ ...manifest.diagnostics.knowledge.map((d) => d.severity),
496
530
  ];
497
531
 
498
532
  const keyedEquations = manifest.lessons.reduce(
499
533
  (count, lesson) => count + lesson.equations.length,
500
534
  0,
501
535
  );
536
+ const keyedTables = manifest.lessons.reduce(
537
+ (count, lesson) => count + lesson.tables.length,
538
+ 0,
539
+ );
540
+ const keyedFigures = manifest.lessons.reduce(
541
+ (count, lesson) => count + lesson.figures.length,
542
+ 0,
543
+ );
502
544
  const assessments = new Set(
503
545
  manifest.lessons.flatMap((lesson) => lesson.assessments),
504
546
  ).size;
505
547
 
506
548
  return {
549
+ knowledgeVersion: manifest.about.knowledge.version,
507
550
  counts: {
508
551
  lessons: manifest.lessons.length,
509
552
  competencies: manifest.competencies.length,
@@ -512,6 +555,8 @@ export function contentStatus(manifest: Manifest): StatusReport {
512
555
  tracks: manifest.tracks.length,
513
556
  notationDefinitions: manifest.notation.definitions.length,
514
557
  keyedEquations,
558
+ keyedTables,
559
+ keyedFigures,
515
560
  },
516
561
  draftDebt: { lessons: draftLessons, notation: draftNotation },
517
562
  orphans,
@@ -550,6 +595,8 @@ export interface ContextReport {
550
595
  formula?: string;
551
596
  }[];
552
597
  readonly equations: readonly { key: string; number: string }[];
598
+ readonly tables: readonly { key: string; number: string }[];
599
+ readonly figures: readonly { key: string; number: string }[];
553
600
  readonly sources: readonly {
554
601
  id: string;
555
602
  title: string;
@@ -618,6 +665,8 @@ export function contentContext(
618
665
  : { formula: definition.formula }),
619
666
  })),
620
667
  equations: lesson.equations.map((equation) => ({ ...equation })),
668
+ tables: lesson.tables.map((table) => ({ ...table })),
669
+ figures: lesson.figures.map((figure) => ({ ...figure })),
621
670
  sources: lesson.sources.map((id) => {
622
671
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
623
672
  return {
@@ -32,10 +32,13 @@ import type {
32
32
  NotationRegistryInput,
33
33
  SharedNotationDefinitionInput,
34
34
  SourceSpan,
35
+ NotationGlossEntry,
35
36
  } from '../reference/types';
36
37
  import { isSubstantiveMeaning } from '../reference/prose';
37
38
  import { parseEquationRef } from '../reference/equations.mjs';
39
+ import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
38
40
  import { resolveLabel } from '../reference/label';
41
+ import { resolveGlosses } from '../reference/gloss';
39
42
  import {
40
43
  deriveRequires,
41
44
  deriveSources,
@@ -227,9 +230,17 @@ export function extractNotationReferences(
227
230
  for (const [pattern, referenceKind] of patterns) {
228
231
  for (const match of searchable.matchAll(pattern)) {
229
232
  const key = (match[1] ?? '').trim();
230
- // `[[eq:key]]` / `[[slug#eq:key]]` are equation references (D7), resolved
231
- // by the remark pass and the manifest, not notation keys.
232
- if (parseEquationRef(key)) continue;
233
+ // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
234
+ // `[[tab-key]]`/`[[fig-key]]` (and their cross-page forms) are
235
+ // `<Table>`/`<Figure>` references (F-tables-figures) — all resolved by
236
+ // the remark pass and the manifest, not notation keys.
237
+ if (
238
+ parseEquationRef(key) ||
239
+ parseKeyedFloatRef(key, { prefix: 'tab' }) ||
240
+ parseKeyedFloatRef(key, { prefix: 'fig' })
241
+ ) {
242
+ continue;
243
+ }
233
244
  references.push({
234
245
  key,
235
246
  kind: referenceKind,
@@ -322,6 +333,42 @@ export async function loadCurriculumCatalog(): Promise<CurriculumCatalog> {
322
333
 
323
334
  // --- notation registry input -------------------------------------------
324
335
 
336
+ /**
337
+ * A card's authored `glosses:` list (D15). Keys are derived from the names, so
338
+ * a gloss is entry-scoped and promoting one to a card needs no reference
339
+ * rewrite anywhere in the corpus — see `reference/gloss.ts`.
340
+ */
341
+ function glossEntries(
342
+ raw: unknown,
343
+ ownerKey: string,
344
+ at: string,
345
+ ): NotationGlossEntry[] {
346
+ if (raw === undefined) return [];
347
+ if (!Array.isArray(raw)) throw new Error(`${at} must be an array`);
348
+
349
+ const authored = raw.map((value, index) => {
350
+ const data = record(value, `${at}[${index}]`);
351
+ const units = optionalString(data.units, `${at}[${index}].units`);
352
+ return {
353
+ latex: string(data.latex, `${at}[${index}].latex`),
354
+ name: string(data.name, `${at}[${index}].name`),
355
+ ...(units === undefined ? {} : { units }),
356
+ };
357
+ });
358
+
359
+ const glosses = resolveGlosses(ownerKey, authored);
360
+ const seen = new Set<string>();
361
+ for (const gloss of glosses) {
362
+ if (seen.has(gloss.key)) {
363
+ throw new Error(
364
+ `${at} declares two glosses named ${JSON.stringify(gloss.label)}`,
365
+ );
366
+ }
367
+ seen.add(gloss.key);
368
+ }
369
+ return glosses;
370
+ }
371
+
325
372
  function localDefinition(
326
373
  raw: unknown,
327
374
  file: string,
@@ -350,6 +397,7 @@ function localDefinition(
350
397
  ? { kind: 'general', rationale: 'Lesson-local symbol.' }
351
398
  : alignment(data.alignment, `${at}.alignment`),
352
399
  references: extractNotationReferences(definitionText, file, 'definition'),
400
+ glosses: glossEntries(data.glosses, key, `${at}.glosses`),
353
401
  source: { file },
354
402
  ...(formula === undefined ? {} : { formula }),
355
403
  ...(units === undefined ? {} : { units }),
@@ -382,6 +430,7 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
382
430
  aiAssisted: boolean(data.aiAssisted, `${file} aiAssisted`),
383
431
  body,
384
432
  references: extractNotationReferences(body, file, undefined),
433
+ glosses: glossEntries(data.glosses, key, `${file} glosses`),
385
434
  source: { file },
386
435
  ...(formula === undefined ? {} : { formula }),
387
436
  ...(units === undefined ? {} : { units }),
@@ -21,6 +21,56 @@ export interface CourseConventions {
21
21
  readonly cashFlowPerspectiveCompetency: string;
22
22
  }
23
23
 
24
+ /**
25
+ * A course's edits to the shipped named-operator baseline (`BASE_OPERATORS`),
26
+ * consumed by the `operator-explained` consistency check (rule N8). An operator
27
+ * a lesson uses is fine when it is on the baseline plus `allow` minus `deny`;
28
+ * otherwise it needs a notation entry the reader can hover.
29
+ *
30
+ * Names are the KaTeX command without its backslash (`erf`, not `\erf`) for a
31
+ * built-in, and the verbatim body for an `\operatorname{…}` operator (`Var`).
32
+ */
33
+ export interface CourseOperators {
34
+ /** Operators this course also treats as universal (`Var`, `Cov`, `sgn`, …). */
35
+ readonly allow?: readonly string[];
36
+ /** Baseline operators this course wants explained anyway. */
37
+ readonly deny?: readonly string[];
38
+ }
39
+
40
+ /** One person or team credited for the study material. */
41
+ export interface CourseAuthor {
42
+ readonly name: string;
43
+ /**
44
+ * Email, URL, or handle. Rendered in the About panel as a link when it looks
45
+ * like a `mailto:` or `https:` target, plain text otherwise.
46
+ */
47
+ readonly contact?: string;
48
+ }
49
+
50
+ /**
51
+ * The reader-facing "about this material" block, surfaced by the `[?]` panel in
52
+ * the header (`components/starlight/AboutPanel.astro`) on every page.
53
+ *
54
+ * `version` is the course's **knowledge version** — semver over the
55
+ * learning-facing content, independent of the `explico` engine's own version.
56
+ * It must equal the newest entry in `content/CHANGELOG.md`; a mismatch is a
57
+ * Tier-3 `knowledge-version-mismatch` warning (rule C3, `docs/authoring/versioning.md`).
58
+ */
59
+ export interface CourseAbout {
60
+ /** Display name of the material. Defaults to {@link CourseConfig.title}. */
61
+ readonly materialName?: string;
62
+ /** One or two sentences on what the material covers. Defaults to {@link CourseConfig.description}. */
63
+ readonly summary?: string;
64
+ /** Knowledge version — semver; equals the latest `content/CHANGELOG.md` heading. */
65
+ readonly version: string;
66
+ /** Who authored / maintains the material. At least one entry. */
67
+ readonly authors: readonly CourseAuthor[];
68
+ /** Shown verbatim in the About panel — scope, warranty, AI-assistance note. */
69
+ readonly disclaimer: string;
70
+ /** Link to the published content changelog (repo blob, docs page, …). */
71
+ readonly changelogHref?: string;
72
+ }
73
+
24
74
  export interface CourseConfig {
25
75
  readonly title: string;
26
76
  readonly description: string;
@@ -29,4 +79,8 @@ export interface CourseConfig {
29
79
  /** Namespaces used by competency / notation `domain` fields. */
30
80
  readonly domains: readonly string[];
31
81
  readonly conventions: CourseConventions;
82
+ /** Reader-facing identity + knowledge version (the `[?]` About panel). */
83
+ readonly about: CourseAbout;
84
+ /** Edits to the shipped named-operator baseline (rule N8). Optional. */
85
+ readonly operators?: CourseOperators;
32
86
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The course's **knowledge version** — semver over the learning-facing content,
3
+ * independent of the `explico` engine's own package version.
4
+ *
5
+ * The declared value lives in `content/course.config.ts` (`about.version`); the
6
+ * history and the human-readable notes live in `content/CHANGELOG.md`
7
+ * (Keep a Changelog shape). This module parses that file and checks the two
8
+ * agree — rule C3 in `docs/authoring/versioning.md`. Every finding is a Tier-3
9
+ * warning: `pnpm validate:content` prints it, `manifest.diagnostics.knowledge`
10
+ * records it, and the build still passes. The resolved entry is what the `[?]`
11
+ * About panel publishes to readers (`manifest.about.knowledge`).
12
+ *
13
+ * Pure: `parseKnowledgeChangelog` takes text, `resolveKnowledgeVersion` takes
14
+ * text + the declared version. The caller (`compileManifest`) does the file IO.
15
+ */
16
+
17
+ /** A released entry from `content/CHANGELOG.md`, newest first in file order. */
18
+ export interface KnowledgeChangelogEntry {
19
+ readonly version: string;
20
+ /** The `- YYYY-MM-DD` part of the heading, if present. */
21
+ readonly date?: string;
22
+ /** Bullet lines under the heading, marker stripped, in file order. */
23
+ readonly notes: readonly string[];
24
+ }
25
+
26
+ export type KnowledgeVersionCode =
27
+ | 'knowledge-changelog-missing'
28
+ | 'knowledge-version-invalid'
29
+ | 'knowledge-version-mismatch';
30
+
31
+ export interface KnowledgeVersionDiagnostic {
32
+ readonly code: KnowledgeVersionCode;
33
+ /** Tier 3 — a knowledge-version drift never fails the build. */
34
+ readonly severity: 'warning';
35
+ readonly message: string;
36
+ readonly file?: string;
37
+ }
38
+
39
+ export interface KnowledgeVersion {
40
+ /** The declared `about.version`, echoed for consumers. */
41
+ readonly version: string;
42
+ /** The newest released `## [x.y.z]` entry, when the changelog has one. */
43
+ readonly latest?: KnowledgeChangelogEntry;
44
+ readonly diagnostics: readonly KnowledgeVersionDiagnostic[];
45
+ }
46
+
47
+ // `1.2.3`, optionally `-rc.1` / `+build.5` — the shape `about.version` and a
48
+ // `## [x.y.z]` heading must both take.
49
+ const SEMVER_RE =
50
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
51
+ const HEADING_RE = /^##\s+\[([^\]]+)\]\s*(?:[-–—]\s*(\S.*?))?\s*$/;
52
+ const BULLET_RE = /^[-*]\s+(.*\S)\s*$/;
53
+
54
+ export function isKnowledgeSemver(value: string): boolean {
55
+ return SEMVER_RE.test(value.trim());
56
+ }
57
+
58
+ /**
59
+ * Every released entry in a `content/CHANGELOG.md` body, in file order (newest
60
+ * first, by Keep a Changelog convention). `## [Unreleased]` is skipped.
61
+ */
62
+ export function parseKnowledgeChangelog(
63
+ text: string,
64
+ ): KnowledgeChangelogEntry[] {
65
+ const lines = text.split(/\r?\n/);
66
+ const entries: {
67
+ version: string;
68
+ date?: string;
69
+ notes: string[];
70
+ }[] = [];
71
+ let current: { version: string; date?: string; notes: string[] } | null =
72
+ null;
73
+
74
+ for (const line of lines) {
75
+ const heading = HEADING_RE.exec(line);
76
+ if (heading) {
77
+ const token = (heading[1] ?? '').trim();
78
+ if (/^unreleased$/i.test(token)) {
79
+ current = null;
80
+ continue;
81
+ }
82
+ current = { version: token, notes: [] };
83
+ const date = (heading[2] ?? '').trim();
84
+ if (date) current.date = date;
85
+ entries.push(current);
86
+ continue;
87
+ }
88
+ // A top-level `#` or a deeper `###` heading ends the current entry's notes.
89
+ if (/^#{1,3}\s/.test(line) && !line.startsWith('## [')) {
90
+ current = null;
91
+ continue;
92
+ }
93
+ if (!current) continue;
94
+ const bullet = BULLET_RE.exec(line);
95
+ if (bullet) current.notes.push(bullet[1] ?? '');
96
+ }
97
+
98
+ return entries.map((entry) => ({
99
+ version: entry.version,
100
+ ...(entry.date === undefined ? {} : { date: entry.date }),
101
+ notes: [...entry.notes],
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * Check the declared knowledge version against `content/CHANGELOG.md`.
107
+ *
108
+ * @param options.changelogText the file body, or `undefined` when it is absent
109
+ * @param options.declaredVersion `courseConfig.about.version`
110
+ * @param options.file repo-relative path, for diagnostic messages
111
+ */
112
+ export function resolveKnowledgeVersion(options: {
113
+ readonly changelogText: string | undefined;
114
+ readonly declaredVersion: string;
115
+ readonly file?: string;
116
+ }): KnowledgeVersion {
117
+ const { changelogText, declaredVersion } = options;
118
+ const file = options.file ?? 'content/CHANGELOG.md';
119
+ const diagnostics: KnowledgeVersionDiagnostic[] = [];
120
+ const warn = (code: KnowledgeVersionCode, message: string): void => {
121
+ diagnostics.push({ code, severity: 'warning', message, file });
122
+ };
123
+
124
+ if (!isKnowledgeSemver(declaredVersion)) {
125
+ warn(
126
+ 'knowledge-version-invalid',
127
+ `about.version ${JSON.stringify(declaredVersion)} in content/course.config.ts is not a semver \`x.y.z\` string.`,
128
+ );
129
+ }
130
+
131
+ if (changelogText === undefined) {
132
+ warn(
133
+ 'knowledge-changelog-missing',
134
+ `No ${file}. Add a Keep a Changelog file whose newest \`## [x.y.z]\` heading is ${JSON.stringify(declaredVersion)} (rule C1).`,
135
+ );
136
+ return { version: declaredVersion, diagnostics };
137
+ }
138
+
139
+ const entries = parseKnowledgeChangelog(changelogText);
140
+ const latest = entries[0];
141
+ if (!latest) {
142
+ warn(
143
+ 'knowledge-changelog-missing',
144
+ `${file} has no released \`## [x.y.z]\` entry — only \`## [Unreleased]\`. Cut a release: move it to \`## [${declaredVersion}] - <date>\` (rule C4).`,
145
+ );
146
+ return { version: declaredVersion, diagnostics };
147
+ }
148
+
149
+ if (isKnowledgeSemver(declaredVersion) && latest.version !== declaredVersion) {
150
+ warn(
151
+ 'knowledge-version-mismatch',
152
+ `about.version is ${JSON.stringify(declaredVersion)} but the newest ${file} entry is ${JSON.stringify(latest.version)}. Bump one to match the other (rule C3).`,
153
+ );
154
+ }
155
+
156
+ return { version: declaredVersion, latest, diagnostics };
157
+ }