explico 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +46 -5
- package/dist/compiler/cli.d.ts +17 -0
- package/dist/compiler/cli.js +41 -3
- package/dist/compiler/collections.js +31 -1
- package/dist/compiler/course-config.d.ts +52 -1
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/manifest.d.ts +87 -3
- package/dist/compiler/manifest.js +133 -20
- package/dist/compiler/resolution-report.d.ts +5 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +142 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +5 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +76 -18
- package/dist/reference/equations.mjs +118 -41
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/gate-math.d.ts +5 -4
- package/dist/reference/gate-math.js +73 -2
- package/dist/reference/gloss.d.ts +87 -0
- package/dist/reference/gloss.js +59 -0
- package/dist/reference/keyed-float-validate.d.ts +67 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +56 -0
- package/dist/reference/keyed-jsx-floats.mjs +167 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/registry.js +25 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -0
- package/dist/reference/rehype-heading-anchors.mjs +144 -0
- package/dist/reference/remark-notation.d.mts +12 -0
- package/dist/reference/remark-notation.mjs +228 -32
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/types.d.ts +25 -2
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +96 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +107 -0
- package/docs/authoring/notation.md +296 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/cli.ts +49 -0
- package/src/compiler/collections.ts +52 -3
- package/src/compiler/course-config.ts +54 -0
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/manifest.ts +270 -17
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/HoverPanel.astro +42 -0
- package/src/components/citation/CitationLayer.astro +1 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/hover-panel.ts +71 -5
- package/src/components/notation/NotationFormula.astro +154 -0
- package/src/components/notation/NotationGlossary.astro +60 -0
- package/src/components/notation/NotationLayer.astro +248 -103
- package/src/components/notation/render-notation-math.ts +66 -0
- package/src/components/popup-stack.ts +345 -0
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +8 -2
- package/src/components/table/Table.astro +104 -0
- package/src/content-config.ts +38 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +236 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +6 -1
- package/src/reference/equations.mjs +118 -41
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/gate-math.ts +104 -7
- package/src/reference/gloss.ts +153 -0
- package/src/reference/keyed-float-validate.ts +207 -0
- package/src/reference/keyed-jsx-floats.mjs +167 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/registry.ts +43 -0
- package/src/reference/rehype-heading-anchors.mjs +144 -0
- package/src/reference/remark-notation.mjs +228 -32
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/types.ts +26 -1
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +193 -3
- package/src/components/starlight/EquationEnhancer.astro +0 -72
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
|
|
18
|
+
import {
|
|
19
|
+
KEYED_FLOAT_KEY_PATTERN,
|
|
20
|
+
parseKeyedFloatRef,
|
|
21
|
+
scanKeyedJsxFloats,
|
|
22
|
+
} from './keyed-jsx-floats.mjs';
|
|
23
|
+
|
|
24
|
+
export interface KeyedFloatKind {
|
|
25
|
+
/** The MDX component tag, e.g. `Table` or `Figure`. */
|
|
26
|
+
readonly tagName: string;
|
|
27
|
+
/** The `[[<prefix>-key]]` reference prefix, e.g. `tab` or `fig`. */
|
|
28
|
+
readonly prefix: string;
|
|
29
|
+
/** Lowercase noun used in diagnostic messages, e.g. `table` or `figure`. */
|
|
30
|
+
readonly noun: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KeyedFloatLabelRecord {
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly number: string;
|
|
36
|
+
readonly lessonId: string;
|
|
37
|
+
readonly slug: string;
|
|
38
|
+
readonly section: number;
|
|
39
|
+
readonly indexInSection: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KeyedFloatRefRecord {
|
|
43
|
+
readonly key: string;
|
|
44
|
+
readonly lessonId: string;
|
|
45
|
+
/** Target lesson slug for a cross-page reference; `undefined` = same page. */
|
|
46
|
+
readonly targetSlug?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KeyedFloatDiagnostic {
|
|
50
|
+
readonly code: string;
|
|
51
|
+
readonly severity: 'error' | 'warning';
|
|
52
|
+
readonly message: string;
|
|
53
|
+
readonly file?: string;
|
|
54
|
+
readonly lessonId?: string;
|
|
55
|
+
readonly key?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface KeyedFloatValidation {
|
|
59
|
+
/** Every keyed float, sorted by lesson then appearance. */
|
|
60
|
+
readonly labels: readonly KeyedFloatLabelRecord[];
|
|
61
|
+
/** `slug -> { key -> number }` — the render path's cross-page lookup table. */
|
|
62
|
+
readonly numbersBySlug: Record<string, Record<string, string>>;
|
|
63
|
+
readonly diagnostics: readonly KeyedFloatDiagnostic[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface KeyedFloatLessonInput {
|
|
67
|
+
readonly lessonId: string;
|
|
68
|
+
readonly slug: string;
|
|
69
|
+
readonly file: string;
|
|
70
|
+
readonly body: string;
|
|
71
|
+
/**
|
|
72
|
+
* The lesson's 1-based ordinal in the course. When set, floats are numbered
|
|
73
|
+
* `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
|
|
74
|
+
*/
|
|
75
|
+
readonly chapter?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
79
|
+
|
|
80
|
+
function stripCode(text: string): string {
|
|
81
|
+
return text
|
|
82
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
83
|
+
.replace(/~~~[\s\S]*?~~~/g, ' ')
|
|
84
|
+
.replace(/`[^`\n]*`/g, ' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bodyFloatRefs(
|
|
88
|
+
lesson: KeyedFloatLessonInput,
|
|
89
|
+
prefix: string,
|
|
90
|
+
): KeyedFloatRefRecord[] {
|
|
91
|
+
const refs: KeyedFloatRefRecord[] = [];
|
|
92
|
+
for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
|
|
93
|
+
const parsed = parseKeyedFloatRef((match[1] ?? '').trim(), { prefix });
|
|
94
|
+
if (!parsed) continue;
|
|
95
|
+
refs.push({
|
|
96
|
+
key: parsed.key,
|
|
97
|
+
lessonId: lesson.lessonId,
|
|
98
|
+
...(parsed.lesson ? { targetSlug: parsed.lesson } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return refs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateKeyedFloats(
|
|
105
|
+
lessons: readonly KeyedFloatLessonInput[],
|
|
106
|
+
kind: KeyedFloatKind,
|
|
107
|
+
): KeyedFloatValidation {
|
|
108
|
+
const { tagName, prefix, noun } = kind;
|
|
109
|
+
const labels: KeyedFloatLabelRecord[] = [];
|
|
110
|
+
const diagnostics: KeyedFloatDiagnostic[] = [];
|
|
111
|
+
const numbersBySlug: Record<string, Record<string, string>> = {};
|
|
112
|
+
|
|
113
|
+
for (const lesson of [...lessons].sort((a, b) =>
|
|
114
|
+
a.lessonId.localeCompare(b.lessonId),
|
|
115
|
+
)) {
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
const table: Record<string, string> = {};
|
|
118
|
+
for (const raw of scanKeyedJsxFloats(lesson.body, {
|
|
119
|
+
tagName,
|
|
120
|
+
chapter: lesson.chapter,
|
|
121
|
+
}) as {
|
|
122
|
+
key: string | null;
|
|
123
|
+
number: string;
|
|
124
|
+
section: number;
|
|
125
|
+
indexInSection: number;
|
|
126
|
+
}[]) {
|
|
127
|
+
if (raw.key == null) continue;
|
|
128
|
+
if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
|
|
129
|
+
diagnostics.push({
|
|
130
|
+
code: `${prefix}-invalid-key`,
|
|
131
|
+
severity: 'error',
|
|
132
|
+
message: `${lesson.lessonId} has a <${tagName} id="${raw.key}"> whose id is not a valid key — expected lowercase letters, digits, and hyphens only.`,
|
|
133
|
+
file: lesson.file,
|
|
134
|
+
lessonId: lesson.lessonId,
|
|
135
|
+
key: raw.key,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (seen.has(raw.key)) {
|
|
140
|
+
diagnostics.push({
|
|
141
|
+
code: `${prefix}-duplicate-key`,
|
|
142
|
+
severity: 'error',
|
|
143
|
+
message: `${lesson.lessonId} has two <${tagName}> elements with id="${raw.key}"; an id is unique per lesson.`,
|
|
144
|
+
file: lesson.file,
|
|
145
|
+
lessonId: lesson.lessonId,
|
|
146
|
+
key: raw.key,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
seen.add(raw.key);
|
|
151
|
+
table[raw.key] = raw.number;
|
|
152
|
+
labels.push({
|
|
153
|
+
key: raw.key,
|
|
154
|
+
number: raw.number,
|
|
155
|
+
lessonId: lesson.lessonId,
|
|
156
|
+
slug: lesson.slug,
|
|
157
|
+
section: raw.section,
|
|
158
|
+
indexInSection: raw.indexInSection,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
numbersBySlug[lesson.slug] = table;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const referenced = new Set<string>();
|
|
165
|
+
for (const lesson of lessons) {
|
|
166
|
+
const slug = lesson.slug;
|
|
167
|
+
for (const ref of bodyFloatRefs(lesson, prefix)) {
|
|
168
|
+
const targetSlug = ref.targetSlug ?? slug;
|
|
169
|
+
const number = numbersBySlug[targetSlug]?.[ref.key];
|
|
170
|
+
referenced.add(`${targetSlug}#${ref.key}`);
|
|
171
|
+
if (number === undefined) {
|
|
172
|
+
diagnostics.push({
|
|
173
|
+
code: `${prefix}-ref-resolves`,
|
|
174
|
+
severity: 'error',
|
|
175
|
+
message: ref.targetSlug
|
|
176
|
+
? `${lesson.lessonId} references [[${ref.targetSlug}#${prefix}-${ref.key}]] but that lesson has no <${tagName} id="${ref.key}">`
|
|
177
|
+
: `${lesson.lessonId} references [[${prefix}-${ref.key}]] but no <${tagName}> on the page carries id="${ref.key}"`,
|
|
178
|
+
file: lesson.file,
|
|
179
|
+
lessonId: lesson.lessonId,
|
|
180
|
+
key: ref.key,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const label of labels) {
|
|
187
|
+
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
188
|
+
diagnostics.push({
|
|
189
|
+
code: `${prefix}-key-unused`,
|
|
190
|
+
severity: 'warning',
|
|
191
|
+
message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
|
|
192
|
+
file: label.slug,
|
|
193
|
+
lessonId: label.lessonId,
|
|
194
|
+
key: label.key,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
diagnostics.sort(
|
|
199
|
+
(a, b) =>
|
|
200
|
+
a.code.localeCompare(b.code) ||
|
|
201
|
+
(a.lessonId ?? '').localeCompare(b.lessonId ?? '') ||
|
|
202
|
+
(a.key ?? '').localeCompare(b.key ?? '') ||
|
|
203
|
+
a.message.localeCompare(b.message),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return { labels, numbersBySlug, diagnostics };
|
|
207
|
+
}
|
|
@@ -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
|
+
|
|
@@ -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
|
+
}
|
|
@@ -58,6 +58,8 @@ export function buildNotationRegistry(
|
|
|
58
58
|
);
|
|
59
59
|
const availableDefinitionIds = new Set(definitions.keys());
|
|
60
60
|
|
|
61
|
+
validateGlossKeys(definitions, diagnostics);
|
|
62
|
+
|
|
61
63
|
// `notation.uses` is retired (D1 decision): a lesson pulls a shared key into
|
|
62
64
|
// scope by referencing it (`[[key]]`/`\explain`), and every reference resolves
|
|
63
65
|
// directly against page-local then shared definitions.
|
|
@@ -411,6 +413,43 @@ function normalizeLocalDefinition(
|
|
|
411
413
|
};
|
|
412
414
|
}
|
|
413
415
|
|
|
416
|
+
/**
|
|
417
|
+
* A gloss key is derived as `<card key>.<slug(name)>`, so it is entry-scoped
|
|
418
|
+
* and cannot clash with another card's glosses. It can still collide with a
|
|
419
|
+
* **card** whose own key happens to take that shape — and a colliding key
|
|
420
|
+
* would make the same string mean two things in the flat glyph table, which
|
|
421
|
+
* is exactly what the semantic-key design exists to prevent. Tier 1.
|
|
422
|
+
*
|
|
423
|
+
* This is also the promotion boundary: promoting a gloss means deleting the
|
|
424
|
+
* gloss line *and* adding the card. Leaving both in place fails here rather
|
|
425
|
+
* than resolving to whichever the table saw first.
|
|
426
|
+
*/
|
|
427
|
+
function validateGlossKeys(
|
|
428
|
+
definitions: ReadonlyMap<string, DefinitionCandidate>,
|
|
429
|
+
diagnostics: NotationDiagnostic[],
|
|
430
|
+
): void {
|
|
431
|
+
const cardKeys = new Set(
|
|
432
|
+
[...definitions.values()].map((candidate) => candidate.input.key),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
for (const candidate of definitions.values()) {
|
|
436
|
+
for (const gloss of candidate.input.glosses) {
|
|
437
|
+
if (!cardKeys.has(gloss.key)) continue;
|
|
438
|
+
diagnostics.push(
|
|
439
|
+
diagnostic({
|
|
440
|
+
code: 'gloss-collides-with-card',
|
|
441
|
+
message: `${candidate.id} glosses ${JSON.stringify(gloss.label)} as ${gloss.key}, which is also a notation card; delete the gloss or rename it`,
|
|
442
|
+
key: gloss.key,
|
|
443
|
+
lessonId:
|
|
444
|
+
candidate.kind === 'local' ? candidate.lesson.lessonId : undefined,
|
|
445
|
+
definitionId: candidate.id,
|
|
446
|
+
source: candidate.input.source,
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
414
453
|
function validateReferenceKey(
|
|
415
454
|
reference: NotationReferenceInput,
|
|
416
455
|
owner: DefinitionCandidate,
|
|
@@ -503,6 +542,7 @@ function toDefinitionRecord(
|
|
|
503
542
|
status: input.status,
|
|
504
543
|
aiAssisted: input.aiAssisted,
|
|
505
544
|
body: input.body,
|
|
545
|
+
glosses: input.glosses,
|
|
506
546
|
source: input.source,
|
|
507
547
|
resolvedReferences: candidate.resolvedReferences,
|
|
508
548
|
...(input.formula === undefined ? {} : { formula: input.formula }),
|
|
@@ -525,6 +565,7 @@ function toDefinitionRecord(
|
|
|
525
565
|
seeAlso: input.seeAlso,
|
|
526
566
|
alignment: input.alignment,
|
|
527
567
|
status: candidate.lesson.status,
|
|
568
|
+
glosses: input.glosses,
|
|
528
569
|
source: input.source,
|
|
529
570
|
resolvedReferences: candidate.resolvedReferences,
|
|
530
571
|
...(input.formula === undefined ? {} : { formula: input.formula }),
|
|
@@ -570,6 +611,7 @@ function definitionFingerprint(candidate: DefinitionCandidate): string {
|
|
|
570
611
|
status: input.status,
|
|
571
612
|
aiAssisted: input.aiAssisted,
|
|
572
613
|
body: normalizeText(input.body),
|
|
614
|
+
glosses: input.glosses,
|
|
573
615
|
references,
|
|
574
616
|
});
|
|
575
617
|
}
|
|
@@ -586,6 +628,7 @@ function definitionFingerprint(candidate: DefinitionCandidate): string {
|
|
|
586
628
|
sources: [...input.sources].sort(),
|
|
587
629
|
seeAlso: [...input.seeAlso].sort(),
|
|
588
630
|
alignment,
|
|
631
|
+
glosses: input.glosses,
|
|
589
632
|
references,
|
|
590
633
|
});
|
|
591
634
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-linkable content headings (Step F3).
|
|
3
|
+
*
|
|
4
|
+
* The lesson pipeline uses a custom Markdown `processor` (see `astro.config.mjs`),
|
|
5
|
+
* which bypasses Starlight's own heading-link rehype pass. This plugin restores
|
|
6
|
+
* it with the *same* markup Starlight emits, so its bundled `anchor-links.css`
|
|
7
|
+
* (shipped globally because `markdown.headingLinks` defaults on) styles it for
|
|
8
|
+
* free — including the hover/focus reveal of an otherwise invisible anchor:
|
|
9
|
+
*
|
|
10
|
+
* <div class="sl-heading-wrapper level-h2">
|
|
11
|
+
* <h2 id="slug">Heading</h2>
|
|
12
|
+
* <a class="sl-anchor-link" href="#slug"> <icon> <sr-only label> </a>
|
|
13
|
+
* </div>
|
|
14
|
+
*
|
|
15
|
+
* The `:target` flash, sticky-header scroll offset, and the JS niceties
|
|
16
|
+
* (re-trigger on repeat activation, smooth scroll, `history.pushState`, brief
|
|
17
|
+
* section highlight) live in `global.css` + `HeadingAnchorEnhancer.astro`, the
|
|
18
|
+
* same split the keyed-equation feature uses.
|
|
19
|
+
*
|
|
20
|
+
* `rehypeHeadingIds` from `@astrojs/markdown-remark` normally runs *after* the
|
|
21
|
+
* user rehype plugins, so heading `id`s do not exist yet when this runs. It is
|
|
22
|
+
* idempotent (only assigns a missing `id`, and rebuilds `file.data.astro.headings`
|
|
23
|
+
* from an unchanged document order), so this plugin runs it first and Astro's
|
|
24
|
+
* later pass becomes a no-op for these headings.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
|
28
|
+
|
|
29
|
+
const HEADING_TAG = /^h([1-6])$/;
|
|
30
|
+
|
|
31
|
+
// Starlight's `link-alt` icon (`components-internals/Icons.ts`), the same glyph
|
|
32
|
+
// its own `rehypeAutolinkHeadings` uses.
|
|
33
|
+
const LINK_ICON_PATH =
|
|
34
|
+
'm12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42';
|
|
35
|
+
|
|
36
|
+
function headingText(node) {
|
|
37
|
+
let text = '';
|
|
38
|
+
const walk = (current) => {
|
|
39
|
+
if (!current || typeof current !== 'object') return;
|
|
40
|
+
if (current.type === 'text' || current.type === 'raw') {
|
|
41
|
+
text += String(current.value ?? '');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(current.children)) current.children.forEach(walk);
|
|
45
|
+
};
|
|
46
|
+
walk(node);
|
|
47
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHeadingWrapper(node) {
|
|
51
|
+
const className = node?.properties?.className;
|
|
52
|
+
return (
|
|
53
|
+
node?.type === 'element' &&
|
|
54
|
+
Array.isArray(className) &&
|
|
55
|
+
className.includes('sl-heading-wrapper')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function anchorLink(id, label) {
|
|
60
|
+
return {
|
|
61
|
+
type: 'element',
|
|
62
|
+
tagName: 'a',
|
|
63
|
+
properties: { className: ['sl-anchor-link'], href: `#${id}` },
|
|
64
|
+
children: [
|
|
65
|
+
{
|
|
66
|
+
type: 'element',
|
|
67
|
+
tagName: 'span',
|
|
68
|
+
properties: { ariaHidden: 'true', className: ['sl-anchor-icon'] },
|
|
69
|
+
children: [
|
|
70
|
+
{
|
|
71
|
+
type: 'element',
|
|
72
|
+
tagName: 'svg',
|
|
73
|
+
properties: {
|
|
74
|
+
width: 16,
|
|
75
|
+
height: 16,
|
|
76
|
+
viewBox: '0 0 24 24',
|
|
77
|
+
fill: 'currentColor',
|
|
78
|
+
},
|
|
79
|
+
children: [
|
|
80
|
+
{
|
|
81
|
+
type: 'element',
|
|
82
|
+
tagName: 'path',
|
|
83
|
+
properties: { d: LINK_ICON_PATH },
|
|
84
|
+
children: [],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'element',
|
|
92
|
+
tagName: 'span',
|
|
93
|
+
properties: { className: ['sr-only'], 'data-pagefind-ignore': 'true' },
|
|
94
|
+
children: [{ type: 'text', value: label }],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function rehypeHeadingAnchors() {
|
|
101
|
+
const assignHeadingIds = rehypeHeadingIds();
|
|
102
|
+
|
|
103
|
+
return function transform(tree, file) {
|
|
104
|
+
assignHeadingIds(tree, file);
|
|
105
|
+
|
|
106
|
+
const walk = (node) => {
|
|
107
|
+
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (isHeadingWrapper(node)) return; // already processed
|
|
111
|
+
|
|
112
|
+
for (let index = 0; index < node.children.length; index++) {
|
|
113
|
+
const child = node.children[index];
|
|
114
|
+
const tag =
|
|
115
|
+
child?.type === 'element' && typeof child.tagName === 'string'
|
|
116
|
+
? HEADING_TAG.exec(child.tagName)
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
tag &&
|
|
121
|
+
tag[1] !== '1' &&
|
|
122
|
+
typeof child.properties?.id === 'string' &&
|
|
123
|
+
child.properties.id.length > 0
|
|
124
|
+
) {
|
|
125
|
+
const id = child.properties.id;
|
|
126
|
+
const label = `Section titled “${headingText(child)}”`;
|
|
127
|
+
node.children[index] = {
|
|
128
|
+
type: 'element',
|
|
129
|
+
tagName: 'div',
|
|
130
|
+
properties: {
|
|
131
|
+
className: ['sl-heading-wrapper', `level-${child.tagName}`],
|
|
132
|
+
},
|
|
133
|
+
children: [child, anchorLink(id, label)],
|
|
134
|
+
};
|
|
135
|
+
continue; // the heading is a leaf for our purposes
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
walk(child);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
walk(tree);
|
|
143
|
+
};
|
|
144
|
+
}
|