explico 0.5.0 → 0.7.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 (88) hide show
  1. package/CHANGELOG.md +295 -7
  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 +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/course-config.d.ts +31 -1
  8. package/dist/compiler/course-config.js +7 -0
  9. package/dist/compiler/exports/standalone-html.js +13 -0
  10. package/dist/compiler/logging.d.ts +109 -0
  11. package/dist/compiler/logging.js +107 -0
  12. package/dist/compiler/manifest.d.ts +16 -4
  13. package/dist/compiler/manifest.js +7 -3
  14. package/dist/reference/base-abbreviations.mjs +8 -2
  15. package/dist/reference/consistency.d.ts +8 -10
  16. package/dist/reference/consistency.js +3 -47
  17. package/dist/reference/delimiter-size.d.ts +39 -0
  18. package/dist/reference/delimiter-size.js +177 -0
  19. package/dist/reference/diagrams-validate.d.ts +1 -1
  20. package/dist/reference/equations-validate.d.ts +2 -2
  21. package/dist/reference/equations-validate.js +1 -1
  22. package/dist/reference/figures-validate.d.ts +1 -1
  23. package/dist/reference/gate-heading-length.d.ts +12 -0
  24. package/dist/reference/gate-heading-length.js +79 -0
  25. package/dist/reference/gate-math.d.ts +8 -2
  26. package/dist/reference/gate-math.js +30 -7
  27. package/dist/reference/keyed-float-validate.d.ts +2 -2
  28. package/dist/reference/keyed-float-validate.js +1 -1
  29. package/dist/reference/math-glyphs.d.mts +12 -0
  30. package/dist/reference/math-glyphs.mjs +183 -42
  31. package/dist/reference/references.js +2 -1
  32. package/dist/reference/registry.js +1 -1
  33. package/dist/reference/rehype-base-links.d.mts +23 -0
  34. package/dist/reference/rehype-base-links.mjs +40 -0
  35. package/dist/reference/remark-abbreviations.mjs +10 -12
  36. package/dist/reference/remark-citation.d.mts +5 -1
  37. package/dist/reference/remark-citation.mjs +0 -0
  38. package/dist/reference/render-lab-math.d.ts +18 -1
  39. package/dist/reference/render-lab-math.js +10 -3
  40. package/dist/reference/tables-validate.d.ts +1 -1
  41. package/dist/reference/types.d.ts +1 -1
  42. package/docs/VERSIONING.md +24 -4
  43. package/docs/authoring/README.md +14 -2
  44. package/docs/authoring/agents.md +7 -3
  45. package/docs/authoring/components.md +4 -1
  46. package/docs/authoring/migrating.md +477 -8
  47. package/docs/authoring/notation.md +194 -7
  48. package/docs/authoring/prose.md +64 -0
  49. package/docs/authoring/structure-rules.md +69 -0
  50. package/docs/authoring/visualizations.md +2 -2
  51. package/package.json +1 -1
  52. package/src/compiler/astro.ts +89 -0
  53. package/src/compiler/cli.ts +144 -12
  54. package/src/compiler/course-config.ts +40 -0
  55. package/src/compiler/exports/standalone-html.ts +15 -0
  56. package/src/compiler/logging.ts +248 -0
  57. package/src/compiler/manifest.ts +20 -3
  58. package/src/components/diagram/Diagram.astro +0 -55
  59. package/src/components/memo/Memo.astro +2 -5
  60. package/src/components/memo/ShortNote.astro +2 -5
  61. package/src/components/notation/NotationFormula.astro +199 -10
  62. package/src/components/notation/NotationGlossary.astro +89 -58
  63. package/src/components/notation/NotationLayer.astro +238 -156
  64. package/src/components/scroll-animation.ts +160 -0
  65. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  66. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  67. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  68. package/src/components/starlight/LessonFooter.astro +5 -1
  69. package/src/content-config.ts +16 -1
  70. package/src/reference/base-abbreviations.mjs +8 -2
  71. package/src/reference/consistency.ts +10 -66
  72. package/src/reference/delimiter-size.ts +301 -0
  73. package/src/reference/diagrams-validate.ts +1 -1
  74. package/src/reference/equations-validate.ts +3 -3
  75. package/src/reference/figures-validate.ts +1 -1
  76. package/src/reference/gate-heading-length.ts +131 -0
  77. package/src/reference/gate-math.ts +48 -8
  78. package/src/reference/keyed-float-validate.ts +3 -3
  79. package/src/reference/math-glyphs.mjs +183 -42
  80. package/src/reference/references.ts +2 -1
  81. package/src/reference/registry.ts +1 -1
  82. package/src/reference/rehype-base-links.mjs +40 -0
  83. package/src/reference/remark-abbreviations.mjs +10 -12
  84. package/src/reference/remark-citation.mjs +0 -0
  85. package/src/reference/render-lab-math.ts +24 -1
  86. package/src/reference/tables-validate.ts +1 -1
  87. package/src/reference/types.ts +1 -1
  88. package/src/styles/global.css +120 -15
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Rule N17: nested scalable delimiters must make their hierarchy visible.
3
+ *
4
+ * This deliberately parses only sizing syntax. It does not decide whether a
5
+ * parenthesis, bracket, or brace is semantic, and it never recommends changing
6
+ * one delimiter shape into another. N16's alternating-shape preference remains
7
+ * a review-only authoring concern.
8
+ */
9
+
10
+ export const DELIMITER_SIZE_DIAGNOSTIC_CODE =
11
+ 'delimiter-size-hierarchy' as const;
12
+
13
+ export type DelimiterSizeName =
14
+ | 'automatic'
15
+ | 'big'
16
+ | 'Big'
17
+ | 'bigg'
18
+ | 'Bigg';
19
+
20
+ export interface DelimiterSizeSuggestion {
21
+ readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
22
+ readonly open: string;
23
+ readonly close: string;
24
+ readonly latex: string;
25
+ }
26
+
27
+ export interface DelimiterSizeDiagnostic {
28
+ readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
29
+ readonly severity: 'warning';
30
+ readonly offset: number;
31
+ readonly line: number;
32
+ readonly column: number;
33
+ readonly outerSize: DelimiterSizeName;
34
+ readonly nestedSize: DelimiterSizeName;
35
+ readonly suggestion?: DelimiterSizeSuggestion;
36
+ readonly message: string;
37
+ }
38
+
39
+ type TokenKind = 'open' | 'close';
40
+ type PairKind = 'automatic' | 'manual';
41
+
42
+ interface SizeSpec {
43
+ readonly name: DelimiterSizeName;
44
+ /** `\left` and `\big` can be visually equivalent, so share level 1. */
45
+ readonly rank: number;
46
+ readonly openCommand: string;
47
+ readonly closeCommand: string;
48
+ readonly kind: PairKind;
49
+ }
50
+
51
+ interface DelimiterToken {
52
+ readonly kind: TokenKind;
53
+ readonly pairKind: PairKind;
54
+ readonly rank: number;
55
+ readonly size: DelimiterSizeName;
56
+ readonly delimiter: string;
57
+ readonly offset: number;
58
+ readonly end: number;
59
+ }
60
+
61
+ interface DelimiterPair {
62
+ readonly open: DelimiterToken;
63
+ close?: DelimiterToken;
64
+ readonly children: DelimiterPair[];
65
+ requiredRank?: number;
66
+ }
67
+
68
+ const SIZE_SPECS: readonly SizeSpec[] = [
69
+ {
70
+ name: 'automatic',
71
+ rank: 1,
72
+ openCommand: '\\left',
73
+ closeCommand: '\\right',
74
+ kind: 'automatic',
75
+ },
76
+ {
77
+ name: 'big',
78
+ rank: 1,
79
+ openCommand: '\\bigl',
80
+ closeCommand: '\\bigr',
81
+ kind: 'manual',
82
+ },
83
+ {
84
+ name: 'Big',
85
+ rank: 2,
86
+ openCommand: '\\Bigl',
87
+ closeCommand: '\\Bigr',
88
+ kind: 'manual',
89
+ },
90
+ {
91
+ name: 'bigg',
92
+ rank: 3,
93
+ openCommand: '\\biggl',
94
+ closeCommand: '\\biggr',
95
+ kind: 'manual',
96
+ },
97
+ {
98
+ name: 'Bigg',
99
+ rank: 4,
100
+ openCommand: '\\Biggl',
101
+ closeCommand: '\\Biggr',
102
+ kind: 'manual',
103
+ },
104
+ ];
105
+
106
+ interface CommandSpec extends SizeSpec {
107
+ readonly command: string;
108
+ readonly tokenKind: TokenKind;
109
+ }
110
+
111
+ // Longest first prevents `\Biggl` from being considered as `\Bigl`.
112
+ const COMMAND_SPECS: readonly CommandSpec[] = SIZE_SPECS.flatMap((spec) => [
113
+ { ...spec, command: spec.openCommand, tokenKind: 'open' as const },
114
+ { ...spec, command: spec.closeCommand, tokenKind: 'close' as const },
115
+ ]).sort((left, right) => right.command.length - left.command.length);
116
+
117
+ const SUGGESTION_BY_RANK = new Map(
118
+ SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec]),
119
+ );
120
+
121
+ function delimiterAt(
122
+ latex: string,
123
+ offset: number,
124
+ kind: TokenKind,
125
+ ): { delimiter: string; end: number } | undefined {
126
+ const candidates =
127
+ kind === 'open'
128
+ ? (['\\{', '(', '['] as const)
129
+ : (['\\}', ')', ']'] as const);
130
+ for (const delimiter of candidates) {
131
+ if (latex.startsWith(delimiter, offset)) {
132
+ return { delimiter, end: offset + delimiter.length };
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function tokenAt(latex: string, offset: number): DelimiterToken | undefined {
139
+ if (latex[offset] !== '\\') return undefined;
140
+ for (const spec of COMMAND_SPECS) {
141
+ if (!latex.startsWith(spec.command, offset)) continue;
142
+ const afterCommand = offset + spec.command.length;
143
+ // A longer control word such as `\leftarrow` is not `\left`.
144
+ if (/[A-Za-z]/.test(latex[afterCommand] ?? '')) continue;
145
+ let delimiterOffset = afterCommand;
146
+ while (/\s/.test(latex[delimiterOffset] ?? '')) delimiterOffset += 1;
147
+ const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
148
+ if (!delimiter) continue;
149
+ return {
150
+ kind: spec.tokenKind,
151
+ pairKind: spec.kind,
152
+ rank: spec.rank,
153
+ size: spec.name,
154
+ delimiter: delimiter.delimiter,
155
+ offset,
156
+ end: delimiter.end,
157
+ };
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function pairMatches(open: DelimiterToken, close: DelimiterToken): boolean {
163
+ return (
164
+ open.kind === 'open' &&
165
+ close.kind === 'close' &&
166
+ open.pairKind === close.pairKind &&
167
+ open.rank === close.rank
168
+ );
169
+ }
170
+
171
+ function isEscaped(latex: string, offset: number): boolean {
172
+ let slashes = 0;
173
+ for (let index = offset - 1; index >= 0 && latex[index] === '\\'; index -= 1) {
174
+ slashes += 1;
175
+ }
176
+ return slashes % 2 === 1;
177
+ }
178
+
179
+ /**
180
+ * Build a real nesting tree using an explicit stack. Malformed input is left
181
+ * to KaTeX's syntax diagnostics; this linter emits no potentially misleading
182
+ * size advice for a source whose pairs cannot be matched reliably.
183
+ */
184
+ function parseDelimiterPairs(latex: string): DelimiterPair[] | undefined {
185
+ const roots: DelimiterPair[] = [];
186
+ const stack: DelimiterPair[] = [];
187
+
188
+ for (let offset = 0; offset < latex.length; offset += 1) {
189
+ if (latex[offset] === '%' && !isEscaped(latex, offset)) {
190
+ const newline = latex.indexOf('\n', offset + 1);
191
+ if (newline === -1) break;
192
+ offset = newline;
193
+ continue;
194
+ }
195
+ const token = isEscaped(latex, offset) ? undefined : tokenAt(latex, offset);
196
+ if (!token) continue;
197
+ offset = token.end - 1;
198
+
199
+ if (token.kind === 'open') {
200
+ const pair: DelimiterPair = { open: token, children: [] };
201
+ const parent = stack[stack.length - 1];
202
+ if (parent) parent.children.push(pair);
203
+ else roots.push(pair);
204
+ stack.push(pair);
205
+ continue;
206
+ }
207
+
208
+ const pair = stack[stack.length - 1];
209
+ if (!pair || !pairMatches(pair.open, token)) return undefined;
210
+ pair.close = token;
211
+ stack.pop();
212
+ }
213
+
214
+ return stack.length === 0 ? roots : undefined;
215
+ }
216
+
217
+ function lineAndColumn(
218
+ source: string,
219
+ offset: number,
220
+ ): { line: number; column: number } {
221
+ const before = source.slice(0, offset);
222
+ const lastNewline = before.lastIndexOf('\n');
223
+ return {
224
+ line: before.split('\n').length,
225
+ column: offset - lastNewline,
226
+ };
227
+ }
228
+
229
+ function sizeName(rank: number): DelimiterSizeName {
230
+ return SUGGESTION_BY_RANK.get(rank)?.name ?? 'Bigg';
231
+ }
232
+
233
+ function suggestionFor(
234
+ pair: DelimiterPair,
235
+ requiredRank: number,
236
+ ): DelimiterSizeSuggestion | undefined {
237
+ const close = pair.close;
238
+ const spec = SUGGESTION_BY_RANK.get(requiredRank);
239
+ if (!close || !spec || spec.name === 'automatic' || spec.name === 'big') {
240
+ return undefined;
241
+ }
242
+ return {
243
+ size: spec.name,
244
+ open: `${spec.openCommand}${pair.open.delimiter}`,
245
+ close: `${spec.closeCommand}${close.delimiter}`,
246
+ latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Warn when a scalable outer pair is not at least one manual size level above
252
+ * everything nested inside it. `\left` and `\big` form the baseline level;
253
+ * progressively larger levels are `\Big`, `\bigg`, and `\Bigg`.
254
+ *
255
+ * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
256
+ * ignored: N15 owns that separate error, while ordinary semantic notation
257
+ * such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
258
+ */
259
+ export function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[] {
260
+ const roots = parseDelimiterPairs(latex);
261
+ if (!roots) return [];
262
+ const diagnostics: DelimiterSizeDiagnostic[] = [];
263
+
264
+ const assess = (pair: DelimiterPair): number => {
265
+ let largestNestedRequirement = 0;
266
+ let representativeNestedSize: DelimiterSizeName = 'automatic';
267
+ for (const child of pair.children) {
268
+ const childRequired = assess(child);
269
+ if (childRequired > largestNestedRequirement) {
270
+ largestNestedRequirement = childRequired;
271
+ representativeNestedSize = sizeName(childRequired);
272
+ }
273
+ }
274
+
275
+ const requiredRank =
276
+ largestNestedRequirement === 0
277
+ ? pair.open.rank
278
+ : largestNestedRequirement + 1;
279
+ pair.requiredRank = Math.max(pair.open.rank, requiredRank);
280
+ if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
281
+ const location = lineAndColumn(latex, pair.open.offset);
282
+ const suggestion = suggestionFor(pair, requiredRank);
283
+ diagnostics.push({
284
+ code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
285
+ severity: 'warning',
286
+ offset: pair.open.offset,
287
+ ...location,
288
+ outerSize: pair.open.size,
289
+ nestedSize: representativeNestedSize,
290
+ ...(suggestion ? { suggestion } : {}),
291
+ message: suggestion
292
+ ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).`
293
+ : `Outer delimiters are not larger than their nested pair, and no larger standard manual delimiter size remains. Preserve the original opening and closing shapes and restructure the expression if necessary (rule N17).`,
294
+ });
295
+ }
296
+ return pair.requiredRank;
297
+ };
298
+
299
+ for (const root of roots) assess(root);
300
+ return diagnostics.sort((left, right) => left.offset - right.offset);
301
+ }
@@ -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,
@@ -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;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The heading/title-length gate (rule S1): no Markdown heading (`##`
3
+ * through `######`) and no `<Figure title>`, `<Diagram label>`, `<Memo
4
+ * title>`, or `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT`
5
+ * characters. A long heading or float title is usually a full sentence that
6
+ * belongs in the body paragraph (for a heading) or the `caption` (for a
7
+ * float) instead — see docs/authoring/structure-rules.md#s1.
8
+ *
9
+ * `<Table>` is deliberately not covered: it has only a `caption`, no
10
+ * separate `title`, and that caption is the LaTeX-convention full sentence
11
+ * shown above the table — capping it would fight the rule that puts it
12
+ * there. `<Figure>`'s `caption` is the same full sentence; only its short
13
+ * `title` (the text on the collapsible title bar) is in scope.
14
+ *
15
+ * A lesson's own `#` (its `frontmatter.title`, rendered as the page H1 by
16
+ * Starlight) is Review-only — it never appears in `lesson.body`, so this
17
+ * build-time gate, which only scans body text, cannot see it.
18
+ *
19
+ * Ships straight as an error, same as N9/N14: no partial corpus to absorb,
20
+ * no `lint-ignore.yml` entry.
21
+ */
22
+ import { stripFencedCode } from './equations.mjs';
23
+ import type { MathGateDiagnostic } from './gate-math';
24
+ import { lineOf } from './gate-prose-notation';
25
+ import type { NotationLessonInput } from './types';
26
+
27
+ export const HEADING_LENGTH_LIMIT = 65;
28
+
29
+ // ATX heading: 1-6 `#`, then the text, with any trailing closing `#`s
30
+ // trimmed off (the `# text #` form CommonMark also allows).
31
+ const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
32
+
33
+ // The JSX attribute that reads as a float's own short title, per component.
34
+ // `Table` has no entry — see the module doc comment.
35
+ const TITLE_ATTRIBUTE_BY_TAG: Record<string, string> = {
36
+ Figure: 'title',
37
+ Diagram: 'label',
38
+ Memo: 'title',
39
+ ShortNote: 'title',
40
+ };
41
+
42
+ interface Finding {
43
+ readonly index: number;
44
+ readonly token: string;
45
+ readonly message: string;
46
+ }
47
+
48
+ function readAttribute(attributesSource: string, name: string): string | undefined {
49
+ const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
50
+ const match = pattern.exec(attributesSource);
51
+ return match ? match[2] : undefined;
52
+ }
53
+
54
+ function overLimitMessage(kind: string, text: string): string {
55
+ return (
56
+ `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ` +
57
+ `${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move ` +
58
+ 'the rest into the paragraph or caption that follows.'
59
+ );
60
+ }
61
+
62
+ function headingFindings(text: string): Finding[] {
63
+ const out: Finding[] = [];
64
+ const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
65
+ let match: RegExpExecArray | null;
66
+ while ((match = re.exec(text))) {
67
+ const level = match[1]?.length ?? 1;
68
+ const title = (match[2] ?? '').trim();
69
+ if (title.length > HEADING_LENGTH_LIMIT) {
70
+ out.push({
71
+ index: match.index,
72
+ token: title,
73
+ message: overLimitMessage(`${'#'.repeat(level)} heading`, title),
74
+ });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function floatTitleFindings(text: string): Finding[] {
81
+ const out: Finding[] = [];
82
+ for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
83
+ const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
84
+ let match: RegExpExecArray | null;
85
+ while ((match = opening.exec(text))) {
86
+ const value = readAttribute(match[1] ?? '', attribute)?.trim();
87
+ if (value && value.length > HEADING_LENGTH_LIMIT) {
88
+ out.push({
89
+ index: match.index,
90
+ token: value,
91
+ message: overLimitMessage(`<${tagName} ${attribute}>`, value),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Every S1 finding in one lesson body, in source order. */
100
+ function findingsIn(text: string): Finding[] {
101
+ return [...headingFindings(text), ...floatTitleFindings(text)].sort(
102
+ (a, b) => a.index - b.index,
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Scan every lesson body for a heading or a keyed float's title running past
108
+ * `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
109
+ * (context `'structure'`, always an error) per finding.
110
+ */
111
+ export function gateHeadingLength(
112
+ lessons: readonly NotationLessonInput[],
113
+ ): MathGateDiagnostic[] {
114
+ const diagnostics: MathGateDiagnostic[] = [];
115
+
116
+ for (const lesson of lessons) {
117
+ const text = stripFencedCode(lesson.body);
118
+ for (const finding of findingsIn(text)) {
119
+ diagnostics.push({
120
+ context: 'structure',
121
+ severity: 'error',
122
+ file: lesson.source.file,
123
+ line: lineOf(lesson.body, finding.index),
124
+ token: finding.token,
125
+ message: finding.message,
126
+ });
127
+ }
128
+ }
129
+
130
+ return diagnostics;
131
+ }
@@ -7,10 +7,12 @@
7
7
  * a single diagnostic shape; `astro build` re-runs the same resolver on the
8
8
  * render path as a backstop.
9
9
  *
10
- * Every failure names `file:line: "token"` and the fix.
10
+ * Every finding names its source and the fix. N17's size-only findings are
11
+ * warnings and include a column when the math came from lesson/assessment text.
11
12
  */
12
13
  import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
13
14
  import { stripEquationLabels } from './equations.mjs';
15
+ import { lintDelimiterSizes } from './delimiter-size';
14
16
  import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
15
17
  import type { GlyphScopeEntry } from './gloss';
16
18
  import type {
@@ -20,7 +22,7 @@ import type {
20
22
  } from './types';
21
23
 
22
24
  export interface MathGateDiagnostic {
23
- readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation';
25
+ readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
24
26
  /**
25
27
  * Body / component-slot math blocks the build (the render path needs every
26
28
  * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
@@ -38,11 +40,17 @@ export interface MathGateDiagnostic {
38
40
  * same change that introduced the check. `abbreviation` (N14,
39
41
  * `gate-abbreviations.ts`) is the same "ships straight as an error" story
40
42
  * for a different shape of plain-text token — an unexplained acronym or
41
- * dotted abbreviation, rather than a mis-typed quantity/unit.
43
+ * dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
44
+ * (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
45
+ * long a heading or a keyed float's title may run — but reuses this same
46
+ * diagnostic shape and the one `manifest.diagnostics.math` array rather
47
+ * than adding a second array the CLI has to know about.
42
48
  */
43
49
  readonly severity: 'error' | 'warning';
50
+ readonly code?: string;
44
51
  readonly file: string;
45
52
  readonly line?: number;
53
+ readonly column?: number;
46
54
  readonly token?: string;
47
55
  readonly message: string;
48
56
  }
@@ -53,6 +61,7 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
53
61
  assessment: 'error',
54
62
  prose: 'error',
55
63
  abbreviation: 'error',
64
+ structure: 'error',
56
65
  };
57
66
 
58
67
  interface AssessmentItemLike {
@@ -87,6 +96,7 @@ function lineOf(text: string, index: number): number {
87
96
  interface MathSpan {
88
97
  readonly latex: string;
89
98
  readonly line: number;
99
+ readonly column: number;
90
100
  }
91
101
 
92
102
  function mathSpans(body: string): MathSpan[] {
@@ -96,10 +106,14 @@ function mathSpans(body: string): MathSpan[] {
96
106
  pattern.lastIndex = 0;
97
107
  let match: RegExpExecArray | null;
98
108
  while ((match = pattern.exec(searchable))) {
109
+ const latex = match[1] ?? '';
110
+ const latexOffset = match.index + match[0].indexOf(latex);
111
+ const before = body.slice(0, latexOffset);
112
+ const lastNewline = before.lastIndexOf('\n');
99
113
  spans.push({
100
- // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
101
- latex: stripEquationLabels(match[1] ?? ''),
102
- line: lineOf(body, match.index),
114
+ latex,
115
+ line: lineOf(body, latexOffset),
116
+ column: latexOffset - lastNewline,
103
117
  });
104
118
  }
105
119
  }
@@ -145,12 +159,34 @@ function resolveInto(
145
159
  context: MathGateDiagnostic['context'],
146
160
  file: string,
147
161
  line: number | undefined,
162
+ column: number | undefined,
148
163
  fix: string,
149
164
  out: MathGateDiagnostic[],
150
165
  ): void {
166
+ for (const diagnostic of lintDelimiterSizes(latex)) {
167
+ const locatedLine =
168
+ line === undefined ? undefined : line + diagnostic.line - 1;
169
+ const locatedColumn =
170
+ column === undefined
171
+ ? undefined
172
+ : diagnostic.line === 1
173
+ ? column + diagnostic.column - 1
174
+ : diagnostic.column;
175
+ out.push({
176
+ context,
177
+ code: diagnostic.code,
178
+ severity: 'warning',
179
+ file,
180
+ ...(locatedLine === undefined ? {} : { line: locatedLine }),
181
+ ...(locatedColumn === undefined ? {} : { column: locatedColumn }),
182
+ message: diagnostic.message,
183
+ });
184
+ }
185
+
151
186
  let result;
152
187
  try {
153
- result = resolveMathGlyphs(latex, [...scope]);
188
+ // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
189
+ result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
154
190
  } catch (error) {
155
191
  out.push({
156
192
  context,
@@ -278,6 +314,7 @@ export function gateContentMath(input: {
278
314
  'body',
279
315
  file,
280
316
  span.line,
317
+ span.column,
281
318
  BODY_FIX,
282
319
  diagnostics,
283
320
  );
@@ -300,6 +337,7 @@ export function gateContentMath(input: {
300
337
  'formula',
301
338
  file,
302
339
  undefined,
340
+ undefined,
303
341
  FORMULA_FIX,
304
342
  diagnostics,
305
343
  );
@@ -325,6 +363,7 @@ export function gateContentMath(input: {
325
363
  'formula',
326
364
  definition.source.file,
327
365
  undefined,
366
+ undefined,
328
367
  SHARED_FORMULA_FIX,
329
368
  diagnostics,
330
369
  );
@@ -353,7 +392,8 @@ export function gateContentMath(input: {
353
392
  scope,
354
393
  'assessment',
355
394
  `${assessmentId} (${item.id ?? '?'})`,
356
- undefined,
395
+ span.line,
396
+ span.column,
357
397
  ASSESSMENT_FIX,
358
398
  diagnostics,
359
399
  );
@@ -7,7 +7,7 @@
7
7
  * - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
8
8
  * - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
9
9
  * `[[slug#<prefix>-key]]` points at the matching keyed float with that `id`.
10
- * - `<prefix>-key-unused` (warning): a keyed float never referenced.
10
+ * - `<prefix>-key-unused` (info): a keyed float is never referenced.
11
11
  *
12
12
  * `tables-validate.ts`, `figures-validate.ts`, and `diagrams-validate.ts` are
13
13
  * thin wrappers over this so callers import a kind-specific validator with
@@ -48,7 +48,7 @@ export interface KeyedFloatRefRecord {
48
48
 
49
49
  export interface KeyedFloatDiagnostic {
50
50
  readonly code: string;
51
- readonly severity: 'error' | 'warning';
51
+ readonly severity: 'error' | 'warning' | 'info';
52
52
  readonly message: string;
53
53
  readonly file?: string;
54
54
  readonly lessonId?: string;
@@ -190,7 +190,7 @@ export function validateKeyedFloats(
190
190
  if (referenced.has(`${label.slug}#${label.key}`)) continue;
191
191
  diagnostics.push({
192
192
  code: `${prefix}-key-unused`,
193
- severity: 'warning',
193
+ severity: 'info',
194
194
  message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
195
195
  file: label.slug,
196
196
  lessonId: label.lessonId,