explico 0.6.0 → 0.8.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 (95) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
@@ -37,6 +37,7 @@
37
37
 
38
38
  <script>
39
39
  import { HEADINGS, sectionParts } from './section-parts';
40
+ import { animatedScrollIntoView } from '../scroll-animation';
40
41
 
41
42
  (() => {
42
43
  const root = document.querySelector('.sl-markdown-content');
@@ -78,10 +79,7 @@
78
79
  if (!heading) return;
79
80
  const parts = sectionParts(heading);
80
81
  if (doScroll) {
81
- parts[0].scrollIntoView({
82
- behavior: reduce.matches ? 'auto' : 'smooth',
83
- block: 'start',
84
- });
82
+ animatedScrollIntoView(parts[0], { block: 'start' });
85
83
  }
86
84
  for (const part of parts) flash(part);
87
85
  heading.setAttribute('tabindex', '-1');
@@ -40,6 +40,8 @@
40
40
  ---
41
41
 
42
42
  <script>
43
+ import { animatedScrollIntoView } from '../scroll-animation';
44
+
43
45
  (() => {
44
46
  const root = document.querySelector('.sl-markdown-content');
45
47
  if (!root) return;
@@ -97,10 +99,7 @@
97
99
  target.style.removeProperty('animation');
98
100
  }
99
101
  if (doScroll) {
100
- target.scrollIntoView({
101
- behavior: reduce.matches ? 'auto' : 'smooth',
102
- block: 'start',
103
- });
102
+ animatedScrollIntoView(target, { block: 'start' });
104
103
  }
105
104
  target.setAttribute('tabindex', '-1');
106
105
  try {
@@ -132,13 +131,15 @@
132
131
 
133
132
  // "Copy LaTeX" for an equation, two ways: its own button (only a numbered
134
133
  // display equation has one — `data-eq-copy`, `remark-notation.mjs`'s
135
- // `equationNode`), or a click anywhere else in the equation that isn't a
136
- // symbol with its own notation card (an operator, a bare number, a
137
- // parenthesis, …) — the same gesture works on an inline equation too
138
- // (`inlineEquationNode`), which has no room for a persistent button of
139
- // its own without breaking the reading line. A notation-registry symbol
140
- // keeps its existing click-to-pin behavior either way
141
- // (`NotationLayer.astro`), so this skips anything under `[data-notation-key]`.
134
+ // `equationNode`), or a click anywhere else in the box — including the
135
+ // reserved gutter between the equation and its number/button, not just
136
+ // the rendered `.katex-html` — that isn't the number link or a symbol
137
+ // with its own notation card. The same gesture works on an inline
138
+ // equation too (`inlineEquationNode`), which has no room for a
139
+ // persistent button of its own without breaking the reading line. A
140
+ // notation-registry symbol keeps its existing click-to-pin behavior
141
+ // either way (`NotationLayer.astro`), so this skips anything under
142
+ // `[data-notation-key]`.
142
143
  root.addEventListener('click', (event) => {
143
144
  const origin = event.target instanceof Element ? event.target : null;
144
145
  if (!origin) return;
@@ -150,9 +151,7 @@
150
151
  const container = ownButton
151
152
  ? null
152
153
  : origin.closest<HTMLElement>('.keyed-equation, .inline-equation');
153
- if (!ownButton && (!container || !origin.closest('.katex-html'))) {
154
- return;
155
- }
154
+ if (!ownButton && !container) return;
156
155
 
157
156
  const feedbackTarget =
158
157
  ownButton ??
@@ -185,12 +184,14 @@
185
184
  root.addEventListener('mouseover', (event) => {
186
185
  const origin = event.target instanceof Element ? event.target : null;
187
186
  if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
188
- if (origin.closest('[data-notation-key], [data-eq-copy]')) return;
189
187
  if (
190
- !origin.closest('.keyed-equation .katex-html, .inline-equation .katex-html')
188
+ origin.closest(
189
+ '[data-notation-key], [data-eq-copy], a.keyed-equation__number',
190
+ )
191
191
  ) {
192
192
  return;
193
193
  }
194
+ if (!origin.closest('.keyed-equation, .inline-equation')) return;
194
195
  origin.setAttribute('title', 'Copy LaTeX');
195
196
  });
196
197
 
@@ -23,6 +23,7 @@ import AbbreviationLayer from '../abbreviation/AbbreviationLayer.astro';
23
23
  import KeyedFloatEnhancer from './KeyedFloatEnhancer.astro';
24
24
  import FloatRefLayer from '../reference/FloatRefLayer.astro';
25
25
  import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
26
+ import AnchorScrollEnhancer from './AnchorScrollEnhancer.astro';
26
27
  import CollapsibleSections from './CollapsibleSections.astro';
27
28
  import TableOverflowExpander from '../table/TableOverflowExpander.astro';
28
29
  import DiagramEnhancer from './DiagramEnhancer.astro';
@@ -83,6 +84,7 @@ if (lessonId) {
83
84
  ---
84
85
 
85
86
  <NotationLayer definitions={definitions} defaultOpen={notationDefaultOpen} />
87
+ <AnchorScrollEnhancer />
86
88
  {lessonId && <CitationLayer />}
87
89
  {lessonId && <AbbreviationLayer />}
88
90
  {lessonId && <KeyedFloatEnhancer />}
@@ -20,12 +20,18 @@ export const BASE_ABBREVIATIONS = Object.freeze(
20
20
  'e.g.',
21
21
  'i.e.',
22
22
  'etc.',
23
+ 'e.t.c.',
23
24
  'cf.',
24
25
  'vs.',
25
- 'a.m.',
26
- 'p.m.',
27
26
  'a.k.a.',
28
27
  'et al.',
28
+ // time of day, in each casing/punctuation style a lesson might use
29
+ 'a.m.',
30
+ 'p.m.',
31
+ 'A.M.',
32
+ 'P.M.',
33
+ 'AM',
34
+ 'PM',
29
35
  // generic acronyms with no domain-specific meaning in this engine
30
36
  'AI',
31
37
  'ID',
@@ -2,11 +2,11 @@
2
2
  * Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
3
3
  * plan's "Enforced checks" section that no earlier step covers.
4
4
  *
5
- * Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
6
- * surfaces the counts, `manifest.diagnostics.consistency` records the detail,
7
- * and `resolution/consistency-report.json` is the committed, diff-checkable
8
- * snapshot. Nothing here changes content — each finding is an open item for a
9
- * separate human-quantitative-reviewed pass. A finding is silenced only by a
5
+ * Every check here is non-blocking Tier 3: repairable violations are warnings
6
+ * and optional restructuring advice is info. `pnpm validate:content` surfaces
7
+ * warning counts, `manifest.diagnostics.consistency` records every detail, and
8
+ * `resolution/consistency-report.json` is the committed, diff-checkable
9
+ * snapshot. Nothing here changes content. A finding is silenced only by a
10
10
  * reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
11
11
  *
12
12
  * (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
@@ -14,8 +14,6 @@
14
14
  * unit strings drawn from a controlled vocab
15
15
  * (c) convention-single-definition a sign / cash-flow-perspective convention a
16
16
  * lesson uses resolves to one declaration
17
- * (e) numerals-tagged numerals in `$…$` / result tables are tagged
18
- * to a `src/domain/` call or a source locator
19
17
  * (f) weak-local a `notation.local` symbol used only in its
20
18
  * own defining equation and prose
21
19
  * (g) notation-source-locator every notation `sources` entry has a locator
@@ -54,7 +52,6 @@ export type ConsistencyCode =
54
52
  | 'notation-units'
55
53
  | 'units-vocab'
56
54
  | 'convention-single-definition'
57
- | 'numerals-tagged'
58
55
  | 'weak-local'
59
56
  | 'notation-source-locator'
60
57
  | 'gloss-wants-promoting'
@@ -65,8 +62,8 @@ export type ConsistencyCode =
65
62
 
66
63
  export interface ConsistencyDiagnostic {
67
64
  readonly code: ConsistencyCode;
68
- /** Tier 3 — every finding here is a warning until content is brought clean. */
69
- readonly severity: 'warning';
65
+ /** Tier 3 — repairable violations are warnings; optional advice is info. */
66
+ readonly severity: 'warning' | 'info';
70
67
  readonly message: string;
71
68
  readonly file?: string;
72
69
  readonly lessonId?: string;
@@ -119,28 +116,6 @@ function mathSpans(body: string): MathSpan[] {
119
116
  return spans;
120
117
  }
121
118
 
122
- function tableRows(body: string): string[] {
123
- const rows: string[] = [];
124
- for (const line of stripCode(body).split('\n')) {
125
- const trimmed = line.trim();
126
- if (!trimmed.startsWith('|') || !trimmed.slice(1).includes('|')) continue;
127
- if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
128
- rows.push(line);
129
- }
130
- return rows;
131
- }
132
-
133
- const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
134
-
135
- function numeralCount(text: string): number {
136
- return (text.replace(/\\[a-zA-Z]+/g, ' ').match(NUMERAL_RE) ?? []).length;
137
- }
138
-
139
- /** A math span / table row is "tagged" when it carries a citation or a domain marker. */
140
- function isTagged(text: string): boolean {
141
- return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
142
- }
143
-
144
119
  // --- glyph regex for weak-local --------------------------------------
145
120
 
146
121
  function glyphMatcher(latex: string): RegExp | undefined {
@@ -274,35 +249,6 @@ function checkConventionSingleDefinition(
274
249
  }
275
250
  }
276
251
 
277
- function checkNumeralsTagged(
278
- input: ConsistencyInput,
279
- out: ConsistencyDiagnostic[],
280
- ): void {
281
- for (const lesson of input.notationInput.lessons) {
282
- if (input.lintIgnore.matches('numerals-tagged', lesson.lessonId)) continue;
283
- let math = 0;
284
- for (const span of mathSpans(lesson.body)) {
285
- if (isTagged(span.latex)) continue;
286
- math += numeralCount(span.latex);
287
- }
288
- let table = 0;
289
- for (const row of tableRows(lesson.body)) {
290
- if (isTagged(row)) continue;
291
- table += numeralCount(row.replace(/\$[^$\n]*\$/g, ' '));
292
- }
293
- const count = math + table;
294
- if (count === 0) continue;
295
- out.push({
296
- code: 'numerals-tagged',
297
- severity: 'warning',
298
- lessonId: lesson.lessonId,
299
- file: lesson.source.file,
300
- count,
301
- message: `${lesson.lessonId}: ${count} numeral(s) in $…$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`,
302
- });
303
- }
304
- }
305
-
306
252
  function checkWeakLocal(
307
253
  input: ConsistencyInput,
308
254
  out: ConsistencyDiagnostic[],
@@ -317,7 +263,7 @@ function checkWeakLocal(
317
263
  if (uses > 1) continue;
318
264
  out.push({
319
265
  code: 'weak-local',
320
- severity: 'warning',
266
+ severity: 'info',
321
267
  lessonId: lesson.lessonId,
322
268
  file: lesson.source.file,
323
269
  key: definition.key,
@@ -390,7 +336,7 @@ function checkGlossWantsPromoting(
390
336
  if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
391
337
  out.push({
392
338
  code: 'gloss-wants-promoting',
393
- severity: 'warning',
339
+ severity: 'info',
394
340
  glyph,
395
341
  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`,
396
342
  });
@@ -425,7 +371,7 @@ function checkCardWantsDemoting(
425
371
  }
426
372
  out.push({
427
373
  code: 'card-wants-demoting',
428
- severity: 'warning',
374
+ severity: 'info',
429
375
  key: definition.key,
430
376
  file: definition.source.file,
431
377
  count: words,
@@ -627,7 +573,6 @@ const CHECKS: ((
627
573
  checkGlyphUniqueness,
628
574
  checkNotationUnits,
629
575
  checkConventionSingleDefinition,
630
- checkNumeralsTagged,
631
576
  checkWeakLocal,
632
577
  checkNotationSourceLocator,
633
578
  checkGlossWantsPromoting,
@@ -666,7 +611,6 @@ export function consistencyCounts(
666
611
  'notation-units': 0,
667
612
  'units-vocab': 0,
668
613
  'convention-single-definition': 0,
669
- 'numerals-tagged': 0,
670
614
  'weak-local': 0,
671
615
  'notation-source-locator': 0,
672
616
  'gloss-wants-promoting': 0,
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Rule N17: a nested scalable delimiter is sized by the engine, never by hand.
3
+ *
4
+ * The engine installs a KaTeX whose `\delimitershortfall` is `-1pt`
5
+ * (`katex-delimiters.mjs`), so a `\left`/`\right` pair is always
6
+ * strictly taller than what it wraps. Nesting depth therefore shows up on its
7
+ * own, and a hand-picked `\bigl`…`\Biggr` inside a nest only replaces a size
8
+ * that tracks the content with a fixed glyph that does not.
9
+ *
10
+ * This deliberately parses only sizing syntax. It does not decide whether a
11
+ * parenthesis, bracket, or brace is semantic, and it never recommends changing
12
+ * one delimiter shape into another. N16's alternating-shape preference remains
13
+ * a review-only authoring concern.
14
+ */
15
+
16
+ export const DELIMITER_SIZE_DIAGNOSTIC_CODE = 'delimiter-manual-size' as const;
17
+
18
+ export type DelimiterSizeName =
19
+ | 'automatic'
20
+ | 'big'
21
+ | 'Big'
22
+ | 'bigg'
23
+ | 'Bigg';
24
+
25
+ export interface DelimiterSizeSuggestion {
26
+ readonly open: string;
27
+ readonly close: string;
28
+ readonly latex: string;
29
+ }
30
+
31
+ export interface DelimiterSizeDiagnostic {
32
+ readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
33
+ readonly severity: 'warning';
34
+ readonly offset: number;
35
+ readonly line: number;
36
+ readonly column: number;
37
+ /** The hand-picked size that should become `\left`/`\right`. */
38
+ readonly size: Exclude<DelimiterSizeName, 'automatic'>;
39
+ readonly suggestion: DelimiterSizeSuggestion;
40
+ readonly message: string;
41
+ }
42
+
43
+ type TokenKind = 'open' | 'close';
44
+ type PairKind = 'automatic' | 'manual';
45
+
46
+ interface SizeSpec {
47
+ readonly name: DelimiterSizeName;
48
+ /** `\left` and `\big` can be visually equivalent, so share level 1. */
49
+ readonly rank: number;
50
+ readonly openCommand: string;
51
+ readonly closeCommand: string;
52
+ readonly kind: PairKind;
53
+ }
54
+
55
+ interface DelimiterToken {
56
+ readonly kind: TokenKind;
57
+ readonly pairKind: PairKind;
58
+ readonly rank: number;
59
+ readonly size: DelimiterSizeName;
60
+ readonly delimiter: string;
61
+ readonly offset: number;
62
+ readonly end: number;
63
+ }
64
+
65
+ interface DelimiterPair {
66
+ readonly open: DelimiterToken;
67
+ close?: DelimiterToken;
68
+ readonly children: DelimiterPair[];
69
+ }
70
+
71
+ const SIZE_SPECS: readonly SizeSpec[] = [
72
+ {
73
+ name: 'automatic',
74
+ rank: 1,
75
+ openCommand: '\\left',
76
+ closeCommand: '\\right',
77
+ kind: 'automatic',
78
+ },
79
+ {
80
+ name: 'big',
81
+ rank: 1,
82
+ openCommand: '\\bigl',
83
+ closeCommand: '\\bigr',
84
+ kind: 'manual',
85
+ },
86
+ {
87
+ name: 'Big',
88
+ rank: 2,
89
+ openCommand: '\\Bigl',
90
+ closeCommand: '\\Bigr',
91
+ kind: 'manual',
92
+ },
93
+ {
94
+ name: 'bigg',
95
+ rank: 3,
96
+ openCommand: '\\biggl',
97
+ closeCommand: '\\biggr',
98
+ kind: 'manual',
99
+ },
100
+ {
101
+ name: 'Bigg',
102
+ rank: 4,
103
+ openCommand: '\\Biggl',
104
+ closeCommand: '\\Biggr',
105
+ kind: 'manual',
106
+ },
107
+ ];
108
+
109
+ interface CommandSpec extends SizeSpec {
110
+ readonly command: string;
111
+ readonly tokenKind: TokenKind;
112
+ }
113
+
114
+ // Longest first prevents `\Biggl` from being considered as `\Bigl`.
115
+ const COMMAND_SPECS: readonly CommandSpec[] = SIZE_SPECS.flatMap((spec) => [
116
+ { ...spec, command: spec.openCommand, tokenKind: 'open' as const },
117
+ { ...spec, command: spec.closeCommand, tokenKind: 'close' as const },
118
+ ]).sort((left, right) => right.command.length - left.command.length);
119
+
120
+ function delimiterAt(
121
+ latex: string,
122
+ offset: number,
123
+ kind: TokenKind,
124
+ ): { delimiter: string; end: number } | undefined {
125
+ const candidates =
126
+ kind === 'open'
127
+ ? (['\\{', '(', '['] as const)
128
+ : (['\\}', ')', ']'] as const);
129
+ for (const delimiter of candidates) {
130
+ if (latex.startsWith(delimiter, offset)) {
131
+ return { delimiter, end: offset + delimiter.length };
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function tokenAt(latex: string, offset: number): DelimiterToken | undefined {
138
+ if (latex[offset] !== '\\') return undefined;
139
+ for (const spec of COMMAND_SPECS) {
140
+ if (!latex.startsWith(spec.command, offset)) continue;
141
+ const afterCommand = offset + spec.command.length;
142
+ // A longer control word such as `\leftarrow` is not `\left`.
143
+ if (/[A-Za-z]/.test(latex[afterCommand] ?? '')) continue;
144
+ let delimiterOffset = afterCommand;
145
+ while (/\s/.test(latex[delimiterOffset] ?? '')) delimiterOffset += 1;
146
+ const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
147
+ if (!delimiter) continue;
148
+ return {
149
+ kind: spec.tokenKind,
150
+ pairKind: spec.kind,
151
+ rank: spec.rank,
152
+ size: spec.name,
153
+ delimiter: delimiter.delimiter,
154
+ offset,
155
+ end: delimiter.end,
156
+ };
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ function pairMatches(open: DelimiterToken, close: DelimiterToken): boolean {
162
+ return (
163
+ open.kind === 'open' &&
164
+ close.kind === 'close' &&
165
+ open.pairKind === close.pairKind &&
166
+ open.rank === close.rank
167
+ );
168
+ }
169
+
170
+ function isEscaped(latex: string, offset: number): boolean {
171
+ let slashes = 0;
172
+ for (let index = offset - 1; index >= 0 && latex[index] === '\\'; index -= 1) {
173
+ slashes += 1;
174
+ }
175
+ return slashes % 2 === 1;
176
+ }
177
+
178
+ /**
179
+ * Build a real nesting tree using an explicit stack. Malformed input is left
180
+ * to KaTeX's syntax diagnostics; this linter emits no potentially misleading
181
+ * size advice for a source whose pairs cannot be matched reliably.
182
+ */
183
+ function parseDelimiterPairs(latex: string): DelimiterPair[] | undefined {
184
+ const roots: DelimiterPair[] = [];
185
+ const stack: DelimiterPair[] = [];
186
+
187
+ for (let offset = 0; offset < latex.length; offset += 1) {
188
+ if (latex[offset] === '%' && !isEscaped(latex, offset)) {
189
+ const newline = latex.indexOf('\n', offset + 1);
190
+ if (newline === -1) break;
191
+ offset = newline;
192
+ continue;
193
+ }
194
+ const token = isEscaped(latex, offset) ? undefined : tokenAt(latex, offset);
195
+ if (!token) continue;
196
+ offset = token.end - 1;
197
+
198
+ if (token.kind === 'open') {
199
+ const pair: DelimiterPair = { open: token, children: [] };
200
+ const parent = stack[stack.length - 1];
201
+ if (parent) parent.children.push(pair);
202
+ else roots.push(pair);
203
+ stack.push(pair);
204
+ continue;
205
+ }
206
+
207
+ const pair = stack[stack.length - 1];
208
+ if (!pair || !pairMatches(pair.open, token)) return undefined;
209
+ pair.close = token;
210
+ stack.pop();
211
+ }
212
+
213
+ return stack.length === 0 ? roots : undefined;
214
+ }
215
+
216
+ function lineAndColumn(
217
+ source: string,
218
+ offset: number,
219
+ ): { line: number; column: number } {
220
+ const before = source.slice(0, offset);
221
+ const lastNewline = before.lastIndexOf('\n');
222
+ return {
223
+ line: before.split('\n').length,
224
+ column: offset - lastNewline,
225
+ };
226
+ }
227
+
228
+ const SPEC_BY_SIZE = new Map(SIZE_SPECS.map((spec) => [spec.name, spec]));
229
+
230
+ function suggestionFor(pair: DelimiterPair): DelimiterSizeSuggestion {
231
+ const open = `\\left${pair.open.delimiter}`;
232
+ const close = `\\right${pair.close?.delimiter ?? pair.open.delimiter}`;
233
+ return { open, close, latex: `${open} ... ${close}` };
234
+ }
235
+
236
+ /**
237
+ * Warn when a pair that takes part in a nest of scalable delimiters is sized
238
+ * by hand instead of by `\left`/`\right`.
239
+ *
240
+ * A pair takes part in a nest when it contains, or sits inside, another pair
241
+ * this linter tracks. A lone hand-sized pair around otherwise bare content —
242
+ * `\bigl(x(t),\ y(t)\bigr)` for a tuple — is a deliberate typographic
243
+ * choice at a single level, has no hierarchy to get wrong, and is left alone.
244
+ *
245
+ * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
246
+ * ignored: N15 owns that separate error, while ordinary semantic notation
247
+ * such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
248
+ */
249
+ export function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[] {
250
+ const roots = parseDelimiterPairs(latex);
251
+ if (!roots) return [];
252
+ const diagnostics: DelimiterSizeDiagnostic[] = [];
253
+
254
+ const visit = (pair: DelimiterPair, nested: boolean): void => {
255
+ const inNest = nested || pair.children.length > 0;
256
+ if (inNest && pair.open.pairKind === 'manual') {
257
+ const location = lineAndColumn(latex, pair.open.offset);
258
+ const suggestion = suggestionFor(pair);
259
+ diagnostics.push({
260
+ code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
261
+ severity: 'warning',
262
+ offset: pair.open.offset,
263
+ ...location,
264
+ size: pair.open.size as Exclude<DelimiterSizeName, 'automatic'>,
265
+ suggestion,
266
+ message:
267
+ `A nested delimiter pair is sized by hand with ` +
268
+ `${SPEC_BY_SIZE.get(pair.open.size)?.openCommand ?? pair.open.size}` +
269
+ `${pair.open.delimiter}. Use ` +
270
+ `${suggestion.latex} and let the engine size the nest; preserve the ` +
271
+ `original opening and closing delimiter shapes (rule N17).`,
272
+ });
273
+ }
274
+ for (const child of pair.children) visit(child, true);
275
+ };
276
+
277
+ for (const root of roots) visit(root, false);
278
+ return diagnostics.sort((left, right) => left.offset - right.offset);
279
+ }
@@ -15,7 +15,7 @@ export type DiagramDiagnosticCode =
15
15
 
16
16
  export interface DiagramDiagnostic {
17
17
  readonly code: DiagramDiagnosticCode;
18
- readonly severity: 'error' | 'warning';
18
+ readonly severity: 'error' | 'warning' | 'info';
19
19
  readonly message: string;
20
20
  readonly file?: string;
21
21
  readonly lessonId?: string;
@@ -4,7 +4,7 @@
4
4
  * - `eq-duplicate-key` (Tier 1, error): an `eq:` key is unique per lesson.
5
5
  * - `eq-ref-resolves` (Tier 1, error): every `[[eq-key]]` / `[[slug#eq-key]]`
6
6
  * points at a display equation that carries `\label{eq:key}`.
7
- * - `eq-key-unused` (Tier 3, warning): a labelled equation never referenced.
7
+ * - `eq-key-unused` (info): a labelled equation is never referenced.
8
8
  *
9
9
  * The remark pass enforces the same rules on the render path; this gives the
10
10
  * fast `pnpm validate:content` the same coverage and feeds the manifest.
@@ -33,7 +33,7 @@ export type EquationDiagnosticCode =
33
33
 
34
34
  export interface EquationDiagnostic {
35
35
  readonly code: EquationDiagnosticCode;
36
- readonly severity: 'error' | 'warning';
36
+ readonly severity: 'error' | 'warning' | 'info';
37
37
  readonly message: string;
38
38
  readonly file?: string;
39
39
  readonly lessonId?: string;
@@ -154,7 +154,7 @@ export function validateEquations(
154
154
  if (referenced.has(`${label.slug}#${label.key}`)) continue;
155
155
  diagnostics.push({
156
156
  code: 'eq-key-unused',
157
- severity: 'warning',
157
+ severity: 'info',
158
158
  message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
159
159
  file: label.slug,
160
160
  lessonId: label.lessonId,
@@ -106,7 +106,13 @@ export function displayEquationNumber({
106
106
  : `${prefix}.${offset + indexInLesson}`;
107
107
  }
108
108
 
109
- /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
109
+ /**
110
+ * A display equation's anchor id: `eq-<key>` when it carries a
111
+ * `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
112
+ *
113
+ * @param {{ key?: string, number?: string | number }} equation - `number` is
114
+ * read only when there is no `key`, so a keyed caller may omit it.
115
+ */
110
116
  export function equationAnchorId({ key, number }) {
111
117
  return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
112
118
  }
@@ -15,7 +15,7 @@ export type FigureDiagnosticCode =
15
15
 
16
16
  export interface FigureDiagnostic {
17
17
  readonly code: FigureDiagnosticCode;
18
- readonly severity: 'error' | 'warning';
18
+ readonly severity: 'error' | 'warning' | 'info';
19
19
  readonly message: string;
20
20
  readonly file?: string;
21
21
  readonly lessonId?: string;
@@ -25,7 +25,7 @@ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
25
25
  import { isBaseAbbreviation } from './base-abbreviations.mjs';
26
26
  import { pageScope } from './gate-math';
27
27
  import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
28
- import { lineOf, proseOnly } from './gate-prose-notation';
28
+ import { lessonLine, proseOnly } from './gate-prose-notation';
29
29
  import type {
30
30
  AbbreviationDefinitionInput,
31
31
  NotationRegistryInput,
@@ -117,7 +117,7 @@ export function gateAbbreviations(input: {
117
117
  context: 'abbreviation',
118
118
  severity: 'error',
119
119
  file: lesson.source.file,
120
- line: lineOf(lesson.body, index),
120
+ line: lessonLine(lesson, index),
121
121
  token,
122
122
  message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
123
123
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { stripFencedCode } from './equations.mjs';
23
23
  import type { MathGateDiagnostic } from './gate-math';
24
- import { lineOf } from './gate-prose-notation';
24
+ import { lessonLine } from './gate-prose-notation';
25
25
  import type { NotationLessonInput } from './types';
26
26
 
27
27
  export const HEADING_LENGTH_LIMIT = 65;
@@ -120,7 +120,7 @@ export function gateHeadingLength(
120
120
  context: 'structure',
121
121
  severity: 'error',
122
122
  file: lesson.source.file,
123
- line: lineOf(lesson.body, finding.index),
123
+ line: lessonLine(lesson, finding.index),
124
124
  token: finding.token,
125
125
  message: finding.message,
126
126
  });