explico 0.2.0 → 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 (73) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +16 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +4 -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 +77 -2
  10. package/dist/compiler/manifest.js +115 -18
  11. package/dist/compiler/resolution-report.d.ts +4 -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 +51 -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 +42 -11
  21. package/dist/reference/equations.mjs +58 -17
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/keyed-float-validate.d.ts +67 -0
  25. package/dist/reference/keyed-float-validate.js +110 -0
  26. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  27. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  28. package/dist/reference/math-glyphs.d.mts +18 -0
  29. package/dist/reference/math-glyphs.mjs +92 -0
  30. package/dist/reference/remark-notation.d.mts +12 -0
  31. package/dist/reference/remark-notation.mjs +199 -9
  32. package/dist/reference/tables-validate.d.ts +21 -0
  33. package/dist/reference/tables-validate.js +13 -0
  34. package/dist/reference/units-vocab.js +19 -1
  35. package/docs/VERSIONING.md +66 -0
  36. package/docs/authoring/README.md +96 -0
  37. package/docs/authoring/agents.md +63 -0
  38. package/docs/authoring/migrating.md +107 -0
  39. package/docs/authoring/notation.md +296 -0
  40. package/docs/authoring/versioning.md +116 -0
  41. package/docs/authoring/visualizations.md +195 -0
  42. package/package.json +20 -5
  43. package/src/compiler/cli.ts +49 -0
  44. package/src/compiler/collections.ts +12 -3
  45. package/src/compiler/course-config.ts +54 -0
  46. package/src/compiler/knowledge-version.ts +157 -0
  47. package/src/compiler/manifest.ts +239 -17
  48. package/src/components/CurriculumMap.astro +27 -0
  49. package/src/components/diagram/Diagram.astro +317 -0
  50. package/src/components/examples/CompactExamples.astro +1 -1
  51. package/src/components/figure/Figure.astro +104 -0
  52. package/src/components/notation/render-notation-math.ts +13 -2
  53. package/src/components/reference/FloatRefLayer.astro +671 -0
  54. package/src/components/starlight/AboutPanel.astro +255 -0
  55. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  56. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  57. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  58. package/src/components/starlight/LessonFooter.astro +4 -2
  59. package/src/components/table/Table.astro +104 -0
  60. package/src/reference/base-operators.mjs +103 -0
  61. package/src/reference/consistency.ts +83 -2
  62. package/src/reference/diagram-layout.ts +246 -0
  63. package/src/reference/equations-validate.ts +6 -1
  64. package/src/reference/equations.mjs +58 -17
  65. package/src/reference/figures-validate.ts +40 -0
  66. package/src/reference/keyed-float-validate.ts +207 -0
  67. package/src/reference/keyed-jsx-floats.mjs +167 -0
  68. package/src/reference/math-glyphs.mjs +92 -0
  69. package/src/reference/remark-notation.mjs +199 -9
  70. package/src/reference/tables-validate.ts +40 -0
  71. package/src/reference/units-vocab.ts +18 -0
  72. package/src/styles/global.css +115 -7
  73. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -0,0 +1,317 @@
