explico 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -1
- package/dist/compiler/cli.d.ts +8 -1
- package/dist/compiler/cli.js +10 -2
- package/dist/compiler/collections.js +16 -3
- package/dist/compiler/logging.d.ts +11 -1
- package/dist/compiler/logging.js +2 -1
- package/dist/compiler/manifest.d.ts +1 -1
- package/dist/compiler/manifest.js +1 -1
- package/dist/reference/delimiter-size.d.ts +18 -9
- package/dist/reference/delimiter-size.js +15 -39
- package/dist/reference/equations.d.mts +9 -3
- package/dist/reference/equations.mjs +7 -1
- package/dist/reference/gate-abbreviations.js +2 -2
- package/dist/reference/gate-heading-length.js +2 -2
- package/dist/reference/gate-math.d.ts +23 -1
- package/dist/reference/gate-math.js +23 -4
- package/dist/reference/gate-prose-notation.d.ts +12 -1
- package/dist/reference/gate-prose-notation.js +5 -1
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +125 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/math-glyphs.mjs +16 -2
- package/dist/reference/render-lab-math.js +1 -1
- package/dist/reference/types.d.ts +7 -0
- package/docs/authoring/README.md +1 -1
- package/docs/authoring/migrating.md +228 -0
- package/docs/authoring/notation.md +80 -62
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +2 -1
- package/src/compiler/cli.ts +23 -0
- package/src/compiler/collections.ts +33 -3
- package/src/compiler/logging.ts +14 -3
- package/src/compiler/manifest.ts +1 -1
- package/src/components/notation/NotationGlossary.astro +21 -10
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/starlight/AboutPanel.astro +17 -2
- package/src/reference/delimiter-size.ts +37 -59
- package/src/reference/equations.mjs +7 -1
- package/src/reference/gate-abbreviations.ts +2 -2
- package/src/reference/gate-heading-length.ts +2 -2
- package/src/reference/gate-math.ts +50 -2
- package/src/reference/gate-prose-notation.ts +15 -1
- package/src/reference/katex-delimiters.mjs +125 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/math-glyphs.mjs +16 -2
- package/src/reference/render-lab-math.ts +1 -1
- package/src/reference/types.ts +7 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import rehypeKatex from 'rehype-katex';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createNotationKatexOptions,
|
|
4
|
+
trustNotationMarker,
|
|
5
|
+
} from '../../reference/katex-options.mjs';
|
|
3
6
|
import { NOTATION_KEY_PATTERN } from '../../reference/remark-notation.mjs';
|
|
4
7
|
import {
|
|
5
8
|
GlyphResolutionError,
|
|
@@ -81,32 +84,96 @@ const allowedTags = new Set([
|
|
|
81
84
|
'line',
|
|
82
85
|
]);
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every inert presentation attribute KaTeX's MathML builder can set, sorted.
|
|
89
|
+
*
|
|
90
|
+
* This list is deliberately KaTeX's whole surface rather than the attributes
|
|
91
|
+
* the corpus happens to have hit: anything missing here is a hard build error
|
|
92
|
+
* on valid, ordinary math, and the author has no way to work around it. `\Bigl`
|
|
93
|
+
* (`minsize`/`maxsize`), `\boxed` (`notation`), and `\colorbox`
|
|
94
|
+
* (`voffset`/`mathbackground`) each used to fail that way.
|
|
95
|
+
* `tests/notation/render-notation-math.test.ts` scans KaTeX's own
|
|
96
|
+
* `setAttribute` calls and fails when a release adds a name that is in neither
|
|
97
|
+
* this set nor {@link deniedAttributeNames}, so a KaTeX upgrade cannot quietly
|
|
98
|
+
* reintroduce the gap.
|
|
99
|
+
*/
|
|
84
100
|
const simpleAttributeNames = new Set([
|
|
85
101
|
'accent',
|
|
86
102
|
'accentunder',
|
|
87
103
|
'columnalign',
|
|
104
|
+
'columnlines',
|
|
88
105
|
'columnspacing',
|
|
106
|
+
'depth',
|
|
107
|
+
'display',
|
|
89
108
|
'displaystyle',
|
|
90
109
|
'fence',
|
|
91
110
|
'height',
|
|
111
|
+
'largeop',
|
|
112
|
+
'linebreak',
|
|
113
|
+
'linethickness',
|
|
92
114
|
'lspace',
|
|
115
|
+
'mathbackground',
|
|
116
|
+
'mathcolor',
|
|
117
|
+
'mathsize',
|
|
93
118
|
'mathvariant',
|
|
119
|
+
'maxsize',
|
|
120
|
+
'minsize',
|
|
121
|
+
'notation',
|
|
94
122
|
'preserveaspectratio',
|
|
123
|
+
'rowlines',
|
|
95
124
|
'rowspacing',
|
|
96
125
|
'rspace',
|
|
97
126
|
'scriptlevel',
|
|
98
127
|
'separator',
|
|
99
128
|
'stretchy',
|
|
129
|
+
'valign',
|
|
100
130
|
'viewbox',
|
|
131
|
+
'voffset',
|
|
101
132
|
'width',
|
|
102
133
|
]);
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The attributes KaTeX can set that must never reach the page, each one a
|
|
137
|
+
* navigation or external-resource target: `href` (`\href`), `src`/`alt`
|
|
138
|
+
* (`\includegraphics`), and `title` (`\htmlTitle`). Every command that
|
|
139
|
+
* produces one already needs KaTeX's `trust` callback and is refused earlier by
|
|
140
|
+
* {@link unsafeExtensionCommand}, so this set is the second line — and the
|
|
141
|
+
* reason the completeness scan above can tell "KaTeX added an attribute nobody
|
|
142
|
+
* classified" apart from "we decided against this one".
|
|
143
|
+
*/
|
|
144
|
+
const deniedAttributeNames = new Set(['alt', 'href', 'src', 'title']);
|
|
145
|
+
|
|
146
|
+
// `#` is here for `mathcolor` / `mathbackground` / a `background-color:` style,
|
|
147
|
+
// which KaTeX writes as a hex literal. No attribute reaching this point can
|
|
148
|
+
// carry a URL — `href` and `src` are denied above — so it opens no target.
|
|
149
|
+
const safeAttributeValue = /^[ A-Za-z0-9+.,()\-/%#]*$/;
|
|
105
150
|
const safeClassName = /^[A-Za-z0-9_-]+$/;
|
|
106
|
-
const
|
|
151
|
+
const safeDeclaration = /^\s*[a-z-]+:[ A-Za-z0-9+.,()\-/%#]*$/;
|
|
107
152
|
const safePathData = /^[\sA-Za-z0-9+.,\-]*$/;
|
|
108
153
|
const unsafeExtensionCommand =
|
|
109
154
|
/\\(?:href|url|includegraphics|htmlClass|htmlData|htmlId|htmlStyle)\b/;
|
|
155
|
+
// `\explain{key}{latex}` and `\slot{name}{latex}`, the two macros that expand
|
|
156
|
+
// to a trusted `\htmlData` marker. Only the first argument is captured.
|
|
157
|
+
const MARKER_MACRO = /\\(explain|slot)\{([^{}]*)\}/g;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A declaration list KaTeX generated: inert properties only, and no
|
|
161
|
+
* `url(...)`/`expression(...)` to fetch or evaluate anything.
|
|
162
|
+
*
|
|
163
|
+
* Validated per declaration rather than with one regex over the whole string,
|
|
164
|
+
* because KaTeX does not always terminate the last one — `\vec` emits
|
|
165
|
+
* `style="width:0.471em"` with no trailing `;`, which a
|
|
166
|
+
* `(?:declaration;)*`-shaped pattern rejects outright.
|
|
167
|
+
*/
|
|
168
|
+
function isSafeStyle(value: string): boolean {
|
|
169
|
+
if (/(?:expression|url)\s*\(/i.test(value)) return false;
|
|
170
|
+
return value
|
|
171
|
+
.split(';')
|
|
172
|
+
.every(
|
|
173
|
+
(declaration) =>
|
|
174
|
+
declaration.trim().length === 0 || safeDeclaration.test(declaration),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
110
177
|
|
|
111
178
|
function attributeValue(value: unknown, propertyName: string): string {
|
|
112
179
|
if (typeof value === 'boolean') return String(value);
|
|
@@ -170,22 +237,27 @@ function safeProperties(
|
|
|
170
237
|
continue;
|
|
171
238
|
}
|
|
172
239
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
!/(?:expression|url)\s*\(/i.test(value)
|
|
178
|
-
) {
|
|
240
|
+
if (name === 'style') {
|
|
241
|
+
if (typeof value !== 'string' || !isSafeStyle(value)) {
|
|
242
|
+
throw new Error('Unsafe KaTeX style value.');
|
|
243
|
+
}
|
|
179
244
|
properties.style = value;
|
|
180
245
|
continue;
|
|
181
246
|
}
|
|
182
247
|
|
|
183
|
-
if (name === 'd'
|
|
248
|
+
if (name === 'd') {
|
|
249
|
+
if (typeof value !== 'string' || !safePathData.test(value)) {
|
|
250
|
+
throw new Error('Unsafe KaTeX path data.');
|
|
251
|
+
}
|
|
184
252
|
properties.d = value;
|
|
185
253
|
continue;
|
|
186
254
|
}
|
|
187
255
|
|
|
188
256
|
const normalizedName = name.toLowerCase();
|
|
257
|
+
if (deniedAttributeNames.has(normalizedName)) {
|
|
258
|
+
throw new Error(`Disallowed KaTeX property: ${name}.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
189
261
|
if (simpleAttributeNames.has(normalizedName)) {
|
|
190
262
|
const outputName =
|
|
191
263
|
normalizedName === 'viewbox'
|
|
@@ -228,6 +300,34 @@ function sanitizeNode(node: HastNode): SafeMathNode {
|
|
|
228
300
|
// this module already imports from `remark-notation.mjs`.
|
|
229
301
|
export { stripNotationMarkers };
|
|
230
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Refuse a marker argument the trust callback would reject, before KaTeX sees
|
|
305
|
+
* it.
|
|
306
|
+
*
|
|
307
|
+
* KaTeX does not treat a denied `trust` as a parse error: it renders the
|
|
308
|
+
* command name in `errorColor` and carries on, so `\explain{Not A Key}{\Omega}`
|
|
309
|
+
* would otherwise reach a reader as red `\htmlData` text with the build still
|
|
310
|
+
* green. `\htmlData` itself is already refused by {@link
|
|
311
|
+
* unsafeExtensionCommand}, so the two marker macros are the only way into that
|
|
312
|
+
* callback and this check covers all of them. It asks
|
|
313
|
+
* {@link trustNotationMarker} rather than re-deriving the key patterns, so the
|
|
314
|
+
* answer here cannot drift from the answer KaTeX gets.
|
|
315
|
+
*/
|
|
316
|
+
function assertTrustedMarkers(latex: string): void {
|
|
317
|
+
for (const [, macro, argument] of latex.matchAll(MARKER_MACRO)) {
|
|
318
|
+
const attribute =
|
|
319
|
+
macro === 'explain' ? 'data-notation-key' : 'data-lab-slot';
|
|
320
|
+
if (
|
|
321
|
+
!trustNotationMarker({
|
|
322
|
+
command: '\\htmlData',
|
|
323
|
+
attributes: { [attribute]: argument },
|
|
324
|
+
})
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(`Notation LaTeX has an invalid \\${macro} key: ${argument}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
231
331
|
/**
|
|
232
332
|
* Render repository-authored LaTeX through the same build-time KaTeX adapter
|
|
233
333
|
* as lesson math, then expose a constrained structured tree to Astro. No HTML
|
|
@@ -240,6 +340,7 @@ export function renderNotationMath(latex: string): readonly SafeMathNode[] {
|
|
|
240
340
|
if (unsafeExtensionCommand.test(latex)) {
|
|
241
341
|
throw new Error('Notation LaTeX cannot use a trust-requiring extension.');
|
|
242
342
|
}
|
|
343
|
+
assertTrustedMarkers(latex);
|
|
243
344
|
|
|
244
345
|
const root: HastRoot = {
|
|
245
346
|
type: 'root',
|
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
* It is a plain `<details>` disclosure: the browser gives keyboard toggle and
|
|
13
13
|
* `aria-expanded` for free; the inline script only adds Escape-to-close and
|
|
14
14
|
* outside-click-to-close.
|
|
15
|
+
*
|
|
16
|
+
* The panel also *publishes* the identity it displays: the `data-knowledge-*`
|
|
17
|
+
* and `data-engine-version` attributes carry the same values as the rendered
|
|
18
|
+
* text. A build check that wants to confirm the shipped page really carries the
|
|
19
|
+
* configured version would otherwise have to match the panel's prose and its
|
|
20
|
+
* `version · date` join, which pins a course's test to one version string and
|
|
21
|
+
* makes the check fail on every bump; a bare version substring is not even
|
|
22
|
+
* scoped to the panel, so the engine's own version elsewhere on the page can
|
|
23
|
+
* satisfy it. These attributes are exact, scoped, and stable across a bump.
|
|
15
24
|
*/
|
|
16
25
|
import { loadManifest } from '../../compiler/manifest';
|
|
17
26
|
|
|
@@ -32,7 +41,13 @@ const knowledgeLine = knowledge.date
|
|
|
32
41
|
: knowledge.version;
|
|
33
42
|
---
|
|
34
43
|
|
|
35
|
-
<details
|
|
44
|
+
<details
|
|
45
|
+
class="about"
|
|
46
|
+
data-about-panel
|
|
47
|
+
data-knowledge-version={knowledge.version}
|
|
48
|
+
data-knowledge-date={knowledge.date}
|
|
49
|
+
data-engine-version={engine.version}
|
|
50
|
+
>
|
|
36
51
|
<summary
|
|
37
52
|
class="about-toggle"
|
|
38
53
|
aria-label={`About ${material.name}`}
|
|
@@ -47,7 +62,7 @@ const knowledgeLine = knowledge.date
|
|
|
47
62
|
<dl class="about-facts">
|
|
48
63
|
<div>
|
|
49
64
|
<dt>Knowledge version</dt>
|
|
50
|
-
<dd>{knowledgeLine}</dd>
|
|
65
|
+
<dd data-knowledge-line>{knowledgeLine}</dd>
|
|
51
66
|
</div>
|
|
52
67
|
<div>
|
|
53
68
|
<dt>Engine</dt>
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rule N17: nested scalable
|
|
2
|
+
* Rule N17: a nested scalable delimiter is sized by the engine, never by hand.
|
|
3
|
+
*
|
|
4
|
+
* The engine installs a KaTeX whose `\delimitershortfall` is `-1pt`
|
|
5
|
+
* (`katex-delimiters.mjs`), so a `\left`/`\right` pair is always
|
|
6
|
+
* strictly taller than what it wraps. Nesting depth therefore shows up on its
|
|
7
|
+
* own, and a hand-picked `\bigl`…`\Biggr` inside a nest only replaces a size
|
|
8
|
+
* that tracks the content with a fixed glyph that does not.
|
|
3
9
|
*
|
|
4
10
|
* This deliberately parses only sizing syntax. It does not decide whether a
|
|
5
11
|
* parenthesis, bracket, or brace is semantic, and it never recommends changing
|
|
@@ -7,8 +13,7 @@
|
|
|
7
13
|
* a review-only authoring concern.
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
|
-
export const DELIMITER_SIZE_DIAGNOSTIC_CODE =
|
|
11
|
-
'delimiter-size-hierarchy' as const;
|
|
16
|
+
export const DELIMITER_SIZE_DIAGNOSTIC_CODE = 'delimiter-manual-size' as const;
|
|
12
17
|
|
|
13
18
|
export type DelimiterSizeName =
|
|
14
19
|
| 'automatic'
|
|
@@ -18,7 +23,6 @@ export type DelimiterSizeName =
|
|
|
18
23
|
| 'Bigg';
|
|
19
24
|
|
|
20
25
|
export interface DelimiterSizeSuggestion {
|
|
21
|
-
readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
|
|
22
26
|
readonly open: string;
|
|
23
27
|
readonly close: string;
|
|
24
28
|
readonly latex: string;
|
|
@@ -30,9 +34,9 @@ export interface DelimiterSizeDiagnostic {
|
|
|
30
34
|
readonly offset: number;
|
|
31
35
|
readonly line: number;
|
|
32
36
|
readonly column: number;
|
|
33
|
-
|
|
34
|
-
readonly
|
|
35
|
-
readonly suggestion
|
|
37
|
+
/** The hand-picked size that should become `\left`/`\right`. */
|
|
38
|
+
readonly size: Exclude<DelimiterSizeName, 'automatic'>;
|
|
39
|
+
readonly suggestion: DelimiterSizeSuggestion;
|
|
36
40
|
readonly message: string;
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -62,7 +66,6 @@ interface DelimiterPair {
|
|
|
62
66
|
readonly open: DelimiterToken;
|
|
63
67
|
close?: DelimiterToken;
|
|
64
68
|
readonly children: DelimiterPair[];
|
|
65
|
-
requiredRank?: number;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const SIZE_SPECS: readonly SizeSpec[] = [
|
|
@@ -114,10 +117,6 @@ const COMMAND_SPECS: readonly CommandSpec[] = SIZE_SPECS.flatMap((spec) => [
|
|
|
114
117
|
{ ...spec, command: spec.closeCommand, tokenKind: 'close' as const },
|
|
115
118
|
]).sort((left, right) => right.command.length - left.command.length);
|
|
116
119
|
|
|
117
|
-
const SUGGESTION_BY_RANK = new Map(
|
|
118
|
-
SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec]),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
120
|
function delimiterAt(
|
|
122
121
|
latex: string,
|
|
123
122
|
offset: number,
|
|
@@ -226,31 +225,22 @@ function lineAndColumn(
|
|
|
226
225
|
};
|
|
227
226
|
}
|
|
228
227
|
|
|
229
|
-
|
|
230
|
-
return SUGGESTION_BY_RANK.get(rank)?.name ?? 'Bigg';
|
|
231
|
-
}
|
|
228
|
+
const SPEC_BY_SIZE = new Map(SIZE_SPECS.map((spec) => [spec.name, spec]));
|
|
232
229
|
|
|
233
|
-
function suggestionFor(
|
|
234
|
-
pair
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
};
|
|
230
|
+
function suggestionFor(pair: DelimiterPair): DelimiterSizeSuggestion {
|
|
231
|
+
const open = `\\left${pair.open.delimiter}`;
|
|
232
|
+
const close = `\\right${pair.close?.delimiter ?? pair.open.delimiter}`;
|
|
233
|
+
return { open, close, latex: `${open} ... ${close}` };
|
|
248
234
|
}
|
|
249
235
|
|
|
250
236
|
/**
|
|
251
|
-
* Warn when a
|
|
252
|
-
*
|
|
253
|
-
*
|
|
237
|
+
* Warn when a pair that takes part in a nest of scalable delimiters is sized
|
|
238
|
+
* by hand instead of by `\left`/`\right`.
|
|
239
|
+
*
|
|
240
|
+
* A pair takes part in a nest when it contains, or sits inside, another pair
|
|
241
|
+
* this linter tracks. A lone hand-sized pair around otherwise bare content —
|
|
242
|
+
* `\bigl(x(t),\ y(t)\bigr)` for a tuple — is a deliberate typographic
|
|
243
|
+
* choice at a single level, has no hierarchy to get wrong, and is left alone.
|
|
254
244
|
*
|
|
255
245
|
* Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
|
|
256
246
|
* ignored: N15 owns that separate error, while ordinary semantic notation
|
|
@@ -261,41 +251,29 @@ export function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[] {
|
|
|
261
251
|
if (!roots) return [];
|
|
262
252
|
const diagnostics: DelimiterSizeDiagnostic[] = [];
|
|
263
253
|
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
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) {
|
|
254
|
+
const visit = (pair: DelimiterPair, nested: boolean): void => {
|
|
255
|
+
const inNest = nested || pair.children.length > 0;
|
|
256
|
+
if (inNest && pair.open.pairKind === 'manual') {
|
|
281
257
|
const location = lineAndColumn(latex, pair.open.offset);
|
|
282
|
-
const suggestion = suggestionFor(pair
|
|
258
|
+
const suggestion = suggestionFor(pair);
|
|
283
259
|
diagnostics.push({
|
|
284
260
|
code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
|
|
285
261
|
severity: 'warning',
|
|
286
262
|
offset: pair.open.offset,
|
|
287
263
|
...location,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
264
|
+
size: pair.open.size as Exclude<DelimiterSizeName, 'automatic'>,
|
|
265
|
+
suggestion,
|
|
266
|
+
message:
|
|
267
|
+
`A nested delimiter pair is sized by hand with ` +
|
|
268
|
+
`${SPEC_BY_SIZE.get(pair.open.size)?.openCommand ?? pair.open.size}` +
|
|
269
|
+
`${pair.open.delimiter}. Use ` +
|
|
270
|
+
`${suggestion.latex} and let the engine size the nest; preserve the ` +
|
|
271
|
+
`original opening and closing delimiter shapes (rule N17).`,
|
|
294
272
|
});
|
|
295
273
|
}
|
|
296
|
-
|
|
274
|
+
for (const child of pair.children) visit(child, true);
|
|
297
275
|
};
|
|
298
276
|
|
|
299
|
-
for (const root of roots)
|
|
277
|
+
for (const root of roots) visit(root, false);
|
|
300
278
|
return diagnostics.sort((left, right) => left.offset - right.offset);
|
|
301
279
|
}
|
|
@@ -106,7 +106,13 @@ export function displayEquationNumber({
|
|
|
106
106
|
: `${prefix}.${offset + indexInLesson}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A display equation's anchor id: `eq-<key>` when it carries a
|
|
111
|
+
* `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ key?: string, number?: string | number }} equation - `number` is
|
|
114
|
+
* read only when there is no `key`, so a keyed caller may omit it.
|
|
115
|
+
*/
|
|
110
116
|
export function equationAnchorId({ key, number }) {
|
|
111
117
|
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
112
118
|
}
|
|
@@ -25,7 +25,7 @@ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
|
|
|
25
25
|
import { isBaseAbbreviation } from './base-abbreviations.mjs';
|
|
26
26
|
import { pageScope } from './gate-math';
|
|
27
27
|
import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
|
|
28
|
-
import {
|
|
28
|
+
import { lessonLine, proseOnly } from './gate-prose-notation';
|
|
29
29
|
import type {
|
|
30
30
|
AbbreviationDefinitionInput,
|
|
31
31
|
NotationRegistryInput,
|
|
@@ -117,7 +117,7 @@ export function gateAbbreviations(input: {
|
|
|
117
117
|
context: 'abbreviation',
|
|
118
118
|
severity: 'error',
|
|
119
119
|
file: lesson.source.file,
|
|
120
|
-
line:
|
|
120
|
+
line: lessonLine(lesson, index),
|
|
121
121
|
token,
|
|
122
122
|
message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
|
|
123
123
|
});
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { stripFencedCode } from './equations.mjs';
|
|
23
23
|
import type { MathGateDiagnostic } from './gate-math';
|
|
24
|
-
import {
|
|
24
|
+
import { lessonLine } from './gate-prose-notation';
|
|
25
25
|
import type { NotationLessonInput } from './types';
|
|
26
26
|
|
|
27
27
|
export const HEADING_LENGTH_LIMIT = 65;
|
|
@@ -120,7 +120,7 @@ export function gateHeadingLength(
|
|
|
120
120
|
context: 'structure',
|
|
121
121
|
severity: 'error',
|
|
122
122
|
file: lesson.source.file,
|
|
123
|
-
line:
|
|
123
|
+
line: lessonLine(lesson, finding.index),
|
|
124
124
|
token: finding.token,
|
|
125
125
|
message: finding.message,
|
|
126
126
|
});
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* warnings and include a column when the math came from lesson/assessment text.
|
|
12
12
|
*/
|
|
13
13
|
import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
EQ_LABEL_PATTERN,
|
|
16
|
+
equationAnchorId,
|
|
17
|
+
stripEquationLabels,
|
|
18
|
+
} from './equations.mjs';
|
|
15
19
|
import { lintDelimiterSizes } from './delimiter-size';
|
|
16
20
|
import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
|
|
17
21
|
import type { GlyphScopeEntry } from './gloss';
|
|
@@ -21,6 +25,21 @@ import type {
|
|
|
21
25
|
SharedNotationDefinitionInput,
|
|
22
26
|
} from './types';
|
|
23
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The equation a finding sits in, when its math carries a `\label{eq:…}`.
|
|
30
|
+
*
|
|
31
|
+
* A location tells you where to edit; this tells you *what you are looking
|
|
32
|
+
* at*. A size-only N17 finding inside a six-line `aligned` block is otherwise
|
|
33
|
+
* anonymous — the message names delimiters, not the result they belong to —
|
|
34
|
+
* so the key and the rendered page's anchor both travel with the diagnostic.
|
|
35
|
+
*/
|
|
36
|
+
export interface EquationLink {
|
|
37
|
+
/** The label's key, without the `eq:` prefix. */
|
|
38
|
+
readonly key: string;
|
|
39
|
+
/** Where the reader sees it, e.g. `/l1-ar/ar1-basics/#eq-ar1-variance`. */
|
|
40
|
+
readonly href: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export interface MathGateDiagnostic {
|
|
25
44
|
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
26
45
|
/**
|
|
@@ -52,6 +71,8 @@ export interface MathGateDiagnostic {
|
|
|
52
71
|
readonly line?: number;
|
|
53
72
|
readonly column?: number;
|
|
54
73
|
readonly token?: string;
|
|
74
|
+
/** Present when the offending math carries a `\label{eq:…}`. */
|
|
75
|
+
readonly equation?: EquationLink;
|
|
55
76
|
readonly message: string;
|
|
56
77
|
}
|
|
57
78
|
|
|
@@ -153,6 +174,25 @@ export function pageScope(
|
|
|
153
174
|
return scope;
|
|
154
175
|
}
|
|
155
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The `\label{eq:…}` a math source carries, as a key plus the anchor the
|
|
179
|
+
* rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
|
|
180
|
+
* reset before use — a stale one would skip the first label of the next span.
|
|
181
|
+
*/
|
|
182
|
+
export function equationLinkFor(
|
|
183
|
+
lessonId: string,
|
|
184
|
+
latex: string,
|
|
185
|
+
): EquationLink | undefined {
|
|
186
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
187
|
+
const match = EQ_LABEL_PATTERN.exec(latex);
|
|
188
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
189
|
+
if (!match) return undefined;
|
|
190
|
+
|
|
191
|
+
const key = match[1];
|
|
192
|
+
const slug = lessonId.replace(/\./g, '/');
|
|
193
|
+
return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
|
|
194
|
+
}
|
|
195
|
+
|
|
156
196
|
function resolveInto(
|
|
157
197
|
latex: string,
|
|
158
198
|
scope: readonly ScopeEntry[],
|
|
@@ -162,7 +202,9 @@ function resolveInto(
|
|
|
162
202
|
column: number | undefined,
|
|
163
203
|
fix: string,
|
|
164
204
|
out: MathGateDiagnostic[],
|
|
205
|
+
equation?: EquationLink,
|
|
165
206
|
): void {
|
|
207
|
+
const at = equation === undefined ? {} : { equation };
|
|
166
208
|
for (const diagnostic of lintDelimiterSizes(latex)) {
|
|
167
209
|
const locatedLine =
|
|
168
210
|
line === undefined ? undefined : line + diagnostic.line - 1;
|
|
@@ -179,6 +221,7 @@ function resolveInto(
|
|
|
179
221
|
file,
|
|
180
222
|
...(locatedLine === undefined ? {} : { line: locatedLine }),
|
|
181
223
|
...(locatedColumn === undefined ? {} : { column: locatedColumn }),
|
|
224
|
+
...at,
|
|
182
225
|
message: diagnostic.message,
|
|
183
226
|
});
|
|
184
227
|
}
|
|
@@ -209,6 +252,7 @@ function resolveInto(
|
|
|
209
252
|
file,
|
|
210
253
|
line,
|
|
211
254
|
token,
|
|
255
|
+
...at,
|
|
212
256
|
message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`,
|
|
213
257
|
});
|
|
214
258
|
}
|
|
@@ -307,16 +351,20 @@ export function gateContentMath(input: {
|
|
|
307
351
|
const scope = pageScope(lesson, sharedByKey);
|
|
308
352
|
const file = lesson.source.file;
|
|
309
353
|
|
|
354
|
+
// `mathSpans` indexes the body, which starts after the frontmatter, so
|
|
355
|
+
// every span line is short by the frontmatter's height until corrected.
|
|
356
|
+
const bodyOffset = (lesson.bodyLine ?? 1) - 1;
|
|
310
357
|
for (const span of mathSpans(lesson.body)) {
|
|
311
358
|
resolveInto(
|
|
312
359
|
span.latex,
|
|
313
360
|
scope,
|
|
314
361
|
'body',
|
|
315
362
|
file,
|
|
316
|
-
span.line,
|
|
363
|
+
span.line + bodyOffset,
|
|
317
364
|
span.column,
|
|
318
365
|
BODY_FIX,
|
|
319
366
|
diagnostics,
|
|
367
|
+
equationLinkFor(lesson.lessonId, span.latex),
|
|
320
368
|
);
|
|
321
369
|
}
|
|
322
370
|
|
|
@@ -71,6 +71,20 @@ export function lineOf(text: string, index: number): number {
|
|
|
71
71
|
return text.slice(0, index).split('\n').length;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* {@link lineOf} corrected to the file the lesson came from. A gate scans
|
|
76
|
+
* `lesson.body`, which starts *after* the frontmatter, so a raw body line is
|
|
77
|
+
* short by the frontmatter's height and points at the wrong source line —
|
|
78
|
+
* silently, because the number is still plausible. Every gate that reports a
|
|
79
|
+
* `line` against `lesson.source.file` must go through this.
|
|
80
|
+
*/
|
|
81
|
+
export function lessonLine(
|
|
82
|
+
lesson: { readonly body: string; readonly bodyLine?: number },
|
|
83
|
+
index: number,
|
|
84
|
+
): number {
|
|
85
|
+
return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
// Symbolic/abbreviated unit tokens that must be typeset in `$…$`, longest
|
|
75
89
|
// first so the alternation can't match a prefix of a longer unit.
|
|
76
90
|
const UNIT = String.raw`m/s²|m/s|km/h|rad/s|m²/s²|J/kg|m²|s⁻¹|kg|N|J|W|Hz|deg|°|rad|m|s`;
|
|
@@ -178,7 +192,7 @@ export function gateProseNotation(
|
|
|
178
192
|
context: 'prose',
|
|
179
193
|
severity: 'error',
|
|
180
194
|
file: lesson.source.file,
|
|
181
|
-
line:
|
|
195
|
+
line: lessonLine(lesson, finding.index),
|
|
182
196
|
token: finding.token,
|
|
183
197
|
message: finding.message,
|
|
184
198
|
});
|