explico 0.6.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 +180 -1
- 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/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 +7 -3
- package/dist/compiler/manifest.js +1 -1
- 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-math.d.ts +2 -0
- package/dist/reference/gate-math.js +28 -6
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- 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.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 +2 -2
- package/docs/authoring/README.md +12 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/migrating.md +286 -0
- package/docs/authoring/notation.md +139 -7
- package/docs/authoring/prose.md +64 -0
- package/package.json +1 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +144 -12
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +248 -0
- package/src/compiler/manifest.ts +7 -3
- 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 +83 -81
- 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 +2 -0
- 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-math.ts +41 -6
- package/src/reference/keyed-float-validate.ts +3 -3
- 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 +119 -14
|
@@ -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;
|
|
@@ -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 {
|
|
@@ -45,8 +47,10 @@ export interface MathGateDiagnostic {
|
|
|
45
47
|
* than adding a second array the CLI has to know about.
|
|
46
48
|
*/
|
|
47
49
|
readonly severity: 'error' | 'warning';
|
|
50
|
+
readonly code?: string;
|
|
48
51
|
readonly file: string;
|
|
49
52
|
readonly line?: number;
|
|
53
|
+
readonly column?: number;
|
|
50
54
|
readonly token?: string;
|
|
51
55
|
readonly message: string;
|
|
52
56
|
}
|
|
@@ -92,6 +96,7 @@ function lineOf(text: string, index: number): number {
|
|
|
92
96
|
interface MathSpan {
|
|
93
97
|
readonly latex: string;
|
|
94
98
|
readonly line: number;
|
|
99
|
+
readonly column: number;
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
function mathSpans(body: string): MathSpan[] {
|
|
@@ -101,10 +106,14 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
101
106
|
pattern.lastIndex = 0;
|
|
102
107
|
let match: RegExpExecArray | null;
|
|
103
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');
|
|
104
113
|
spans.push({
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
latex,
|
|
115
|
+
line: lineOf(body, latexOffset),
|
|
116
|
+
column: latexOffset - lastNewline,
|
|
108
117
|
});
|
|
109
118
|
}
|
|
110
119
|
}
|
|
@@ -150,12 +159,34 @@ function resolveInto(
|
|
|
150
159
|
context: MathGateDiagnostic['context'],
|
|
151
160
|
file: string,
|
|
152
161
|
line: number | undefined,
|
|
162
|
+
column: number | undefined,
|
|
153
163
|
fix: string,
|
|
154
164
|
out: MathGateDiagnostic[],
|
|
155
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
|
+
|
|
156
186
|
let result;
|
|
157
187
|
try {
|
|
158
|
-
|
|
188
|
+
// D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
|
|
189
|
+
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
|
|
159
190
|
} catch (error) {
|
|
160
191
|
out.push({
|
|
161
192
|
context,
|
|
@@ -283,6 +314,7 @@ export function gateContentMath(input: {
|
|
|
283
314
|
'body',
|
|
284
315
|
file,
|
|
285
316
|
span.line,
|
|
317
|
+
span.column,
|
|
286
318
|
BODY_FIX,
|
|
287
319
|
diagnostics,
|
|
288
320
|
);
|
|
@@ -305,6 +337,7 @@ export function gateContentMath(input: {
|
|
|
305
337
|
'formula',
|
|
306
338
|
file,
|
|
307
339
|
undefined,
|
|
340
|
+
undefined,
|
|
308
341
|
FORMULA_FIX,
|
|
309
342
|
diagnostics,
|
|
310
343
|
);
|
|
@@ -330,6 +363,7 @@ export function gateContentMath(input: {
|
|
|
330
363
|
'formula',
|
|
331
364
|
definition.source.file,
|
|
332
365
|
undefined,
|
|
366
|
+
undefined,
|
|
333
367
|
SHARED_FORMULA_FIX,
|
|
334
368
|
diagnostics,
|
|
335
369
|
);
|
|
@@ -358,7 +392,8 @@ export function gateContentMath(input: {
|
|
|
358
392
|
scope,
|
|
359
393
|
'assessment',
|
|
360
394
|
`${assessmentId} (${item.id ?? '?'})`,
|
|
361
|
-
|
|
395
|
+
span.line,
|
|
396
|
+
span.column,
|
|
362
397
|
ASSESSMENT_FIX,
|
|
363
398
|
diagnostics,
|
|
364
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,
|
|
@@ -214,7 +214,7 @@ export function buildNotationRegistry(
|
|
|
214
214
|
if (!reachableDefinitions.has(candidate.id)) {
|
|
215
215
|
diagnostics.push({
|
|
216
216
|
code: 'unused-definition',
|
|
217
|
-
severity: '
|
|
217
|
+
severity: 'info',
|
|
218
218
|
message: `${candidate.id} is not reachable from any lesson reference`,
|
|
219
219
|
key: candidate.input.key,
|
|
220
220
|
lessonId:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefixes root-relative `<a href="/...">` links in rendered lesson content
|
|
3
|
+
* with the course's Astro `base` path.
|
|
4
|
+
*
|
|
5
|
+
* A course deployed under a subpath (any GitHub Pages *project* site, e.g.
|
|
6
|
+
* `base: '/my-course'`) gets that prefix applied automatically to Starlight's
|
|
7
|
+
* own generated navigation — sidebar, pagination, breadcrumbs — because those
|
|
8
|
+
* are rendered by Starlight's own `.astro` components, which call its
|
|
9
|
+
* internal `pathWithBase()` helper. They are **not** applied to anything
|
|
10
|
+
* produced by the Markdown/MDX → rehype pipeline: a lesson's own prose links
|
|
11
|
+
* (`[text](/other-lesson/)`), `remark-notation.mjs`'s equation cross-
|
|
12
|
+
* references, and `remark-citation.mjs`'s citation links are all root-
|
|
13
|
+
* relative absolute paths written or generated without the base in mind, so
|
|
14
|
+
* without this plugin every one of them 404s on a subpath deployment. A
|
|
15
|
+
* lesson's Starlight frontmatter (e.g. `hero.actions[].link`) is untouched
|
|
16
|
+
* either way — that YAML never enters this pipeline — so write those as
|
|
17
|
+
* paths relative to the current page instead.
|
|
18
|
+
*
|
|
19
|
+
* A no-op when the course has no base (`base` omitted or `'/'`, e.g. local
|
|
20
|
+
* dev or a GitHub *user/org* Pages site), so a course can wire this in
|
|
21
|
+
* unconditionally and it only does anything on the deployments that need it.
|
|
22
|
+
*/
|
|
23
|
+
export default function rehypeBaseLinks(base) {
|
|
24
|
+
const prefix = base && base !== '/' ? base.replace(/\/+$/, '') : '';
|
|
25
|
+
|
|
26
|
+
return function transform(tree) {
|
|
27
|
+
if (!prefix) return;
|
|
28
|
+
visit(tree);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function visit(node) {
|
|
32
|
+
if (node.type === 'element' && node.tagName === 'a') {
|
|
33
|
+
const href = node.properties?.href;
|
|
34
|
+
if (typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')) {
|
|
35
|
+
node.properties.href = prefix + href;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(node.children)) node.children.forEach(visit);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* requires every abbreviation-shaped word in prose to either be universal or
|
|
6
6
|
* carry a `content/abbreviations/*.md` entry, so the literal written form
|
|
7
7
|
* (`abbr`, or one of its `aliases`) is itself the trigger. This plugin scans
|
|
8
|
-
* ordinary prose for that literal text and wraps
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* ordinary prose for that literal text and wraps **every** occurrence in a
|
|
9
|
+
* real link (`/glossary/#abbr-<key>`, so it still navigates with no JS)
|
|
10
|
+
* carrying the `data-abbr-key` an island script reads to render a hover/click
|
|
11
|
+
* card, exactly the way `remarkCitation` wraps `[@id]` markers. A reader who
|
|
12
|
+
* deep-links into a mid-page heading — the sidebar TOC, a heading anchor, an
|
|
13
|
+
* external link — sees the same hoverable cue no matter which occurrence they
|
|
14
|
+
* land on first, rather than only the earliest one in document order.
|
|
15
|
+
* `AbbreviationLayer.astro` already resolves every marker independently by
|
|
16
|
+
* `data-abbr-key`, so wrapping every occurrence needs no island-side change.
|
|
14
17
|
*
|
|
15
18
|
* Matches only ever come from the known table by construction, so unlike
|
|
16
19
|
* citation's `[@id]` there is no "unknown id" failure path here.
|
|
@@ -118,10 +121,6 @@ export default function remarkAbbreviations(options = {}) {
|
|
|
118
121
|
const { regex, keyByMatch } = buildMatcher(suppliedAbbreviations);
|
|
119
122
|
if (!regex) return;
|
|
120
123
|
|
|
121
|
-
// Fresh per file, same lifetime as citation's own `pairs` Map: only the
|
|
122
|
-
// first occurrence of a given key on this page gets wrapped.
|
|
123
|
-
const usedKeys = new Set();
|
|
124
|
-
|
|
125
124
|
const replaceText = (value) => {
|
|
126
125
|
regex.lastIndex = 0;
|
|
127
126
|
const output = [];
|
|
@@ -131,13 +130,12 @@ export default function remarkAbbreviations(options = {}) {
|
|
|
131
130
|
|
|
132
131
|
while ((match = regex.exec(value))) {
|
|
133
132
|
const key = keyByMatch.get(match[0]);
|
|
134
|
-
if (!key
|
|
133
|
+
if (!key) continue;
|
|
135
134
|
|
|
136
135
|
if (match.index > cursor) {
|
|
137
136
|
output.push({ type: 'text', value: value.slice(cursor, match.index) });
|
|
138
137
|
}
|
|
139
138
|
output.push(termNode(key, match[0], base));
|
|
140
|
-
usedKeys.add(key);
|
|
141
139
|
changed = true;
|
|
142
140
|
cursor = match.index + match[0].length;
|
|
143
141
|
}
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import katex from 'katex';
|
|
2
|
-
import { resolveMathGlyphs } from './math-glyphs.mjs';
|
|
2
|
+
import { resolveMathGlyphs, stripNotationMarkers } from './math-glyphs.mjs';
|
|
3
3
|
import { createNotationKatexOptions } from './katex-options.mjs';
|
|
4
4
|
|
|
5
5
|
export interface LabMathScopeDefinition {
|
|
@@ -14,6 +14,26 @@ export interface RenderedLabMath {
|
|
|
14
14
|
readonly slots: readonly string[];
|
|
15
15
|
/** Notation keys the template resolved to, for diagnostics. */
|
|
16
16
|
readonly keys: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Plain, copyable LaTeX — every explained glyph resolved to its bare
|
|
19
|
+
* symbol, every `\slot{name}{…}` replaced by `slotPlaceholder(name)` — so
|
|
20
|
+
* the island can substitute each slot's *current* live value in and keep
|
|
21
|
+
* `[data-notation-formula]`'s `data-latex` accurate as the reader drags a
|
|
22
|
+
* control, the same "click the equation to copy it" gesture a static
|
|
23
|
+
* lesson or glossary equation gets (`NotationLayer.astro`).
|
|
24
|
+
*/
|
|
25
|
+
readonly plainLatexTemplate: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A slot's placeholder inside `plainLatexTemplate` — distinct from the
|
|
30
|
+
* numeric sentinel `sentinelFor` below uses internally (that one only has to
|
|
31
|
+
* survive a single notation-binding pass at build time; this one is the
|
|
32
|
+
* stable, name-keyed token an island substitutes with a live value at
|
|
33
|
+
* runtime, so it's exported for that purpose).
|
|
34
|
+
*/
|
|
35
|
+
export function slotPlaceholder(name: string): string {
|
|
36
|
+
return `⟦${name}⟧`;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
interface BindResult {
|
|
@@ -72,6 +92,7 @@ export function renderLabMath(
|
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
let bound = result.latex;
|
|
95
|
+
let copyBound = result.latex;
|
|
75
96
|
slots.forEach(({ name, fallback }, index) => {
|
|
76
97
|
const sentinel = sentinelFor(index);
|
|
77
98
|
if (!bound.includes(sentinel)) {
|
|
@@ -84,6 +105,7 @@ export function renderLabMath(
|
|
|
84
105
|
sentinel,
|
|
85
106
|
`\\htmlData{lab-slot=${name}}{${fallback}}`,
|
|
86
107
|
);
|
|
108
|
+
copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
|
|
87
109
|
});
|
|
88
110
|
|
|
89
111
|
const html = katex.renderToString(
|
|
@@ -95,5 +117,6 @@ export function renderLabMath(
|
|
|
95
117
|
html,
|
|
96
118
|
slots: slots.map(({ name }) => name),
|
|
97
119
|
keys: [...new Set(result.bindings.map(({ key }) => key))],
|
|
120
|
+
plainLatexTemplate: stripNotationMarkers(copyBound),
|
|
98
121
|
};
|
|
99
122
|
}
|
|
@@ -15,7 +15,7 @@ export type TableDiagnosticCode =
|
|
|
15
15
|
|
|
16
16
|
export interface TableDiagnostic {
|
|
17
17
|
readonly code: TableDiagnosticCode;
|
|
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;
|
package/src/reference/types.ts
CHANGED
|
@@ -127,7 +127,7 @@ export type NotationDiagnosticCode =
|
|
|
127
127
|
| 'alignment-review-state'
|
|
128
128
|
| 'gloss-collides-with-card';
|
|
129
129
|
|
|
130
|
-
export type DiagnosticSeverity = 'error' | 'warning';
|
|
130
|
+
export type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
131
131
|
|
|
132
132
|
export interface NotationDiagnostic {
|
|
133
133
|
readonly code: NotationDiagnosticCode;
|