1
+ ---
2
+ /**
3
+ * A small directed diagram whose node math is checked and explained exactly
4
+ * like lesson body math (Dagre for build-time layout, no runtime layout
5
+ * library shipped; KaTeX through the same trust boundary as everywhere
6
+ * else).
7
+ *
8
+ * Each node is plain `text`, a `formula` (raw LaTeX resolved against
9
+ * `scope` — the same page glyph table `NotationFormula` uses), or both. A
10
+ * formula's every symbol must resolve: an unresolved glyph fails the build
11
+ * with the offending node id, the same severity the completeness gate gives
12
+ * lesson body math — this component exists specifically so diagram math is
13
+ * never a silent exception to that rule.
14
+ *
15
+ * The rendered symbols carry the same `data-notation-key` markers as prose
16
+ * and equations, so they become working hover/pin triggers with **no new
17
+ * client script**: `NotationLayer`'s trigger scan already queries the whole
18
+ * document, not just its own subtree.
19
+ *
20
+ * Box sizes are a generous estimate (`reference/diagram-layout.ts`) unless a
21
+ * node gives an explicit `width`/`height` — there is no DOM at build time to
22
+ * measure a real render against.
23
+ */
24
+ import { estimateBoxSize, layoutDiagram } from '../../reference/diagram-layout';
25
+ import { renderNotationFormula } from '../notation/render-notation-math';
26
+ import type { SafeMathNode as SafeMathNodeType } from '../notation/render-notation-math';
27
+ import SafeMathNode from '../notation/SafeMathNode.astro';
28
+ import type { GlyphScopeEntry } from '../../reference/gloss';
29
+
30
+ export interface DiagramNode {
31
+ readonly id: string;
32
+ /** Plain prose caption, e.g. "Decompose velocity". */
33
+ readonly text?: string;
34
+ /** Raw LaTeX, resolved against `scope` before rendering. */
35
+ readonly formula?: string;
36
+ /** Overrides the size estimate — use for a label the heuristic gets wrong. */
37
+ readonly width?: number;
38
+ readonly height?: number;
39
+ }
40
+
41
+ export interface DiagramEdge {
42
+ readonly from: string;
43
+ readonly to: string;
44
+ }
45
+
46
+ export interface Props {
47
+ /** Unique on the page — namespaces the arrowhead marker id. */
48
+ id: string;
49
+ /** Visible caption; also the diagram's accessible name. */
50
+ label: string;
51
+ nodes: readonly DiagramNode[];
52
+ edges: readonly DiagramEdge[];
53
+ /** Glyph scope `formula` resolves against — omit if no node uses `formula`. */
54
+ scope?: readonly GlyphScopeEntry[];
55
+ direction?: 'TB' | 'LR';
56
+ /**
57
+ * Prose text alternative for the SVG. When omitted, the fallback is
58
+ * generated from the node labels and the edge chain — readable, but a
59
+ * hand-written sentence usually reads better for a screen-reader user.
60
+ */
61
+ description?: string;
62
+ }
63
+
64
+ const {
65
+ id,
66
+ label,
67
+ nodes,
68
+ edges,
69
+ scope = [],
70
+ direction = 'TB',
71
+ description,
72
+ } = Astro.props;
73
+
74
+ const idPattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
75
+ if (!idPattern.test(id)) {
76
+ throw new Error(`Unsafe Diagram id: ${id}`);
77
+ }
78
+ if (nodes.length === 0) {
79
+ throw new Error(`Diagram "${id}" has no nodes.`);
80
+ }
81
+
82
+ interface RenderedNode extends DiagramNode {
83
+ readonly mathNodes?: readonly SafeMathNodeType[];
84
+ }
85
+
86
+ const rendered: RenderedNode[] = nodes.map((node) => {
87
+ if (!node.text && !node.formula) {
88
+ throw new Error(`Diagram "${id}" node "${node.id}" has neither text nor formula.`);
89
+ }
90
+ if (!node.formula) return { ...node };
91
+
92
+ const { nodes: mathNodes, unresolved, degraded } = renderNotationFormula(
93
+ node.formula,
94
+ scope,
95
+ );
96
+ if (degraded) {
97
+ throw new Error(
98
+ `Diagram "${id}" node "${node.id}" formula "${node.formula}" could not be ` +
99
+ `bound to the notation scope: the LaTeX did not parse, or a bare glyph is ` +
100
+ `the base of more than one page symbol. Wrap each symbol in ` +
101
+ `\\explain{key}{latex}, or brace a subscripted base (write {v_0}^{2}, not ` +
102
+ `v_0^{2}), so every glyph resolves — a diagram formula is never allowed to ` +
103
+ `render as silent, unexplained math.`,
104
+ );
105
+ }
106
+ if (unresolved.length > 0) {
107
+ throw new Error(
108
+ `Diagram "${id}" node "${node.id}" formula uses undefined notation ${unresolved
109
+ .map((token) => `"${token}"`)
110
+ .join(', ')}. Introduce the symbol with [[key]], define it in notation.local, ` +
111
+ `or pass it in the diagram's scope.`,
112
+ );
113
+ }
114
+ return { ...node, mathNodes };
115
+ });
116
+
117
+ const sized = rendered.map((node) => ({
118
+ node,
119
+ box:
120
+ node.width && node.height
121
+ ? { width: node.width, height: node.height }
122
+ : estimateBoxSize(
123
+ node.formula ?? node.text ?? node.id,
124
+ node.formula ? 'formula' : 'text',
125
+ ),
126
+ }));
127
+
128
+ const layout = layoutDiagram(
129
+ sized.map(({ node, box }) => ({ id: node.id, width: box.width, height: box.height })),
130
+ edges.map((edge) => ({ from: edge.from, to: edge.to })),
131
+ { direction },
132
+ );
133
+
134
+ const nodeById = new Map(rendered.map((node) => [node.id, node]));
135
+ const laidOutById = new Map(layout.nodes.map((node) => [node.id, node]));
136
+ const markerId = `diagram-arrow-${id}`;
137
+
138
+ /** A node's human label for the text fallback — never the internal id. */
139
+ function fallbackLabel(nodeId: string): string {
140
+ const node = nodeById.get(nodeId);
141
+ return node?.text ?? node?.formula ?? nodeId;
142
+ }
143
+
144
+ function pathFrom(points: readonly { x: number; y: number }[]): string {
145
+ if (points.length === 0) return '';
146
+ const [first, ...rest] = points;
147
+ return `M ${first!.x} ${first!.y} ` + rest.map((point) => `L ${point.x} ${point.y}`).join(' ');
148
+ }
149
+ ---
150
+
151
+ <figure class="diagram" id={`diagram-${id}`}>
152
+ <figcaption id={`${id}-caption`}>{label}</figcaption>
153
+ <div class="diagram-scroll" tabindex="0" role="group" aria-labelledby={`${id}-caption`}>
154
+ <svg
155
+ viewBox={`0 0 ${layout.width} ${layout.height}`}
156
+ width={layout.width}
157
+ height={layout.height}
158
+ >
159
+ <defs>
160
+ <marker
161
+ id={markerId}
162
+ viewBox="0 0 10 10"
163
+ refX="9"
164
+ refY="5"
165
+ markerWidth="7"
166
+ markerHeight="7"
167
+ orient="auto-start-reverse"
168
+ >
169
+ <path d="M 0 0 L 10 5 L 0 10 z" class="diagram-arrowhead" />
170
+ </marker>
171
+ </defs>
172
+
173
+ {
174
+ layout.edges.map((edge) => (
175
+ <path
176
+ d={pathFrom(edge.points)}
177
+ class="diagram-edge"
178
+ marker-end={`url(#${markerId})`}
179
+ />
180
+ ))
181
+ }
182
+
183
+ {
184
+ layout.nodes.map((position) => {
185
+ const node = nodeById.get(position.id)!;
186
+ return (
187
+ <g transform={`translate(${position.x}, ${position.y})`} class="diagram-node">
188
+ <rect width={position.width} height={position.height} rx="8" ry="8" />
189
+ <foreignObject width={position.width} height={position.height}>
190
+ <div xmlns="http://www.w3.org/1999/xhtml" class="diagram-node-content">
191
+ {node.text && <span class="diagram-node-text">{node.text}</span>}
192
+ {node.mathNodes && (
193
+ <span class="diagram-node-formula notation-math">
194
+ {node.mathNodes.map((mathNode) => (
195
+ <SafeMathNode node={mathNode} />
196
+ ))}
197
+ </span>
198
+ )}
199
+ </div>
200
+ </foreignObject>
201
+ </g>
202
+ );
203
+ })
204
+ }
205
+ </svg>
206
+ </div>
207
+
208
+ <details class="diagram-fallback">
209
+ <summary>Diagram description</summary>
210
+ {
211
+ description ? (
212
+ <p>{description}</p>
213
+ ) : (
214
+ <Fragment>
215
+ <ol>
216
+ {rendered.map((node) => (
217
+ <li>{fallbackLabel(node.id)}</li>
218
+ ))}
219
+ </ol>
220
+ {edges.length > 0 && (
221
+ <p class="diagram-fallback-flow">
222
+ {edges.map((edge, index) => (
223
+ <Fragment>
224
+ {index > 0 && '; '}
225
+ {fallbackLabel(edge.from)} → {fallbackLabel(edge.to)}
226
+ </Fragment>
227
+ ))}
228
+ </p>
229
+ )}
230
+ </Fragment>
231
+ )
232
+ }
233
+ </details>
234
+ </figure>
235
+
236
+ <style>
237
+ @layer starlight.components {
238
+ .diagram {
239
+ margin: 1.5rem 0;
240
+ }
241
+
242
+ .diagram figcaption {
243
+ margin-block-end: 0.5rem;
244
+ font-weight: 600;
245
+ }
246
+
247
+ /* A wide graph scrolls inside its own box rather than shrinking to
248
+ illegibility — the same tradeoff `.notation-formula-math` makes for a
249
+ wide equation. `tabindex` on the scroller is the accessibility half of
250
+ that: a scrollable region a keyboard user cannot reach is unreachable
251
+ content, not just an awkward one. */
252
+ .diagram-scroll {
253
+ overflow-x: auto;
254
+ padding-block: 0.15rem;
255
+ }
256
+
257
+ .diagram-scroll:focus-visible {
258
+ outline: 3px solid var(--sl-color-accent);
259
+ outline-offset: 2px;
260
+ }
261
+
262
+ .diagram svg {
263
+ display: block;
264
+ overflow: visible;
265
+ }
266
+
267
+ .diagram-node rect {
268
+ fill: var(--sl-color-gray-7);
269
+ stroke: var(--sl-color-gray-5);
270
+ stroke-width: 1;
271
+ }
272
+
273
+ .diagram-node-content {
274
+ display: flex;
275
+ height: 100%;
276
+ flex-direction: column;
277
+ align-items: center;
278
+ justify-content: center;
279
+ gap: 0.15rem;
280
+ padding: 0.2rem 0.5rem;
281
+ color: var(--sl-color-text);
282
+ font-family: var(--sl-font);
283
+ font-size: 0.85rem;
284
+ line-height: 1.25;
285
+ text-align: center;
286
+ }
287
+
288
+ .diagram-node-formula :global(.katex) {
289
+ font-size: 1em;
290
+ }
291
+
292
+ .diagram-edge {
293
+ fill: none;
294
+ stroke: var(--sl-color-gray-4);
295
+ stroke-width: 1.5;
296
+ }
297
+
298
+ .diagram-arrowhead {
299
+ fill: var(--sl-color-gray-4);
300
+ }
301
+
302
+ .diagram-fallback {
303
+ margin-block-start: 0.5rem;
304
+ color: var(--sl-color-gray-3);
305
+ font-size: var(--sl-text-sm);
306
+ }
307
+
308
+ .diagram-fallback ol,
309
+ .diagram-fallback p {
310
+ margin: 0.35rem 0 0;
311
+ }
312
+
313
+ .diagram-fallback ol {
314
+ padding-inline-start: 1.25rem;
315
+ }
316
+ }
317
+ </style>
@@ -161,7 +161,7 @@ const {
161
161
  this.dataset.enhanced = 'true';
162
162
  const instanceId = `compact-examples-${CompactExamplesElement.nextId++}`;
163
163
  const tablist = document.createElement('div');
164
- tablist.className = 'compact-examples__tabs';
164
+ tablist.className = 'compact-examples__tabs not-content';
165
165
  tablist.setAttribute('role', 'tablist');
166
166
  tablist.setAttribute(
167
167
  'aria-label',
@@ -0,0 +1,104 @@
1
+ ---
2
+ /**
3
+ * A referenceable figure (F-tables-figures) — static image, inline SVG, a
4
+ * `Diagram`, or a hydrated React lab — numbered and anchored the same way
5
+ * `<Table>` is; see `Table.astro` for the full identity model, which this
6
+ * mirrors exactly (separate counter, same `[[fig-<id>]]` / cross-page
7
+ * reference grammar, `number`/`anchorId` computed by `remark-notation.mjs`).
8
+ *
9
+ * `previewable` (default `true`) controls what the hover-preview card
10
+ * (`FloatRefLayer.astro`) shows for a `[[fig-<id>]]` reference elsewhere in
11
+ * the prose: `true` clones this figure's visual into the card alongside its
12
+ * caption; `false` shows the caption and number only. Set it `false` for
13
+ * interactive content — a hydrated `client:*` island cloned into a floating
14
+ * card would be an inert, non-hydrated, id-colliding duplicate, not a
15
+ * working preview.
16
+ */
17
+ export interface Props {
18
+ /** Stable key, e.g. `id="projectile-trajectory"`. Omit for a positional-only figure. */
19
+ id?: string;
20
+ /** Visible caption, shown below the figure and in the hover-preview card. */
21
+ caption: string;
22
+ /** `false` for interactive content the hover-preview must not clone. */
23
+ previewable?: boolean;
24
+ /** Computed by `remark-notation.mjs` — never author this directly. */
25
+ number?: string;
26
+ /** Computed by `remark-notation.mjs` — never author this directly. */
27
+ anchorId?: string;
28
+ }
29
+
30
+ const { id, caption, previewable = true, number, anchorId } = Astro.props;
31
+
32
+ if (typeof caption !== 'string' || !caption.trim()) {
33
+ throw new Error('Figure requires a non-empty `caption` prop.');
34
+ }
35
+ if (!number || !anchorId) {
36
+ throw new Error(
37
+ 'Figure is missing its computed `number`/`anchorId` — this page must run remark-notation with a `figures` cross-page table configured (see astro.config.mjs).',
38
+ );
39
+ }
40
+ ---
41
+
42
+ <figure
43
+ class="keyed-figure"
44
+ id={anchorId}
45
+ data-fig-number={number}
46
+ data-fig-key={id}
47
+ data-fig-previewable={String(previewable)}
48
+ >
49
+ <div class="keyed-figure__body">
50
+ <slot />
51
+ </div>
52
+ <figcaption class="keyed-figure__caption">
53
+ <a
54
+ class="keyed-figure__number"
55
+ href={`#${anchorId}`}
56
+ aria-label={`Figure ${number}`}>Figure {number}</a
57
+ >
58
+ <span class="keyed-figure__caption-text">{caption}</span>
59
+ </figcaption>
60
+ </figure>
61
+
62
+ <style>
63
+ .keyed-figure {
64
+ margin-block: 1.5rem;
65
+ padding: 0;
66
+ }
67
+
68
+ .keyed-figure__body {
69
+ overflow-x: auto;
70
+ }
71
+
72
+ .keyed-figure__caption {
73
+ margin-block-start: 0.5rem;
74
+ font-size: var(--sl-text-sm);
75
+ color: var(--sl-color-gray-3);
76
+ }
77
+
78
+ .keyed-figure__number {
79
+ font-weight: 600;
80
+ font-variant-numeric: tabular-nums;
81
+ color: var(--sl-color-text-accent);
82
+ text-decoration: none;
83
+ }
84
+
85
+ .keyed-figure__number:hover,
86
+ .keyed-figure__number:focus-visible {
87
+ text-decoration: underline;
88
+ }
89
+
90
+ .keyed-figure__number:focus-visible {
91
+ outline: 2px solid var(--sl-color-accent);
92
+ outline-offset: 0.15rem;
93
+ }
94
+
95
+ .keyed-figure__caption-text::before {
96
+ content: ' — ';
97
+ }
98
+
99
+ @media print {
100
+ .keyed-figure__number {
101
+ color: inherit;
102
+ }
103
+ }
104
+ </style>
@@ -250,6 +250,14 @@ export function renderNotationMath(latex: string): readonly SafeMathNode[] {
250
250
  * by the `formula` completeness gate (Tier 3) and by the CI test that holds
251
251
  * the corpus at zero. A scope error degrades all the way to today's behaviour,
252
252
  * an inert but correct equation.
253
+ *
254
+ * `unresolved` lists glyphs that are simply not in `scope`. `degraded` is a
255
+ * coarser, separate signal: it is `true` when resolution *threw* — an ambiguous
256
+ * bare base glyph, LaTeX that did not parse, a marker KaTeX or the sanitizer
257
+ * rejected — so the returned `nodes` fell back to the raw formula and carry no
258
+ * (or only literal `\explain`) markers. A caller that cannot tolerate silent,
259
+ * unmarked math (the `Diagram` component) turns `degraded` into a build error;
260
+ * `NotationFormula` keeps the inert-but-correct fallback.
253
261
  */
254
262
  export function renderNotationFormula(
255
263
  formula: string,
@@ -257,22 +265,25 @@ export function renderNotationFormula(
257
265
  ): {
258
266
  readonly nodes: readonly SafeMathNode[];
259
267
  readonly unresolved: readonly string[];
268
+ readonly degraded: boolean;
260
269
  } {
261
270
  let bound = formula;
262
271
  let unresolved: string[] = [];
272
+ let degraded = false;
263
273
  try {
264
274
  const result = resolveMathGlyphs(formula, [...scope]);
265
275
  bound = result.latex;
266
276
  unresolved = result.unresolved.map(({ token }: { token: string }) => token);
267
277
  } catch {
268
278
  bound = formula;
279
+ degraded = true;
269
280
  }
270
281
 
271
282
  try {
272
- return { nodes: renderNotationMath(bound), unresolved };
283
+ return { nodes: renderNotationMath(bound), unresolved, degraded };
273
284
  } catch {
274
285
  // A marker that KaTeX or the sanitizer rejects must not cost the reader
275
286
  // the equation itself.
276
- return { nodes: renderNotationMath(formula), unresolved };
287
+ return { nodes: renderNotationMath(formula), unresolved, degraded: true };
277
288
  }
278
289
  }