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.
- package/CHANGELOG.md +102 -0
- package/README.md +46 -5
- package/dist/compiler/cli.d.ts +17 -0
- package/dist/compiler/cli.js +41 -3
- package/dist/compiler/collections.js +31 -1
- package/dist/compiler/course-config.d.ts +52 -1
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/manifest.d.ts +87 -3
- package/dist/compiler/manifest.js +133 -20
- package/dist/compiler/resolution-report.d.ts +5 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +142 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +5 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +76 -18
- package/dist/reference/equations.mjs +118 -41
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/gate-math.d.ts +5 -4
- package/dist/reference/gate-math.js +73 -2
- package/dist/reference/gloss.d.ts +87 -0
- package/dist/reference/gloss.js +59 -0
- package/dist/reference/keyed-float-validate.d.ts +67 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +56 -0
- package/dist/reference/keyed-jsx-floats.mjs +167 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/registry.js +25 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -0
- package/dist/reference/rehype-heading-anchors.mjs +144 -0
- package/dist/reference/remark-notation.d.mts +12 -0
- package/dist/reference/remark-notation.mjs +228 -32
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/types.d.ts +25 -2
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +96 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +107 -0
- package/docs/authoring/notation.md +296 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/cli.ts +49 -0
- package/src/compiler/collections.ts +52 -3
- package/src/compiler/course-config.ts +54 -0
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/manifest.ts +270 -17
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/HoverPanel.astro +42 -0
- package/src/components/citation/CitationLayer.astro +1 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/hover-panel.ts +71 -5
- package/src/components/notation/NotationFormula.astro +154 -0
- package/src/components/notation/NotationGlossary.astro +60 -0
- package/src/components/notation/NotationLayer.astro +248 -103
- package/src/components/notation/render-notation-math.ts +66 -0
- package/src/components/popup-stack.ts +345 -0
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +8 -2
- package/src/components/table/Table.astro +104 -0
- package/src/content-config.ts +38 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +236 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +6 -1
- package/src/reference/equations.mjs +118 -41
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/gate-math.ts +104 -7
- package/src/reference/gloss.ts +153 -0
- package/src/reference/keyed-float-validate.ts +207 -0
- package/src/reference/keyed-jsx-floats.mjs +167 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/registry.ts +43 -0
- package/src/reference/rehype-heading-anchors.mjs +144 -0
- package/src/reference/remark-notation.mjs +228 -32
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/types.ts +26 -1
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +193 -3
- 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.
|
|
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
|
}
|
package/src/compiler/cli.ts
CHANGED
|
@@ -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
|
|
231
|
-
//
|
|
232
|
-
|
|
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
|
+
}
|