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,56 @@
|
|
|
1
|
+
/** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
|
|
2
|
+
export function keyedFloatAnchorId({ key, number, prefix }: {
|
|
3
|
+
key: any;
|
|
4
|
+
number: any;
|
|
5
|
+
prefix: any;
|
|
6
|
+
}): string;
|
|
7
|
+
/**
|
|
8
|
+
* A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
|
|
9
|
+
* the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
|
|
10
|
+
*/
|
|
11
|
+
export function parseKeyedFloatRef(raw: any, { prefix }: {
|
|
12
|
+
prefix: any;
|
|
13
|
+
}): {
|
|
14
|
+
lesson: string;
|
|
15
|
+
key: string;
|
|
16
|
+
} | {
|
|
17
|
+
key: string;
|
|
18
|
+
lesson?: undefined;
|
|
19
|
+
} | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
|
|
22
|
+
* `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
|
|
23
|
+
* With `chapter` (the lesson's course ordinal) the number is `‹chapter›.‹n›`,
|
|
24
|
+
* `n` sequential over the whole lesson; without it, `##` headings advance a
|
|
25
|
+
* section counter. Other JSX components' bodies are blanked first so a
|
|
26
|
+
* `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like text
|
|
27
|
+
* inside an unrelated component) is never counted.
|
|
28
|
+
*/
|
|
29
|
+
export function scanKeyedJsxFloats(markdown: any, { tagName, chapter }: {
|
|
30
|
+
tagName: any;
|
|
31
|
+
chapter: any;
|
|
32
|
+
}): {
|
|
33
|
+
key: string | null | undefined;
|
|
34
|
+
number: string;
|
|
35
|
+
section: number;
|
|
36
|
+
indexInSection: number;
|
|
37
|
+
}[];
|
|
38
|
+
/**
|
|
39
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
|
|
40
|
+
* children in document order, advancing the section counter on every
|
|
41
|
+
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
42
|
+
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `chapter`
|
|
43
|
+
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
44
|
+
*/
|
|
45
|
+
export function collectKeyedJsxFloats(tree: any, { tagName, chapter }: {
|
|
46
|
+
tagName: any;
|
|
47
|
+
chapter: any;
|
|
48
|
+
}): {
|
|
49
|
+
key: any;
|
|
50
|
+
number: string;
|
|
51
|
+
section: number;
|
|
52
|
+
indexInSection: number;
|
|
53
|
+
node: any;
|
|
54
|
+
}[];
|
|
55
|
+
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
56
|
+
export const KEYED_FLOAT_KEY_PATTERN: RegExp;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed-float identity for a JSX-wrapped table or figure, shared between
|
|
3
|
+
* `<Table>` and `<Figure>` (Step F-tables-figures).
|
|
4
|
+
*
|
|
5
|
+
* This mirrors `equations.mjs`, but the key comes from a plain `id` prop on
|
|
6
|
+
* the wrapper component rather than a `\label{eq:key}` embedded in shared
|
|
7
|
+
* LaTeX source — a `<Table>`/`<Figure>` is purpose-built, so there is no
|
|
8
|
+
* ambiguity an embedded marker would need to resolve. An author writes the
|
|
9
|
+
* bare key (`id="kinematics-summary"`); the rendered DOM id and the `[[…]]`
|
|
10
|
+
* reference prefix are computed the same way `\label{eq:key}` becomes
|
|
11
|
+
* `#eq-key` — `keyedFloatAnchorId` prepends `tab-`/`fig-`.
|
|
12
|
+
*
|
|
13
|
+
* Only top-level occurrences count, exactly like `collectDisplayEquations`:
|
|
14
|
+
* a `<Table>` nested inside a `<CompactExample>` (or any other component) is
|
|
15
|
+
* illustrative, not part of the lesson's official sequence, so both the
|
|
16
|
+
* mdast walker and the raw-text scanner only look at the document's direct
|
|
17
|
+
* top-level blocks.
|
|
18
|
+
*
|
|
19
|
+
* `scanKeyedJsxFloats` (raw Markdown, for the manifest builder) and
|
|
20
|
+
* `collectKeyedJsxFloats` (mdast, for `remark-notation.mjs`) MUST agree —
|
|
21
|
+
* `tests/notation/keyed-floats.test.ts` checks that, the same contract
|
|
22
|
+
* `equations.test.ts` holds equations to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { stripFencedCode, stripJsxComponents } from './equations.mjs';
|
|
26
|
+
|
|
27
|
+
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
28
|
+
export const KEYED_FLOAT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
29
|
+
|
|
30
|
+
/** The visible number for the `index`-th float of one kind under `section`. */
|
|
31
|
+
function floatNumber(section, index) {
|
|
32
|
+
return section > 0 ? `${section}.${index}` : String(index);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The visible number for one float. With `chapter` (the lesson's course
|
|
37
|
+
* ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings are
|
|
38
|
+
* ignored; without it, the legacy `##`-section scheme. Mirrors
|
|
39
|
+
* `displayEquationNumber` in `equations.mjs`.
|
|
40
|
+
*/
|
|
41
|
+
function keyedFloatNumber({ chapter, section, indexInSection, indexInLesson }) {
|
|
42
|
+
return chapter == null
|
|
43
|
+
? floatNumber(section, indexInSection)
|
|
44
|
+
: `${chapter}.${indexInLesson}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
|
|
48
|
+
export function keyedFloatAnchorId({ key, number, prefix }) {
|
|
49
|
+
return key ? `${prefix}-${key}` : `${prefix}-${String(number).replace(/\./g, '-')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
|
|
54
|
+
* the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
|
|
55
|
+
*/
|
|
56
|
+
export function parseKeyedFloatRef(raw, { prefix }) {
|
|
57
|
+
const pattern = new RegExp(
|
|
58
|
+
`^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?${prefix}-([a-z0-9]+(?:-[a-z0-9]+)*)$`,
|
|
59
|
+
);
|
|
60
|
+
const match = pattern.exec(String(raw).trim());
|
|
61
|
+
if (!match) return undefined;
|
|
62
|
+
return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `id="…"` attribute value of a JSX opening tag's attribute source. */
|
|
66
|
+
function readIdAttribute(attributesSource) {
|
|
67
|
+
const match = /\bid\s*=\s*(["'])([^"']*)\1/.exec(attributesSource);
|
|
68
|
+
return match ? match[2] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
|
|
73
|
+
* `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
|
|
74
|
+
* With `chapter` (the lesson's course ordinal) the number is `‹chapter›.‹n›`,
|
|
75
|
+
* `n` sequential over the whole lesson; without it, `##` headings advance a
|
|
76
|
+
* section counter. Other JSX components' bodies are blanked first so a
|
|
77
|
+
* `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like text
|
|
78
|
+
* inside an unrelated component) is never counted.
|
|
79
|
+
*/
|
|
80
|
+
export function scanKeyedJsxFloats(markdown, { tagName, chapter }) {
|
|
81
|
+
const text = stripJsxComponents(stripFencedCode(markdown), tagName);
|
|
82
|
+
const tokens = [];
|
|
83
|
+
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
84
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
85
|
+
for (const match of text.matchAll(heading)) {
|
|
86
|
+
tokens.push({ kind: 'section', index: match.index });
|
|
87
|
+
}
|
|
88
|
+
for (const match of text.matchAll(opening)) {
|
|
89
|
+
const key = readIdAttribute(match[1] ?? '');
|
|
90
|
+
tokens.push({ kind: 'float', index: match.index, key: key ?? null });
|
|
91
|
+
}
|
|
92
|
+
tokens.sort((a, b) => a.index - b.index);
|
|
93
|
+
|
|
94
|
+
const labels = [];
|
|
95
|
+
let section = 0;
|
|
96
|
+
let indexInSection = 0;
|
|
97
|
+
let indexInLesson = 0;
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
if (token.kind === 'section') {
|
|
100
|
+
section += 1;
|
|
101
|
+
indexInSection = 0;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
indexInSection += 1;
|
|
105
|
+
indexInLesson += 1;
|
|
106
|
+
labels.push({
|
|
107
|
+
key: token.key,
|
|
108
|
+
number: keyedFloatNumber({
|
|
109
|
+
chapter,
|
|
110
|
+
section,
|
|
111
|
+
indexInSection,
|
|
112
|
+
indexInLesson,
|
|
113
|
+
}),
|
|
114
|
+
section,
|
|
115
|
+
indexInSection,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return labels;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
|
|
123
|
+
* children in document order, advancing the section counter on every
|
|
124
|
+
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
125
|
+
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `chapter`
|
|
126
|
+
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
127
|
+
*/
|
|
128
|
+
export function collectKeyedJsxFloats(tree, { tagName, chapter }) {
|
|
129
|
+
const floats = [];
|
|
130
|
+
let section = 0;
|
|
131
|
+
let indexInSection = 0;
|
|
132
|
+
let indexInLesson = 0;
|
|
133
|
+
|
|
134
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
135
|
+
for (const node of children) {
|
|
136
|
+
if (!node || typeof node !== 'object') continue;
|
|
137
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
138
|
+
section += 1;
|
|
139
|
+
indexInSection = 0;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
|
|
143
|
+
indexInSection += 1;
|
|
144
|
+
indexInLesson += 1;
|
|
145
|
+
const idAttribute = (node.attributes ?? []).find(
|
|
146
|
+
(attribute) =>
|
|
147
|
+
attribute?.type === 'mdxJsxAttribute' && attribute.name === 'id',
|
|
148
|
+
);
|
|
149
|
+
const key =
|
|
150
|
+
typeof idAttribute?.value === 'string' ? idAttribute.value : undefined;
|
|
151
|
+
floats.push({
|
|
152
|
+
key: key ?? null,
|
|
153
|
+
number: keyedFloatNumber({
|
|
154
|
+
chapter,
|
|
155
|
+
section,
|
|
156
|
+
indexInSection,
|
|
157
|
+
indexInLesson,
|
|
158
|
+
}),
|
|
159
|
+
section,
|
|
160
|
+
indexInSection,
|
|
161
|
+
node,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return floats;
|
|
166
|
+
}
|
|
167
|
+
|
|
@@ -18,6 +18,24 @@ export function resolveMathGlyphs(latex: string, definitions: Array<{
|
|
|
18
18
|
}[];
|
|
19
19
|
unresolved: any;
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* Every named operator in one KaTeX expression: a built-in `op` node
|
|
23
|
+
* (`\exp`, `\int`, `\sum`, …) or an author-declared `\operatorname{…}` /
|
|
24
|
+
* `\mathop{…}` (`Var`, `Cov`, …). The `name` is the KaTeX command without its
|
|
25
|
+
* backslash for a built-in, and the verbatim body for a declared one — the
|
|
26
|
+
* form `BASE_OPERATORS` and `course.config.ts` `operators` are keyed by.
|
|
27
|
+
*
|
|
28
|
+
* Pure read: unlike `resolveMathGlyphs`, nothing here rewrites the source.
|
|
29
|
+
* Used by the `operator-explained` consistency check (rule N8); the glyph
|
|
30
|
+
* resolver still skips operator bodies whole.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} latex
|
|
33
|
+
* @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
|
|
34
|
+
*/
|
|
35
|
+
export function collectOperators(latex: string): Array<{
|
|
36
|
+
name: string;
|
|
37
|
+
source: "named" | "operatorname";
|
|
38
|
+
}>;
|
|
21
39
|
/**
|
|
22
40
|
* Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
|
|
23
41
|
*
|
|
@@ -828,3 +828,95 @@ export function resolveMathGlyphs(latex, definitions) {
|
|
|
828
828
|
unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
|
|
829
829
|
};
|
|
830
830
|
}
|
|
831
|
+
|
|
832
|
+
// --- named operators ------------------------------------------------
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Every named operator in one KaTeX expression: a built-in `op` node
|
|
836
|
+
* (`\exp`, `\int`, `\sum`, …) or an author-declared `\operatorname{…}` /
|
|
837
|
+
* `\mathop{…}` (`Var`, `Cov`, …). The `name` is the KaTeX command without its
|
|
838
|
+
* backslash for a built-in, and the verbatim body for a declared one — the
|
|
839
|
+
* form `BASE_OPERATORS` and `course.config.ts` `operators` are keyed by.
|
|
840
|
+
*
|
|
841
|
+
* Pure read: unlike `resolveMathGlyphs`, nothing here rewrites the source.
|
|
842
|
+
* Used by the `operator-explained` consistency check (rule N8); the glyph
|
|
843
|
+
* resolver still skips operator bodies whole.
|
|
844
|
+
*
|
|
845
|
+
* @param {string} latex
|
|
846
|
+
* @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
|
|
847
|
+
*/
|
|
848
|
+
export function collectOperators(latex) {
|
|
849
|
+
if (typeof latex !== 'string' || latex.trim() === '') return [];
|
|
850
|
+
|
|
851
|
+
let tree;
|
|
852
|
+
try {
|
|
853
|
+
tree = parseMath(latex, 'operator scan');
|
|
854
|
+
} catch (error) {
|
|
855
|
+
// A span that will not parse is the math gate's problem, not this check's.
|
|
856
|
+
// A KaTeX version-pin failure is nobody's to swallow — let it through.
|
|
857
|
+
if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
|
|
858
|
+
return [];
|
|
859
|
+
}
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const seen = new Set();
|
|
864
|
+
const operators = [];
|
|
865
|
+
const add = (name, source) => {
|
|
866
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
867
|
+
if (trimmed === '') return;
|
|
868
|
+
const identity = `${source}:${trimmed}`;
|
|
869
|
+
if (seen.has(identity)) return;
|
|
870
|
+
seen.add(identity);
|
|
871
|
+
operators.push({ name: trimmed, source });
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// The declared-operator name is its letters with any internal spacing
|
|
875
|
+
// dropped, so `\operatorname{arg\,max}` keys as `argmax` — `fontBodyText`
|
|
876
|
+
// alone bails on the `\,`.
|
|
877
|
+
const declaredName = (body) => {
|
|
878
|
+
const direct = fontBodyText(body);
|
|
879
|
+
if (typeof direct === 'string') return direct;
|
|
880
|
+
const letters = [];
|
|
881
|
+
const gather = (value) => {
|
|
882
|
+
if (Array.isArray(value)) {
|
|
883
|
+
value.forEach(gather);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!isNode(value)) return;
|
|
887
|
+
if (value.type === 'mathord' || value.type === 'textord') {
|
|
888
|
+
letters.push(value.text ?? '');
|
|
889
|
+
}
|
|
890
|
+
if (value.type === 'ordgroup') gather(value.body);
|
|
891
|
+
};
|
|
892
|
+
gather(body);
|
|
893
|
+
return letters.join('');
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const visit = (value) => {
|
|
897
|
+
if (Array.isArray(value)) {
|
|
898
|
+
for (const child of value) visit(child);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (!isNode(value)) return;
|
|
902
|
+
|
|
903
|
+
if (value.type === 'operatorname') {
|
|
904
|
+
add(declaredName(value.body), 'operatorname');
|
|
905
|
+
} else if (value.type === 'op') {
|
|
906
|
+
if (typeof value.name === 'string' && value.name.length > 0) {
|
|
907
|
+
add(value.name.replace(/^\\/, ''), 'named');
|
|
908
|
+
} else if (value.body !== undefined) {
|
|
909
|
+
// `\mathop{Foo}` — a hand-rolled operator with no command name.
|
|
910
|
+
add(declaredName(value.body), 'operatorname');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
for (const key of Object.keys(value)) {
|
|
915
|
+
if (key === 'loc') continue;
|
|
916
|
+
visit(value[key]);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
visit(tree);
|
|
921
|
+
return operators;
|
|
922
|
+
}
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* unknown?: 'error' | 'warn' | 'ignore',
|
|
6
6
|
* base?: string,
|
|
7
7
|
* equations?: Record<string, Record<string, string>>,
|
|
8
|
+
* tables?: Record<string, Record<string, string>>,
|
|
9
|
+
* figures?: Record<string, Record<string, string>>,
|
|
10
|
+
* chapters?: Record<string, number>,
|
|
8
11
|
* }} [options]
|
|
12
|
+
*
|
|
13
|
+
* `chapters` maps a lesson slug to its 1-based course ordinal. When the
|
|
14
|
+
* current page has one, every display equation / `<Table>` / `<Figure>` is
|
|
15
|
+
* numbered `‹chapter›.‹n›` (`##` headings ignored); with no entry the legacy
|
|
16
|
+
* `##`-section scheme applies. It must agree with `manifest.lessons[].chapter`,
|
|
17
|
+
* which is what feeds `equations`/`tables`/`figures` above.
|
|
9
18
|
*/
|
|
10
19
|
export default function remarkNotation(options?: {
|
|
11
20
|
definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>);
|
|
@@ -13,6 +22,9 @@ export default function remarkNotation(options?: {
|
|
|
13
22
|
unknown?: "error" | "warn" | "ignore";
|
|
14
23
|
base?: string;
|
|
15
24
|
equations?: Record<string, Record<string, string>>;
|
|
25
|
+
tables?: Record<string, Record<string, string>>;
|
|
26
|
+
figures?: Record<string, Record<string, string>>;
|
|
27
|
+
chapters?: Record<string, number>;
|
|
16
28
|
}): (tree: any, file: any) => void;
|
|
17
29
|
/**
|
|
18
30
|
* Remark adapter for semantic notation references.
|
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
parseEquationRef,
|
|
43
43
|
stripEquationLabels,
|
|
44
44
|
} from './equations.mjs';
|
|
45
|
+
import {
|
|
46
|
+
collectKeyedJsxFloats,
|
|
47
|
+
keyedFloatAnchorId,
|
|
48
|
+
parseKeyedFloatRef,
|
|
49
|
+
} from './keyed-jsx-floats.mjs';
|
|
45
50
|
|
|
46
51
|
const PROSE_SKIP_TYPES = new Set([
|
|
47
52
|
'code',
|
|
@@ -340,6 +345,8 @@ function equationNode(mathNode, { key, number }) {
|
|
|
340
345
|
className: ['keyed-equation__number'],
|
|
341
346
|
'data-eq-number': number,
|
|
342
347
|
'aria-label': `Equation ${number}`,
|
|
348
|
+
// Plain hover tooltip — the number is a permalink to this equation.
|
|
349
|
+
title: 'Anchor to this equation',
|
|
343
350
|
},
|
|
344
351
|
},
|
|
345
352
|
},
|
|
@@ -347,6 +354,32 @@ function equationNode(mathNode, { key, number }) {
|
|
|
347
354
|
};
|
|
348
355
|
}
|
|
349
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Give a `<Table>`/`<Figure>` `mdxJsxFlowElement` its computed `number` and
|
|
359
|
+
* `anchorId` as real JSX attributes, so the actual Astro component (which
|
|
360
|
+
* renders the numbered anchor, unlike `equationNode`'s synthesized hast div)
|
|
361
|
+
* receives them as props. The compiler is the single source of truth for
|
|
362
|
+
* both — the component only renders what it's given.
|
|
363
|
+
*/
|
|
364
|
+
function injectFloatNumberAttributes(node, float, prefix) {
|
|
365
|
+
const anchorId = keyedFloatAnchorId({
|
|
366
|
+
key: float.key,
|
|
367
|
+
number: float.number,
|
|
368
|
+
prefix,
|
|
369
|
+
});
|
|
370
|
+
node.attributes = (node.attributes ?? []).filter(
|
|
371
|
+
(attribute) =>
|
|
372
|
+
!(
|
|
373
|
+
attribute?.type === 'mdxJsxAttribute' &&
|
|
374
|
+
(attribute.name === 'number' || attribute.name === 'anchorId')
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
node.attributes.push(
|
|
378
|
+
{ type: 'mdxJsxAttribute', name: 'number', value: float.number },
|
|
379
|
+
{ type: 'mdxJsxAttribute', name: 'anchorId', value: anchorId },
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
350
383
|
/** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
|
|
351
384
|
function resolveEquationRef(eqRef, eqCtx, file, node) {
|
|
352
385
|
if (eqRef.lesson) {
|
|
@@ -374,7 +407,47 @@ function resolveEquationRef(eqRef, eqCtx, file, node) {
|
|
|
374
407
|
return { number, href: `#eq-${eqRef.key}` };
|
|
375
408
|
}
|
|
376
409
|
|
|
377
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a `[[<prefix>-key]]` / `[[slug#<prefix>-key]]` reference (a
|
|
412
|
+
* `<Table>`/`<Figure>` float) to `{ number, href }` — the table/figure
|
|
413
|
+
* equivalent of `resolveEquationRef`.
|
|
414
|
+
*/
|
|
415
|
+
function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
|
|
416
|
+
if (floatRef.lesson) {
|
|
417
|
+
const number = ctx.crossPage?.[floatRef.lesson]?.[floatRef.key];
|
|
418
|
+
if (number === undefined) {
|
|
419
|
+
fail(
|
|
420
|
+
file,
|
|
421
|
+
`Unknown ${tagName} reference [[${floatRef.lesson}#${prefix}-${floatRef.key}]]: lesson ${floatRef.lesson} has no <${tagName}> with id="${floatRef.key}".`,
|
|
422
|
+
node,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
number,
|
|
427
|
+
href: `${ctx.base.replace(/\/$/, '')}/${floatRef.lesson}/#${prefix}-${floatRef.key}`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const number = ctx.pageLabels.get(floatRef.key);
|
|
431
|
+
if (number === undefined) {
|
|
432
|
+
fail(
|
|
433
|
+
file,
|
|
434
|
+
`Unknown ${tagName} reference [[${prefix}-${floatRef.key}]]: no <${tagName}> on this page carries id="${floatRef.key}".`,
|
|
435
|
+
node,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return { number, href: `#${prefix}-${floatRef.key}` };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function proseNodes(
|
|
442
|
+
value,
|
|
443
|
+
resolve,
|
|
444
|
+
file,
|
|
445
|
+
node,
|
|
446
|
+
references,
|
|
447
|
+
eqCtx,
|
|
448
|
+
tableCtx,
|
|
449
|
+
figureCtx,
|
|
450
|
+
) {
|
|
378
451
|
const base = eqCtx?.base ?? '';
|
|
379
452
|
const strayExplain = firstUnescaped(value, EXPLAIN);
|
|
380
453
|
if (strayExplain !== -1) {
|
|
@@ -395,7 +468,18 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
|
|
|
395
468
|
|
|
396
469
|
const key = match[1].trim();
|
|
397
470
|
const eqRef = eqCtx ? parseEquationRef(key) : undefined;
|
|
398
|
-
|
|
471
|
+
const tableRef =
|
|
472
|
+
!eqRef && tableCtx ? parseKeyedFloatRef(key, { prefix: 'tab' }) : undefined;
|
|
473
|
+
const figureRef =
|
|
474
|
+
!eqRef && !tableRef && figureCtx
|
|
475
|
+
? parseKeyedFloatRef(key, { prefix: 'fig' })
|
|
476
|
+
: undefined;
|
|
477
|
+
if (
|
|
478
|
+
!eqRef &&
|
|
479
|
+
!tableRef &&
|
|
480
|
+
!figureRef &&
|
|
481
|
+
!NOTATION_KEY_PATTERN.test(key)
|
|
482
|
+
) {
|
|
399
483
|
fail(file, `Invalid notation key "${key}" in [[...]].`, node);
|
|
400
484
|
}
|
|
401
485
|
|
|
@@ -426,9 +510,46 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
|
|
|
426
510
|
continue;
|
|
427
511
|
}
|
|
428
512
|
|
|
513
|
+
const floatRef = tableRef ?? figureRef;
|
|
514
|
+
if (floatRef) {
|
|
515
|
+
const kind = tableRef ? 'table' : 'figure';
|
|
516
|
+
const prefix = tableRef ? 'tab' : 'fig';
|
|
517
|
+
const tagName = tableRef ? 'Table' : 'Figure';
|
|
518
|
+
const { number, href } = resolveKeyedFloatRef(
|
|
519
|
+
floatRef,
|
|
520
|
+
tableRef ? tableCtx : figureCtx,
|
|
521
|
+
{ prefix, tagName },
|
|
522
|
+
file,
|
|
523
|
+
node,
|
|
524
|
+
);
|
|
525
|
+
output.push({
|
|
526
|
+
type: 'link',
|
|
527
|
+
url: href,
|
|
528
|
+
children: [{ type: 'text', value: `${tagName} ${number}` }],
|
|
529
|
+
data: {
|
|
530
|
+
hProperties: {
|
|
531
|
+
className: [`${kind}-ref`],
|
|
532
|
+
[`data-${prefix}-ref`]: floatRef.key,
|
|
533
|
+
...(floatRef.lesson ? { [`data-${prefix}-lesson`]: floatRef.lesson } : {}),
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
references.push({
|
|
538
|
+
key: floatRef.key,
|
|
539
|
+
kind,
|
|
540
|
+
...(floatRef.lesson ? { lesson: floatRef.lesson } : {}),
|
|
541
|
+
});
|
|
542
|
+
cursor = match.index + match[0].length;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
429
546
|
const definition = resolve(key, 'prose', node);
|
|
430
|
-
|
|
431
|
-
|
|
547
|
+
// `title`/`summary` are the pre-C1b field names, kept as the first
|
|
548
|
+
// fallback for a caller still supplying them; real content authors
|
|
549
|
+
// `label`/`meaning` (see `content-config.ts`), which raw frontmatter
|
|
550
|
+
// (`manifest.notation.raw`, a page's own `notation.local`) carries as-is.
|
|
551
|
+
const title = definition?.title ?? definition?.label ?? key;
|
|
552
|
+
const summary = definition?.summary ?? definition?.meaning;
|
|
432
553
|
const href = defaultHref(definition, key, base);
|
|
433
554
|
|
|
434
555
|
const properties = {
|
|
@@ -557,7 +678,16 @@ function inspectExplainCalls(value, resolve, file, node, references) {
|
|
|
557
678
|
* unknown?: 'error' | 'warn' | 'ignore',
|
|
558
679
|
* base?: string,
|
|
559
680
|
* equations?: Record<string, Record<string, string>>,
|
|
681
|
+
* tables?: Record<string, Record<string, string>>,
|
|
682
|
+
* figures?: Record<string, Record<string, string>>,
|
|
683
|
+
* chapters?: Record<string, number>,
|
|
560
684
|
* }} [options]
|
|
685
|
+
*
|
|
686
|
+
* `chapters` maps a lesson slug to its 1-based course ordinal. When the
|
|
687
|
+
* current page has one, every display equation / `<Table>` / `<Figure>` is
|
|
688
|
+
* numbered `‹chapter›.‹n›` (`##` headings ignored); with no entry the legacy
|
|
689
|
+
* `##`-section scheme applies. It must agree with `manifest.lessons[].chapter`,
|
|
690
|
+
* which is what feeds `equations`/`tables`/`figures` above.
|
|
561
691
|
*/
|
|
562
692
|
export default function remarkNotation(options = {}) {
|
|
563
693
|
const unknown = options.unknown ?? 'error';
|
|
@@ -573,6 +703,10 @@ export default function remarkNotation(options = {}) {
|
|
|
573
703
|
);
|
|
574
704
|
const localDefinitions = localDefinitionsFromFile(file);
|
|
575
705
|
const lessonId = lessonIdFrom(file);
|
|
706
|
+
// The lesson's course ordinal (see the `chapters` option). Falsy / missing
|
|
707
|
+
// ⇒ the legacy `##`-section numbering, so `0` and `undefined` behave alike.
|
|
708
|
+
const chapter =
|
|
709
|
+
(lessonId && options.chapters?.[lessonId.replace(/\./g, '/')]) || undefined;
|
|
576
710
|
const registryFile = isNotationRegistryFile(file);
|
|
577
711
|
const mathScope = pageGlyphScope(
|
|
578
712
|
localDefinitions,
|
|
@@ -582,10 +716,10 @@ export default function remarkNotation(options = {}) {
|
|
|
582
716
|
const references = [];
|
|
583
717
|
const resolved = new Map();
|
|
584
718
|
|
|
585
|
-
// Equation identity (D7): assign every `\label{eq:…}` its
|
|
586
|
-
//
|
|
587
|
-
// the
|
|
588
|
-
const equationLabels = collectEquationLabels(tree);
|
|
719
|
+
// Equation identity (D7): assign every `\label{eq:…}` its number before any
|
|
720
|
+
// math node is rewritten, so a `[[eq-key]]` earlier in the page still
|
|
721
|
+
// resolves. `chapter` picks the `‹chapter›.‹n›` vs `##`-section scheme.
|
|
722
|
+
const equationLabels = collectEquationLabels(tree, chapter);
|
|
589
723
|
const pageLabels = new Map();
|
|
590
724
|
for (const label of equationLabels) {
|
|
591
725
|
if (pageLabels.has(label.key)) {
|
|
@@ -606,11 +740,50 @@ export default function remarkNotation(options = {}) {
|
|
|
606
740
|
// Number and anchor every display equation on a lesson page, keyed or
|
|
607
741
|
// not (F3). Non-lesson pages (notation registry, stray docs) keep the
|
|
608
742
|
// keyed-only behaviour so their body math is left untouched.
|
|
609
|
-
const displayEquations = lessonId
|
|
743
|
+
const displayEquations = lessonId
|
|
744
|
+
? collectDisplayEquations(tree, chapter)
|
|
745
|
+
: [];
|
|
610
746
|
const equationByNode = new Map(
|
|
611
747
|
displayEquations.map((equation) => [equation.node, equation]),
|
|
612
748
|
);
|
|
613
749
|
|
|
750
|
+
// Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
|
|
751
|
+
// page is numbered and anchored, keyed or not — same rule as equations,
|
|
752
|
+
// via the shared engine in `keyed-jsx-floats.mjs`.
|
|
753
|
+
const makeFloatCtx = (tagName, crossPage) => {
|
|
754
|
+
const floats = lessonId
|
|
755
|
+
? collectKeyedJsxFloats(tree, { tagName, chapter })
|
|
756
|
+
: [];
|
|
757
|
+
const pageFloatLabels = new Map();
|
|
758
|
+
for (const float of floats) {
|
|
759
|
+
if (float.key == null) continue;
|
|
760
|
+
if (pageFloatLabels.has(float.key)) {
|
|
761
|
+
fail(
|
|
762
|
+
file,
|
|
763
|
+
`Duplicate <${tagName} id="${float.key}"> on this page; an id is unique per lesson.`,
|
|
764
|
+
float.node,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
pageFloatLabels.set(float.key, float.number);
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
ctx: {
|
|
771
|
+
base: options.base ?? '',
|
|
772
|
+
pageLabels: pageFloatLabels,
|
|
773
|
+
crossPage: crossPage ?? {},
|
|
774
|
+
},
|
|
775
|
+
byNode: new Map(floats.map((float) => [float.node, float])),
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
|
|
779
|
+
'Table',
|
|
780
|
+
options.tables,
|
|
781
|
+
);
|
|
782
|
+
const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
|
|
783
|
+
'Figure',
|
|
784
|
+
options.figures,
|
|
785
|
+
);
|
|
786
|
+
|
|
614
787
|
const resolve = (key, kind, node) => {
|
|
615
788
|
const context = {
|
|
616
789
|
file,
|
|
@@ -729,6 +902,8 @@ export default function remarkNotation(options = {}) {
|
|
|
729
902
|
child,
|
|
730
903
|
references,
|
|
731
904
|
eqCtx,
|
|
905
|
+
tableCtx,
|
|
906
|
+
figureCtx,
|
|
732
907
|
);
|
|
733
908
|
if (replacement) {
|
|
734
909
|
node.children.splice(index, 1, ...replacement);
|
|
@@ -739,6 +914,10 @@ export default function remarkNotation(options = {}) {
|
|
|
739
914
|
visit(child, false);
|
|
740
915
|
if (child && child.type === 'math' && equationByNode.has(child)) {
|
|
741
916
|
node.children[index] = equationNode(child, equationByNode.get(child));
|
|
917
|
+
} else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
|
|
918
|
+
injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
|
|
919
|
+
} else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
|
|
920
|
+
injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
|
|
742
921
|
}
|
|
743
922
|
}
|
|
744
923
|
};
|
|
@@ -755,5 +934,16 @@ export default function remarkNotation(options = {}) {
|
|
|
755
934
|
indexInSection,
|
|
756
935
|
}),
|
|
757
936
|
);
|
|
937
|
+
const floatLabelData = (byNode) =>
|
|
938
|
+
[...byNode.values()]
|
|
939
|
+
.filter((float) => float.key != null)
|
|
940
|
+
.map(({ key, number, section, indexInSection }) => ({
|
|
941
|
+
key,
|
|
942
|
+
number,
|
|
943
|
+
section,
|
|
944
|
+
indexInSection,
|
|
945
|
+
}));
|
|
946
|
+
file.data.tableLabels = floatLabelData(tableByNode);
|
|
947
|
+
file.data.figureLabels = floatLabelData(figureByNode);
|
|
758
948
|
};
|
|
759
949
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } from './keyed-float-validate.js';
|
|
2
|
+
|
|
3
|
+
/** `validateKeyedFloats` for `<Table>` — see `keyed-float-validate.ts`. */
|
|
4
|
+
|
|
5
|
+
type TableDiagnosticCode = 'tab-invalid-key' | 'tab-duplicate-key' | 'tab-ref-resolves' | 'tab-key-unused';
|
|
6
|
+
interface TableDiagnostic {
|
|
7
|
+
readonly code: TableDiagnosticCode;
|
|
8
|
+
readonly severity: 'error' | 'warning';
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly file?: string;
|
|
11
|
+
readonly lessonId?: string;
|
|
12
|
+
readonly key?: string;
|
|
13
|
+
}
|
|
14
|
+
type TableLabelRecord = KeyedFloatLabelRecord;
|
|
15
|
+
type TableLessonInput = KeyedFloatLessonInput;
|
|
16
|
+
interface TableValidation extends KeyedFloatValidation {
|
|
17
|
+
readonly diagnostics: readonly TableDiagnostic[];
|
|
18
|
+
}
|
|
19
|
+
declare function validateTables(lessons: readonly TableLessonInput[]): TableValidation;
|
|
20
|
+
|
|
21
|
+
export { type TableDiagnostic, type TableDiagnosticCode, type TableLabelRecord, type TableLessonInput, type TableValidation, validateTables };
|