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
|
@@ -53,6 +53,11 @@ export interface EquationLessonInput {
|
|
|
53
53
|
readonly slug: string;
|
|
54
54
|
readonly file: string;
|
|
55
55
|
readonly body: string;
|
|
56
|
+
/**
|
|
57
|
+
* The lesson's 1-based ordinal in the course. When set, equations are
|
|
58
|
+
* numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
|
|
59
|
+
*/
|
|
60
|
+
readonly chapter?: number;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
@@ -90,7 +95,7 @@ export function validateEquations(
|
|
|
90
95
|
)) {
|
|
91
96
|
const seen = new Set<string>();
|
|
92
97
|
const table: Record<string, string> = {};
|
|
93
|
-
for (const raw of scanEquationLabels(lesson.body) as {
|
|
98
|
+
for (const raw of scanEquationLabels(lesson.body, lesson.chapter) as {
|
|
94
99
|
key: string;
|
|
95
100
|
number: string;
|
|
96
101
|
section: number;
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Equation identity (Step D7) — shared, framework-free helpers.
|
|
2
|
+
* Equation identity (Step D7, extended F3) — shared, framework-free helpers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
|
|
5
|
+
* from appearance order. When the caller supplies a `chapter` (the lesson's
|
|
6
|
+
* ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
|
|
7
|
+
* every display equation in the lesson and `##` headings do not affect it
|
|
8
|
+
* (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
|
|
9
|
+
* the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
|
|
10
|
+
* (used by hand-built test trees and non-lesson pages). An author additionally
|
|
11
|
+
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
12
|
+
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
13
|
+
* they want a *stable* anchor that survives edits and cross-page references:
|
|
12
14
|
*
|
|
13
15
|
* [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
|
|
14
16
|
* [[foundations/present-value#eq-pv]] cross page → number from the manifest
|
|
15
17
|
*
|
|
18
|
+
* An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
|
|
19
|
+
* deep-linked, but only a keyed equation is referenceable by name.
|
|
20
|
+
*
|
|
16
21
|
* `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
|
|
17
22
|
* MUST agree — `tests/notation/equations.test.ts` checks that on the real
|
|
18
23
|
* corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
|
|
19
|
-
* numbering algorithm.
|
|
24
|
+
* numbering algorithm. `collectDisplayEquations` is the superset the render
|
|
25
|
+
* path uses to number and anchor every display equation, keyed or not.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
/** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
|
|
@@ -51,25 +57,68 @@ export function stripEquationLabels(latex) {
|
|
|
51
57
|
.trim();
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
/** The visible number for the `index`-th
|
|
60
|
+
/** The visible number for the `index`-th display equation under `section`. */
|
|
55
61
|
function equationNumber(section, index) {
|
|
56
62
|
return section > 0 ? `${section}.${index}` : String(index);
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The visible number for one display equation. With `chapter` (the lesson's
|
|
67
|
+
* course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
|
|
68
|
+
* are ignored; without it, the legacy `##`-section scheme.
|
|
69
|
+
*/
|
|
70
|
+
export function displayEquationNumber({
|
|
71
|
+
chapter,
|
|
72
|
+
section,
|
|
73
|
+
indexInSection,
|
|
74
|
+
indexInLesson,
|
|
75
|
+
}) {
|
|
76
|
+
return chapter == null
|
|
77
|
+
? equationNumber(section, indexInSection)
|
|
78
|
+
: `${chapter}.${indexInLesson}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
82
|
+
export function equationAnchorId({ key, number }) {
|
|
83
|
+
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function stripFencedCode(markdown) {
|
|
60
87
|
return String(markdown)
|
|
61
88
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
62
89
|
.replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
63
90
|
.replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
|
|
64
91
|
}
|
|
65
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
|
|
95
|
+
* so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
|
|
96
|
+
* (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
|
|
97
|
+
* outermost match wins, so nested components are blanked in one pass;
|
|
98
|
+
* newlines are kept so line/section structure is unchanged.
|
|
99
|
+
*
|
|
100
|
+
* `keep`, when given, leaves a component of that name untouched instead of
|
|
101
|
+
* blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
|
|
102
|
+
* component while still finding `<Table>`/`<Figure>` tags at the top level.
|
|
103
|
+
*/
|
|
104
|
+
export function stripJsxComponents(markdown, keep) {
|
|
105
|
+
return String(markdown).replace(
|
|
106
|
+
/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
|
|
107
|
+
(block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
/**
|
|
67
112
|
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
68
|
-
* its assigned number. `$$…$$` blocks only
|
|
69
|
-
* the
|
|
113
|
+
* its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
|
|
114
|
+
* course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
|
|
115
|
+
* display block in the lesson; without it, `##` headings advance a section
|
|
116
|
+
* counter and the number is `‹section›.‹indexInSection›`. Either way every
|
|
117
|
+
* display block advances the index, so a keyed equation's number reflects its
|
|
118
|
+
* position among *all* display equations, not only the keyed ones.
|
|
70
119
|
*/
|
|
71
|
-
export function scanEquationLabels(markdown) {
|
|
72
|
-
const text = stripFencedCode(markdown);
|
|
120
|
+
export function scanEquationLabels(markdown, chapter) {
|
|
121
|
+
const text = stripJsxComponents(stripFencedCode(markdown));
|
|
73
122
|
const tokens = [];
|
|
74
123
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
75
124
|
const display = /\$\$([\s\S]+?)\$\$/g;
|
|
@@ -79,15 +128,18 @@ export function scanEquationLabels(markdown) {
|
|
|
79
128
|
for (const match of text.matchAll(display)) {
|
|
80
129
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
81
130
|
const label = EQ_LABEL_PATTERN.exec(match[1]);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
131
|
+
tokens.push({
|
|
132
|
+
kind: 'equation',
|
|
133
|
+
index: match.index,
|
|
134
|
+
key: label ? label[1] : null,
|
|
135
|
+
});
|
|
85
136
|
}
|
|
86
137
|
tokens.sort((a, b) => a.index - b.index);
|
|
87
138
|
|
|
88
139
|
const labels = [];
|
|
89
140
|
let section = 0;
|
|
90
141
|
let indexInSection = 0;
|
|
142
|
+
let indexInLesson = 0;
|
|
91
143
|
for (const token of tokens) {
|
|
92
144
|
if (token.kind === 'section') {
|
|
93
145
|
section += 1;
|
|
@@ -95,9 +147,16 @@ export function scanEquationLabels(markdown) {
|
|
|
95
147
|
continue;
|
|
96
148
|
}
|
|
97
149
|
indexInSection += 1;
|
|
150
|
+
indexInLesson += 1;
|
|
151
|
+
if (token.key == null) continue;
|
|
98
152
|
labels.push({
|
|
99
153
|
key: token.key,
|
|
100
|
-
number:
|
|
154
|
+
number: displayEquationNumber({
|
|
155
|
+
chapter,
|
|
156
|
+
section,
|
|
157
|
+
indexInSection,
|
|
158
|
+
indexInLesson,
|
|
159
|
+
}),
|
|
101
160
|
section,
|
|
102
161
|
indexInSection,
|
|
103
162
|
});
|
|
@@ -106,39 +165,57 @@ export function scanEquationLabels(markdown) {
|
|
|
106
165
|
}
|
|
107
166
|
|
|
108
167
|
/**
|
|
109
|
-
* The mdast equivalent of `scanEquationLabels
|
|
110
|
-
* order, advancing the section counter on every
|
|
111
|
-
*
|
|
168
|
+
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
169
|
+
* walk the tree in document order, advancing the section counter on every
|
|
170
|
+
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
171
|
+
* `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
|
|
172
|
+
* same way as in `scanEquationLabels`.
|
|
112
173
|
*/
|
|
113
|
-
export function
|
|
114
|
-
const
|
|
174
|
+
export function collectDisplayEquations(tree, chapter) {
|
|
175
|
+
const equations = [];
|
|
115
176
|
let section = 0;
|
|
116
177
|
let indexInSection = 0;
|
|
178
|
+
let indexInLesson = 0;
|
|
117
179
|
|
|
118
|
-
|
|
119
|
-
|
|
180
|
+
// Only top-level blocks: a `$$…$$` nested in an MDX component (a
|
|
181
|
+
// `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
|
|
182
|
+
// of the lesson's equation sequence, and its container may be collapsed.
|
|
183
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
184
|
+
for (const node of children) {
|
|
185
|
+
if (!node || typeof node !== 'object') continue;
|
|
120
186
|
if (node.type === 'heading' && node.depth === 2) {
|
|
121
187
|
section += 1;
|
|
122
188
|
indexInSection = 0;
|
|
123
|
-
|
|
189
|
+
continue;
|
|
124
190
|
}
|
|
125
191
|
if (node.type === 'math') {
|
|
192
|
+
indexInSection += 1;
|
|
193
|
+
indexInLesson += 1;
|
|
126
194
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
127
195
|
const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
number: equationNumber(section, indexInSection),
|
|
196
|
+
equations.push({
|
|
197
|
+
key: label ? label[1] : null,
|
|
198
|
+
number: displayEquationNumber({
|
|
199
|
+
chapter,
|
|
133
200
|
section,
|
|
134
201
|
indexInSection,
|
|
135
|
-
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
|
|
202
|
+
indexInLesson,
|
|
203
|
+
}),
|
|
204
|
+
section,
|
|
205
|
+
indexInSection,
|
|
206
|
+
node,
|
|
207
|
+
});
|
|
139
208
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
209
|
+
}
|
|
210
|
+
return equations;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
215
|
+
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
216
|
+
*/
|
|
217
|
+
export function collectEquationLabels(tree, chapter) {
|
|
218
|
+
return collectDisplayEquations(tree, chapter).filter(
|
|
219
|
+
(equation) => equation.key != null,
|
|
220
|
+
);
|
|
144
221
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `validateKeyedFloats` for `<Figure>` — see `keyed-float-validate.ts`. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
validateKeyedFloats,
|
|
5
|
+
type KeyedFloatLabelRecord,
|
|
6
|
+
type KeyedFloatLessonInput,
|
|
7
|
+
type KeyedFloatValidation,
|
|
8
|
+
} from './keyed-float-validate';
|
|
9
|
+
|
|
10
|
+
export type FigureDiagnosticCode =
|
|
11
|
+
| 'fig-invalid-key'
|
|
12
|
+
| 'fig-duplicate-key'
|
|
13
|
+
| 'fig-ref-resolves'
|
|
14
|
+
| 'fig-key-unused';
|
|
15
|
+
|
|
16
|
+
export interface FigureDiagnostic {
|
|
17
|
+
readonly code: FigureDiagnosticCode;
|
|
18
|
+
readonly severity: 'error' | 'warning';
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
readonly lessonId?: string;
|
|
22
|
+
readonly key?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FigureLabelRecord = KeyedFloatLabelRecord;
|
|
26
|
+
export type FigureLessonInput = KeyedFloatLessonInput;
|
|
27
|
+
|
|
28
|
+
export interface FigureValidation extends KeyedFloatValidation {
|
|
29
|
+
readonly diagnostics: readonly FigureDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateFigures(
|
|
33
|
+
lessons: readonly FigureLessonInput[],
|
|
34
|
+
): FigureValidation {
|
|
35
|
+
return validateKeyedFloats(lessons, {
|
|
36
|
+
tagName: 'Figure',
|
|
37
|
+
prefix: 'fig',
|
|
38
|
+
noun: 'figure',
|
|
39
|
+
}) as FigureValidation;
|
|
40
|
+
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
|
|
13
13
|
import { stripEquationLabels } from './equations.mjs';
|
|
14
|
+
import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
|
|
15
|
+
import type { GlyphScopeEntry } from './gloss';
|
|
14
16
|
import type {
|
|
15
17
|
NotationLessonInput,
|
|
16
18
|
NotationRegistryInput,
|
|
@@ -21,10 +23,11 @@ export interface MathGateDiagnostic {
|
|
|
21
23
|
readonly context: 'body' | 'formula' | 'assessment';
|
|
22
24
|
/**
|
|
23
25
|
* Body / component-slot math blocks the build (the render path needs every
|
|
24
|
-
* glyph resolved for the KaTeX trust callback). `formula`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
27
|
+
* a formula's glyphs are now rendered live and explorable, so an unresolved
|
|
28
|
+
* one is a hole the reader can see, and the corpus was brought to zero when
|
|
29
|
+
* the tier was promoted. `assessment` math stays a Tier-3 warning until its
|
|
30
|
+
* own reviewed content pass (D6/D13/D14).
|
|
28
31
|
*/
|
|
29
32
|
readonly severity: 'error' | 'warning';
|
|
30
33
|
readonly file: string;
|
|
@@ -35,7 +38,7 @@ export interface MathGateDiagnostic {
|
|
|
35
38
|
|
|
36
39
|
const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
|
|
37
40
|
body: 'error',
|
|
38
|
-
formula: '
|
|
41
|
+
formula: 'error',
|
|
39
42
|
assessment: 'warning',
|
|
40
43
|
};
|
|
41
44
|
|
|
@@ -157,6 +160,51 @@ function resolveInto(
|
|
|
157
160
|
|
|
158
161
|
const BODY_FIX =
|
|
159
162
|
'Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.';
|
|
163
|
+
function dedupeScope(scope: readonly GlyphScopeEntry[]): GlyphScopeEntry[] {
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
const out: GlyphScopeEntry[] = [];
|
|
166
|
+
for (const entry of scope) {
|
|
167
|
+
if (seen.has(entry.key)) continue;
|
|
168
|
+
seen.add(entry.key);
|
|
169
|
+
out.push(entry);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `\explain{key}` in a formula widens the glyph scope without becoming a
|
|
176
|
+
* reference edge (D15), so nothing else checks that the key exists. Report it
|
|
177
|
+
* here: a marker pointing at no card is an authoring mistake, not a silent
|
|
178
|
+
* fall-through to an unresolved glyph.
|
|
179
|
+
*/
|
|
180
|
+
function reportUnknownScopeKeys(
|
|
181
|
+
definition: {
|
|
182
|
+
readonly key: string;
|
|
183
|
+
readonly formula?: string;
|
|
184
|
+
readonly glosses: readonly { readonly key: string }[];
|
|
185
|
+
},
|
|
186
|
+
cardKeys: ReadonlySet<string>,
|
|
187
|
+
file: string,
|
|
188
|
+
out: MathGateDiagnostic[],
|
|
189
|
+
): void {
|
|
190
|
+
const glossKeys = new Set(definition.glosses.map((gloss) => gloss.key));
|
|
191
|
+
for (const key of unknownFormulaScopeKeys(
|
|
192
|
+
definition.formula,
|
|
193
|
+
glossKeys,
|
|
194
|
+
cardKeys,
|
|
195
|
+
)) {
|
|
196
|
+
out.push({
|
|
197
|
+
context: 'formula',
|
|
198
|
+
severity: SEVERITY.formula,
|
|
199
|
+
file,
|
|
200
|
+
token: key,
|
|
201
|
+
message: `${definition.key} formula names unknown notation key ${JSON.stringify(key)}. Name an existing card, or declare the symbol under glosses: on this entry.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SHARED_FORMULA_FIX =
|
|
207
|
+
'Every symbol in a notation entry formula must resolve to the entry itself, one of its glosses:, a card the formula names with \\explain{key}{latex}, or the base library.';
|
|
160
208
|
const FORMULA_FIX =
|
|
161
209
|
'Every right-hand-side variable of a notation.local formula must resolve to the entry key, another page symbol, or the base library.';
|
|
162
210
|
const ASSESSMENT_FIX =
|
|
@@ -178,6 +226,18 @@ export function gateContentMath(input: {
|
|
|
178
226
|
definition,
|
|
179
227
|
]),
|
|
180
228
|
);
|
|
229
|
+
const sharedCards = new Map<string, GlyphScopeEntry>(
|
|
230
|
+
notationInput.sharedDefinitions.map((definition) => [
|
|
231
|
+
definition.key,
|
|
232
|
+
{ key: definition.key, notation: definition.notation },
|
|
233
|
+
]),
|
|
234
|
+
);
|
|
235
|
+
const cardKeys = new Set<string>([
|
|
236
|
+
...sharedCards.keys(),
|
|
237
|
+
...notationInput.lessons.flatMap((lesson) =>
|
|
238
|
+
lesson.localDefinitions.map((definition) => definition.key),
|
|
239
|
+
),
|
|
240
|
+
]);
|
|
181
241
|
const lessonById = new Map(
|
|
182
242
|
notationInput.lessons.map((lesson) => [lesson.lessonId, lesson]),
|
|
183
243
|
);
|
|
@@ -206,19 +266,56 @@ export function gateContentMath(input: {
|
|
|
206
266
|
for (const definition of lesson.localDefinitions) {
|
|
207
267
|
if (!definition.formula) continue;
|
|
208
268
|
// `scope` already carries every page-local entry, including this one, so
|
|
209
|
-
// the formula's LHS key resolves against itself
|
|
269
|
+
// the formula's LHS key resolves against itself; its glosses (D15) name
|
|
270
|
+
// the letters the formula introduces and nothing else on the page.
|
|
210
271
|
resolveInto(
|
|
211
272
|
definition.formula,
|
|
212
|
-
|
|
273
|
+
dedupeScope([
|
|
274
|
+
...scope,
|
|
275
|
+
...definition.glosses.map((gloss) => ({
|
|
276
|
+
key: gloss.key,
|
|
277
|
+
notation: gloss.notation,
|
|
278
|
+
})),
|
|
279
|
+
]),
|
|
213
280
|
'formula',
|
|
214
281
|
file,
|
|
215
282
|
undefined,
|
|
216
283
|
FORMULA_FIX,
|
|
217
284
|
diagnostics,
|
|
218
285
|
);
|
|
286
|
+
reportUnknownScopeKeys(definition, cardKeys, file, diagnostics);
|
|
219
287
|
}
|
|
220
288
|
}
|
|
221
289
|
|
|
290
|
+
// Shared cards carry their own rigorous `formula`. It resolves against the
|
|
291
|
+
// entry-scoped table (D15) — the card, its glosses, and the cards the
|
|
292
|
+
// formula names with `\explain` — not the registry-wide table the card's
|
|
293
|
+
// body math uses.
|
|
294
|
+
for (const definition of notationInput.sharedDefinitions) {
|
|
295
|
+
if (!definition.formula) continue;
|
|
296
|
+
resolveInto(
|
|
297
|
+
definition.formula,
|
|
298
|
+
formulaGlyphScope({
|
|
299
|
+
key: definition.key,
|
|
300
|
+
notation: definition.notation,
|
|
301
|
+
formula: definition.formula,
|
|
302
|
+
glosses: definition.glosses,
|
|
303
|
+
cardsByKey: sharedCards,
|
|
304
|
+
}),
|
|
305
|
+
'formula',
|
|
306
|
+
definition.source.file,
|
|
307
|
+
undefined,
|
|
308
|
+
SHARED_FORMULA_FIX,
|
|
309
|
+
diagnostics,
|
|
310
|
+
);
|
|
311
|
+
reportUnknownScopeKeys(
|
|
312
|
+
definition,
|
|
313
|
+
cardKeys,
|
|
314
|
+
definition.source.file,
|
|
315
|
+
diagnostics,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
222
319
|
for (const link of lessonAssessments) {
|
|
223
320
|
const lesson = lessonById.get(link.lessonId);
|
|
224
321
|
if (!lesson) continue;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glosses and formula scope (D15) — the second tier of the notation
|
|
3
|
+
* vocabulary.
|
|
4
|
+
*
|
|
5
|
+
* A **card** is a `content/notation/*.md` entry (or a `notation.local` entry):
|
|
6
|
+
* symbol, name, description, units, sources, curriculum alignment, review
|
|
7
|
+
* state. It gets a glossary card and is a node in the curriculum graph.
|
|
8
|
+
*
|
|
9
|
+
* A **gloss** is declared on a card, in that card's `glosses:` frontmatter,
|
|
10
|
+
* and carries a symbol, a name, and optionally a bare units string. It exists
|
|
11
|
+
* for one reason: so a card's rigorous `formula` can name its own letters
|
|
12
|
+
* without each letter having to become a card. A gloss has no description, no
|
|
13
|
+
* sources, no alignment, no review state, and no glossary card — and, the
|
|
14
|
+
* point of the whole tier, **it creates no curriculum edge**. A card that
|
|
15
|
+
* dragged its formula's letters into the registry would drag them into the
|
|
16
|
+
* lesson bundle of every lesson that uses it, and a foundations lesson would
|
|
17
|
+
* fail `alignment-introduction` on a symbol introduced six lessons later.
|
|
18
|
+
*
|
|
19
|
+
* The editorial convention (not enforced here): a card's formula may introduce
|
|
20
|
+
* glosses; glosses terminate — they have no formula of their own. A symbol
|
|
21
|
+
* that later needs a real definition is **promoted**: delete the gloss line,
|
|
22
|
+
* add `content/notation/<key>.md`. Because a formula spells LaTeX and never a
|
|
23
|
+
* key, nothing else in the corpus changes.
|
|
24
|
+
*/
|
|
25
|
+
export interface NotationGlossInput {
|
|
26
|
+
readonly latex: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly units?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A gloss with its derived key and display label. */
|
|
32
|
+
export interface ResolvedGloss {
|
|
33
|
+
/** `<card key>.<slug(name)>` — derived, never authored. */
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly notation: string;
|
|
36
|
+
readonly label: string;
|
|
37
|
+
readonly units?: string;
|
|
38
|
+
/** The card that declares it. */
|
|
39
|
+
readonly ownerKey: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One `{key, notation}` row of the flat table `resolveMathGlyphs` consumes. */
|
|
43
|
+
export interface GlyphScopeEntry {
|
|
44
|
+
readonly key: string;
|
|
45
|
+
readonly notation: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The key segment derived from a gloss name: lower-cased, runs of anything
|
|
50
|
+
* that is not a letter or digit collapsed to a single `-`. Returns `undefined`
|
|
51
|
+
* when the name has no usable characters, so the schema can reject it rather
|
|
52
|
+
* than the loader minting an invalid key.
|
|
53
|
+
*/
|
|
54
|
+
export function glossKeySegment(name: string): string | undefined {
|
|
55
|
+
const segment = name
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
58
|
+
.replace(/^-+|-+$/g, '');
|
|
59
|
+
return segment.length > 0 ? segment : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive a gloss's key. Dotted onto the owning card's key, so a gloss is
|
|
64
|
+
* entry-scoped by construction: two cards may each gloss `T`, and a gloss can
|
|
65
|
+
* never be confused with a card in the flat registry.
|
|
66
|
+
*/
|
|
67
|
+
export function glossKey(ownerKey: string, name: string): string {
|
|
68
|
+
const segment = glossKeySegment(name);
|
|
69
|
+
if (segment === undefined) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Gloss name ${JSON.stringify(name)} on ${ownerKey} yields no key segment.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return `${ownerKey}.${segment}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve a card's authored `glosses:` list into keyed, labelled entries. */
|
|
78
|
+
export function resolveGlosses(
|
|
79
|
+
ownerKey: string,
|
|
80
|
+
glosses: readonly NotationGlossInput[],
|
|
81
|
+
): ResolvedGloss[] {
|
|
82
|
+
return glosses.map((gloss) => ({
|
|
83
|
+
key: glossKey(ownerKey, gloss.name),
|
|
84
|
+
notation: gloss.latex,
|
|
85
|
+
label: gloss.name,
|
|
86
|
+
ownerKey,
|
|
87
|
+
...(gloss.units === undefined ? {} : { units: gloss.units }),
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `\explain{key}` occurrences in a formula — scope only, never a reference. */
|
|
92
|
+
const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
|
|
93
|
+
|
|
94
|
+
export function formulaExplainKeys(formula: string): string[] {
|
|
95
|
+
EXPLAIN_KEY.lastIndex = 0;
|
|
96
|
+
return [...formula.matchAll(EXPLAIN_KEY)].map((match) => match[1] ?? '');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The glyph table a card's `formula` resolves against:
|
|
101
|
+
*
|
|
102
|
+
* {the card itself} ∪ {its glosses} ∪ {cards its formula names} ∪ base library
|
|
103
|
+
*
|
|
104
|
+
* Narrower than the registry-wide table a card's *body* math still uses, and
|
|
105
|
+
* narrower than a lesson page's scope. A card named with `\explain{key}` in
|
|
106
|
+
* the formula joins the table for glyph resolution **only**: its existence is
|
|
107
|
+
* checked, but it deliberately becomes no reference edge, because a
|
|
108
|
+
* definitional letter is not a curriculum prerequisite. See the module note.
|
|
109
|
+
*/
|
|
110
|
+
export function formulaGlyphScope(input: {
|
|
111
|
+
readonly key: string;
|
|
112
|
+
readonly notation: string;
|
|
113
|
+
readonly formula?: string;
|
|
114
|
+
readonly glosses: readonly ResolvedGloss[];
|
|
115
|
+
/** Every card by key — shared entries, plus page-local ones for a lesson. */
|
|
116
|
+
readonly cardsByKey: ReadonlyMap<string, GlyphScopeEntry>;
|
|
117
|
+
}): GlyphScopeEntry[] {
|
|
118
|
+
const scope: GlyphScopeEntry[] = [
|
|
119
|
+
{ key: input.key, notation: input.notation },
|
|
120
|
+
];
|
|
121
|
+
const seen = new Set([input.key]);
|
|
122
|
+
|
|
123
|
+
for (const gloss of input.glosses) {
|
|
124
|
+
if (seen.has(gloss.key)) continue;
|
|
125
|
+
seen.add(gloss.key);
|
|
126
|
+
scope.push({ key: gloss.key, notation: gloss.notation });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const key of formulaExplainKeys(input.formula ?? '')) {
|
|
130
|
+
if (seen.has(key)) continue;
|
|
131
|
+
const card = input.cardsByKey.get(key);
|
|
132
|
+
if (!card) continue;
|
|
133
|
+
seen.add(key);
|
|
134
|
+
scope.push({ key: card.key, notation: card.notation });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return scope;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Keys named by a formula that no card defines. The build reports these — an
|
|
142
|
+
* `\explain{…}` pointing at nothing is an authoring mistake, not a silent
|
|
143
|
+
* fallback to an unresolved glyph.
|
|
144
|
+
*/
|
|
145
|
+
export function unknownFormulaScopeKeys(
|
|
146
|
+
formula: string | undefined,
|
|
147
|
+
glossKeys: ReadonlySet<string>,
|
|
148
|
+
cardKeys: ReadonlySet<string>,
|
|
149
|
+
): string[] {
|
|
150
|
+
return formulaExplainKeys(formula ?? '').filter(
|
|
151
|
+
(key) => !cardKeys.has(key) && !glossKeys.has(key),
|
|
152
|
+
);
|
|
153
|
+
}
|