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.
Files changed (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +142 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -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 { CourseConventions } from '../compiler/course-config';
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
+ }