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
package/src/content-config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { glob } from 'astro/loaders';
|
|
|
3
3
|
import { z } from 'astro/zod';
|
|
4
4
|
import { docsSchema } from '@astrojs/starlight/schema';
|
|
5
5
|
import { isSubstantiveMeaning } from './reference/prose';
|
|
6
|
+
import { glossKeySegment } from './reference/gloss';
|
|
6
7
|
|
|
7
8
|
// Phase F: content lives in the top-level `content/` folder, not `src/content/`.
|
|
8
9
|
// `docsLoader()` is hard-wired to `<srcDir>/content/docs`, so the `docs`
|
|
@@ -71,6 +72,42 @@ const notationSource = z.union([
|
|
|
71
72
|
* `label` is authored only where that reads wrong. The `notationProse`
|
|
72
73
|
* refinement rejects an under-specified `meaning`.
|
|
73
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* A **gloss** on a notation card: a symbol the card's own `formula` needs in
|
|
77
|
+
* order to be written down, carrying a name and nothing else (D15).
|
|
78
|
+
*
|
|
79
|
+
* A gloss is deliberately cheaper than a card. It has no description, no
|
|
80
|
+
* sources, no curriculum alignment, no review state, and no glossary card of
|
|
81
|
+
* its own; it is not a node in the curriculum graph and never creates a
|
|
82
|
+
* reference edge. Its key is derived, not authored — `<card key>.<slug(name)>`
|
|
83
|
+
* — so a gloss is scoped to the card that declares it and two cards may each
|
|
84
|
+
* gloss `T`. Because a formula spells LaTeX and never a key, **promoting** a
|
|
85
|
+
* gloss is exactly: delete this line, add `content/notation/<key>.md`.
|
|
86
|
+
*
|
|
87
|
+
* `units` is optional and stays a bare unit string: "T — time" is ambiguous in
|
|
88
|
+
* a corpus with several `T`s, "T — time, years from valuation" is not, and it
|
|
89
|
+
* is still one line. Prose belongs on a card.
|
|
90
|
+
*/
|
|
91
|
+
const notationGloss = z.object({
|
|
92
|
+
latex: z.string().min(1),
|
|
93
|
+
name: z
|
|
94
|
+
.string()
|
|
95
|
+
.min(1)
|
|
96
|
+
.refine((value) => glossKeySegment(value) !== undefined, {
|
|
97
|
+
message:
|
|
98
|
+
'A gloss name must contain letters or digits so a key can be derived from it.',
|
|
99
|
+
})
|
|
100
|
+
// The symbol goes in `latex`, the name names it. A name carrying LaTeX is
|
|
101
|
+
// the one unambiguous way to get this field wrong, so it blocks; a name
|
|
102
|
+
// that is merely long is a Tier-3 `gloss-name-shape` warning instead,
|
|
103
|
+
// because a complicated quantity may honestly need several words.
|
|
104
|
+
.refine((value) => !/[\\$`\n]/.test(value), {
|
|
105
|
+
message:
|
|
106
|
+
'A gloss name is a plain noun phrase; put the symbol in `latex`, not in the name.',
|
|
107
|
+
}),
|
|
108
|
+
units: z.string().min(1).optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
74
111
|
const notationEntryShape = z.object({
|
|
75
112
|
key: id,
|
|
76
113
|
latex: z.string().min(1),
|
|
@@ -82,6 +119,7 @@ const notationEntryShape = z.object({
|
|
|
82
119
|
sources: z.array(notationSource).default([]),
|
|
83
120
|
alignment: notationAlignmentDefault,
|
|
84
121
|
label: z.string().min(1).optional(),
|
|
122
|
+
glosses: z.array(notationGloss).default([]),
|
|
85
123
|
});
|
|
86
124
|
|
|
87
125
|
const localNotationDefinition = notationEntryShape;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base operator library: the named mathematical operators every reader is
|
|
3
|
+
* assumed to know, so a lesson never has to introduce them with a notation
|
|
4
|
+
* entry.
|
|
5
|
+
*
|
|
6
|
+
* Rationale (parallel to `base-library.mjs`): the completeness gate has no
|
|
7
|
+
* opinion about operators — KaTeX parses `\exp`, `\int`, `\sin`, … as operator
|
|
8
|
+
* nodes, and `\operatorname{Var}` as an `operatorname` node, none of which the
|
|
9
|
+
* glyph resolver treats as a bindable symbol. The `operator-explained`
|
|
10
|
+
* consistency check (rule N8) fills that gap: an operator a lesson uses is
|
|
11
|
+
* either on this list (or the course's additions to it) or it carries a
|
|
12
|
+
* notation entry the reader can hover. This set is the "everyone knows these"
|
|
13
|
+
* baseline — trig, the exponential and logarithms, limits, extrema, and the
|
|
14
|
+
* big operators — kept greppable and small like the base library.
|
|
15
|
+
*
|
|
16
|
+
* A domain operator (`Var`, `Cov`, `Corr`, `sgn`, `erf`, `Tr`, `argmax`, …) is
|
|
17
|
+
* written `\operatorname{…}` and is NOT here: it earns a card, a
|
|
18
|
+
* `notation.local` entry, or an explicit line in `course.config.ts`
|
|
19
|
+
* (`operators.allow`).
|
|
20
|
+
*
|
|
21
|
+
* Entries are the KaTeX command without its backslash (`exp`, not `\exp`) for
|
|
22
|
+
* `op` nodes, and the verbatim body for `\operatorname{…}` nodes (`Var`).
|
|
23
|
+
* Matching is case-sensitive.
|
|
24
|
+
*/
|
|
25
|
+
export const BASE_OPERATORS = Object.freeze(
|
|
26
|
+
new Set([
|
|
27
|
+
// exponential / logarithms
|
|
28
|
+
'exp',
|
|
29
|
+
'log',
|
|
30
|
+
'ln',
|
|
31
|
+
'lg',
|
|
32
|
+
// trigonometric
|
|
33
|
+
'sin',
|
|
34
|
+
'cos',
|
|
35
|
+
'tan',
|
|
36
|
+
'cot',
|
|
37
|
+
'sec',
|
|
38
|
+
'csc',
|
|
39
|
+
// inverse trigonometric
|
|
40
|
+
'arcsin',
|
|
41
|
+
'arccos',
|
|
42
|
+
'arctan',
|
|
43
|
+
// hyperbolic
|
|
44
|
+
'sinh',
|
|
45
|
+
'cosh',
|
|
46
|
+
'tanh',
|
|
47
|
+
'coth',
|
|
48
|
+
// limits
|
|
49
|
+
'lim',
|
|
50
|
+
'limsup',
|
|
51
|
+
'liminf',
|
|
52
|
+
'injlim',
|
|
53
|
+
'projlim',
|
|
54
|
+
'varlimsup',
|
|
55
|
+
'varliminf',
|
|
56
|
+
'varinjlim',
|
|
57
|
+
'varprojlim',
|
|
58
|
+
// extrema
|
|
59
|
+
'max',
|
|
60
|
+
'min',
|
|
61
|
+
'sup',
|
|
62
|
+
'inf',
|
|
63
|
+
// linear algebra / number theory / misc
|
|
64
|
+
'arg',
|
|
65
|
+
'deg',
|
|
66
|
+
'det',
|
|
67
|
+
'dim',
|
|
68
|
+
'gcd',
|
|
69
|
+
'hom',
|
|
70
|
+
'ker',
|
|
71
|
+
'Pr',
|
|
72
|
+
'mod',
|
|
73
|
+
// big operators
|
|
74
|
+
'sum',
|
|
75
|
+
'prod',
|
|
76
|
+
'coprod',
|
|
77
|
+
'int',
|
|
78
|
+
'iint',
|
|
79
|
+
'iiint',
|
|
80
|
+
'oint',
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The operators a course treats as common, given its `course.config.ts`
|
|
86
|
+
* `operators` block: the shipped baseline, plus `allow`, minus `deny`.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} operators
|
|
89
|
+
* @returns {Set<string>}
|
|
90
|
+
*/
|
|
91
|
+
export function effectiveOperatorAllowlist(operators) {
|
|
92
|
+
const allow = operators?.allow ?? [];
|
|
93
|
+
const deny = new Set(operators?.deny ?? []);
|
|
94
|
+
const effective = new Set(BASE_OPERATORS);
|
|
95
|
+
for (const name of allow) effective.add(name);
|
|
96
|
+
for (const name of deny) effective.delete(name);
|
|
97
|
+
return effective;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when `name` needs no notation entry for this course. */
|
|
101
|
+
export function isBaseOperator(name, operators) {
|
|
102
|
+
return effectiveOperatorAllowlist(operators).has(name);
|
|
103
|
+
}
|
|
@@ -19,15 +19,23 @@
|
|
|
19
19
|
* (f) weak-local a `notation.local` symbol used only in its
|
|
20
20
|
* own defining equation and prose
|
|
21
21
|
* (g) notation-source-locator every notation `sources` entry has a locator
|
|
22
|
+
* (h) operator-explained a named operator a lesson uses is on the
|
|
23
|
+
* shipped/course operator allowlist or
|
|
24
|
+
* carries a notation entry (rule N8)
|
|
22
25
|
*
|
|
23
26
|
* Check (d) — the committed per-lesson resolution report — lives in
|
|
24
27
|
* `src/content/resolution-report.ts`.
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
CourseConventions,
|
|
32
|
+
CourseOperators,
|
|
33
|
+
} from '../compiler/course-config';
|
|
28
34
|
import type { CurriculumCatalog } from '../curriculum/validation';
|
|
29
35
|
import type { NotationSpec } from '../compiler/collections';
|
|
30
36
|
import type { LintIgnore } from './lint-ignore';
|
|
37
|
+
import { effectiveOperatorAllowlist } from './base-operators.mjs';
|
|
38
|
+
import { collectOperators } from './math-glyphs.mjs';
|
|
31
39
|
import { offVocabularyTokens } from './units-vocab';
|
|
32
40
|
import type {
|
|
33
41
|
NotationDefinitionRecord,
|
|
@@ -42,7 +50,11 @@ export type ConsistencyCode =
|
|
|
42
50
|
| 'convention-single-definition'
|
|
43
51
|
| 'numerals-tagged'
|
|
44
52
|
| 'weak-local'
|
|
45
|
-
| 'notation-source-locator'
|
|
53
|
+
| 'notation-source-locator'
|
|
54
|
+
| 'gloss-wants-promoting'
|
|
55
|
+
| 'card-wants-demoting'
|
|
56
|
+
| 'gloss-name-shape'
|
|
57
|
+
| 'operator-explained';
|
|
46
58
|
|
|
47
59
|
export interface ConsistencyDiagnostic {
|
|
48
60
|
readonly code: ConsistencyCode;
|
|
@@ -65,6 +77,8 @@ export interface ConsistencyInput {
|
|
|
65
77
|
readonly lintIgnore: LintIgnore;
|
|
66
78
|
/** Convention keys this course anchors on (from `content/course.config.ts`). */
|
|
67
79
|
readonly conventions: CourseConventions;
|
|
80
|
+
/** This course's edits to the named-operator baseline (rule N8). */
|
|
81
|
+
readonly operators?: CourseOperators;
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
const CONVENTION_RE =
|
|
@@ -325,6 +339,218 @@ function checkNotationSourceLocator(
|
|
|
325
339
|
}
|
|
326
340
|
}
|
|
327
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The two-tier vocabulary needs both directions kept honest (D15).
|
|
344
|
+
*
|
|
345
|
+
* A gloss is cheap on purpose, and cheapness invites copying: the same symbol
|
|
346
|
+
* and name glossed on three cards is a shared meaning that should be a card,
|
|
347
|
+
* reviewed once, with sources and a curriculum home. `GLOSS_PROMOTION_LIMIT`
|
|
348
|
+
* is where "a letter this formula needs" stops being a plausible reading.
|
|
349
|
+
*/
|
|
350
|
+
const GLOSS_PROMOTION_LIMIT = 3;
|
|
351
|
+
|
|
352
|
+
function checkGlossWantsPromoting(
|
|
353
|
+
input: ConsistencyInput,
|
|
354
|
+
out: ConsistencyDiagnostic[],
|
|
355
|
+
): void {
|
|
356
|
+
const owners = new Map<string, { label: string; keys: Set<string> }>();
|
|
357
|
+
const record = (
|
|
358
|
+
ownerKey: string,
|
|
359
|
+
gloss: { notation: string; label: string },
|
|
360
|
+
) => {
|
|
361
|
+
const id = `${gloss.notation}\u0000${gloss.label.toLowerCase()}`;
|
|
362
|
+
const current = owners.get(id) ?? { label: gloss.label, keys: new Set() };
|
|
363
|
+
current.keys.add(ownerKey);
|
|
364
|
+
owners.set(id, current);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
368
|
+
for (const gloss of definition.glosses) record(definition.key, gloss);
|
|
369
|
+
}
|
|
370
|
+
for (const lesson of input.notationInput.lessons) {
|
|
371
|
+
for (const definition of lesson.localDefinitions) {
|
|
372
|
+
for (const gloss of definition.glosses) record(definition.key, gloss);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const [id, { label, keys }] of [...owners].sort(([a], [b]) =>
|
|
377
|
+
a.localeCompare(b),
|
|
378
|
+
)) {
|
|
379
|
+
if (keys.size < GLOSS_PROMOTION_LIMIT) continue;
|
|
380
|
+
const glyph = id.split('\u0000')[0] ?? '';
|
|
381
|
+
if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
|
|
382
|
+
out.push({
|
|
383
|
+
code: 'gloss-wants-promoting',
|
|
384
|
+
severity: 'warning',
|
|
385
|
+
glyph,
|
|
386
|
+
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`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The other direction: a card that never grew past its name is carrying the
|
|
393
|
+
* full weight of the card tier — a glossary entry, a curriculum node, a review
|
|
394
|
+
* obligation — for something a gloss would say as well.
|
|
395
|
+
*/
|
|
396
|
+
const CARD_BODY_MINIMUM_WORDS = 25;
|
|
397
|
+
|
|
398
|
+
function checkCardWantsDemoting(
|
|
399
|
+
input: ConsistencyInput,
|
|
400
|
+
out: ConsistencyDiagnostic[],
|
|
401
|
+
): void {
|
|
402
|
+
const used = new Set(
|
|
403
|
+
input.registry.backlinks.map((backlink) => backlink.key),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
407
|
+
if (definition.formula !== undefined) continue;
|
|
408
|
+
if (definition.sources.length > 0) continue;
|
|
409
|
+
if (used.has(definition.key)) continue;
|
|
410
|
+
const words = stripCode(definition.body)
|
|
411
|
+
.split(/\s+/)
|
|
412
|
+
.filter((word) => word.length > 0).length;
|
|
413
|
+
if (words >= CARD_BODY_MINIMUM_WORDS) continue;
|
|
414
|
+
if (input.lintIgnore.matches('card-wants-demoting', definition.key)) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
out.push({
|
|
418
|
+
code: 'card-wants-demoting',
|
|
419
|
+
severity: 'warning',
|
|
420
|
+
key: definition.key,
|
|
421
|
+
file: definition.source.file,
|
|
422
|
+
count: words,
|
|
423
|
+
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 — a gloss on the entry that needs it may be the right home`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A gloss `name` is the name of a symbol, not a description of it — the
|
|
430
|
+
* description tier is the card. The schema already blocks the unambiguous
|
|
431
|
+
* failure (LaTeX in the name); this catches the softer drift toward prose.
|
|
432
|
+
*
|
|
433
|
+
* The word limit is measured, not guessed: across the 62 shared cards the
|
|
434
|
+
* longest name is six words (`cds protection buyer net present value`), and
|
|
435
|
+
* that is the most complicated quantity in the course. A gloss names a *letter*
|
|
436
|
+
* inside one formula, so it should be shorter still. Seven or more words means
|
|
437
|
+
* the author is describing rather than naming — a warning, never a block,
|
|
438
|
+
* because a genuinely complicated quantity may earn the exception through
|
|
439
|
+
* `lint-ignore.yml`.
|
|
440
|
+
*/
|
|
441
|
+
const GLOSS_NAME_WORD_LIMIT = 6;
|
|
442
|
+
const GLOSS_NAME_PROSE = /[.;]|\b(?:which|whose|that is|such that)\b/i;
|
|
443
|
+
|
|
444
|
+
function checkGlossNameShape(
|
|
445
|
+
input: ConsistencyInput,
|
|
446
|
+
out: ConsistencyDiagnostic[],
|
|
447
|
+
): void {
|
|
448
|
+
const check = (
|
|
449
|
+
gloss: { key: string; label: string },
|
|
450
|
+
file: string,
|
|
451
|
+
lessonId?: string,
|
|
452
|
+
) => {
|
|
453
|
+
const words = gloss.label.trim().split(/\s+/).filter(Boolean);
|
|
454
|
+
const reason =
|
|
455
|
+
words.length > GLOSS_NAME_WORD_LIMIT
|
|
456
|
+
? `${words.length} words`
|
|
457
|
+
: GLOSS_NAME_PROSE.test(gloss.label)
|
|
458
|
+
? 'sentence punctuation or a relative clause'
|
|
459
|
+
: undefined;
|
|
460
|
+
if (reason === undefined) return;
|
|
461
|
+
if (input.lintIgnore.matches('gloss-name-shape', gloss.key)) return;
|
|
462
|
+
out.push({
|
|
463
|
+
code: 'gloss-name-shape',
|
|
464
|
+
severity: 'warning',
|
|
465
|
+
key: gloss.key,
|
|
466
|
+
file,
|
|
467
|
+
...(lessonId ? { lessonId } : {}),
|
|
468
|
+
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`,
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
for (const definition of input.notationInput.sharedDefinitions) {
|
|
473
|
+
for (const gloss of definition.glosses) {
|
|
474
|
+
check(gloss, definition.source.file);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
for (const lesson of input.notationInput.lessons) {
|
|
478
|
+
for (const definition of lesson.localDefinitions) {
|
|
479
|
+
for (const gloss of definition.glosses) {
|
|
480
|
+
check(gloss, definition.source.file, lesson.lessonId);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* A named operator a lesson uses is either universal — on the shipped
|
|
488
|
+
* `BASE_OPERATORS` baseline plus this course's `operators.allow`, minus its
|
|
489
|
+
* `operators.deny` — or it is explained: a notation card or `notation.local`
|
|
490
|
+
* entry the lesson binds with `[[key]]` whose `latex` is that operator, so the
|
|
491
|
+
* reader can hover it. Anything else (`\operatorname{Var}` with no entry, or a
|
|
492
|
+
* baseline operator the course chose to `deny`) is a finding. Rule N8.
|
|
493
|
+
*
|
|
494
|
+
* The glyph resolver skips operator bodies whole, so this is the only check
|
|
495
|
+
* that looks at them; it reads `collectOperators`, which parses with the same
|
|
496
|
+
* pinned KaTeX as the completeness gate.
|
|
497
|
+
*/
|
|
498
|
+
function checkOperatorExplained(
|
|
499
|
+
input: ConsistencyInput,
|
|
500
|
+
out: ConsistencyDiagnostic[],
|
|
501
|
+
): void {
|
|
502
|
+
const allowlist = effectiveOperatorAllowlist(input.operators);
|
|
503
|
+
const byId = new Map<string, NotationDefinitionRecord>(
|
|
504
|
+
input.registry.definitions.map((definition) => [definition.id, definition]),
|
|
505
|
+
);
|
|
506
|
+
const bindingsByLesson = new Map(
|
|
507
|
+
input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
for (const lesson of input.notationInput.lessons) {
|
|
511
|
+
// Operators the reader is shown on this page: the lesson's own
|
|
512
|
+
// `notation.local` entries, plus every shared card it binds with `[[key]]`.
|
|
513
|
+
const explained = new Set<string>();
|
|
514
|
+
const noteEntryOperators = (latex: string): void => {
|
|
515
|
+
for (const operator of collectOperators(latex)) {
|
|
516
|
+
explained.add(operator.name);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
for (const definition of lesson.localDefinitions) {
|
|
520
|
+
noteEntryOperators(definition.notation);
|
|
521
|
+
}
|
|
522
|
+
for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
|
|
523
|
+
const definition = byId.get(binding.definitionId);
|
|
524
|
+
if (definition) noteEntryOperators(definition.notation);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const reported = new Set<string>();
|
|
528
|
+
for (const span of mathSpans(lesson.body)) {
|
|
529
|
+
for (const operator of collectOperators(span.latex)) {
|
|
530
|
+
if (allowlist.has(operator.name)) continue;
|
|
531
|
+
if (explained.has(operator.name)) continue;
|
|
532
|
+
if (reported.has(operator.name)) continue;
|
|
533
|
+
reported.add(operator.name);
|
|
534
|
+
if (input.lintIgnore.matches('operator-explained', operator.name)) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const display =
|
|
538
|
+
operator.source === 'operatorname'
|
|
539
|
+
? `\\operatorname{${operator.name}}`
|
|
540
|
+
: `\\${operator.name}`;
|
|
541
|
+
out.push({
|
|
542
|
+
code: 'operator-explained',
|
|
543
|
+
severity: 'warning',
|
|
544
|
+
lessonId: lesson.lessonId,
|
|
545
|
+
file: lesson.source.file,
|
|
546
|
+
glyph: operator.name,
|
|
547
|
+
message: `${lesson.lessonId}: operator ${JSON.stringify(display)} is neither on the course operator allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(display)}) introduced with [[key]], or add ${JSON.stringify(operator.name)} to operators.allow in course.config.ts`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
328
554
|
const CHECKS: ((
|
|
329
555
|
input: ConsistencyInput,
|
|
330
556
|
out: ConsistencyDiagnostic[],
|
|
@@ -335,6 +561,10 @@ const CHECKS: ((
|
|
|
335
561
|
checkNumeralsTagged,
|
|
336
562
|
checkWeakLocal,
|
|
337
563
|
checkNotationSourceLocator,
|
|
564
|
+
checkGlossWantsPromoting,
|
|
565
|
+
checkCardWantsDemoting,
|
|
566
|
+
checkGlossNameShape,
|
|
567
|
+
checkOperatorExplained,
|
|
338
568
|
];
|
|
339
569
|
|
|
340
570
|
/**
|
|
@@ -369,6 +599,10 @@ export function consistencyCounts(
|
|
|
369
599
|
'numerals-tagged': 0,
|
|
370
600
|
'weak-local': 0,
|
|
371
601
|
'notation-source-locator': 0,
|
|
602
|
+
'gloss-wants-promoting': 0,
|
|
603
|
+
'card-wants-demoting': 0,
|
|
604
|
+
'gloss-name-shape': 0,
|
|
605
|
+
'operator-explained': 0,
|
|
372
606
|
} satisfies Record<ConsistencyCode, number>;
|
|
373
607
|
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
374
608
|
return counts;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time graph layout for the `Diagram` component.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@dagrejs/dagre` — a pure layout algorithm, no DOM — so node
|
|
5
|
+
* positions and edge routes are computed once at build time, the same way
|
|
6
|
+
* `renderLabMath` and the notation formulas are: static output, no runtime
|
|
7
|
+
* layout library shipped to the reader. `Diagram.astro` turns the result into
|
|
8
|
+
* SVG; this module only computes geometry and is deliberately framework-free
|
|
9
|
+
* so it is unit-testable without KaTeX, Astro, or a browser.
|
|
10
|
+
*
|
|
11
|
+
* Box sizes are not measured from a real render (there is no DOM at build
|
|
12
|
+
* time) — `estimateBoxSize` is a deliberately generous character-count
|
|
13
|
+
* heuristic. It is meant to avoid overlap and clipping in the common case,
|
|
14
|
+
* not to be pixel-accurate; a diagram author can always pass an explicit
|
|
15
|
+
* `width`/`height` on a node for a wide equation.
|
|
16
|
+
*/
|
|
17
|
+
import dagre from '@dagrejs/dagre';
|
|
18
|
+
import type {
|
|
19
|
+
EdgeLabel as DagreEdgeLabel,
|
|
20
|
+
GraphLabel as DagreGraphLabel,
|
|
21
|
+
NodeLabel as DagreNodeLabel,
|
|
22
|
+
} from '@dagrejs/dagre';
|
|
23
|
+
|
|
24
|
+
export interface DiagramNodeInput {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DiagramEdgeInput {
|
|
31
|
+
readonly from: string;
|
|
32
|
+
readonly to: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DiagramLayoutOptions {
|
|
36
|
+
/** Top-to-bottom (default) or left-to-right. */
|
|
37
|
+
readonly direction?: 'TB' | 'LR';
|
|
38
|
+
readonly nodeSeparation?: number;
|
|
39
|
+
readonly rankSeparation?: number;
|
|
40
|
+
/** Uniform padding added around the computed bounding box. */
|
|
41
|
+
readonly padding?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Point {
|
|
45
|
+
readonly x: number;
|
|
46
|
+
readonly y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LaidOutNode {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
/** Top-left corner, not the center dagre itself works in. */
|
|
52
|
+
readonly x: number;
|
|
53
|
+
readonly y: number;
|
|
54
|
+
readonly width: number;
|
|
55
|
+
readonly height: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LaidOutEdge {
|
|
59
|
+
readonly from: string;
|
|
60
|
+
readonly to: string;
|
|
61
|
+
readonly points: readonly Point[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DiagramLayout {
|
|
65
|
+
readonly nodes: readonly LaidOutNode[];
|
|
66
|
+
readonly edges: readonly LaidOutEdge[];
|
|
67
|
+
/** The SVG canvas size a caller should use as the `viewBox`. */
|
|
68
|
+
readonly width: number;
|
|
69
|
+
readonly height: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lay out a small directed graph. Node ids must be unique; an edge naming an
|
|
74
|
+
* undeclared node fails loudly rather than silently dropping it (dagre itself
|
|
75
|
+
* would otherwise create a phantom zero-size node).
|
|
76
|
+
*/
|
|
77
|
+
export function layoutDiagram(
|
|
78
|
+
nodes: readonly DiagramNodeInput[],
|
|
79
|
+
edges: readonly DiagramEdgeInput[],
|
|
80
|
+
options: DiagramLayoutOptions = {},
|
|
81
|
+
): DiagramLayout {
|
|
82
|
+
const ids = new Set<string>();
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
if (ids.has(node.id)) {
|
|
85
|
+
throw new Error(`Diagram declares node "${node.id}" more than once.`);
|
|
86
|
+
}
|
|
87
|
+
if (!(node.width > 0) || !(node.height > 0)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Diagram node "${node.id}" must have a positive width and height.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
ids.add(node.id);
|
|
93
|
+
}
|
|
94
|
+
for (const edge of edges) {
|
|
95
|
+
if (!ids.has(edge.from)) {
|
|
96
|
+
throw new Error(`Diagram edge references unknown node "${edge.from}".`);
|
|
97
|
+
}
|
|
98
|
+
if (!ids.has(edge.to)) {
|
|
99
|
+
throw new Error(`Diagram edge references unknown node "${edge.to}".`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const graph = new dagre.graphlib.Graph<
|
|
104
|
+
DagreGraphLabel,
|
|
105
|
+
DagreNodeLabel,
|
|
106
|
+
DagreEdgeLabel
|
|
107
|
+
>();
|
|
108
|
+
graph.setGraph({
|
|
109
|
+
rankdir: options.direction ?? 'TB',
|
|
110
|
+
nodesep: options.nodeSeparation ?? 24,
|
|
111
|
+
ranksep: options.rankSeparation ?? 48,
|
|
112
|
+
marginx: 0,
|
|
113
|
+
marginy: 0,
|
|
114
|
+
});
|
|
115
|
+
graph.setDefaultEdgeLabel(() => ({}));
|
|
116
|
+
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
graph.setNode(node.id, { width: node.width, height: node.height });
|
|
119
|
+
}
|
|
120
|
+
for (const edge of edges) {
|
|
121
|
+
graph.setEdge(edge.from, edge.to);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dagre.layout(graph);
|
|
125
|
+
|
|
126
|
+
const centeredNodes = nodes.map((node) => {
|
|
127
|
+
const label = graph.node(node.id);
|
|
128
|
+
const cx = label.x ?? 0;
|
|
129
|
+
const cy = label.y ?? 0;
|
|
130
|
+
return {
|
|
131
|
+
id: node.id,
|
|
132
|
+
x: cx - node.width / 2,
|
|
133
|
+
y: cy - node.height / 2,
|
|
134
|
+
width: node.width,
|
|
135
|
+
height: node.height,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const rawEdges = edges.map((edge) => {
|
|
140
|
+
const label = graph.edge(edge.from, edge.to) ?? {};
|
|
141
|
+
return {
|
|
142
|
+
from: edge.from,
|
|
143
|
+
to: edge.to,
|
|
144
|
+
points: (label.points ?? []).map((point) => ({
|
|
145
|
+
x: point.x,
|
|
146
|
+
y: point.y,
|
|
147
|
+
})),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// dagre does not guarantee the layout starts at (0, 0); compute the real
|
|
152
|
+
// bounding box from node boxes and edge routing points, then shift
|
|
153
|
+
// everything so the canvas starts at `padding`.
|
|
154
|
+
const padding = options.padding ?? 12;
|
|
155
|
+
const xs = [
|
|
156
|
+
...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
|
|
157
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.x)),
|
|
158
|
+
];
|
|
159
|
+
const ys = [
|
|
160
|
+
...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
|
|
161
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.y)),
|
|
162
|
+
];
|
|
163
|
+
const minX = xs.length > 0 ? Math.min(...xs) : 0;
|
|
164
|
+
const minY = ys.length > 0 ? Math.min(...ys) : 0;
|
|
165
|
+
const maxX = xs.length > 0 ? Math.max(...xs) : 0;
|
|
166
|
+
const maxY = ys.length > 0 ? Math.max(...ys) : 0;
|
|
167
|
+
const shiftX = padding - minX;
|
|
168
|
+
const shiftY = padding - minY;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
nodes: centeredNodes.map((node) => ({
|
|
172
|
+
...node,
|
|
173
|
+
x: node.x + shiftX,
|
|
174
|
+
y: node.y + shiftY,
|
|
175
|
+
})),
|
|
176
|
+
edges: rawEdges.map((edge) => ({
|
|
177
|
+
...edge,
|
|
178
|
+
points: edge.points.map((point) => ({
|
|
179
|
+
x: point.x + shiftX,
|
|
180
|
+
y: point.y + shiftY,
|
|
181
|
+
})),
|
|
182
|
+
})),
|
|
183
|
+
width: maxX - minX + padding * 2,
|
|
184
|
+
height: maxY - minY + padding * 2,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- box-size estimation (no DOM at build time) --------------------------
|
|
189
|
+
|
|
190
|
+
export interface BoxSizeEstimate {
|
|
191
|
+
readonly width: number;
|
|
192
|
+
readonly height: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const DEFAULT_MAX_WIDTH = 220;
|
|
196
|
+
const MIN_WIDTH = 90;
|
|
197
|
+
const PADDING_X = 20;
|
|
198
|
+
const PADDING_Y = 16;
|
|
199
|
+
const LINE_HEIGHT = 22;
|
|
200
|
+
/** Generous per-visible-character width estimates, in px, at diagram scale. */
|
|
201
|
+
const CHAR_WIDTH: Record<'text' | 'formula', number> = {
|
|
202
|
+
text: 7.2,
|
|
203
|
+
formula: 11,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A LaTeX command name (`\theta`, `\cdot`, …) renders as roughly one glyph,
|
|
208
|
+
* not one character per letter of its name; braces are structural, not
|
|
209
|
+
* visible. Collapsing both keeps the estimate from reading a formula as far
|
|
210
|
+
* wider than it will actually render.
|
|
211
|
+
*/
|
|
212
|
+
function formulaVisibleLength(latex: string): number {
|
|
213
|
+
const collapsed = latex
|
|
214
|
+
.replace(/\\[a-zA-Z]+/g, 'x')
|
|
215
|
+
.replace(/[{}$]/g, '')
|
|
216
|
+
.trim();
|
|
217
|
+
return Math.max(1, collapsed.length);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Estimate a diagram node's box from its label text (or raw, pre-resolution
|
|
222
|
+
* LaTeX for a formula node). Deliberately generous: it is meant to avoid
|
|
223
|
+
* clipping, not to match a real render pixel-for-pixel. Pass an explicit
|
|
224
|
+
* `width`/`height` on the node to override for a label this estimate gets
|
|
225
|
+
* badly wrong.
|
|
226
|
+
*/
|
|
227
|
+
export function estimateBoxSize(
|
|
228
|
+
content: string,
|
|
229
|
+
kind: 'text' | 'formula',
|
|
230
|
+
options: { readonly maxWidth?: number } = {},
|
|
231
|
+
): BoxSizeEstimate {
|
|
232
|
+
const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
233
|
+
const visibleLength =
|
|
234
|
+
kind === 'formula' ? formulaVisibleLength(content) : content.trim().length || 1;
|
|
235
|
+
const charWidth = CHAR_WIDTH[kind];
|
|
236
|
+
|
|
237
|
+
const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
|
|
238
|
+
const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
|
|
239
|
+
|
|
240
|
+
const usableWidth = width - PADDING_X * 2;
|
|
241
|
+
const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
|
|
242
|
+
const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
|
|
243
|
+
const height = lines * LINE_HEIGHT + PADDING_Y * 2;
|
|
244
|
+
|
|
245
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
246
|
+
}
|