explico 0.1.1 → 0.2.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/dist/compiler/cli.d.ts +1 -0
- package/dist/compiler/collections.js +27 -0
- package/dist/compiler/manifest.d.ts +10 -1
- package/dist/compiler/manifest.js +18 -2
- package/dist/compiler/resolution-report.d.ts +1 -0
- package/dist/reference/consistency.d.ts +1 -1
- package/dist/reference/consistency.js +93 -2
- package/dist/reference/equations.d.mts +43 -16
- package/dist/reference/equations.mjs +75 -39
- 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/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.mjs +30 -24
- package/dist/reference/types.d.ts +25 -2
- package/package.json +1 -1
- package/src/compiler/collections.ts +40 -0
- package/src/compiler/manifest.ts +31 -0
- package/src/components/HoverPanel.astro +42 -0
- package/src/components/citation/CitationLayer.astro +1 -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 +55 -0
- package/src/components/popup-stack.ts +345 -0
- package/src/components/starlight/EquationEnhancer.astro +20 -5
- package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
- package/src/components/starlight/LessonFooter.astro +4 -0
- package/src/content-config.ts +38 -0
- package/src/reference/consistency.ts +154 -1
- package/src/reference/equations.mjs +75 -39
- package/src/reference/gate-math.ts +104 -7
- package/src/reference/gloss.ts +153 -0
- package/src/reference/registry.ts +43 -0
- package/src/reference/rehype-heading-anchors.mjs +144 -0
- package/src/reference/remark-notation.mjs +30 -24
- package/src/reference/types.ts +26 -1
- package/src/styles/global.css +84 -2
package/dist/compiler/cli.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { parseFrontmatter } from "@astrojs/markdown-remark";
|
|
|
4
4
|
import { isSubstantiveMeaning } from "../reference/prose.js";
|
|
5
5
|
import { parseEquationRef } from "../reference/equations.mjs";
|
|
6
6
|
import { resolveLabel } from "../reference/label.js";
|
|
7
|
+
import { resolveGlosses } from "../reference/gloss.js";
|
|
7
8
|
import {
|
|
8
9
|
deriveRequires,
|
|
9
10
|
deriveSources,
|
|
@@ -206,6 +207,30 @@ async function loadCurriculumCatalog() {
|
|
|
206
207
|
tracks: jsonEntries(join(CONTENT_ROOT, "tracks"))
|
|
207
208
|
};
|
|
208
209
|
}
|
|
210
|
+
function glossEntries(raw, ownerKey, at) {
|
|
211
|
+
if (raw === void 0) return [];
|
|
212
|
+
if (!Array.isArray(raw)) throw new Error(`${at} must be an array`);
|
|
213
|
+
const authored = raw.map((value, index) => {
|
|
214
|
+
const data = record(value, `${at}[${index}]`);
|
|
215
|
+
const units = optionalString(data.units, `${at}[${index}].units`);
|
|
216
|
+
return {
|
|
217
|
+
latex: string(data.latex, `${at}[${index}].latex`),
|
|
218
|
+
name: string(data.name, `${at}[${index}].name`),
|
|
219
|
+
...units === void 0 ? {} : { units }
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
const glosses = resolveGlosses(ownerKey, authored);
|
|
223
|
+
const seen = /* @__PURE__ */ new Set();
|
|
224
|
+
for (const gloss of glosses) {
|
|
225
|
+
if (seen.has(gloss.key)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`${at} declares two glosses named ${JSON.stringify(gloss.label)}`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
seen.add(gloss.key);
|
|
231
|
+
}
|
|
232
|
+
return glosses;
|
|
233
|
+
}
|
|
209
234
|
function localDefinition(raw, file, index) {
|
|
210
235
|
const at = `${file} notation.local[${index}]`;
|
|
211
236
|
const data = record(raw, at);
|
|
@@ -224,6 +249,7 @@ function localDefinition(raw, file, index) {
|
|
|
224
249
|
seeAlso: strings(data.seeAlso, `${at}.seeAlso`),
|
|
225
250
|
alignment: data.alignment === void 0 ? { kind: "general", rationale: "Lesson-local symbol." } : alignment(data.alignment, `${at}.alignment`),
|
|
226
251
|
references: extractNotationReferences(definitionText, file, "definition"),
|
|
252
|
+
glosses: glossEntries(data.glosses, key, `${at}.glosses`),
|
|
227
253
|
source: { file },
|
|
228
254
|
...formula === void 0 ? {} : { formula },
|
|
229
255
|
...units === void 0 ? {} : { units }
|
|
@@ -255,6 +281,7 @@ function sharedNotationEntries() {
|
|
|
255
281
|
aiAssisted: boolean(data.aiAssisted, `${file} aiAssisted`),
|
|
256
282
|
body,
|
|
257
283
|
references: extractNotationReferences(body, file, void 0),
|
|
284
|
+
glosses: glossEntries(data.glosses, key, `${file} glosses`),
|
|
258
285
|
source: { file },
|
|
259
286
|
...formula === void 0 ? {} : { formula },
|
|
260
287
|
...units === void 0 ? {} : { units }
|
|
@@ -2,7 +2,8 @@ import { CurriculumIssue } from '../curriculum/validation.js';
|
|
|
2
2
|
import { ConsistencyDiagnostic } from '../reference/consistency.js';
|
|
3
3
|
import { EquationLabelRecord, EquationDiagnostic } from '../reference/equations-validate.js';
|
|
4
4
|
import { MathGateDiagnostic } from '../reference/gate-math.js';
|
|
5
|
-
import {
|
|
5
|
+
import { GlyphScopeEntry } from '../reference/gloss.js';
|
|
6
|
+
import { NotationDefinitionRecord, NotationPageBundle, NotationBacklink, NotationDiagnostic, EditorialStatus, NotationGlossEntry } from '../reference/types.js';
|
|
6
7
|
import { CourseConfig } from './course-config.js';
|
|
7
8
|
import { SidebarGroup } from './lesson-derivation.js';
|
|
8
9
|
import './collections.js';
|
|
@@ -81,6 +82,14 @@ interface ManifestLessonNotation {
|
|
|
81
82
|
readonly seeAlso: readonly string[];
|
|
82
83
|
readonly units?: string;
|
|
83
84
|
readonly formula?: string;
|
|
85
|
+
/** Symbols the formula names that have no card of their own (D15). */
|
|
86
|
+
readonly glosses: readonly NotationGlossEntry[];
|
|
87
|
+
/**
|
|
88
|
+
* Entry-scoped glyph table for `formula` — the entry, its glosses, and any
|
|
89
|
+
* card the formula names. Precomputed here because the whole card set lives
|
|
90
|
+
* in the compiler, not in the page component.
|
|
91
|
+
*/
|
|
92
|
+
readonly formulaScope: readonly GlyphScopeEntry[];
|
|
84
93
|
}
|
|
85
94
|
interface ManifestLesson {
|
|
86
95
|
readonly id: string;
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "../reference/gate-math.js";
|
|
24
24
|
import { loadLintIgnore } from "../reference/lint-ignore.js";
|
|
25
25
|
import { buildNotationRegistry } from "../reference/registry.js";
|
|
26
|
+
import { formulaGlyphScope } from "../reference/gloss.js";
|
|
26
27
|
import { resolveReferenceId } from "../reference/references.js";
|
|
27
28
|
import {
|
|
28
29
|
loadCurriculumCatalog,
|
|
@@ -97,7 +98,7 @@ function lessonNotationResolution(lesson, definitionsById, availableDefinitionId
|
|
|
97
98
|
};
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
|
-
function lessonNotationBundle(bundle, definitionsById, resolution) {
|
|
101
|
+
function lessonNotationBundle(bundle, definitionsById, resolution, cardsByKey) {
|
|
101
102
|
const definitions = [];
|
|
102
103
|
for (const definitionId of bundle?.definitionIds ?? []) {
|
|
103
104
|
const definition = definitionsById.get(definitionId);
|
|
@@ -111,6 +112,14 @@ function lessonNotationBundle(bundle, definitionsById, resolution) {
|
|
|
111
112
|
summary: definition.summary,
|
|
112
113
|
editorialStatus: definition.status,
|
|
113
114
|
seeAlso: [...definition.seeAlso],
|
|
115
|
+
glosses: [...definition.glosses],
|
|
116
|
+
formulaScope: formulaGlyphScope({
|
|
117
|
+
key: definition.key,
|
|
118
|
+
notation: definition.notation,
|
|
119
|
+
glosses: definition.glosses,
|
|
120
|
+
cardsByKey,
|
|
121
|
+
...definition.formula === void 0 ? {} : { formula: definition.formula }
|
|
122
|
+
}),
|
|
114
123
|
...definition.units === void 0 ? {} : { units: definition.units },
|
|
115
124
|
...definition.formula === void 0 ? {} : { formula: definition.formula }
|
|
116
125
|
});
|
|
@@ -152,6 +161,12 @@ async function compileManifest(courseConfig) {
|
|
|
152
161
|
loadNotationRegistryInput()
|
|
153
162
|
]);
|
|
154
163
|
const registry = buildNotationRegistry(notationInput);
|
|
164
|
+
const cardsByKey = new Map(
|
|
165
|
+
notationInput.sharedDefinitions.map((definition) => [
|
|
166
|
+
definition.key,
|
|
167
|
+
{ key: definition.key, notation: definition.notation }
|
|
168
|
+
])
|
|
169
|
+
);
|
|
155
170
|
const definitionsById = new Map(
|
|
156
171
|
registry.definitions.map((definition) => [definition.id, definition])
|
|
157
172
|
);
|
|
@@ -237,7 +252,8 @@ async function compileManifest(courseConfig) {
|
|
|
237
252
|
notationLesson,
|
|
238
253
|
definitionsById,
|
|
239
254
|
availableDefinitionIds
|
|
240
|
-
)
|
|
255
|
+
),
|
|
256
|
+
cardsByKey
|
|
241
257
|
)
|
|
242
258
|
});
|
|
243
259
|
}
|
|
@@ -30,7 +30,7 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
|
|
|
30
30
|
* `src/content/resolution-report.ts`.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator';
|
|
33
|
+
type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape';
|
|
34
34
|
interface ConsistencyDiagnostic {
|
|
35
35
|
readonly code: ConsistencyCode;
|
|
36
36
|
/** Tier 3 — every finding here is a warning until content is brought clean. */
|
|
@@ -200,13 +200,101 @@ function checkNotationSourceLocator(input, out) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
const GLOSS_PROMOTION_LIMIT = 3;
|
|
204
|
+
function checkGlossWantsPromoting(input, out) {
|
|
205
|
+
const owners = /* @__PURE__ */ new Map();
|
|
206
|
+
const record = (ownerKey, gloss) => {
|
|
207
|
+
const id = `${gloss.notation}\0${gloss.label.toLowerCase()}`;
|
|
208
|
+
const current = owners.get(id) ?? { label: gloss.label, keys: /* @__PURE__ */ new Set() };
|
|
209
|
+
current.keys.add(ownerKey);
|
|
210
|
+
owners.set(id, current);
|
|
211
|
+
};
|
|
212
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
213
|
+
for (const gloss of definition.glosses) record(definition.key, gloss);
|
|
214
|
+
}
|
|
215
|
+
for (const lesson of input.notationInput.lessons) {
|
|
216
|
+
for (const definition of lesson.localDefinitions) {
|
|
217
|
+
for (const gloss of definition.glosses) record(definition.key, gloss);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const [id, { label, keys }] of [...owners].sort(
|
|
221
|
+
([a], [b]) => a.localeCompare(b)
|
|
222
|
+
)) {
|
|
223
|
+
if (keys.size < GLOSS_PROMOTION_LIMIT) continue;
|
|
224
|
+
const glyph = id.split("\0")[0] ?? "";
|
|
225
|
+
if (input.lintIgnore.matches("gloss-wants-promoting", glyph)) continue;
|
|
226
|
+
out.push({
|
|
227
|
+
code: "gloss-wants-promoting",
|
|
228
|
+
severity: "warning",
|
|
229
|
+
glyph,
|
|
230
|
+
message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(", ")}); a meaning reused this widely wants promoting to a card`
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const CARD_BODY_MINIMUM_WORDS = 25;
|
|
235
|
+
function checkCardWantsDemoting(input, out) {
|
|
236
|
+
const used = new Set(
|
|
237
|
+
input.registry.backlinks.map((backlink) => backlink.key)
|
|
238
|
+
);
|
|
239
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
240
|
+
if (definition.formula !== void 0) continue;
|
|
241
|
+
if (definition.sources.length > 0) continue;
|
|
242
|
+
if (used.has(definition.key)) continue;
|
|
243
|
+
const words = stripCode(definition.body).split(/\s+/).filter((word) => word.length > 0).length;
|
|
244
|
+
if (words >= CARD_BODY_MINIMUM_WORDS) continue;
|
|
245
|
+
if (input.lintIgnore.matches("card-wants-demoting", definition.key)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
out.push({
|
|
249
|
+
code: "card-wants-demoting",
|
|
250
|
+
severity: "warning",
|
|
251
|
+
key: definition.key,
|
|
252
|
+
file: definition.source.file,
|
|
253
|
+
count: words,
|
|
254
|
+
message: `notation card ${definition.key} has no formula, no sources, a ${words}-word body, and no lesson uses it; it carries no more than a name \u2014 a gloss on the entry that needs it may be the right home`
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const GLOSS_NAME_WORD_LIMIT = 6;
|
|
259
|
+
const GLOSS_NAME_PROSE = /[.;]|\b(?:which|whose|that is|such that)\b/i;
|
|
260
|
+
function checkGlossNameShape(input, out) {
|
|
261
|
+
const check = (gloss, file, lessonId) => {
|
|
262
|
+
const words = gloss.label.trim().split(/\s+/).filter(Boolean);
|
|
263
|
+
const reason = words.length > GLOSS_NAME_WORD_LIMIT ? `${words.length} words` : GLOSS_NAME_PROSE.test(gloss.label) ? "sentence punctuation or a relative clause" : void 0;
|
|
264
|
+
if (reason === void 0) return;
|
|
265
|
+
if (input.lintIgnore.matches("gloss-name-shape", gloss.key)) return;
|
|
266
|
+
out.push({
|
|
267
|
+
code: "gloss-name-shape",
|
|
268
|
+
severity: "warning",
|
|
269
|
+
key: gloss.key,
|
|
270
|
+
file,
|
|
271
|
+
...lessonId ? { lessonId } : {},
|
|
272
|
+
message: `gloss ${gloss.key} is named ${JSON.stringify(gloss.label)} (${reason}); a gloss name is a short noun phrase naming the symbol, not a description of it`
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
276
|
+
for (const gloss of definition.glosses) {
|
|
277
|
+
check(gloss, definition.source.file);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const lesson of input.notationInput.lessons) {
|
|
281
|
+
for (const definition of lesson.localDefinitions) {
|
|
282
|
+
for (const gloss of definition.glosses) {
|
|
283
|
+
check(gloss, definition.source.file, lesson.lessonId);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
203
288
|
const CHECKS = [
|
|
204
289
|
checkGlyphUniqueness,
|
|
205
290
|
checkNotationUnits,
|
|
206
291
|
checkConventionSingleDefinition,
|
|
207
292
|
checkNumeralsTagged,
|
|
208
293
|
checkWeakLocal,
|
|
209
|
-
checkNotationSourceLocator
|
|
294
|
+
checkNotationSourceLocator,
|
|
295
|
+
checkGlossWantsPromoting,
|
|
296
|
+
checkCardWantsDemoting,
|
|
297
|
+
checkGlossNameShape
|
|
210
298
|
];
|
|
211
299
|
function runConsistencyChecks(input) {
|
|
212
300
|
const diagnostics = [];
|
|
@@ -223,7 +311,10 @@ function consistencyCounts(diagnostics) {
|
|
|
223
311
|
"convention-single-definition": 0,
|
|
224
312
|
"numerals-tagged": 0,
|
|
225
313
|
"weak-local": 0,
|
|
226
|
-
"notation-source-locator": 0
|
|
314
|
+
"notation-source-locator": 0,
|
|
315
|
+
"gloss-wants-promoting": 0,
|
|
316
|
+
"card-wants-demoting": 0,
|
|
317
|
+
"gloss-name-shape": 0
|
|
227
318
|
};
|
|
228
319
|
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
229
320
|
return counts;
|
|
@@ -8,42 +8,69 @@ export function parseEquationRef(raw: any): {
|
|
|
8
8
|
} | undefined;
|
|
9
9
|
/** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
|
|
10
10
|
export function stripEquationLabels(latex: any): string;
|
|
11
|
+
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
12
|
+
export function equationAnchorId({ key, number }: {
|
|
13
|
+
key: any;
|
|
14
|
+
number: any;
|
|
15
|
+
}): string;
|
|
11
16
|
/**
|
|
12
17
|
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
13
18
|
* its assigned number. `$$…$$` blocks only; `##` headings between them advance
|
|
14
|
-
* the section counter.
|
|
19
|
+
* the section counter. Every display block advances the in-section index, so a
|
|
20
|
+
* keyed equation's number reflects its position among *all* display equations,
|
|
21
|
+
* not only the keyed ones.
|
|
15
22
|
*/
|
|
16
23
|
export function scanEquationLabels(markdown: any): {
|
|
17
|
-
key: string
|
|
24
|
+
key: string;
|
|
25
|
+
number: string;
|
|
26
|
+
section: number;
|
|
27
|
+
indexInSection: number;
|
|
28
|
+
}[];
|
|
29
|
+
/**
|
|
30
|
+
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
31
|
+
* walk the tree in document order, advancing the section counter on every
|
|
32
|
+
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
33
|
+
* `\label{eq:…}` payload when present, else `null`.
|
|
34
|
+
*/
|
|
35
|
+
export function collectDisplayEquations(tree: any): {
|
|
36
|
+
key: string | null;
|
|
18
37
|
number: string;
|
|
19
38
|
section: number;
|
|
20
39
|
indexInSection: number;
|
|
40
|
+
node: any;
|
|
21
41
|
}[];
|
|
22
42
|
/**
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
* label for every `math` node whose source carries `\label{eq:…}`.
|
|
43
|
+
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
44
|
+
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
26
45
|
*/
|
|
27
|
-
export function collectEquationLabels(tree: any):
|
|
46
|
+
export function collectEquationLabels(tree: any): {
|
|
47
|
+
key: string | null;
|
|
48
|
+
number: string;
|
|
49
|
+
section: number;
|
|
50
|
+
indexInSection: number;
|
|
51
|
+
node: any;
|
|
52
|
+
}[];
|
|
28
53
|
/**
|
|
29
|
-
* Equation identity (Step D7) — shared, framework-free helpers.
|
|
54
|
+
* Equation identity (Step D7, extended F3) — shared, framework-free helpers.
|
|
30
55
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
|
|
38
|
-
* be eaten by `remark-directive`):
|
|
56
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an
|
|
57
|
+
* `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
|
|
58
|
+
* fourth display equation under the second section). An author additionally
|
|
59
|
+
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
60
|
+
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
61
|
+
* they want a *stable* anchor that survives edits and cross-page references:
|
|
39
62
|
*
|
|
40
63
|
* [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
|
|
41
64
|
* [[foundations/present-value#eq-pv]] cross page → number from the manifest
|
|
42
65
|
*
|
|
66
|
+
* An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
|
|
67
|
+
* deep-linked, but only a keyed equation is referenceable by name.
|
|
68
|
+
*
|
|
43
69
|
* `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
|
|
44
70
|
* MUST agree — `tests/notation/equations.test.ts` checks that on the real
|
|
45
71
|
* corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
|
|
46
|
-
* numbering algorithm.
|
|
72
|
+
* numbering algorithm. `collectDisplayEquations` is the superset the render
|
|
73
|
+
* path uses to number and anchor every display equation, keyed or not.
|
|
47
74
|
*/
|
|
48
75
|
/** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
|
|
49
76
|
export const EQ_KEY_PATTERN: RegExp;
|
|
@@ -1,22 +1,24 @@
|
|
|
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
|
-
* equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
|
|
11
|
-
* be eaten by `remark-directive`):
|
|
4
|
+
* Every display equation (`$$…$$`) in a lesson is numbered and given an
|
|
5
|
+
* `id`, from appearance order scoped to the enclosing `##` section (`(2.4)` =
|
|
6
|
+
* fourth display equation under the second section). An author additionally
|
|
7
|
+
* marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
|
|
8
|
+
* block (inside math, so the `:` is safe and the form matches amsmath) when
|
|
9
|
+
* they want a *stable* anchor that survives edits and cross-page references:
|
|
12
10
|
*
|
|
13
11
|
* [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
|
|
14
12
|
* [[foundations/present-value#eq-pv]] cross page → number from the manifest
|
|
15
13
|
*
|
|
14
|
+
* An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
|
|
15
|
+
* deep-linked, but only a keyed equation is referenceable by name.
|
|
16
|
+
*
|
|
16
17
|
* `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
|
|
17
18
|
* MUST agree — `tests/notation/equations.test.ts` checks that on the real
|
|
18
19
|
* corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
|
|
19
|
-
* numbering algorithm.
|
|
20
|
+
* numbering algorithm. `collectDisplayEquations` is the superset the render
|
|
21
|
+
* path uses to number and anchor every display equation, keyed or not.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
/** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
|
|
@@ -51,11 +53,16 @@ export function stripEquationLabels(latex) {
|
|
|
51
53
|
.trim();
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
/** The visible number for the `index`-th
|
|
56
|
+
/** The visible number for the `index`-th display equation under `section`. */
|
|
55
57
|
function equationNumber(section, index) {
|
|
56
58
|
return section > 0 ? `${section}.${index}` : String(index);
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
|
|
62
|
+
export function equationAnchorId({ key, number }) {
|
|
63
|
+
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
function stripFencedCode(markdown) {
|
|
60
67
|
return String(markdown)
|
|
61
68
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
@@ -63,13 +70,29 @@ function stripFencedCode(markdown) {
|
|
|
63
70
|
.replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Blank the body of every block-level MDX component (`<Capitalised …> … </…>`)
|
|
75
|
+
* so a `$$…$$` used to illustrate a worked example inside `<CompactExample>`
|
|
76
|
+
* (or an `<Aside>`, etc.) is not counted as a lesson-body equation. The
|
|
77
|
+
* outermost match wins, so nested components are blanked in one pass;
|
|
78
|
+
* newlines are kept so line/section structure is unchanged.
|
|
79
|
+
*/
|
|
80
|
+
function stripJsxComponents(markdown) {
|
|
81
|
+
return String(markdown).replace(
|
|
82
|
+
/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
|
|
83
|
+
(block) => block.replace(/[^\n]/g, ' '),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
/**
|
|
67
88
|
* Every keyed display equation in a Markdown body, in appearance order, with
|
|
68
89
|
* its assigned number. `$$…$$` blocks only; `##` headings between them advance
|
|
69
|
-
* the section counter.
|
|
90
|
+
* the section counter. Every display block advances the in-section index, so a
|
|
91
|
+
* keyed equation's number reflects its position among *all* display equations,
|
|
92
|
+
* not only the keyed ones.
|
|
70
93
|
*/
|
|
71
94
|
export function scanEquationLabels(markdown) {
|
|
72
|
-
const text = stripFencedCode(markdown);
|
|
95
|
+
const text = stripJsxComponents(stripFencedCode(markdown));
|
|
73
96
|
const tokens = [];
|
|
74
97
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
75
98
|
const display = /\$\$([\s\S]+?)\$\$/g;
|
|
@@ -79,9 +102,11 @@ export function scanEquationLabels(markdown) {
|
|
|
79
102
|
for (const match of text.matchAll(display)) {
|
|
80
103
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
81
104
|
const label = EQ_LABEL_PATTERN.exec(match[1]);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
tokens.push({
|
|
106
|
+
kind: 'equation',
|
|
107
|
+
index: match.index,
|
|
108
|
+
key: label ? label[1] : null,
|
|
109
|
+
});
|
|
85
110
|
}
|
|
86
111
|
tokens.sort((a, b) => a.index - b.index);
|
|
87
112
|
|
|
@@ -95,6 +120,7 @@ export function scanEquationLabels(markdown) {
|
|
|
95
120
|
continue;
|
|
96
121
|
}
|
|
97
122
|
indexInSection += 1;
|
|
123
|
+
if (token.key == null) continue;
|
|
98
124
|
labels.push({
|
|
99
125
|
key: token.key,
|
|
100
126
|
number: equationNumber(section, indexInSection),
|
|
@@ -106,39 +132,49 @@ export function scanEquationLabels(markdown) {
|
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
/**
|
|
109
|
-
* The mdast equivalent of `scanEquationLabels
|
|
110
|
-
* order, advancing the section counter on every
|
|
111
|
-
*
|
|
135
|
+
* The mdast equivalent of `scanEquationLabels` for *every* display equation:
|
|
136
|
+
* walk the tree in document order, advancing the section counter on every
|
|
137
|
+
* depth-2 heading and emitting a record for every `math` node. `key` is the
|
|
138
|
+
* `\label{eq:…}` payload when present, else `null`.
|
|
112
139
|
*/
|
|
113
|
-
export function
|
|
114
|
-
const
|
|
140
|
+
export function collectDisplayEquations(tree) {
|
|
141
|
+
const equations = [];
|
|
115
142
|
let section = 0;
|
|
116
143
|
let indexInSection = 0;
|
|
117
144
|
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
// Only top-level blocks: a `$$…$$` nested in an MDX component (a
|
|
146
|
+
// `<CompactExample>` worked example, an `<Aside>`) is illustrative, not part
|
|
147
|
+
// of the lesson's equation sequence, and its container may be collapsed.
|
|
148
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
149
|
+
for (const node of children) {
|
|
150
|
+
if (!node || typeof node !== 'object') continue;
|
|
120
151
|
if (node.type === 'heading' && node.depth === 2) {
|
|
121
152
|
section += 1;
|
|
122
153
|
indexInSection = 0;
|
|
123
|
-
|
|
154
|
+
continue;
|
|
124
155
|
}
|
|
125
156
|
if (node.type === 'math') {
|
|
157
|
+
indexInSection += 1;
|
|
126
158
|
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
127
159
|
const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
node,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
return;
|
|
160
|
+
equations.push({
|
|
161
|
+
key: label ? label[1] : null,
|
|
162
|
+
number: equationNumber(section, indexInSection),
|
|
163
|
+
section,
|
|
164
|
+
indexInSection,
|
|
165
|
+
node,
|
|
166
|
+
});
|
|
139
167
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
168
|
+
}
|
|
169
|
+
return equations;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
|
|
174
|
+
* `scanEquationLabels`. Numbers reflect position among all display equations.
|
|
175
|
+
*/
|
|
176
|
+
export function collectEquationLabels(tree) {
|
|
177
|
+
return collectDisplayEquations(tree).filter(
|
|
178
|
+
(equation) => equation.key != null,
|
|
179
|
+
);
|
|
144
180
|
}
|
|
@@ -4,10 +4,11 @@ interface MathGateDiagnostic {
|
|
|
4
4
|
readonly context: 'body' | 'formula' | 'assessment';
|
|
5
5
|
/**
|
|
6
6
|
* Body / component-slot math blocks the build (the render path needs every
|
|
7
|
-
* glyph resolved for the KaTeX trust callback). `formula`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
8
|
+
* a formula's glyphs are now rendered live and explorable, so an unresolved
|
|
9
|
+
* one is a hole the reader can see, and the corpus was brought to zero when
|
|
10
|
+
* the tier was promoted. `assessment` math stays a Tier-3 warning until its
|
|
11
|
+
* own reviewed content pass (D6/D13/D14).
|
|
11
12
|
*/
|
|
12
13
|
readonly severity: 'error' | 'warning';
|
|
13
14
|
readonly file: string;
|