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.
- package/CHANGELOG.md +102 -0
- package/README.md +46 -5
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +41 -3
- package/dist/compiler/collections.js +4 -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 +77 -2
- package/dist/compiler/manifest.js +115 -18
- package/dist/compiler/resolution-report.d.ts +4 -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 +51 -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 +42 -11
- package/dist/reference/equations.mjs +58 -17
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -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/remark-notation.d.mts +12 -0
- package/dist/reference/remark-notation.mjs +199 -9
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- 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 +12 -3
- package/src/compiler/course-config.ts +54 -0
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/manifest.ts +239 -17
- package/src/components/CurriculumMap.astro +27 -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/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +4 -2
- package/src/components/table/Table.astro +104 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +6 -1
- package/src/reference/equations.mjs +58 -17
- package/src/reference/figures-validate.ts +40 -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/remark-notation.mjs +199 -9
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +115 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
interface DiagramNodeInput {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly width: number;
|
|
4
|
+
readonly height: number;
|
|
5
|
+
}
|
|
6
|
+
interface DiagramEdgeInput {
|
|
7
|
+
readonly from: string;
|
|
8
|
+
readonly to: string;
|
|
9
|
+
}
|
|
10
|
+
interface DiagramLayoutOptions {
|
|
11
|
+
/** Top-to-bottom (default) or left-to-right. */
|
|
12
|
+
readonly direction?: 'TB' | 'LR';
|
|
13
|
+
readonly nodeSeparation?: number;
|
|
14
|
+
readonly rankSeparation?: number;
|
|
15
|
+
/** Uniform padding added around the computed bounding box. */
|
|
16
|
+
readonly padding?: number;
|
|
17
|
+
}
|
|
18
|
+
interface Point {
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
}
|
|
22
|
+
interface LaidOutNode {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** Top-left corner, not the center dagre itself works in. */
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
readonly width: number;
|
|
28
|
+
readonly height: number;
|
|
29
|
+
}
|
|
30
|
+
interface LaidOutEdge {
|
|
31
|
+
readonly from: string;
|
|
32
|
+
readonly to: string;
|
|
33
|
+
readonly points: readonly Point[];
|
|
34
|
+
}
|
|
35
|
+
interface DiagramLayout {
|
|
36
|
+
readonly nodes: readonly LaidOutNode[];
|
|
37
|
+
readonly edges: readonly LaidOutEdge[];
|
|
38
|
+
/** The SVG canvas size a caller should use as the `viewBox`. */
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lay out a small directed graph. Node ids must be unique; an edge naming an
|
|
44
|
+
* undeclared node fails loudly rather than silently dropping it (dagre itself
|
|
45
|
+
* would otherwise create a phantom zero-size node).
|
|
46
|
+
*/
|
|
47
|
+
declare function layoutDiagram(nodes: readonly DiagramNodeInput[], edges: readonly DiagramEdgeInput[], options?: DiagramLayoutOptions): DiagramLayout;
|
|
48
|
+
interface BoxSizeEstimate {
|
|
49
|
+
readonly width: number;
|
|
50
|
+
readonly height: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Estimate a diagram node's box from its label text (or raw, pre-resolution
|
|
54
|
+
* LaTeX for a formula node). Deliberately generous: it is meant to avoid
|
|
55
|
+
* clipping, not to match a real render pixel-for-pixel. Pass an explicit
|
|
56
|
+
* `width`/`height` on the node to override for a label this estimate gets
|
|
57
|
+
* badly wrong.
|
|
58
|
+
*/
|
|
59
|
+
declare function estimateBoxSize(content: string, kind: 'text' | 'formula', options?: {
|
|
60
|
+
readonly maxWidth?: number;
|
|
61
|
+
}): BoxSizeEstimate;
|
|
62
|
+
|
|
63
|
+
export { type BoxSizeEstimate, type DiagramEdgeInput, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodeInput, type LaidOutEdge, type LaidOutNode, type Point, estimateBoxSize, layoutDiagram };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import dagre from "@dagrejs/dagre";
|
|
2
|
+
function layoutDiagram(nodes, edges, options = {}) {
|
|
3
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4
|
+
for (const node of nodes) {
|
|
5
|
+
if (ids.has(node.id)) {
|
|
6
|
+
throw new Error(`Diagram declares node "${node.id}" more than once.`);
|
|
7
|
+
}
|
|
8
|
+
if (!(node.width > 0) || !(node.height > 0)) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Diagram node "${node.id}" must have a positive width and height.`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
ids.add(node.id);
|
|
14
|
+
}
|
|
15
|
+
for (const edge of edges) {
|
|
16
|
+
if (!ids.has(edge.from)) {
|
|
17
|
+
throw new Error(`Diagram edge references unknown node "${edge.from}".`);
|
|
18
|
+
}
|
|
19
|
+
if (!ids.has(edge.to)) {
|
|
20
|
+
throw new Error(`Diagram edge references unknown node "${edge.to}".`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const graph = new dagre.graphlib.Graph();
|
|
24
|
+
graph.setGraph({
|
|
25
|
+
rankdir: options.direction ?? "TB",
|
|
26
|
+
nodesep: options.nodeSeparation ?? 24,
|
|
27
|
+
ranksep: options.rankSeparation ?? 48,
|
|
28
|
+
marginx: 0,
|
|
29
|
+
marginy: 0
|
|
30
|
+
});
|
|
31
|
+
graph.setDefaultEdgeLabel(() => ({}));
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
graph.setNode(node.id, { width: node.width, height: node.height });
|
|
34
|
+
}
|
|
35
|
+
for (const edge of edges) {
|
|
36
|
+
graph.setEdge(edge.from, edge.to);
|
|
37
|
+
}
|
|
38
|
+
dagre.layout(graph);
|
|
39
|
+
const centeredNodes = nodes.map((node) => {
|
|
40
|
+
const label = graph.node(node.id);
|
|
41
|
+
const cx = label.x ?? 0;
|
|
42
|
+
const cy = label.y ?? 0;
|
|
43
|
+
return {
|
|
44
|
+
id: node.id,
|
|
45
|
+
x: cx - node.width / 2,
|
|
46
|
+
y: cy - node.height / 2,
|
|
47
|
+
width: node.width,
|
|
48
|
+
height: node.height
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const rawEdges = edges.map((edge) => {
|
|
52
|
+
const label = graph.edge(edge.from, edge.to) ?? {};
|
|
53
|
+
return {
|
|
54
|
+
from: edge.from,
|
|
55
|
+
to: edge.to,
|
|
56
|
+
points: (label.points ?? []).map((point) => ({
|
|
57
|
+
x: point.x,
|
|
58
|
+
y: point.y
|
|
59
|
+
}))
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
const padding = options.padding ?? 12;
|
|
63
|
+
const xs = [
|
|
64
|
+
...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
|
|
65
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.x))
|
|
66
|
+
];
|
|
67
|
+
const ys = [
|
|
68
|
+
...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
|
|
69
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.y))
|
|
70
|
+
];
|
|
71
|
+
const minX = xs.length > 0 ? Math.min(...xs) : 0;
|
|
72
|
+
const minY = ys.length > 0 ? Math.min(...ys) : 0;
|
|
73
|
+
const maxX = xs.length > 0 ? Math.max(...xs) : 0;
|
|
74
|
+
const maxY = ys.length > 0 ? Math.max(...ys) : 0;
|
|
75
|
+
const shiftX = padding - minX;
|
|
76
|
+
const shiftY = padding - minY;
|
|
77
|
+
return {
|
|
78
|
+
nodes: centeredNodes.map((node) => ({
|
|
79
|
+
...node,
|
|
80
|
+
x: node.x + shiftX,
|
|
81
|
+
y: node.y + shiftY
|
|
82
|
+
})),
|
|
83
|
+
edges: rawEdges.map((edge) => ({
|
|
84
|
+
...edge,
|
|
85
|
+
points: edge.points.map((point) => ({
|
|
86
|
+
x: point.x + shiftX,
|
|
87
|
+
y: point.y + shiftY
|
|
88
|
+
}))
|
|
89
|
+
})),
|
|
90
|
+
width: maxX - minX + padding * 2,
|
|
91
|
+
height: maxY - minY + padding * 2
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const DEFAULT_MAX_WIDTH = 220;
|
|
95
|
+
const MIN_WIDTH = 90;
|
|
96
|
+
const PADDING_X = 20;
|
|
97
|
+
const PADDING_Y = 16;
|
|
98
|
+
const LINE_HEIGHT = 22;
|
|
99
|
+
const CHAR_WIDTH = {
|
|
100
|
+
text: 7.2,
|
|
101
|
+
formula: 11
|
|
102
|
+
};
|
|
103
|
+
function formulaVisibleLength(latex) {
|
|
104
|
+
const collapsed = latex.replace(/\\[a-zA-Z]+/g, "x").replace(/[{}$]/g, "").trim();
|
|
105
|
+
return Math.max(1, collapsed.length);
|
|
106
|
+
}
|
|
107
|
+
function estimateBoxSize(content, kind, options = {}) {
|
|
108
|
+
const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
109
|
+
const visibleLength = kind === "formula" ? formulaVisibleLength(content) : content.trim().length || 1;
|
|
110
|
+
const charWidth = CHAR_WIDTH[kind];
|
|
111
|
+
const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
|
|
112
|
+
const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
|
|
113
|
+
const usableWidth = width - PADDING_X * 2;
|
|
114
|
+
const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
|
|
115
|
+
const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
|
|
116
|
+
const height = lines * LINE_HEIGHT + PADDING_Y * 2;
|
|
117
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
estimateBoxSize,
|
|
121
|
+
layoutDiagram
|
|
122
|
+
};
|
|
@@ -44,6 +44,11 @@ interface EquationLessonInput {
|
|
|
44
44
|
readonly slug: string;
|
|
45
45
|
readonly file: string;
|
|
46
46
|
readonly body: string;
|
|
47
|
+
/**
|
|
48
|
+
* The lesson's 1-based ordinal in the course. When set, equations are
|
|
49
|
+
* numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
|
|
50
|
+
*/
|
|
51
|
+
readonly chapter?: number;
|
|
47
52
|
}
|
|
48
53
|
declare function validateEquations(lessons: readonly EquationLessonInput[]): EquationValidation;
|
|
49
54
|
|
|
@@ -25,7 +25,7 @@ function validateEquations(lessons) {
|
|
|
25
25
|
)) {
|
|
26
26
|
const seen = /* @__PURE__ */ new Set();
|
|
27
27
|
const table = {};
|
|
28
|
-
for (const raw of scanEquationLabels(lesson.body)) {
|
|
28
|
+
for (const raw of scanEquationLabels(lesson.body, lesson.chapter)) {
|
|
29
29
|
if (seen.has(raw.key)) {
|
|
30
30
|
diagnostics.push({
|
|
31
31
|
code: "eq-duplicate-key",
|
|
@@ -8,19 +8,45 @@ export function parseEquationRef(raw: any): {
|
|
|
8
8
|
} | undefined;
|
|
9
9
|
/** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
|
|
10
10
|
export function stripEquationLabels(latex: any): string;
|
|
11
|
+
/**
|
|
12
|
+
* The visible number for one display equation. With `chapter` (the lesson's
|
|
13
|
+
* course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
|
|
14
|
+
* are ignored; without it, the legacy `##`-section scheme.
|
|
15
|
+
*/
|
|
16
|
+
export function displayEquationNumber({ chapter, section, indexInSection, indexInLesson, }: {
|
|
17
|
+
chapter: any;
|
|
18
|
+
section: any;
|
|
19
|
+
indexInSection: any;
|
|
20
|
+
indexInLesson: any;
|
|
21
|
+
}): string;
|
|
11
22
|
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
12
23
|
export function equationAnchorId({ key, number }: {
|
|
13
24
|
key: any;
|
|
14
25
|
number: any;
|
|
15
26
|
}): string;
|
|
27
|
+
export function stripFencedCode(markdown: any): string;
|
|
28
|
+
/**
|
|
29
|
+
* Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
|
|
30
|
+
* so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
|
|
31
|
+
* (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
|
|
32
|
+
* outermost match wins, so nested components are blanked in one pass;
|
|
33
|
+
* newlines are kept so line/section structure is unchanged.
|
|
34
|
+
*
|
|
35
|
+
* `keep`, when given, leaves a component of that name untouched instead of
|
|
36
|
+
* blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
|
|
37
|
+
* component while still finding `<Table>`/`<Figure>` tags at the top level.
|
|
38
|
+
*/
|
|
39
|
+
export function stripJsxComponents(markdown: any, keep: any): string;
|
|
16
40
|
/**
|
|
17
41
|
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
18
|
-
* its assigned number. `$$…$$` blocks only
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
42
|
+
* its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
|
|
43
|
+
* course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
|
|
44
|
+
* display block in the lesson; without it, `##` headings advance a section
|
|
45
|
+
* counter and the number is `‹section›.‹indexInSection›`. Either way every
|
|
46
|
+
* display block advances the index, so a keyed equation's number reflects its
|
|
47
|
+
* position among *all* display equations, not only the keyed ones.
|
|
22
48
|
*/
|
|
23
|
-
export function scanEquationLabels(markdown: any): {
|
|
49
|
+
export function scanEquationLabels(markdown: any, chapter: any): {
|
|
24
50
|
key: string;
|
|
25
51
|
number: string;
|
|
26
52
|
section: number;
|
|
@@ -30,9 +56,10 @@ export function scanEquationLabels(markdown: any): {
|
|
|
30
56
|
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
31
57
|
* walk the tree in document order, advancing the section counter on every
|
|
32
58
|
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
33
|
-
* `\label{eq:…}` payload when present, else `null`.
|
|
59
|
+
* `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
|
|
60
|
+
* same way as in `scanEquationLabels`.
|
|
34
61
|
*/
|
|
35
|
-
export function collectDisplayEquations(tree: any): {
|
|
62
|
+
export function collectDisplayEquations(tree: any, chapter: any): {
|
|
36
63
|
key: string | null;
|
|
37
64
|
number: string;
|
|
38
65
|
section: number;
|
|
@@ -43,7 +70,7 @@ export function collectDisplayEquations(tree: any): {
|
|
|
43
70
|
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
44
71
|
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
45
72
|
*/
|
|
46
|
-
export function collectEquationLabels(tree: any): {
|
|
73
|
+
export function collectEquationLabels(tree: any, chapter: any): {
|
|
47
74
|
key: string | null;
|
|
48
75
|
number: string;
|
|
49
76
|
section: number;
|
|
@@ -53,9 +80,13 @@ export function collectEquationLabels(tree: any): {
|
|
|
53
80
|
/**
|
|
54
81
|
* Equation identity (Step D7, extended F3) — shared, framework-free helpers.
|
|
55
82
|
*
|
|
56
|
-
* Every display equation (`$$…$$`) in a lesson is numbered and given an
|
|
57
|
-
*
|
|
58
|
-
*
|
|
83
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
|
|
84
|
+
* from appearance order. When the caller supplies a `chapter` (the lesson's
|
|
85
|
+
* ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
|
|
86
|
+
* every display equation in the lesson and `##` headings do not affect it
|
|
87
|
+
* (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
|
|
88
|
+
* the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
|
|
89
|
+
* (used by hand-built test trees and non-lesson pages). An author additionally
|
|
59
90
|
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
60
91
|
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
61
92
|
* they want a *stable* anchor that survives edits and cross-page references:
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Equation identity (Step D7, extended F3) — shared, framework-free helpers.
|
|
3
3
|
*
|
|
4
|
-
* Every display equation (`$$…$$`) in a lesson is numbered and given an
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
|
|
5
|
+
* from appearance order. When the caller supplies a `chapter` (the lesson's
|
|
6
|
+
* ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
|
|
7
|
+
* every display equation in the lesson and `##` headings do not affect it
|
|
8
|
+
* (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
|
|
9
|
+
* the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
|
|
10
|
+
* (used by hand-built test trees and non-lesson pages). An author additionally
|
|
7
11
|
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
8
12
|
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
9
13
|
* they want a *stable* anchor that survives edits and cross-page references:
|
|
@@ -58,12 +62,28 @@ function equationNumber(section, index) {
|
|
|
58
62
|
return section > 0 ? `${section}.${index}` : String(index);
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The visible number for one display equation. With `chapter` (the lesson's
|
|
67
|
+
* course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
|
|
68
|
+
* are ignored; without it, the legacy `##`-section scheme.
|
|
69
|
+
*/
|
|
70
|
+
export function displayEquationNumber({
|
|
71
|
+
chapter,
|
|
72
|
+
section,
|
|
73
|
+
indexInSection,
|
|
74
|
+
indexInLesson,
|
|
75
|
+
}) {
|
|
76
|
+
return chapter == null
|
|
77
|
+
? equationNumber(section, indexInSection)
|
|
78
|
+
: `${chapter}.${indexInLesson}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
62
82
|
export function equationAnchorId({ key, number }) {
|
|
63
83
|
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
64
84
|
}
|
|
65
85
|
|
|
66
|
-
function stripFencedCode(markdown) {
|
|
86
|
+
export function stripFencedCode(markdown) {
|
|
67
87
|
return String(markdown)
|
|
68
88
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
69
89
|
.replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
@@ -76,22 +96,28 @@ function stripFencedCode(markdown) {
|
|
|
76
96
|
* (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
|
|
77
97
|
* outermost match wins, so nested components are blanked in one pass;
|
|
78
98
|
* newlines are kept so line/section structure is unchanged.
|
|
99
|
+
*
|
|
100
|
+
* `keep`, when given, leaves a component of that name untouched instead of
|
|
101
|
+
* blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
|
|
102
|
+
* component while still finding `<Table>`/`<Figure>` tags at the top level.
|
|
79
103
|
*/
|
|
80
|
-
function stripJsxComponents(markdown) {
|
|
104
|
+
export function stripJsxComponents(markdown, keep) {
|
|
81
105
|
return String(markdown).replace(
|
|
82
106
|
/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
|
|
83
|
-
(block) => block.replace(/[^\n]/g, ' '),
|
|
107
|
+
(block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
|
|
84
108
|
);
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
/**
|
|
88
112
|
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
89
|
-
* its assigned number. `$$…$$` blocks only
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
113
|
+
* its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
|
|
114
|
+
* course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
|
|
115
|
+
* display block in the lesson; without it, `##` headings advance a section
|
|
116
|
+
* counter and the number is `‹section›.‹indexInSection›`. Either way every
|
|
117
|
+
* display block advances the index, so a keyed equation's number reflects its
|
|
118
|
+
* position among *all* display equations, not only the keyed ones.
|
|
93
119
|
*/
|
|
94
|
-
export function scanEquationLabels(markdown) {
|
|
120
|
+
export function scanEquationLabels(markdown, chapter) {
|
|
95
121
|
const text = stripJsxComponents(stripFencedCode(markdown));
|
|
96
122
|
const tokens = [];
|
|
97
123
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
@@ -113,6 +139,7 @@ export function scanEquationLabels(markdown) {
|
|
|
113
139
|
const labels = [];
|
|
114
140
|
let section = 0;
|
|
115
141
|
let indexInSection = 0;
|
|
142
|
+
let indexInLesson = 0;
|
|
116
143
|
for (const token of tokens) {
|
|
117
144
|
if (token.kind === 'section') {
|
|
118
145
|
section += 1;
|
|
@@ -120,10 +147,16 @@ export function scanEquationLabels(markdown) {
|
|
|
120
147
|
continue;
|
|
121
148
|
}
|
|
122
149
|
indexInSection += 1;
|
|
150
|
+
indexInLesson += 1;
|
|
123
151
|
if (token.key == null) continue;
|
|
124
152
|
labels.push({
|
|
125
153
|
key: token.key,
|
|
126
|
-
number:
|
|
154
|
+
number: displayEquationNumber({
|
|
155
|
+
chapter,
|
|
156
|
+
section,
|
|
157
|
+
indexInSection,
|
|
158
|
+
indexInLesson,
|
|
159
|
+
}),
|
|
127
160
|
section,
|
|
128
161
|
indexInSection,
|
|
129
162
|
});
|
|
@@ -135,12 +168,14 @@ export function scanEquationLabels(markdown) {
|
|
|
135
168
|
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
136
169
|
* walk the tree in document order, advancing the section counter on every
|
|
137
170
|
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
138
|
-
* `\label{eq:…}` payload when present, else `null`.
|
|
171
|
+
* `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
|
|
172
|
+
* same way as in `scanEquationLabels`.
|
|
139
173
|
*/
|
|
140
|
-
export function collectDisplayEquations(tree) {
|
|
174
|
+
export function collectDisplayEquations(tree, chapter) {
|
|
141
175
|
const equations = [];
|
|
142
176
|
let section = 0;
|
|
143
177
|
let indexInSection = 0;
|
|
178
|
+
let indexInLesson = 0;
|
|
144
179
|
|
|
145
180
|
// Only top-level blocks: a `$$…$$` nested in an MDX component (a
|
|
146
181
|
// `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
|
|
@@ -155,11 +190,17 @@ export function collectDisplayEquations(tree) {
|
|
|
155
190
|
}
|
|
156
191
|
if (node.type === 'math') {
|
|
157
192
|
indexInSection += 1;
|
|
193
|
+
indexInLesson += 1;
|
|
158
194
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
159
195
|
const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
|
|
160
196
|
equations.push({
|
|
161
197
|
key: label ? label[1] : null,
|
|
162
|
-
number:
|
|
198
|
+
number: displayEquationNumber({
|
|
199
|
+
chapter,
|
|
200
|
+
section,
|
|
201
|
+
indexInSection,
|
|
202
|
+
indexInLesson,
|
|
203
|
+
}),
|
|
163
204
|
section,
|
|
164
205
|
indexInSection,
|
|
165
206
|
node,
|
|
@@ -173,8 +214,8 @@ export function collectDisplayEquations(tree) {
|
|
|
173
214
|
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
174
215
|
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
175
216
|
*/
|
|
176
|
-
export function collectEquationLabels(tree) {
|
|
177
|
-
return collectDisplayEquations(tree).filter(
|
|
217
|
+
export function collectEquationLabels(tree, chapter) {
|
|
218
|
+
return collectDisplayEquations(tree, chapter).filter(
|
|
178
219
|
(equation) => equation.key != null,
|
|
179
220
|
);
|
|
180
221
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } from './keyed-float-validate.js';
|
|
2
|
+
|
|
3
|
+
/** `validateKeyedFloats` for `<Figure>` — see `keyed-float-validate.ts`. */
|
|
4
|
+
|
|
5
|
+
type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused';
|
|
6
|
+
interface FigureDiagnostic {
|
|
7
|
+
readonly code: FigureDiagnosticCode;
|
|
8
|
+
readonly severity: 'error' | 'warning';
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly file?: string;
|
|
11
|
+
readonly lessonId?: string;
|
|
12
|
+
readonly key?: string;
|
|
13
|
+
}
|
|
14
|
+
type FigureLabelRecord = KeyedFloatLabelRecord;
|
|
15
|
+
type FigureLessonInput = KeyedFloatLessonInput;
|
|
16
|
+
interface FigureValidation extends KeyedFloatValidation {
|
|
17
|
+
readonly diagnostics: readonly FigureDiagnostic[];
|
|
18
|
+
}
|
|
19
|
+
declare function validateFigures(lessons: readonly FigureLessonInput[]): FigureValidation;
|
|
20
|
+
|
|
21
|
+
export { type FigureDiagnostic, type FigureDiagnosticCode, type FigureLabelRecord, type FigureLessonInput, type FigureValidation, validateFigures };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed-float validation for `<Table>`/`<Figure>`, run inside
|
|
3
|
+
* `compileManifest()` — the same three checks `equations-validate.ts` runs
|
|
4
|
+
* for equations, parameterized over the float kind so the logic exists once:
|
|
5
|
+
*
|
|
6
|
+
* - `<prefix>-invalid-key` (error): an `id` must match the key grammar.
|
|
7
|
+
* - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
|
|
8
|
+
* - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
|
|
9
|
+
* `[[slug#<prefix>-key]]` points at a `<Table>`/`<Figure>` with that `id`.
|
|
10
|
+
* - `<prefix>-key-unused` (warning): a keyed float never referenced.
|
|
11
|
+
*
|
|
12
|
+
* `tables-validate.ts` and `figures-validate.ts` are thin wrappers over this
|
|
13
|
+
* so callers import `validateTables` / `validateFigures` with their own
|
|
14
|
+
* narrow diagnostic-code types, while `equations-validate.ts` stays
|
|
15
|
+
* untouched — equations predate this and keep their own dedicated path.
|
|
16
|
+
*/
|
|
17
|
+
interface KeyedFloatKind {
|
|
18
|
+
/** The MDX component tag, e.g. `Table` or `Figure`. */
|
|
19
|
+
readonly tagName: string;
|
|
20
|
+
/** The `[[<prefix>-key]]` reference prefix, e.g. `tab` or `fig`. */
|
|
21
|
+
readonly prefix: string;
|
|
22
|
+
/** Lowercase noun used in diagnostic messages, e.g. `table` or `figure`. */
|
|
23
|
+
readonly noun: string;
|
|
24
|
+
}
|
|
25
|
+
interface KeyedFloatLabelRecord {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly number: string;
|
|
28
|
+
readonly lessonId: string;
|
|
29
|
+
readonly slug: string;
|
|
30
|
+
readonly section: number;
|
|
31
|
+
readonly indexInSection: number;
|
|
32
|
+
}
|
|
33
|
+
interface KeyedFloatRefRecord {
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly lessonId: string;
|
|
36
|
+
/** Target lesson slug for a cross-page reference; `undefined` = same page. */
|
|
37
|
+
readonly targetSlug?: string;
|
|
38
|
+
}
|
|
39
|
+
interface KeyedFloatDiagnostic {
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly severity: 'error' | 'warning';
|
|
42
|
+
readonly message: string;
|
|
43
|
+
readonly file?: string;
|
|
44
|
+
readonly lessonId?: string;
|
|
45
|
+
readonly key?: string;
|
|
46
|
+
}
|
|
47
|
+
interface KeyedFloatValidation {
|
|
48
|
+
/** Every keyed float, sorted by lesson then appearance. */
|
|
49
|
+
readonly labels: readonly KeyedFloatLabelRecord[];
|
|
50
|
+
/** `slug -> { key -> number }` — the render path's cross-page lookup table. */
|
|
51
|
+
readonly numbersBySlug: Record<string, Record<string, string>>;
|
|
52
|
+
readonly diagnostics: readonly KeyedFloatDiagnostic[];
|
|
53
|
+
}
|
|
54
|
+
interface KeyedFloatLessonInput {
|
|
55
|
+
readonly lessonId: string;
|
|
56
|
+
readonly slug: string;
|
|
57
|
+
readonly file: string;
|
|
58
|
+
readonly body: string;
|
|
59
|
+
/**
|
|
60
|
+
* The lesson's 1-based ordinal in the course. When set, floats are numbered
|
|
61
|
+
* `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
|
|
62
|
+
*/
|
|
63
|
+
readonly chapter?: number;
|
|
64
|
+
}
|
|
65
|
+
declare function validateKeyedFloats(lessons: readonly KeyedFloatLessonInput[], kind: KeyedFloatKind): KeyedFloatValidation;
|
|
66
|
+
|
|
67
|
+
export { type KeyedFloatDiagnostic, type KeyedFloatKind, type KeyedFloatLabelRecord, type KeyedFloatLessonInput, type KeyedFloatRefRecord, type KeyedFloatValidation, validateKeyedFloats };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KEYED_FLOAT_KEY_PATTERN,
|
|
3
|
+
parseKeyedFloatRef,
|
|
4
|
+
scanKeyedJsxFloats
|
|
5
|
+
} from "./keyed-jsx-floats.mjs";
|
|
6
|
+
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
7
|
+
function stripCode(text) {
|
|
8
|
+
return text.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
|
|
9
|
+
}
|
|
10
|
+
function bodyFloatRefs(lesson, prefix) {
|
|
11
|
+
const refs = [];
|
|
12
|
+
for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
|
|
13
|
+
const parsed = parseKeyedFloatRef((match[1] ?? "").trim(), { prefix });
|
|
14
|
+
if (!parsed) continue;
|
|
15
|
+
refs.push({
|
|
16
|
+
key: parsed.key,
|
|
17
|
+
lessonId: lesson.lessonId,
|
|
18
|
+
...parsed.lesson ? { targetSlug: parsed.lesson } : {}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return refs;
|
|
22
|
+
}
|
|
23
|
+
function validateKeyedFloats(lessons, kind) {
|
|
24
|
+
const { tagName, prefix, noun } = kind;
|
|
25
|
+
const labels = [];
|
|
26
|
+
const diagnostics = [];
|
|
27
|
+
const numbersBySlug = {};
|
|
28
|
+
for (const lesson of [...lessons].sort(
|
|
29
|
+
(a, b) => a.lessonId.localeCompare(b.lessonId)
|
|
30
|
+
)) {
|
|
31
|
+
const seen = /* @__PURE__ */ new Set();
|
|
32
|
+
const table = {};
|
|
33
|
+
for (const raw of scanKeyedJsxFloats(lesson.body, {
|
|
34
|
+
tagName,
|
|
35
|
+
chapter: lesson.chapter
|
|
36
|
+
})) {
|
|
37
|
+
if (raw.key == null) continue;
|
|
38
|
+
if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
|
|
39
|
+
diagnostics.push({
|
|
40
|
+
code: `${prefix}-invalid-key`,
|
|
41
|
+
severity: "error",
|
|
42
|
+
message: `${lesson.lessonId} has a <${tagName} id="${raw.key}"> whose id is not a valid key \u2014 expected lowercase letters, digits, and hyphens only.`,
|
|
43
|
+
file: lesson.file,
|
|
44
|
+
lessonId: lesson.lessonId,
|
|
45
|
+
key: raw.key
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(raw.key)) {
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
code: `${prefix}-duplicate-key`,
|
|
52
|
+
severity: "error",
|
|
53
|
+
message: `${lesson.lessonId} has two <${tagName}> elements with id="${raw.key}"; an id is unique per lesson.`,
|
|
54
|
+
file: lesson.file,
|
|
55
|
+
lessonId: lesson.lessonId,
|
|
56
|
+
key: raw.key
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
seen.add(raw.key);
|
|
61
|
+
table[raw.key] = raw.number;
|
|
62
|
+
labels.push({
|
|
63
|
+
key: raw.key,
|
|
64
|
+
number: raw.number,
|
|
65
|
+
lessonId: lesson.lessonId,
|
|
66
|
+
slug: lesson.slug,
|
|
67
|
+
section: raw.section,
|
|
68
|
+
indexInSection: raw.indexInSection
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
numbersBySlug[lesson.slug] = table;
|
|
72
|
+
}
|
|
73
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
74
|
+
for (const lesson of lessons) {
|
|
75
|
+
const slug = lesson.slug;
|
|
76
|
+
for (const ref of bodyFloatRefs(lesson, prefix)) {
|
|
77
|
+
const targetSlug = ref.targetSlug ?? slug;
|
|
78
|
+
const number = numbersBySlug[targetSlug]?.[ref.key];
|
|
79
|
+
referenced.add(`${targetSlug}#${ref.key}`);
|
|
80
|
+
if (number === void 0) {
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
code: `${prefix}-ref-resolves`,
|
|
83
|
+
severity: "error",
|
|
84
|
+
message: ref.targetSlug ? `${lesson.lessonId} references [[${ref.targetSlug}#${prefix}-${ref.key}]] but that lesson has no <${tagName} id="${ref.key}">` : `${lesson.lessonId} references [[${prefix}-${ref.key}]] but no <${tagName}> on the page carries id="${ref.key}"`,
|
|
85
|
+
file: lesson.file,
|
|
86
|
+
lessonId: lesson.lessonId,
|
|
87
|
+
key: ref.key
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const label of labels) {
|
|
93
|
+
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
94
|
+
diagnostics.push({
|
|
95
|
+
code: `${prefix}-key-unused`,
|
|
96
|
+
severity: "warning",
|
|
97
|
+
message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
|
|
98
|
+
file: label.slug,
|
|
99
|
+
lessonId: label.lessonId,
|
|
100
|
+
key: label.key
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
diagnostics.sort(
|
|
104
|
+
(a, b) => a.code.localeCompare(b.code) || (a.lessonId ?? "").localeCompare(b.lessonId ?? "") || (a.key ?? "").localeCompare(b.key ?? "") || a.message.localeCompare(b.message)
|
|
105
|
+
);
|
|
106
|
+
return { labels, numbersBySlug, diagnostics };
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
validateKeyedFloats
|
|
110
|
+
};
|