explico 0.2.0 → 0.4.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 +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- 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 +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- 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 +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -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 +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- 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 +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- 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/NotationLayer.astro +11 -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/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -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 +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -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
|
+
numbering: lesson.numbering
|
|
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
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
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 `numbering` (`{ prefix, offset }` — the part's ordinal and the count of
|
|
24
|
+
* this kind of float in earlier lessons of that part) the number is
|
|
25
|
+
* `‹part›.‹offset + n›`, `n` sequential over the whole lesson; without it, `##`
|
|
26
|
+
* headings advance a section counter. Other JSX components' bodies are blanked
|
|
27
|
+
* first so a `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like
|
|
28
|
+
* text inside an unrelated component) is never counted.
|
|
29
|
+
*/
|
|
30
|
+
export function scanKeyedJsxFloats(markdown: any, { tagName, numbering }: {
|
|
31
|
+
tagName: any;
|
|
32
|
+
numbering: any;
|
|
33
|
+
}): {
|
|
34
|
+
key: string | null | undefined;
|
|
35
|
+
number: string;
|
|
36
|
+
section: number;
|
|
37
|
+
indexInSection: number;
|
|
38
|
+
}[];
|
|
39
|
+
/**
|
|
40
|
+
* How many floats of this kind a lesson body holds — the input to the next
|
|
41
|
+
* lesson's `offset` within the same part.
|
|
42
|
+
*/
|
|
43
|
+
export function countKeyedJsxFloats(markdown: any, { tagName }: {
|
|
44
|
+
tagName: any;
|
|
45
|
+
}): number;
|
|
46
|
+
/**
|
|
47
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
|
|
48
|
+
* children in document order, advancing the section counter on every
|
|
49
|
+
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
50
|
+
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `numbering`
|
|
51
|
+
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
52
|
+
*/
|
|
53
|
+
export function collectKeyedJsxFloats(tree: any, { tagName, numbering }: {
|
|
54
|
+
tagName: any;
|
|
55
|
+
numbering: any;
|
|
56
|
+
}): {
|
|
57
|
+
key: any;
|
|
58
|
+
number: string;
|
|
59
|
+
section: number;
|
|
60
|
+
indexInSection: number;
|
|
61
|
+
node: any;
|
|
62
|
+
}[];
|
|
63
|
+
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
64
|
+
export const KEYED_FLOAT_KEY_PATTERN: RegExp;
|
|
@@ -0,0 +1,191 @@
|
|
|
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 {
|
|
26
|
+
resolveNumbering,
|
|
27
|
+
stripFencedCode,
|
|
28
|
+
stripJsxComponents,
|
|
29
|
+
} from './equations.mjs';
|
|
30
|
+
|
|
31
|
+
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
32
|
+
export const KEYED_FLOAT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
33
|
+
|
|
34
|
+
/** The visible number for the `index`-th float of one kind under `section`. */
|
|
35
|
+
function floatNumber(section, index) {
|
|
36
|
+
return section > 0 ? `${section}.${index}` : String(index);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The visible number for one float. With a `prefix` (the part's ordinal) the
|
|
41
|
+
* scheme is `‹part›.‹offset + indexInLesson›` and `##` headings are ignored;
|
|
42
|
+
* without it, the legacy `##`-section scheme. Mirrors `displayEquationNumber`
|
|
43
|
+
* in `equations.mjs`. Each kind of float counts on its own — a `<Table>` never
|
|
44
|
+
* advances the `<Figure>` sequence — so `offset` is per kind too.
|
|
45
|
+
*/
|
|
46
|
+
function keyedFloatNumber({
|
|
47
|
+
prefix,
|
|
48
|
+
offset = 0,
|
|
49
|
+
section,
|
|
50
|
+
indexInSection,
|
|
51
|
+
indexInLesson,
|
|
52
|
+
}) {
|
|
53
|
+
return prefix == null
|
|
54
|
+
? floatNumber(section, indexInSection)
|
|
55
|
+
: `${prefix}.${offset + indexInLesson}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
|
|
59
|
+
export function keyedFloatAnchorId({ key, number, prefix }) {
|
|
60
|
+
return key ? `${prefix}-${key}` : `${prefix}-${String(number).replace(/\./g, '-')}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
|
|
65
|
+
* the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
|
|
66
|
+
*/
|
|
67
|
+
export function parseKeyedFloatRef(raw, { prefix }) {
|
|
68
|
+
const pattern = new RegExp(
|
|
69
|
+
`^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?${prefix}-([a-z0-9]+(?:-[a-z0-9]+)*)$`,
|
|
70
|
+
);
|
|
71
|
+
const match = pattern.exec(String(raw).trim());
|
|
72
|
+
if (!match) return undefined;
|
|
73
|
+
return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `id="…"` attribute value of a JSX opening tag's attribute source. */
|
|
77
|
+
function readIdAttribute(attributesSource) {
|
|
78
|
+
const match = /\bid\s*=\s*(["'])([^"']*)\1/.exec(attributesSource);
|
|
79
|
+
return match ? match[2] : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
|
|
84
|
+
* `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
|
|
85
|
+
* With `numbering` (`{ prefix, offset }` — the part's ordinal and the count of
|
|
86
|
+
* this kind of float in earlier lessons of that part) the number is
|
|
87
|
+
* `‹part›.‹offset + n›`, `n` sequential over the whole lesson; without it, `##`
|
|
88
|
+
* headings advance a section counter. Other JSX components' bodies are blanked
|
|
89
|
+
* first so a `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like
|
|
90
|
+
* text inside an unrelated component) is never counted.
|
|
91
|
+
*/
|
|
92
|
+
export function scanKeyedJsxFloats(markdown, { tagName, numbering }) {
|
|
93
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
94
|
+
const text = stripJsxComponents(stripFencedCode(markdown), tagName);
|
|
95
|
+
const tokens = [];
|
|
96
|
+
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
97
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
98
|
+
for (const match of text.matchAll(heading)) {
|
|
99
|
+
tokens.push({ kind: 'section', index: match.index });
|
|
100
|
+
}
|
|
101
|
+
for (const match of text.matchAll(opening)) {
|
|
102
|
+
const key = readIdAttribute(match[1] ?? '');
|
|
103
|
+
tokens.push({ kind: 'float', index: match.index, key: key ?? null });
|
|
104
|
+
}
|
|
105
|
+
tokens.sort((a, b) => a.index - b.index);
|
|
106
|
+
|
|
107
|
+
const labels = [];
|
|
108
|
+
let section = 0;
|
|
109
|
+
let indexInSection = 0;
|
|
110
|
+
let indexInLesson = 0;
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
if (token.kind === 'section') {
|
|
113
|
+
section += 1;
|
|
114
|
+
indexInSection = 0;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
indexInSection += 1;
|
|
118
|
+
indexInLesson += 1;
|
|
119
|
+
labels.push({
|
|
120
|
+
key: token.key,
|
|
121
|
+
number: keyedFloatNumber({
|
|
122
|
+
prefix,
|
|
123
|
+
offset,
|
|
124
|
+
section,
|
|
125
|
+
indexInSection,
|
|
126
|
+
indexInLesson,
|
|
127
|
+
}),
|
|
128
|
+
section,
|
|
129
|
+
indexInSection,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return labels;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many floats of this kind a lesson body holds — the input to the next
|
|
137
|
+
* lesson's `offset` within the same part.
|
|
138
|
+
*/
|
|
139
|
+
export function countKeyedJsxFloats(markdown, { tagName }) {
|
|
140
|
+
return scanKeyedJsxFloats(markdown, { tagName }).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
|
|
145
|
+
* children in document order, advancing the section counter on every
|
|
146
|
+
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
147
|
+
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `numbering`
|
|
148
|
+
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
149
|
+
*/
|
|
150
|
+
export function collectKeyedJsxFloats(tree, { tagName, numbering }) {
|
|
151
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
152
|
+
const floats = [];
|
|
153
|
+
let section = 0;
|
|
154
|
+
let indexInSection = 0;
|
|
155
|
+
let indexInLesson = 0;
|
|
156
|
+
|
|
157
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
158
|
+
for (const node of children) {
|
|
159
|
+
if (!node || typeof node !== 'object') continue;
|
|
160
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
161
|
+
section += 1;
|
|
162
|
+
indexInSection = 0;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
|
|
166
|
+
indexInSection += 1;
|
|
167
|
+
indexInLesson += 1;
|
|
168
|
+
const idAttribute = (node.attributes ?? []).find(
|
|
169
|
+
(attribute) =>
|
|
170
|
+
attribute?.type === 'mdxJsxAttribute' && attribute.name === 'id',
|
|
171
|
+
);
|
|
172
|
+
const key =
|
|
173
|
+
typeof idAttribute?.value === 'string' ? idAttribute.value : undefined;
|
|
174
|
+
floats.push({
|
|
175
|
+
key: key ?? null,
|
|
176
|
+
number: keyedFloatNumber({
|
|
177
|
+
prefix,
|
|
178
|
+
offset,
|
|
179
|
+
section,
|
|
180
|
+
indexInSection,
|
|
181
|
+
indexInLesson,
|
|
182
|
+
}),
|
|
183
|
+
section,
|
|
184
|
+
indexInSection,
|
|
185
|
+
node,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return floats;
|
|
190
|
+
}
|
|
191
|
+
|
|
@@ -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
|
+
}
|
|
@@ -1,18 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The numbering inputs a lesson page needs, read off one manifest.
|
|
3
|
+
*
|
|
4
|
+
* Every number on a page comes from two places that MUST agree: the page's own
|
|
5
|
+
* counters (`numbering`) and the manifest tables a cross-page `[[slug#eq-key]]`
|
|
6
|
+
* is resolved against (`equations` / `tables` / `figures`). Deriving all four
|
|
7
|
+
* from the same manifest in one step is what stops a course config from
|
|
8
|
+
* supplying one without the other — the omission that produced page-local
|
|
9
|
+
* `(2.2)` and cross-page `(4.5)` for the same equation.
|
|
10
|
+
*
|
|
11
|
+
* `numbering` maps a lesson slug to `{ part, equations, tables, figures }`:
|
|
12
|
+
* the part's ordinal (the number before the dot) and, per kind of float, how
|
|
13
|
+
* many of them the earlier lessons of that part already used.
|
|
14
|
+
*/
|
|
15
|
+
export function numberingFromManifest(manifest: any): {
|
|
16
|
+
equations?: undefined;
|
|
17
|
+
tables?: undefined;
|
|
18
|
+
figures?: undefined;
|
|
19
|
+
numbering?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
equations: any;
|
|
22
|
+
tables: any;
|
|
23
|
+
figures: any;
|
|
24
|
+
numbering: {
|
|
25
|
+
[k: string]: any;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
1
28
|
/**
|
|
2
29
|
* @param {{
|
|
3
30
|
* definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
|
|
4
31
|
* resolve?: (key: string, context: object) => object | undefined,
|
|
5
32
|
* unknown?: 'error' | 'warn' | 'ignore',
|
|
6
33
|
* base?: string,
|
|
34
|
+
* manifest?: object,
|
|
7
35
|
* equations?: Record<string, Record<string, string>>,
|
|
36
|
+
* tables?: Record<string, Record<string, string>>,
|
|
37
|
+
* figures?: Record<string, Record<string, string>>,
|
|
38
|
+
* numbering?: Record<string, { part: number, equations: number, tables: number, figures: number }>,
|
|
39
|
+
* chapters?: Record<string, number>,
|
|
8
40
|
* }} [options]
|
|
41
|
+
*
|
|
42
|
+
* `manifest` is the preferred wiring: pass the compiled manifest and the four
|
|
43
|
+
* numbering inputs below are read off it together, so they cannot drift apart.
|
|
44
|
+
* They stay available individually for hand-built trees, and an explicitly
|
|
45
|
+
* supplied one still wins over the manifest.
|
|
46
|
+
*
|
|
47
|
+
* `numbering` maps a lesson slug to its **part** (the number before the dot)
|
|
48
|
+
* and, per kind of float, how many the earlier lessons of that part already
|
|
49
|
+
* used. Every display equation / `<Table>` / `<Figure>` on the page is then
|
|
50
|
+
* numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
|
|
51
|
+
* entry the legacy `##`-section scheme applies. It must agree with
|
|
52
|
+
* `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`
|
|
53
|
+
* above — supplying the tables without `numbering` numbers the same equation
|
|
54
|
+
* twice, and `assertManifestNumberParity` turns that into a build error rather
|
|
55
|
+
* than a page whose references disagree with their targets.
|
|
56
|
+
*
|
|
57
|
+
* `chapters` is the superseded per-lesson form (slug → the lesson's course
|
|
58
|
+
* ordinal, no running offset). Still read when `numbering` has no entry, so a
|
|
59
|
+
* config written against the old option keeps building.
|
|
9
60
|
*/
|
|
10
61
|
export default function remarkNotation(options?: {
|
|
11
62
|
definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>);
|
|
12
63
|
resolve?: (key: string, context: object) => object | undefined;
|
|
13
64
|
unknown?: "error" | "warn" | "ignore";
|
|
14
65
|
base?: string;
|
|
66
|
+
manifest?: object;
|
|
15
67
|
equations?: Record<string, Record<string, string>>;
|
|
68
|
+
tables?: Record<string, Record<string, string>>;
|
|
69
|
+
figures?: Record<string, Record<string, string>>;
|
|
70
|
+
numbering?: Record<string, {
|
|
71
|
+
part: number;
|
|
72
|
+
equations: number;
|
|
73
|
+
tables: number;
|
|
74
|
+
figures: number;
|
|
75
|
+
}>;
|
|
76
|
+
chapters?: Record<string, number>;
|
|
16
77
|
}): (tree: any, file: any) => void;
|
|
17
78
|
/**
|
|
18
79
|
* Remark adapter for semantic notation references.
|