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.
- package/CHANGELOG.md +295 -7
- package/README.md +16 -2
- package/dist/compiler/astro.d.ts +13 -2
- package/dist/compiler/astro.js +39 -0
- package/dist/compiler/cli.d.ts +22 -3
- package/dist/compiler/cli.js +71 -10
- package/dist/compiler/course-config.d.ts +31 -1
- package/dist/compiler/course-config.js +7 -0
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +109 -0
- package/dist/compiler/logging.js +107 -0
- package/dist/compiler/manifest.d.ts +16 -4
- package/dist/compiler/manifest.js +7 -3
- package/dist/reference/base-abbreviations.mjs +8 -2
- package/dist/reference/consistency.d.ts +8 -10
- package/dist/reference/consistency.js +3 -47
- package/dist/reference/delimiter-size.d.ts +39 -0
- package/dist/reference/delimiter-size.js +177 -0
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +2 -2
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +8 -2
- package/dist/reference/gate-math.js +30 -7
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.d.mts +12 -0
- package/dist/reference/math-glyphs.mjs +183 -42
- package/dist/reference/references.js +2 -1
- package/dist/reference/registry.js +1 -1
- package/dist/reference/rehype-base-links.d.mts +23 -0
- package/dist/reference/rehype-base-links.mjs +40 -0
- package/dist/reference/remark-abbreviations.mjs +10 -12
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +10 -3
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +1 -1
- package/docs/VERSIONING.md +24 -4
- package/docs/authoring/README.md +14 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/components.md +4 -1
- package/docs/authoring/migrating.md +477 -8
- package/docs/authoring/notation.md +194 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/visualizations.md +2 -2
- package/package.json +1 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +144 -12
- package/src/compiler/course-config.ts +40 -0
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +248 -0
- package/src/compiler/manifest.ts +20 -3
- package/src/components/diagram/Diagram.astro +0 -55
- package/src/components/memo/Memo.astro +2 -5
- package/src/components/memo/ShortNote.astro +2 -5
- package/src/components/notation/NotationFormula.astro +199 -10
- package/src/components/notation/NotationGlossary.astro +89 -58
- package/src/components/notation/NotationLayer.astro +238 -156
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
- package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
- package/src/components/starlight/LessonFooter.astro +5 -1
- package/src/content-config.ts +16 -1
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +301 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +48 -8
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +183 -42
- package/src/reference/references.ts +2 -1
- package/src/reference/registry.ts +1 -1
- package/src/reference/rehype-base-links.mjs +40 -0
- package/src/reference/remark-abbreviations.mjs +10 -12
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/render-lab-math.ts +24 -1
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +1 -1
- 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` (
|
|
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: '
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` (
|
|
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: '
|
|
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,
|