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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
|
|
2
2
|
import { stripEquationLabels } from "./equations.mjs";
|
|
3
|
+
import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
|
|
3
4
|
const SEVERITY = {
|
|
4
5
|
body: "error",
|
|
5
|
-
formula: "
|
|
6
|
+
formula: "error",
|
|
6
7
|
assessment: "warning"
|
|
7
8
|
};
|
|
8
9
|
const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
|
|
@@ -75,6 +76,33 @@ function resolveInto(latex, scope, context, file, line, fix, out) {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
const BODY_FIX = "Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.";
|
|
79
|
+
function dedupeScope(scope) {
|
|
80
|
+
const seen = /* @__PURE__ */ new Set();
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const entry of scope) {
|
|
83
|
+
if (seen.has(entry.key)) continue;
|
|
84
|
+
seen.add(entry.key);
|
|
85
|
+
out.push(entry);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function reportUnknownScopeKeys(definition, cardKeys, file, out) {
|
|
90
|
+
const glossKeys = new Set(definition.glosses.map((gloss) => gloss.key));
|
|
91
|
+
for (const key of unknownFormulaScopeKeys(
|
|
92
|
+
definition.formula,
|
|
93
|
+
glossKeys,
|
|
94
|
+
cardKeys
|
|
95
|
+
)) {
|
|
96
|
+
out.push({
|
|
97
|
+
context: "formula",
|
|
98
|
+
severity: SEVERITY.formula,
|
|
99
|
+
file,
|
|
100
|
+
token: key,
|
|
101
|
+
message: `${definition.key} formula names unknown notation key ${JSON.stringify(key)}. Name an existing card, or declare the symbol under glosses: on this entry.`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const SHARED_FORMULA_FIX = "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.";
|
|
78
106
|
const FORMULA_FIX = "Every right-hand-side variable of a notation.local formula must resolve to the entry key, another page symbol, or the base library.";
|
|
79
107
|
const ASSESSMENT_FIX = "Introduce the symbol in the owning lesson's prose with [[key]], or wrap it in \\explain{key}{latex}.";
|
|
80
108
|
function gateContentMath(input) {
|
|
@@ -85,6 +113,18 @@ function gateContentMath(input) {
|
|
|
85
113
|
definition
|
|
86
114
|
])
|
|
87
115
|
);
|
|
116
|
+
const sharedCards = new Map(
|
|
117
|
+
notationInput.sharedDefinitions.map((definition) => [
|
|
118
|
+
definition.key,
|
|
119
|
+
{ key: definition.key, notation: definition.notation }
|
|
120
|
+
])
|
|
121
|
+
);
|
|
122
|
+
const cardKeys = /* @__PURE__ */ new Set([
|
|
123
|
+
...sharedCards.keys(),
|
|
124
|
+
...notationInput.lessons.flatMap(
|
|
125
|
+
(lesson) => lesson.localDefinitions.map((definition) => definition.key)
|
|
126
|
+
)
|
|
127
|
+
]);
|
|
88
128
|
const lessonById = new Map(
|
|
89
129
|
notationInput.lessons.map((lesson) => [lesson.lessonId, lesson])
|
|
90
130
|
);
|
|
@@ -110,15 +150,46 @@ function gateContentMath(input) {
|
|
|
110
150
|
if (!definition.formula) continue;
|
|
111
151
|
resolveInto(
|
|
112
152
|
definition.formula,
|
|
113
|
-
|
|
153
|
+
dedupeScope([
|
|
154
|
+
...scope,
|
|
155
|
+
...definition.glosses.map((gloss) => ({
|
|
156
|
+
key: gloss.key,
|
|
157
|
+
notation: gloss.notation
|
|
158
|
+
}))
|
|
159
|
+
]),
|
|
114
160
|
"formula",
|
|
115
161
|
file,
|
|
116
162
|
void 0,
|
|
117
163
|
FORMULA_FIX,
|
|
118
164
|
diagnostics
|
|
119
165
|
);
|
|
166
|
+
reportUnknownScopeKeys(definition, cardKeys, file, diagnostics);
|
|
120
167
|
}
|
|
121
168
|
}
|
|
169
|
+
for (const definition of notationInput.sharedDefinitions) {
|
|
170
|
+
if (!definition.formula) continue;
|
|
171
|
+
resolveInto(
|
|
172
|
+
definition.formula,
|
|
173
|
+
formulaGlyphScope({
|
|
174
|
+
key: definition.key,
|
|
175
|
+
notation: definition.notation,
|
|
176
|
+
formula: definition.formula,
|
|
177
|
+
glosses: definition.glosses,
|
|
178
|
+
cardsByKey: sharedCards
|
|
179
|
+
}),
|
|
180
|
+
"formula",
|
|
181
|
+
definition.source.file,
|
|
182
|
+
void 0,
|
|
183
|
+
SHARED_FORMULA_FIX,
|
|
184
|
+
diagnostics
|
|
185
|
+
);
|
|
186
|
+
reportUnknownScopeKeys(
|
|
187
|
+
definition,
|
|
188
|
+
cardKeys,
|
|
189
|
+
definition.source.file,
|
|
190
|
+
diagnostics
|
|
191
|
+
);
|
|
192
|
+
}
|
|
122
193
|
for (const link of lessonAssessments) {
|
|
123
194
|
const lesson = lessonById.get(link.lessonId);
|
|
124
195
|
if (!lesson) continue;
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
interface NotationGlossInput {
|
|
26
|
+
readonly latex: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly units?: string;
|
|
29
|
+
}
|
|
30
|
+
/** A gloss with its derived key and display label. */
|
|
31
|
+
interface ResolvedGloss {
|
|
32
|
+
/** `<card key>.<slug(name)>` — derived, never authored. */
|
|
33
|
+
readonly key: string;
|
|
34
|
+
readonly notation: string;
|
|
35
|
+
readonly label: string;
|
|
36
|
+
readonly units?: string;
|
|
37
|
+
/** The card that declares it. */
|
|
38
|
+
readonly ownerKey: string;
|
|
39
|
+
}
|
|
40
|
+
/** One `{key, notation}` row of the flat table `resolveMathGlyphs` consumes. */
|
|
41
|
+
interface GlyphScopeEntry {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly notation: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The key segment derived from a gloss name: lower-cased, runs of anything
|
|
47
|
+
* that is not a letter or digit collapsed to a single `-`. Returns `undefined`
|
|
48
|
+
* when the name has no usable characters, so the schema can reject it rather
|
|
49
|
+
* than the loader minting an invalid key.
|
|
50
|
+
*/
|
|
51
|
+
declare function glossKeySegment(name: string): string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Derive a gloss's key. Dotted onto the owning card's key, so a gloss is
|
|
54
|
+
* entry-scoped by construction: two cards may each gloss `T`, and a gloss can
|
|
55
|
+
* never be confused with a card in the flat registry.
|
|
56
|
+
*/
|
|
57
|
+
declare function glossKey(ownerKey: string, name: string): string;
|
|
58
|
+
/** Resolve a card's authored `glosses:` list into keyed, labelled entries. */
|
|
59
|
+
declare function resolveGlosses(ownerKey: string, glosses: readonly NotationGlossInput[]): ResolvedGloss[];
|
|
60
|
+
declare function formulaExplainKeys(formula: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* The glyph table a card's `formula` resolves against:
|
|
63
|
+
*
|
|
64
|
+
* {the card itself} ∪ {its glosses} ∪ {cards its formula names} ∪ base library
|
|
65
|
+
*
|
|
66
|
+
* Narrower than the registry-wide table a card's *body* math still uses, and
|
|
67
|
+
* narrower than a lesson page's scope. A card named with `\explain{key}` in
|
|
68
|
+
* the formula joins the table for glyph resolution **only**: its existence is
|
|
69
|
+
* checked, but it deliberately becomes no reference edge, because a
|
|
70
|
+
* definitional letter is not a curriculum prerequisite. See the module note.
|
|
71
|
+
*/
|
|
72
|
+
declare function formulaGlyphScope(input: {
|
|
73
|
+
readonly key: string;
|
|
74
|
+
readonly notation: string;
|
|
75
|
+
readonly formula?: string;
|
|
76
|
+
readonly glosses: readonly ResolvedGloss[];
|
|
77
|
+
/** Every card by key — shared entries, plus page-local ones for a lesson. */
|
|
78
|
+
readonly cardsByKey: ReadonlyMap<string, GlyphScopeEntry>;
|
|
79
|
+
}): GlyphScopeEntry[];
|
|
80
|
+
/**
|
|
81
|
+
* Keys named by a formula that no card defines. The build reports these — an
|
|
82
|
+
* `\explain{…}` pointing at nothing is an authoring mistake, not a silent
|
|
83
|
+
* fallback to an unresolved glyph.
|
|
84
|
+
*/
|
|
85
|
+
declare function unknownFormulaScopeKeys(formula: string | undefined, glossKeys: ReadonlySet<string>, cardKeys: ReadonlySet<string>): string[];
|
|
86
|
+
|
|
87
|
+
export { type GlyphScopeEntry, type NotationGlossInput, type ResolvedGloss, formulaExplainKeys, formulaGlyphScope, glossKey, glossKeySegment, resolveGlosses, unknownFormulaScopeKeys };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function glossKeySegment(name) {
|
|
2
|
+
const segment = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3
|
+
return segment.length > 0 ? segment : void 0;
|
|
4
|
+
}
|
|
5
|
+
function glossKey(ownerKey, name) {
|
|
6
|
+
const segment = glossKeySegment(name);
|
|
7
|
+
if (segment === void 0) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Gloss name ${JSON.stringify(name)} on ${ownerKey} yields no key segment.`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return `${ownerKey}.${segment}`;
|
|
13
|
+
}
|
|
14
|
+
function resolveGlosses(ownerKey, glosses) {
|
|
15
|
+
return glosses.map((gloss) => ({
|
|
16
|
+
key: glossKey(ownerKey, gloss.name),
|
|
17
|
+
notation: gloss.latex,
|
|
18
|
+
label: gloss.name,
|
|
19
|
+
ownerKey,
|
|
20
|
+
...gloss.units === void 0 ? {} : { units: gloss.units }
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
|
|
24
|
+
function formulaExplainKeys(formula) {
|
|
25
|
+
EXPLAIN_KEY.lastIndex = 0;
|
|
26
|
+
return [...formula.matchAll(EXPLAIN_KEY)].map((match) => match[1] ?? "");
|
|
27
|
+
}
|
|
28
|
+
function formulaGlyphScope(input) {
|
|
29
|
+
const scope = [
|
|
30
|
+
{ key: input.key, notation: input.notation }
|
|
31
|
+
];
|
|
32
|
+
const seen = /* @__PURE__ */ new Set([input.key]);
|
|
33
|
+
for (const gloss of input.glosses) {
|
|
34
|
+
if (seen.has(gloss.key)) continue;
|
|
35
|
+
seen.add(gloss.key);
|
|
36
|
+
scope.push({ key: gloss.key, notation: gloss.notation });
|
|
37
|
+
}
|
|
38
|
+
for (const key of formulaExplainKeys(input.formula ?? "")) {
|
|
39
|
+
if (seen.has(key)) continue;
|
|
40
|
+
const card = input.cardsByKey.get(key);
|
|
41
|
+
if (!card) continue;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
scope.push({ key: card.key, notation: card.notation });
|
|
44
|
+
}
|
|
45
|
+
return scope;
|
|
46
|
+
}
|
|
47
|
+
function unknownFormulaScopeKeys(formula, glossKeys, cardKeys) {
|
|
48
|
+
return formulaExplainKeys(formula ?? "").filter(
|
|
49
|
+
(key) => !cardKeys.has(key) && !glossKeys.has(key)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
formulaExplainKeys,
|
|
54
|
+
formulaGlyphScope,
|
|
55
|
+
glossKey,
|
|
56
|
+
glossKeySegment,
|
|
57
|
+
resolveGlosses,
|
|
58
|
+
unknownFormulaScopeKeys
|
|
59
|
+
};
|
|
@@ -15,6 +15,7 @@ function buildNotationRegistry(rawInput) {
|
|
|
15
15
|
diagnostics
|
|
16
16
|
);
|
|
17
17
|
const availableDefinitionIds = new Set(definitions.keys());
|
|
18
|
+
validateGlossKeys(definitions, diagnostics);
|
|
18
19
|
for (const candidate of definitions.values()) {
|
|
19
20
|
const references = sortedReferences(candidate.input.references);
|
|
20
21
|
const resolved = [];
|
|
@@ -298,6 +299,26 @@ function normalizeLocalDefinition(input) {
|
|
|
298
299
|
references: sortedReferences(input.references)
|
|
299
300
|
};
|
|
300
301
|
}
|
|
302
|
+
function validateGlossKeys(definitions, diagnostics) {
|
|
303
|
+
const cardKeys = new Set(
|
|
304
|
+
[...definitions.values()].map((candidate) => candidate.input.key)
|
|
305
|
+
);
|
|
306
|
+
for (const candidate of definitions.values()) {
|
|
307
|
+
for (const gloss of candidate.input.glosses) {
|
|
308
|
+
if (!cardKeys.has(gloss.key)) continue;
|
|
309
|
+
diagnostics.push(
|
|
310
|
+
diagnostic({
|
|
311
|
+
code: "gloss-collides-with-card",
|
|
312
|
+
message: `${candidate.id} glosses ${JSON.stringify(gloss.label)} as ${gloss.key}, which is also a notation card; delete the gloss or rename it`,
|
|
313
|
+
key: gloss.key,
|
|
314
|
+
lessonId: candidate.kind === "local" ? candidate.lesson.lessonId : void 0,
|
|
315
|
+
definitionId: candidate.id,
|
|
316
|
+
source: candidate.input.source
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
301
322
|
function validateReferenceKey(reference, owner, diagnostics) {
|
|
302
323
|
if (isNotationKey(reference.key)) return true;
|
|
303
324
|
diagnostics.push(
|
|
@@ -371,6 +392,7 @@ function toDefinitionRecord(candidate) {
|
|
|
371
392
|
status: input2.status,
|
|
372
393
|
aiAssisted: input2.aiAssisted,
|
|
373
394
|
body: input2.body,
|
|
395
|
+
glosses: input2.glosses,
|
|
374
396
|
source: input2.source,
|
|
375
397
|
resolvedReferences: candidate.resolvedReferences,
|
|
376
398
|
...input2.formula === void 0 ? {} : { formula: input2.formula },
|
|
@@ -392,6 +414,7 @@ function toDefinitionRecord(candidate) {
|
|
|
392
414
|
seeAlso: input.seeAlso,
|
|
393
415
|
alignment: input.alignment,
|
|
394
416
|
status: candidate.lesson.status,
|
|
417
|
+
glosses: input.glosses,
|
|
395
418
|
source: input.source,
|
|
396
419
|
resolvedReferences: candidate.resolvedReferences,
|
|
397
420
|
...input.formula === void 0 ? {} : { formula: input.formula },
|
|
@@ -432,6 +455,7 @@ function definitionFingerprint(candidate) {
|
|
|
432
455
|
status: input2.status,
|
|
433
456
|
aiAssisted: input2.aiAssisted,
|
|
434
457
|
body: normalizeText(input2.body),
|
|
458
|
+
glosses: input2.glosses,
|
|
435
459
|
references
|
|
436
460
|
});
|
|
437
461
|
}
|
|
@@ -447,6 +471,7 @@ function definitionFingerprint(candidate) {
|
|
|
447
471
|
sources: [...input.sources].sort(),
|
|
448
472
|
seeAlso: [...input.seeAlso].sort(),
|
|
449
473
|
alignment,
|
|
474
|
+
glosses: input.glosses,
|
|
450
475
|
references
|
|
451
476
|
});
|
|
452
477
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function rehypeHeadingAnchors(): (tree: any, file: any) => void;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-linkable content headings (Step F3).
|
|
3
|
+
*
|
|
4
|
+
* The lesson pipeline uses a custom Markdown `processor` (see `astro.config.mjs`),
|
|
5
|
+
* which bypasses Starlight's own heading-link rehype pass. This plugin restores
|
|
6
|
+
* it with the *same* markup Starlight emits, so its bundled `anchor-links.css`
|
|
7
|
+
* (shipped globally because `markdown.headingLinks` defaults on) styles it for
|
|
8
|
+
* free — including the hover/focus reveal of an otherwise invisible anchor:
|
|
9
|
+
*
|
|
10
|
+
* <div class="sl-heading-wrapper level-h2">
|
|
11
|
+
* <h2 id="slug">Heading</h2>
|
|
12
|
+
* <a class="sl-anchor-link" href="#slug"> <icon> <sr-only label> </a>
|
|
13
|
+
* </div>
|
|
14
|
+
*
|
|
15
|
+
* The `:target` flash, sticky-header scroll offset, and the JS niceties
|
|
16
|
+
* (re-trigger on repeat activation, smooth scroll, `history.pushState`, brief
|
|
17
|
+
* section highlight) live in `global.css` + `HeadingAnchorEnhancer.astro`, the
|
|
18
|
+
* same split the keyed-equation feature uses.
|
|
19
|
+
*
|
|
20
|
+
* `rehypeHeadingIds` from `@astrojs/markdown-remark` normally runs *after* the
|
|
21
|
+
* user rehype plugins, so heading `id`s do not exist yet when this runs. It is
|
|
22
|
+
* idempotent (only assigns a missing `id`, and rebuilds `file.data.astro.headings`
|
|
23
|
+
* from an unchanged document order), so this plugin runs it first and Astro's
|
|
24
|
+
* later pass becomes a no-op for these headings.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
|
28
|
+
|
|
29
|
+
const HEADING_TAG = /^h([1-6])$/;
|
|
30
|
+
|
|
31
|
+
// Starlight's `link-alt` icon (`components-internals/Icons.ts`), the same glyph
|
|
32
|
+
// its own `rehypeAutolinkHeadings` uses.
|
|
33
|
+
const LINK_ICON_PATH =
|
|
34
|
+
'm12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42';
|
|
35
|
+
|
|
36
|
+
function headingText(node) {
|
|
37
|
+
let text = '';
|
|
38
|
+
const walk = (current) => {
|
|
39
|
+
if (!current || typeof current !== 'object') return;
|
|
40
|
+
if (current.type === 'text' || current.type === 'raw') {
|
|
41
|
+
text += String(current.value ?? '');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(current.children)) current.children.forEach(walk);
|
|
45
|
+
};
|
|
46
|
+
walk(node);
|
|
47
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHeadingWrapper(node) {
|
|
51
|
+
const className = node?.properties?.className;
|
|
52
|
+
return (
|
|
53
|
+
node?.type === 'element' &&
|
|
54
|
+
Array.isArray(className) &&
|
|
55
|
+
className.includes('sl-heading-wrapper')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function anchorLink(id, label) {
|
|
60
|
+
return {
|
|
61
|
+
type: 'element',
|
|
62
|
+
tagName: 'a',
|
|
63
|
+
properties: { className: ['sl-anchor-link'], href: `#${id}` },
|
|
64
|
+
children: [
|
|
65
|
+
{
|
|
66
|
+
type: 'element',
|
|
67
|
+
tagName: 'span',
|
|
68
|
+
properties: { ariaHidden: 'true', className: ['sl-anchor-icon'] },
|
|
69
|
+
children: [
|
|
70
|
+
{
|
|
71
|
+
type: 'element',
|
|
72
|
+
tagName: 'svg',
|
|
73
|
+
properties: {
|
|
74
|
+
width: 16,
|
|
75
|
+
height: 16,
|
|
76
|
+
viewBox: '0 0 24 24',
|
|
77
|
+
fill: 'currentColor',
|
|
78
|
+
},
|
|
79
|
+
children: [
|
|
80
|
+
{
|
|
81
|
+
type: 'element',
|
|
82
|
+
tagName: 'path',
|
|
83
|
+
properties: { d: LINK_ICON_PATH },
|
|
84
|
+
children: [],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'element',
|
|
92
|
+
tagName: 'span',
|
|
93
|
+
properties: { className: ['sr-only'], 'data-pagefind-ignore': 'true' },
|
|
94
|
+
children: [{ type: 'text', value: label }],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function rehypeHeadingAnchors() {
|
|
101
|
+
const assignHeadingIds = rehypeHeadingIds();
|
|
102
|
+
|
|
103
|
+
return function transform(tree, file) {
|
|
104
|
+
assignHeadingIds(tree, file);
|
|
105
|
+
|
|
106
|
+
const walk = (node) => {
|
|
107
|
+
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (isHeadingWrapper(node)) return; // already processed
|
|
111
|
+
|
|
112
|
+
for (let index = 0; index < node.children.length; index++) {
|
|
113
|
+
const child = node.children[index];
|
|
114
|
+
const tag =
|
|
115
|
+
child?.type === 'element' && typeof child.tagName === 'string'
|
|
116
|
+
? HEADING_TAG.exec(child.tagName)
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
tag &&
|
|
121
|
+
tag[1] !== '1' &&
|
|
122
|
+
typeof child.properties?.id === 'string' &&
|
|
123
|
+
child.properties.id.length > 0
|
|
124
|
+
) {
|
|
125
|
+
const id = child.properties.id;
|
|
126
|
+
const label = `Section titled “${headingText(child)}”`;
|
|
127
|
+
node.children[index] = {
|
|
128
|
+
type: 'element',
|
|
129
|
+
tagName: 'div',
|
|
130
|
+
properties: {
|
|
131
|
+
className: ['sl-heading-wrapper', `level-${child.tagName}`],
|
|
132
|
+
},
|
|
133
|
+
children: [child, anchorLink(id, label)],
|
|
134
|
+
};
|
|
135
|
+
continue; // the heading is a leaf for our purposes
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
walk(child);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
walk(tree);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -36,7 +36,9 @@ export const NOTATION_KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
|
36
36
|
import { GlyphResolutionError, resolveMathGlyphs } from './math-glyphs.mjs';
|
|
37
37
|
import {
|
|
38
38
|
EQ_LABEL_PATTERN,
|
|
39
|
+
collectDisplayEquations,
|
|
39
40
|
collectEquationLabels,
|
|
41
|
+
equationAnchorId,
|
|
40
42
|
parseEquationRef,
|
|
41
43
|
stripEquationLabels,
|
|
42
44
|
} from './equations.mjs';
|
|
@@ -307,28 +309,31 @@ function defaultHref(definition, key, base = '') {
|
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
/**
|
|
310
|
-
* Wrap a
|
|
311
|
-
* (which splices the inner `math-display` element out).
|
|
312
|
-
* a
|
|
313
|
-
*
|
|
312
|
+
* Wrap a display `$$…$$` `math` node so the container survives `rehype-katex`
|
|
313
|
+
* (which splices the inner `math-display` element out). Every display equation
|
|
314
|
+
* in a lesson is numbered; a keyed one anchors at `#eq-<key>`, an unkeyed one
|
|
315
|
+
* at its positional `#eq-<section>-<index>`. The visible number is a focusable
|
|
316
|
+
* `<a>` sibling — never authored, never inside KaTeX — so it survives print and
|
|
317
|
+
* screen readers name it "Equation 2.4".
|
|
314
318
|
*/
|
|
315
|
-
function
|
|
319
|
+
function equationNode(mathNode, { key, number }) {
|
|
320
|
+
const id = equationAnchorId({ key, number });
|
|
321
|
+
const hProperties = {
|
|
322
|
+
id,
|
|
323
|
+
className: ['keyed-equation'],
|
|
324
|
+
'data-eq-number': number,
|
|
325
|
+
};
|
|
326
|
+
if (key) hProperties['data-eq-key'] = key;
|
|
327
|
+
else hProperties['data-eq-auto'] = '';
|
|
328
|
+
|
|
316
329
|
return {
|
|
317
330
|
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
|
-
},
|
|
331
|
+
data: { hName: 'div', hProperties },
|
|
327
332
|
children: [
|
|
328
333
|
mathNode,
|
|
329
334
|
{
|
|
330
335
|
type: 'link',
|
|
331
|
-
url:
|
|
336
|
+
url: `#${id}`,
|
|
332
337
|
children: [{ type: 'text', value: `(${number})` }],
|
|
333
338
|
data: {
|
|
334
339
|
hProperties: {
|
|
@@ -598,6 +603,14 @@ export default function remarkNotation(options = {}) {
|
|
|
598
603
|
crossPage: options.equations ?? {},
|
|
599
604
|
};
|
|
600
605
|
|
|
606
|
+
// Number and anchor every display equation on a lesson page, keyed or
|
|
607
|
+
// not (F3). Non-lesson pages (notation registry, stray docs) keep the
|
|
608
|
+
// keyed-only behaviour so their body math is left untouched.
|
|
609
|
+
const displayEquations = lessonId ? collectDisplayEquations(tree) : [];
|
|
610
|
+
const equationByNode = new Map(
|
|
611
|
+
displayEquations.map((equation) => [equation.node, equation]),
|
|
612
|
+
);
|
|
613
|
+
|
|
601
614
|
const resolve = (key, kind, node) => {
|
|
602
615
|
const context = {
|
|
603
616
|
file,
|
|
@@ -647,7 +660,6 @@ export default function remarkNotation(options = {}) {
|
|
|
647
660
|
node,
|
|
648
661
|
);
|
|
649
662
|
}
|
|
650
|
-
node.__eqKey = labelMatch[1];
|
|
651
663
|
value = stripEquationLabels(value);
|
|
652
664
|
replaceMathSource(node, value);
|
|
653
665
|
}
|
|
@@ -725,14 +737,8 @@ export default function remarkNotation(options = {}) {
|
|
|
725
737
|
continue;
|
|
726
738
|
}
|
|
727
739
|
visit(child, false);
|
|
728
|
-
if (child && child.
|
|
729
|
-
|
|
730
|
-
delete child.__eqKey;
|
|
731
|
-
node.children[index] = keyedEquationNode(
|
|
732
|
-
child,
|
|
733
|
-
key,
|
|
734
|
-
pageLabels.get(key),
|
|
735
|
-
);
|
|
740
|
+
if (child && child.type === 'math' && equationByNode.has(child)) {
|
|
741
|
+
node.children[index] = equationNode(child, equationByNode.get(child));
|
|
736
742
|
}
|
|
737
743
|
}
|
|
738
744
|
};
|
|
@@ -15,6 +15,19 @@ type NotationAlignment = {
|
|
|
15
15
|
readonly rationale: string;
|
|
16
16
|
};
|
|
17
17
|
type NotationReferenceKind = 'prose' | 'math' | 'definition';
|
|
18
|
+
/**
|
|
19
|
+
* A gloss resolved by the loader (D15). Declared on a card, keyed
|
|
20
|
+
* `<card key>.<slug(name)>`, and deliberately absent from the registry: a
|
|
21
|
+
* gloss is never a definition record, never a reference target, and never a
|
|
22
|
+
* curriculum node. See `reference/gloss.ts`.
|
|
23
|
+
*/
|
|
24
|
+
interface NotationGlossEntry {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly notation: string;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
readonly units?: string;
|
|
29
|
+
readonly ownerKey: string;
|
|
30
|
+
}
|
|
18
31
|
interface NotationReferenceInput {
|
|
19
32
|
readonly key: string;
|
|
20
33
|
readonly kind: NotationReferenceKind;
|
|
@@ -37,6 +50,8 @@ interface SharedNotationDefinitionInput {
|
|
|
37
50
|
readonly body: string;
|
|
38
51
|
/** References harvested from the Markdown body. */
|
|
39
52
|
readonly references: readonly NotationReferenceInput[];
|
|
53
|
+
/** Symbols this entry's `formula` names but does not make cards of. */
|
|
54
|
+
readonly glosses: readonly NotationGlossEntry[];
|
|
40
55
|
readonly source: SourceSpan;
|
|
41
56
|
}
|
|
42
57
|
interface LocalNotationDefinitionInput {
|
|
@@ -51,6 +66,8 @@ interface LocalNotationDefinitionInput {
|
|
|
51
66
|
readonly alignment: NotationAlignment;
|
|
52
67
|
/** References harvested from summary content. */
|
|
53
68
|
readonly references: readonly NotationReferenceInput[];
|
|
69
|
+
/** Symbols this entry's `formula` names but does not make cards of. */
|
|
70
|
+
readonly glosses: readonly NotationGlossEntry[];
|
|
54
71
|
readonly source: SourceSpan;
|
|
55
72
|
}
|
|
56
73
|
interface NotationLessonInput {
|
|
@@ -67,7 +84,7 @@ interface NotationRegistryInput {
|
|
|
67
84
|
readonly sharedDefinitions: readonly SharedNotationDefinitionInput[];
|
|
68
85
|
readonly lessons: readonly NotationLessonInput[];
|
|
69
86
|
}
|
|
70
|
-
type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state';
|
|
87
|
+
type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state' | 'gloss-collides-with-card';
|
|
71
88
|
type DiagnosticSeverity = 'error' | 'warning';
|
|
72
89
|
interface NotationDiagnostic {
|
|
73
90
|
readonly code: NotationDiagnosticCode;
|
|
@@ -95,6 +112,12 @@ interface ResolvedNotationReference {
|
|
|
95
112
|
interface NotationDefinitionRecordBase {
|
|
96
113
|
readonly id: string;
|
|
97
114
|
readonly key: string;
|
|
115
|
+
/**
|
|
116
|
+
* Symbols this entry's `formula` names but does not make cards of (D15).
|
|
117
|
+
* Carried on the record so the manifest can build the entry-scoped formula
|
|
118
|
+
* table; deliberately **not** a definition, a reference target, or an edge.
|
|
119
|
+
*/
|
|
120
|
+
readonly glosses: readonly NotationGlossEntry[];
|
|
98
121
|
readonly scope: NotationDefinitionScope;
|
|
99
122
|
readonly notation: string;
|
|
100
123
|
readonly label: string;
|
|
@@ -148,4 +171,4 @@ interface NotationRegistry {
|
|
|
148
171
|
readonly diagnostics: readonly NotationDiagnostic[];
|
|
149
172
|
}
|
|
150
173
|
|
|
151
|
-
export type { DiagnosticSeverity, EditorialStatus, LocalNotationDefinitionInput, LocalNotationDefinitionRecord, NotationAlignment, NotationBacklink, NotationBinding, NotationDefinitionRecord, NotationDefinitionScope, NotationDiagnostic, NotationDiagnosticCode, NotationLessonInput, NotationPageBundle, NotationReferenceInput, NotationReferenceKind, NotationRegistry, NotationRegistryInput, ResolvedNotationReference, SharedNotationDefinitionInput, SharedNotationDefinitionRecord, SourceSpan };
|
|
174
|
+
export type { DiagnosticSeverity, EditorialStatus, LocalNotationDefinitionInput, LocalNotationDefinitionRecord, NotationAlignment, NotationBacklink, NotationBinding, NotationDefinitionRecord, NotationDefinitionScope, NotationDiagnostic, NotationDiagnosticCode, NotationGlossEntry, NotationLessonInput, NotationPageBundle, NotationReferenceInput, NotationReferenceKind, NotationRegistry, NotationRegistryInput, ResolvedNotationReference, SharedNotationDefinitionInput, SharedNotationDefinitionRecord, SourceSpan };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explico",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|