explico 0.1.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/README.md +108 -0
- package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
- package/dist/analytics/AnalyticsEmitter.js +5 -0
- package/dist/compiler/cli.d.ts +189 -0
- package/dist/compiler/cli.js +552 -0
- package/dist/compiler/collections.d.ts +70 -0
- package/dist/compiler/collections.js +383 -0
- package/dist/compiler/course-config.d.ts +31 -0
- package/dist/compiler/course-config.js +0 -0
- package/dist/compiler/lesson-derivation.d.ts +89 -0
- package/dist/compiler/lesson-derivation.js +91 -0
- package/dist/compiler/manifest.d.ts +178 -0
- package/dist/compiler/manifest.js +345 -0
- package/dist/compiler/resolution-report.d.ts +45 -0
- package/dist/compiler/resolution-report.js +97 -0
- package/dist/curriculum/validation.d.ts +76 -0
- package/dist/curriculum/validation.js +320 -0
- package/dist/progress/ProgressRepository.d.ts +53 -0
- package/dist/progress/ProgressRepository.js +12 -0
- package/dist/reference/base-library.mjs +27 -0
- package/dist/reference/citation-format.mjs +133 -0
- package/dist/reference/consistency.d.ts +64 -0
- package/dist/reference/consistency.js +234 -0
- package/dist/reference/curriculum-alignment.d.ts +7 -0
- package/dist/reference/curriculum-alignment.js +166 -0
- package/dist/reference/equations-validate.d.ts +50 -0
- package/dist/reference/equations-validate.js +90 -0
- package/dist/reference/equations.mjs +144 -0
- package/dist/reference/gate-math.d.ts +41 -0
- package/dist/reference/gate-math.js +151 -0
- package/dist/reference/katex-options.mjs +91 -0
- package/dist/reference/lab-math-scope.d.ts +21 -0
- package/dist/reference/lab-math-scope.js +28 -0
- package/dist/reference/label.d.ts +11 -0
- package/dist/reference/label.js +38 -0
- package/dist/reference/lint-ignore.d.ts +32 -0
- package/dist/reference/lint-ignore.js +39 -0
- package/dist/reference/math-glyphs.mjs +830 -0
- package/dist/reference/prose.d.ts +15 -0
- package/dist/reference/prose.js +12 -0
- package/dist/reference/references.d.ts +17 -0
- package/dist/reference/references.js +42 -0
- package/dist/reference/registry.d.ts +6 -0
- package/dist/reference/registry.js +548 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +753 -0
- package/dist/reference/render-lab-math.d.ts +21 -0
- package/dist/reference/render-lab-math.js +58 -0
- package/dist/reference/types.d.ts +151 -0
- package/dist/reference/types.js +0 -0
- package/dist/reference/units-vocab.d.ts +26 -0
- package/dist/reference/units-vocab.js +119 -0
- package/dist/session/PreferenceStore.d.ts +28 -0
- package/dist/session/PreferenceStore.js +30 -0
- package/dist/session/user-context.d.ts +7 -0
- package/dist/session/user-context.js +10 -0
- package/dist/session/user.d.ts +21 -0
- package/dist/session/user.js +8 -0
- package/package.json +53 -0
- package/src/analytics/AnalyticsEmitter.ts +23 -0
- package/src/compiler/cli.ts +866 -0
- package/src/compiler/collections.ts +554 -0
- package/src/compiler/course-config.ts +32 -0
- package/src/compiler/lesson-derivation.ts +186 -0
- package/src/compiler/manifest.ts +601 -0
- package/src/compiler/resolution-report.ts +150 -0
- package/src/components/CurriculumMap.astro +208 -0
- package/src/components/HoverPanel.astro +67 -0
- package/src/components/assessments/AssessmentRunner.tsx +320 -0
- package/src/components/assessments/AssessmentSet.astro +54 -0
- package/src/components/citation/CitationLayer.astro +173 -0
- package/src/components/examples/CompactExample.astro +34 -0
- package/src/components/examples/CompactExamples.astro +253 -0
- package/src/components/hover-panel.ts +273 -0
- package/src/components/notation/NotationGlossary.astro +412 -0
- package/src/components/notation/NotationLayer.astro +594 -0
- package/src/components/notation/NotationMath.astro +24 -0
- package/src/components/notation/SafeMathNode.astro +23 -0
- package/src/components/notation/render-notation-math.ts +223 -0
- package/src/components/starlight/EquationEnhancer.astro +72 -0
- package/src/components/starlight/LayoutHeader.astro +102 -0
- package/src/components/starlight/LayoutPageSidebar.astro +33 -0
- package/src/components/starlight/LayoutSidebar.astro +29 -0
- package/src/components/starlight/LessonFooter.astro +58 -0
- package/src/content-config.ts +259 -0
- package/src/curriculum/validation.ts +481 -0
- package/src/progress/ProgressRepository.ts +64 -0
- package/src/reference/base-library.mjs +27 -0
- package/src/reference/citation-format.mjs +133 -0
- package/src/reference/consistency.ts +375 -0
- package/src/reference/curriculum-alignment.ts +228 -0
- package/src/reference/equations-validate.ts +167 -0
- package/src/reference/equations.mjs +144 -0
- package/src/reference/gate-math.ts +250 -0
- package/src/reference/katex-options.mjs +91 -0
- package/src/reference/lab-math-scope.ts +54 -0
- package/src/reference/label.ts +47 -0
- package/src/reference/lint-ignore.ts +75 -0
- package/src/reference/math-glyphs.mjs +830 -0
- package/src/reference/prose.ts +21 -0
- package/src/reference/references.ts +82 -0
- package/src/reference/registry.ts +723 -0
- package/src/reference/rehype-fail-katex-errors.mjs +55 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +753 -0
- package/src/reference/render-lab-math.ts +99 -0
- package/src/reference/types.ts +182 -0
- package/src/reference/units-vocab.ts +143 -0
- package/src/session/PreferenceStore.ts +54 -0
- package/src/session/user-context.ts +18 -0
- package/src/session/user.ts +24 -0
- package/src/styles/global.css +763 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remark adapter for semantic notation references.
|
|
3
|
+
*
|
|
4
|
+
* Author syntax:
|
|
5
|
+
* - Prose introduces a meaning: `[[rates.discount-factor]]`
|
|
6
|
+
* - Later math uses ordinary LaTeX: `D(0,t)`
|
|
7
|
+
* - `\\explain{rates.discount-factor}{D(0,t)}` remains available inside math
|
|
8
|
+
* when lexical scope cannot select one meaning unambiguously.
|
|
9
|
+
*
|
|
10
|
+
* Prose references become ordinary links. Lesson math is resolved against the
|
|
11
|
+
* page glyph table — `notation.local` plus the shared entries the page names
|
|
12
|
+
* with `[[key]]` (or `\\explain{key}{…}`) — and the base library; the
|
|
13
|
+
* adapter then injects the narrowly trusted `\\explain` marker before
|
|
14
|
+
* build-time KaTeX rendering. `notation.uses` is retired: the use is the
|
|
15
|
+
* declaration (D1 decision, 2026-09-02).
|
|
16
|
+
*
|
|
17
|
+
* Definitions can be supplied as a Map, an array, an object keyed by the
|
|
18
|
+
* semantic key, or a loader returning one of those forms. Loaders are
|
|
19
|
+
* evaluated for every document transform so a long-running development
|
|
20
|
+
* server can discover definitions created after startup. A resolver can
|
|
21
|
+
* provide page-local lexical scoping:
|
|
22
|
+
*
|
|
23
|
+
* remarkNotation({
|
|
24
|
+
* definitions: sharedDefinitions,
|
|
25
|
+
* resolve(key, { file, kind, localDefinitions, sharedDefinitions }) {
|
|
26
|
+
* return localDefinitions.get(key) ?? sharedDefinitions.get(key);
|
|
27
|
+
* },
|
|
28
|
+
* })
|
|
29
|
+
*
|
|
30
|
+
* A definition may be either its data object or an Astro collection entry
|
|
31
|
+
* with the data object under `.data`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const NOTATION_KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
35
|
+
|
|
36
|
+
import { GlyphResolutionError, resolveMathGlyphs } from './math-glyphs.mjs';
|
|
37
|
+
import {
|
|
38
|
+
EQ_LABEL_PATTERN,
|
|
39
|
+
collectEquationLabels,
|
|
40
|
+
parseEquationRef,
|
|
41
|
+
stripEquationLabels,
|
|
42
|
+
} from './equations.mjs';
|
|
43
|
+
|
|
44
|
+
const PROSE_SKIP_TYPES = new Set([
|
|
45
|
+
'code',
|
|
46
|
+
'definition',
|
|
47
|
+
'html',
|
|
48
|
+
'image',
|
|
49
|
+
'imageReference',
|
|
50
|
+
'inlineCode',
|
|
51
|
+
'link',
|
|
52
|
+
'linkReference',
|
|
53
|
+
'math',
|
|
54
|
+
'mdxFlowExpression',
|
|
55
|
+
'mdxTextExpression',
|
|
56
|
+
'mdxjsEsm',
|
|
57
|
+
'toml',
|
|
58
|
+
'yaml',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const TERM_PATTERN = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
62
|
+
const TERM_OPEN = '[[';
|
|
63
|
+
const EXPLAIN = '\\explain';
|
|
64
|
+
const HTML_DATA = '\\htmlData';
|
|
65
|
+
|
|
66
|
+
function definitionData(value, fallbackKey) {
|
|
67
|
+
const data = value?.data ?? value;
|
|
68
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
69
|
+
|
|
70
|
+
const key = data.key ?? fallbackKey;
|
|
71
|
+
if (typeof key !== 'string') return undefined;
|
|
72
|
+
|
|
73
|
+
// `latex` is the Phase C1b field name; older callers pass `notation`.
|
|
74
|
+
return { ...data, key, notation: data.notation ?? data.latex };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function definitionsMap(input) {
|
|
78
|
+
const definitions = new Map();
|
|
79
|
+
if (!input) return definitions;
|
|
80
|
+
|
|
81
|
+
if (input instanceof Map) {
|
|
82
|
+
for (const [key, value] of input) {
|
|
83
|
+
const definition = definitionData(value, key);
|
|
84
|
+
if (definition) definitions.set(definition.key, definition);
|
|
85
|
+
}
|
|
86
|
+
return definitions;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(input)) {
|
|
90
|
+
for (const value of input) {
|
|
91
|
+
const definition = definitionData(value);
|
|
92
|
+
if (definition) definitions.set(definition.key, definition);
|
|
93
|
+
}
|
|
94
|
+
return definitions;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [key, value] of Object.entries(input)) {
|
|
98
|
+
const definition = definitionData(value, key);
|
|
99
|
+
if (definition) definitions.set(definition.key, definition);
|
|
100
|
+
}
|
|
101
|
+
return definitions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function localDefinitionsFromFile(file) {
|
|
105
|
+
const candidates = [
|
|
106
|
+
file?.data?.astro?.frontmatter?.notation?.local,
|
|
107
|
+
file?.data?.frontmatter?.notation?.local,
|
|
108
|
+
file?.data?.notation?.local,
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
return definitionsMap(candidates.find(Array.isArray));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Registry definition pages (`src/content/notation/*.md`) carry their own
|
|
115
|
+
// worked equations. Their math is resolved against the whole shared registry:
|
|
116
|
+
// an author writes ordinary LaTeX (or an explicit `\explain{key}{latex}`) and
|
|
117
|
+
// every canonical symbol resolves.
|
|
118
|
+
const NOTATION_REGISTRY_PATH = /[\\/]content[\\/]notation[\\/][^\\/]+\.md$/;
|
|
119
|
+
const LESSON_DOC_PATH = /[\\/]content[\\/]docs[\\/](.+?)\.(?:md|mdx)$/;
|
|
120
|
+
|
|
121
|
+
function isNotationRegistryFile(file) {
|
|
122
|
+
const candidates = [file?.path, file?.history?.[0], file?.data?.file];
|
|
123
|
+
return candidates.some(
|
|
124
|
+
(value) => typeof value === 'string' && NOTATION_REGISTRY_PATH.test(value),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Lesson id from the source path (Phase C2 — `lessonId` is no longer authored).
|
|
130
|
+
* Only files in a section directory under `content/docs/` are lessons.
|
|
131
|
+
*/
|
|
132
|
+
function lessonIdFromFile(file) {
|
|
133
|
+
for (const value of [file?.path, file?.history?.[0], file?.data?.file]) {
|
|
134
|
+
if (typeof value !== 'string') continue;
|
|
135
|
+
const match = value.match(LESSON_DOC_PATH);
|
|
136
|
+
if (!match) continue;
|
|
137
|
+
const slug = match[1].replace(/\\/g, '/');
|
|
138
|
+
if (!slug.includes('/') || slug.endsWith('/index')) return undefined;
|
|
139
|
+
return slug.replace(/\//g, '.');
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function lessonIdFrom(file) {
|
|
145
|
+
const frontmatter =
|
|
146
|
+
file?.data?.astro?.frontmatter ?? file?.data?.frontmatter ?? file?.data;
|
|
147
|
+
const lessonId = frontmatter?.lessonId ?? lessonIdFromFile(file);
|
|
148
|
+
return typeof lessonId === 'string' ? lessonId : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const ANY_TERM_KEY = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
152
|
+
const ANY_EXPLAIN_KEY = /\\explain\s*\{([^{}]+)\}/g;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The shared keys a page pulls into scope (D1 decision — `notation.uses` is
|
|
156
|
+
* retired, the declaration is the use): every key it introduces in prose with
|
|
157
|
+
* `[[key]]` and every key it disambiguates in math with `\explain{key}{…}`.
|
|
158
|
+
* Scanning the tree covers both real VFiles and hand-built test trees.
|
|
159
|
+
*/
|
|
160
|
+
function collectPageSharedKeys(tree) {
|
|
161
|
+
const keys = new Set();
|
|
162
|
+
const scan = (node) => {
|
|
163
|
+
if (!node || typeof node !== 'object') return;
|
|
164
|
+
const value = typeof node.value === 'string' ? node.value : '';
|
|
165
|
+
if (value.includes('[[') || value.includes('\\explain')) {
|
|
166
|
+
for (const pattern of [ANY_TERM_KEY, ANY_EXPLAIN_KEY]) {
|
|
167
|
+
pattern.lastIndex = 0;
|
|
168
|
+
let match;
|
|
169
|
+
while ((match = pattern.exec(value))) {
|
|
170
|
+
const key = match[1].trim();
|
|
171
|
+
if (NOTATION_KEY_PATTERN.test(key)) keys.add(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(node.children)) node.children.forEach(scan);
|
|
176
|
+
};
|
|
177
|
+
scan(tree);
|
|
178
|
+
return keys;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pageGlyphScope(localDefinitions, sharedDefinitions, pageSharedKeys) {
|
|
182
|
+
return [
|
|
183
|
+
...localDefinitions.values(),
|
|
184
|
+
// A page-local definition wins; a shared entry the page also names with
|
|
185
|
+
// `[[key]]` does not shadow it or land twice in the resolver's scope.
|
|
186
|
+
...[...pageSharedKeys]
|
|
187
|
+
.filter((key) => !localDefinitions.has(key))
|
|
188
|
+
.map((key) => sharedDefinitions.get(key))
|
|
189
|
+
.filter(Boolean),
|
|
190
|
+
].map((definition) => ({
|
|
191
|
+
key: definition.key,
|
|
192
|
+
notation: definition.notation,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function replaceMathSource(node, latex) {
|
|
197
|
+
node.value = latex;
|
|
198
|
+
|
|
199
|
+
// mdast-util-math snapshots the original source into `data.hChildren`
|
|
200
|
+
// while parsing. mdast-to-hast prefers that snapshot over `node.value`, so
|
|
201
|
+
// both representations must change or rehype-katex will render the stale,
|
|
202
|
+
// unbound source even though this remark node was updated.
|
|
203
|
+
const hastChildren = node?.data?.hChildren;
|
|
204
|
+
if (!Array.isArray(hastChildren)) return;
|
|
205
|
+
|
|
206
|
+
if (node.type === 'inlineMath') {
|
|
207
|
+
node.data.hChildren = [{ type: 'text', value: latex }];
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const code = hastChildren.find(
|
|
212
|
+
(child) =>
|
|
213
|
+
child?.type === 'element' &&
|
|
214
|
+
child.tagName === 'code' &&
|
|
215
|
+
Array.isArray(child.children),
|
|
216
|
+
);
|
|
217
|
+
if (code) code.children = [{ type: 'text', value: latex }];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isEscaped(value, index) {
|
|
221
|
+
let slashes = 0;
|
|
222
|
+
for (
|
|
223
|
+
let cursor = index - 1;
|
|
224
|
+
cursor >= 0 && value[cursor] === '\\';
|
|
225
|
+
cursor--
|
|
226
|
+
) {
|
|
227
|
+
slashes += 1;
|
|
228
|
+
}
|
|
229
|
+
return slashes % 2 === 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function skipWhitespace(value, index) {
|
|
233
|
+
let cursor = index;
|
|
234
|
+
while (/\s/.test(value[cursor] ?? '')) cursor += 1;
|
|
235
|
+
return cursor;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readBracedGroup(value, start) {
|
|
239
|
+
if (value[start] !== '{') return undefined;
|
|
240
|
+
|
|
241
|
+
let depth = 0;
|
|
242
|
+
for (let cursor = start; cursor < value.length; cursor++) {
|
|
243
|
+
const character = value[cursor];
|
|
244
|
+
if (isEscaped(value, cursor)) continue;
|
|
245
|
+
|
|
246
|
+
if (character === '{') depth += 1;
|
|
247
|
+
if (character === '}') {
|
|
248
|
+
depth -= 1;
|
|
249
|
+
if (depth === 0) {
|
|
250
|
+
return {
|
|
251
|
+
content: value.slice(start + 1, cursor),
|
|
252
|
+
end: cursor + 1,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function fail(file, message, node) {
|
|
262
|
+
if (typeof file?.fail === 'function') file.fail(message, node);
|
|
263
|
+
|
|
264
|
+
const location = file?.path ? `${file.path}: ` : '';
|
|
265
|
+
throw new Error(`${location}${message}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function warn(file, message, node) {
|
|
269
|
+
if (typeof file?.message === 'function') {
|
|
270
|
+
file.message(message, node);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
console.warn(message);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `file:line:column` for an unresolved identifier, where `offset` is its
|
|
278
|
+
* position inside the math node's source. Falls back to `file` alone when the
|
|
279
|
+
* node has no parse position (hand-built test trees).
|
|
280
|
+
*/
|
|
281
|
+
function tokenLocation(file, node, value, offset) {
|
|
282
|
+
const path = file?.path ?? 'math';
|
|
283
|
+
const start = node?.position?.start;
|
|
284
|
+
if (!start || typeof start.line !== 'number') return path;
|
|
285
|
+
const before = String(value).slice(0, offset);
|
|
286
|
+
const newlines = before.split('\n').length - 1;
|
|
287
|
+
const line = start.line + newlines;
|
|
288
|
+
const column =
|
|
289
|
+
newlines === 0
|
|
290
|
+
? (start.column ?? 1) + offset
|
|
291
|
+
: before.length - before.lastIndexOf('\n') - 1 + 1;
|
|
292
|
+
return `${path}:${line}:${column}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function firstUnescaped(value, marker, from = 0) {
|
|
296
|
+
let index = value.indexOf(marker, from);
|
|
297
|
+
while (index !== -1 && isEscaped(value, index)) {
|
|
298
|
+
index = value.indexOf(marker, index + marker.length);
|
|
299
|
+
}
|
|
300
|
+
return index;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function defaultHref(definition, key, base = '') {
|
|
304
|
+
if (typeof definition?.href === 'string') return definition.href;
|
|
305
|
+
if (definition?.scope === 'local') return `#notation-${key}`;
|
|
306
|
+
return `${base.replace(/\/$/, '')}/glossary/#notation-${key}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Wrap a keyed `$$…$$` `math` node so the container survives `rehype-katex`
|
|
311
|
+
* (which splices the inner `math-display` element out). The visible number is
|
|
312
|
+
* a focusable `<a>` sibling — never authored, never inside KaTeX — so it
|
|
313
|
+
* survives print and screen readers name it "Equation 2.4".
|
|
314
|
+
*/
|
|
315
|
+
function keyedEquationNode(mathNode, key, number) {
|
|
316
|
+
return {
|
|
317
|
+
type: 'paragraph',
|
|
318
|
+
data: {
|
|
319
|
+
hName: 'div',
|
|
320
|
+
hProperties: {
|
|
321
|
+
id: `eq-${key}`,
|
|
322
|
+
className: ['keyed-equation'],
|
|
323
|
+
'data-eq-key': key,
|
|
324
|
+
'data-eq-number': number,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
children: [
|
|
328
|
+
mathNode,
|
|
329
|
+
{
|
|
330
|
+
type: 'link',
|
|
331
|
+
url: `#eq-${key}`,
|
|
332
|
+
children: [{ type: 'text', value: `(${number})` }],
|
|
333
|
+
data: {
|
|
334
|
+
hProperties: {
|
|
335
|
+
className: ['keyed-equation__number'],
|
|
336
|
+
'data-eq-number': number,
|
|
337
|
+
'aria-label': `Equation ${number}`,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
|
|
346
|
+
function resolveEquationRef(eqRef, eqCtx, file, node) {
|
|
347
|
+
if (eqRef.lesson) {
|
|
348
|
+
const number = eqCtx.crossPage?.[eqRef.lesson]?.[eqRef.key];
|
|
349
|
+
if (number === undefined) {
|
|
350
|
+
fail(
|
|
351
|
+
file,
|
|
352
|
+
`Unknown equation reference [[${eqRef.lesson}#eq-${eqRef.key}]]: lesson ${eqRef.lesson} has no display equation labelled eq:${eqRef.key}.`,
|
|
353
|
+
node,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
number,
|
|
358
|
+
href: `${eqCtx.base.replace(/\/$/, '')}/${eqRef.lesson}/#eq-${eqRef.key}`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const number = eqCtx.pageLabels.get(eqRef.key);
|
|
362
|
+
if (number === undefined) {
|
|
363
|
+
fail(
|
|
364
|
+
file,
|
|
365
|
+
`Unknown equation reference [[eq-${eqRef.key}]]: no display equation on this page carries \\label{eq:${eqRef.key}}.`,
|
|
366
|
+
node,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return { number, href: `#eq-${eqRef.key}` };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function proseNodes(value, resolve, file, node, references, eqCtx) {
|
|
373
|
+
const base = eqCtx?.base ?? '';
|
|
374
|
+
const strayExplain = firstUnescaped(value, EXPLAIN);
|
|
375
|
+
if (strayExplain !== -1) {
|
|
376
|
+
fail(
|
|
377
|
+
file,
|
|
378
|
+
`${EXPLAIN}{key}{latex} is only supported inside $...$ or $$...$$ math.`,
|
|
379
|
+
node,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const output = [];
|
|
384
|
+
let cursor = 0;
|
|
385
|
+
let match;
|
|
386
|
+
TERM_PATTERN.lastIndex = 0;
|
|
387
|
+
|
|
388
|
+
while ((match = TERM_PATTERN.exec(value))) {
|
|
389
|
+
if (isEscaped(value, match.index)) continue;
|
|
390
|
+
|
|
391
|
+
const key = match[1].trim();
|
|
392
|
+
const eqRef = eqCtx ? parseEquationRef(key) : undefined;
|
|
393
|
+
if (!eqRef && !NOTATION_KEY_PATTERN.test(key)) {
|
|
394
|
+
fail(file, `Invalid notation key "${key}" in [[...]].`, node);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (match.index > cursor) {
|
|
398
|
+
output.push({ type: 'text', value: value.slice(cursor, match.index) });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (eqRef) {
|
|
402
|
+
const { number, href } = resolveEquationRef(eqRef, eqCtx, file, node);
|
|
403
|
+
output.push({
|
|
404
|
+
type: 'link',
|
|
405
|
+
url: href,
|
|
406
|
+
children: [{ type: 'text', value: `(${number})` }],
|
|
407
|
+
data: {
|
|
408
|
+
hProperties: {
|
|
409
|
+
className: ['equation-ref'],
|
|
410
|
+
'data-eq-ref': eqRef.key,
|
|
411
|
+
...(eqRef.lesson ? { 'data-eq-lesson': eqRef.lesson } : {}),
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
references.push({
|
|
416
|
+
key: eqRef.key,
|
|
417
|
+
kind: 'equation',
|
|
418
|
+
...(eqRef.lesson ? { lesson: eqRef.lesson } : {}),
|
|
419
|
+
});
|
|
420
|
+
cursor = match.index + match[0].length;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const definition = resolve(key, 'prose', node);
|
|
425
|
+
const title = definition?.title ?? key;
|
|
426
|
+
const summary = definition?.summary;
|
|
427
|
+
const href = defaultHref(definition, key, base);
|
|
428
|
+
|
|
429
|
+
const properties = {
|
|
430
|
+
className: ['notation-term'],
|
|
431
|
+
'data-notation-key': key,
|
|
432
|
+
'data-notation-trigger': '',
|
|
433
|
+
};
|
|
434
|
+
if (typeof summary === 'string' && summary.length > 0) {
|
|
435
|
+
properties['data-notation-summary'] = summary;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
output.push({
|
|
439
|
+
type: 'link',
|
|
440
|
+
url: href,
|
|
441
|
+
title: typeof summary === 'string' ? summary : undefined,
|
|
442
|
+
children: [{ type: 'text', value: title }],
|
|
443
|
+
data: { hProperties: properties },
|
|
444
|
+
});
|
|
445
|
+
references.push({ key, kind: 'prose' });
|
|
446
|
+
cursor = match.index + match[0].length;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (cursor === 0) {
|
|
450
|
+
const malformed = firstUnescaped(value, TERM_OPEN);
|
|
451
|
+
if (malformed !== -1) {
|
|
452
|
+
fail(
|
|
453
|
+
file,
|
|
454
|
+
`Malformed notation reference near "${value.slice(malformed, malformed + 40)}". Expected [[semantic.key]].`,
|
|
455
|
+
node,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (cursor < value.length) {
|
|
462
|
+
output.push({ type: 'text', value: value.slice(cursor) });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const remainder = output
|
|
466
|
+
.filter((child) => child.type === 'text')
|
|
467
|
+
.map((child) => child.value)
|
|
468
|
+
.join('');
|
|
469
|
+
const malformed = firstUnescaped(remainder, TERM_OPEN);
|
|
470
|
+
if (malformed !== -1) {
|
|
471
|
+
fail(
|
|
472
|
+
file,
|
|
473
|
+
`Malformed notation reference. Expected [[semantic.key]].`,
|
|
474
|
+
node,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return output;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function inspectExplainCalls(value, resolve, file, node, references) {
|
|
482
|
+
let htmlDataStart = firstUnescaped(value, HTML_DATA);
|
|
483
|
+
while (htmlDataStart !== -1) {
|
|
484
|
+
const next = value[htmlDataStart + HTML_DATA.length];
|
|
485
|
+
if (!next || !/[A-Za-z]/.test(next)) {
|
|
486
|
+
fail(
|
|
487
|
+
file,
|
|
488
|
+
`Direct ${HTML_DATA} commands are not allowed; use ${EXPLAIN}{semantic.key}{latex} so notation markers are validated.`,
|
|
489
|
+
node,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
htmlDataStart = firstUnescaped(
|
|
493
|
+
value,
|
|
494
|
+
HTML_DATA,
|
|
495
|
+
htmlDataStart + HTML_DATA.length,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let cursor = 0;
|
|
500
|
+
|
|
501
|
+
while (cursor < value.length) {
|
|
502
|
+
const start = firstUnescaped(value, EXPLAIN, cursor);
|
|
503
|
+
if (start === -1) return;
|
|
504
|
+
|
|
505
|
+
const afterName = start + EXPLAIN.length;
|
|
506
|
+
const next = value[afterName];
|
|
507
|
+
if (next && /[A-Za-z]/.test(next)) {
|
|
508
|
+
cursor = afterName;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (next === '[') {
|
|
512
|
+
fail(
|
|
513
|
+
file,
|
|
514
|
+
'Inline notation definitions such as \\explain[def] are not supported; define the entry in lesson frontmatter or the notation collection.',
|
|
515
|
+
node,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const keyStart = skipWhitespace(value, afterName);
|
|
520
|
+
const keyGroup = readBracedGroup(value, keyStart);
|
|
521
|
+
if (!keyGroup) {
|
|
522
|
+
fail(file, `Malformed ${EXPLAIN}: missing {semantic.key}.`, node);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const key = keyGroup.content.trim();
|
|
526
|
+
if (!NOTATION_KEY_PATTERN.test(key)) {
|
|
527
|
+
fail(file, `Invalid notation key "${key}" in ${EXPLAIN}.`, node);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const latexStart = skipWhitespace(value, keyGroup.end);
|
|
531
|
+
const latexGroup = readBracedGroup(value, latexStart);
|
|
532
|
+
if (!latexGroup) {
|
|
533
|
+
fail(file, `Malformed ${EXPLAIN}{${key}}: missing {latex}.`, node);
|
|
534
|
+
}
|
|
535
|
+
if (latexGroup.content.trim().length === 0) {
|
|
536
|
+
fail(file, `Empty LaTeX body in ${EXPLAIN}{${key}}.`, node);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
resolve(key, 'math', node);
|
|
540
|
+
references.push({ key, kind: 'math' });
|
|
541
|
+
|
|
542
|
+
// Nested annotations are legal and retain their own semantic target.
|
|
543
|
+
inspectExplainCalls(latexGroup.content, resolve, file, node, references);
|
|
544
|
+
cursor = latexGroup.end;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* @param {{
|
|
550
|
+
* definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
|
|
551
|
+
* resolve?: (key: string, context: object) => object | undefined,
|
|
552
|
+
* unknown?: 'error' | 'warn' | 'ignore',
|
|
553
|
+
* base?: string,
|
|
554
|
+
* equations?: Record<string, Record<string, string>>,
|
|
555
|
+
* }} [options]
|
|
556
|
+
*/
|
|
557
|
+
export default function remarkNotation(options = {}) {
|
|
558
|
+
const unknown = options.unknown ?? 'error';
|
|
559
|
+
|
|
560
|
+
return function transform(tree, file) {
|
|
561
|
+
const suppliedDefinitions =
|
|
562
|
+
typeof options.definitions === 'function'
|
|
563
|
+
? options.definitions()
|
|
564
|
+
: options.definitions;
|
|
565
|
+
const sharedDefinitions = definitionsMap(suppliedDefinitions);
|
|
566
|
+
const registryMathDefinitions = [...sharedDefinitions.values()].map(
|
|
567
|
+
(definition) => ({ key: definition.key, notation: definition.notation }),
|
|
568
|
+
);
|
|
569
|
+
const localDefinitions = localDefinitionsFromFile(file);
|
|
570
|
+
const lessonId = lessonIdFrom(file);
|
|
571
|
+
const registryFile = isNotationRegistryFile(file);
|
|
572
|
+
const mathScope = pageGlyphScope(
|
|
573
|
+
localDefinitions,
|
|
574
|
+
sharedDefinitions,
|
|
575
|
+
collectPageSharedKeys(tree),
|
|
576
|
+
);
|
|
577
|
+
const references = [];
|
|
578
|
+
const resolved = new Map();
|
|
579
|
+
|
|
580
|
+
// Equation identity (D7): assign every `\label{eq:…}` its section-scoped
|
|
581
|
+
// number before any math node is rewritten, so a `[[eq-key]]` earlier in
|
|
582
|
+
// the page still resolves.
|
|
583
|
+
const equationLabels = collectEquationLabels(tree);
|
|
584
|
+
const pageLabels = new Map();
|
|
585
|
+
for (const label of equationLabels) {
|
|
586
|
+
if (pageLabels.has(label.key)) {
|
|
587
|
+
fail(
|
|
588
|
+
file,
|
|
589
|
+
`Duplicate equation label eq:${label.key} on this page; an eq: key is unique per lesson.`,
|
|
590
|
+
label.node,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
pageLabels.set(label.key, label.number);
|
|
594
|
+
}
|
|
595
|
+
const eqCtx = {
|
|
596
|
+
base: options.base ?? '',
|
|
597
|
+
pageLabels,
|
|
598
|
+
crossPage: options.equations ?? {},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const resolve = (key, kind, node) => {
|
|
602
|
+
const context = {
|
|
603
|
+
file,
|
|
604
|
+
kind,
|
|
605
|
+
localDefinitions,
|
|
606
|
+
sharedDefinitions,
|
|
607
|
+
};
|
|
608
|
+
const rawResult = definitionData(
|
|
609
|
+
options.resolve?.(key, context) ??
|
|
610
|
+
localDefinitions.get(key) ??
|
|
611
|
+
sharedDefinitions.get(key),
|
|
612
|
+
key,
|
|
613
|
+
);
|
|
614
|
+
const result = rawResult
|
|
615
|
+
? {
|
|
616
|
+
...rawResult,
|
|
617
|
+
scope:
|
|
618
|
+
rawResult.scope ??
|
|
619
|
+
(localDefinitions.has(key) ? 'local' : 'shared'),
|
|
620
|
+
}
|
|
621
|
+
: undefined;
|
|
622
|
+
|
|
623
|
+
if (!result) {
|
|
624
|
+
const message = `Unknown notation key "${key}" in ${kind}.`;
|
|
625
|
+
if (unknown === 'error') fail(file, message, node);
|
|
626
|
+
if (unknown === 'warn') warn(file, message, node);
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
resolved.set(key, result);
|
|
631
|
+
return result;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const visit = (node, skipped = false) => {
|
|
635
|
+
if (!node || typeof node !== 'object') return;
|
|
636
|
+
|
|
637
|
+
if (node.type === 'math' || node.type === 'inlineMath') {
|
|
638
|
+
let value = String(node.value ?? '');
|
|
639
|
+
|
|
640
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
641
|
+
const labelMatch = EQ_LABEL_PATTERN.exec(value);
|
|
642
|
+
if (labelMatch) {
|
|
643
|
+
if (node.type === 'inlineMath') {
|
|
644
|
+
fail(
|
|
645
|
+
file,
|
|
646
|
+
`\\label{eq:${labelMatch[1]}} is only valid in a $$…$$ display equation, not inline $…$.`,
|
|
647
|
+
node,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
node.__eqKey = labelMatch[1];
|
|
651
|
+
value = stripEquationLabels(value);
|
|
652
|
+
replaceMathSource(node, value);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
inspectExplainCalls(value, resolve, file, node, references);
|
|
656
|
+
|
|
657
|
+
const bindingScope = lessonId
|
|
658
|
+
? mathScope
|
|
659
|
+
: registryFile
|
|
660
|
+
? registryMathDefinitions
|
|
661
|
+
: undefined;
|
|
662
|
+
|
|
663
|
+
if (bindingScope) {
|
|
664
|
+
const context = lessonId ? 'lesson math' : 'notation definition math';
|
|
665
|
+
|
|
666
|
+
let result;
|
|
667
|
+
try {
|
|
668
|
+
result = resolveMathGlyphs(value, bindingScope);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
const message =
|
|
671
|
+
error instanceof GlyphResolutionError
|
|
672
|
+
? error.message
|
|
673
|
+
: `Could not resolve ${context}: ${
|
|
674
|
+
error instanceof Error ? error.message : String(error)
|
|
675
|
+
}`;
|
|
676
|
+
fail(file, message, node);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (result.unresolved.length > 0) {
|
|
680
|
+
const unresolved = result.unresolved
|
|
681
|
+
.map(
|
|
682
|
+
({ token, start }) =>
|
|
683
|
+
`${tokenLocation(file, node, value, start)}: "${token}"`,
|
|
684
|
+
)
|
|
685
|
+
.join(', ');
|
|
686
|
+
fail(
|
|
687
|
+
file,
|
|
688
|
+
`Unresolved notation in ${context}: ${unresolved}. Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.`,
|
|
689
|
+
node,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
for (const binding of result.bindings) {
|
|
694
|
+
if (binding.level === 'explicit') continue;
|
|
695
|
+
resolve(binding.key, 'math', node);
|
|
696
|
+
references.push({ key: binding.key, kind: 'math' });
|
|
697
|
+
}
|
|
698
|
+
replaceMathSource(node, result.latex);
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// MDX component attributes and expressions are not children in the
|
|
704
|
+
// Markdown AST. The component's child Markdown is still lesson content
|
|
705
|
+
// and must pass through notation resolution; otherwise wrapping an
|
|
706
|
+
// equation in a presentation component silently disables completeness.
|
|
707
|
+
const skipChildren = skipped || PROSE_SKIP_TYPES.has(node.type);
|
|
708
|
+
if (skipChildren || !Array.isArray(node.children)) return;
|
|
709
|
+
|
|
710
|
+
for (let index = 0; index < node.children.length; index++) {
|
|
711
|
+
const child = node.children[index];
|
|
712
|
+
if (child?.type === 'text') {
|
|
713
|
+
const replacement = proseNodes(
|
|
714
|
+
String(child.value ?? ''),
|
|
715
|
+
resolve,
|
|
716
|
+
file,
|
|
717
|
+
child,
|
|
718
|
+
references,
|
|
719
|
+
eqCtx,
|
|
720
|
+
);
|
|
721
|
+
if (replacement) {
|
|
722
|
+
node.children.splice(index, 1, ...replacement);
|
|
723
|
+
index += replacement.length - 1;
|
|
724
|
+
}
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
visit(child, false);
|
|
728
|
+
if (child && child.__eqKey) {
|
|
729
|
+
const key = child.__eqKey;
|
|
730
|
+
delete child.__eqKey;
|
|
731
|
+
node.children[index] = keyedEquationNode(
|
|
732
|
+
child,
|
|
733
|
+
key,
|
|
734
|
+
pageLabels.get(key),
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
visit(tree);
|
|
741
|
+
|
|
742
|
+
file.data.notationReferences = references;
|
|
743
|
+
file.data.notationResolved = Object.fromEntries(resolved);
|
|
744
|
+
file.data.equationLabels = equationLabels.map(
|
|
745
|
+
({ key, number, section, indexInSection }) => ({
|
|
746
|
+
key,
|
|
747
|
+
number,
|
|
748
|
+
section,
|
|
749
|
+
indexInSection,
|
|
750
|
+
}),
|
|
751
|
+
);
|
|
752
|
+
};
|
|
753
|
+
}
|