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.
Files changed (47) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/dist/compiler/cli.d.ts +8 -1
  3. package/dist/compiler/cli.js +10 -2
  4. package/dist/compiler/collections.js +16 -3
  5. package/dist/compiler/logging.d.ts +11 -1
  6. package/dist/compiler/logging.js +2 -1
  7. package/dist/compiler/manifest.d.ts +1 -1
  8. package/dist/compiler/manifest.js +1 -1
  9. package/dist/reference/delimiter-size.d.ts +18 -9
  10. package/dist/reference/delimiter-size.js +15 -39
  11. package/dist/reference/equations.d.mts +9 -3
  12. package/dist/reference/equations.mjs +7 -1
  13. package/dist/reference/gate-abbreviations.js +2 -2
  14. package/dist/reference/gate-heading-length.js +2 -2
  15. package/dist/reference/gate-math.d.ts +23 -1
  16. package/dist/reference/gate-math.js +23 -4
  17. package/dist/reference/gate-prose-notation.d.ts +12 -1
  18. package/dist/reference/gate-prose-notation.js +5 -1
  19. package/dist/reference/katex-delimiters.d.mts +15 -0
  20. package/dist/reference/katex-delimiters.mjs +125 -0
  21. package/dist/reference/katex-options.mjs +4 -0
  22. package/dist/reference/math-glyphs.mjs +16 -2
  23. package/dist/reference/render-lab-math.js +1 -1
  24. package/dist/reference/types.d.ts +7 -0
  25. package/docs/authoring/README.md +1 -1
  26. package/docs/authoring/migrating.md +228 -0
  27. package/docs/authoring/notation.md +80 -62
  28. package/docs/katex-bracket-scaling.md +191 -0
  29. package/package.json +2 -1
  30. package/src/compiler/cli.ts +23 -0
  31. package/src/compiler/collections.ts +33 -3
  32. package/src/compiler/logging.ts +14 -3
  33. package/src/compiler/manifest.ts +1 -1
  34. package/src/components/notation/NotationGlossary.astro +21 -10
  35. package/src/components/notation/render-notation-math.ts +111 -10
  36. package/src/components/starlight/AboutPanel.astro +17 -2
  37. package/src/reference/delimiter-size.ts +37 -59
  38. package/src/reference/equations.mjs +7 -1
  39. package/src/reference/gate-abbreviations.ts +2 -2
  40. package/src/reference/gate-heading-length.ts +2 -2
  41. package/src/reference/gate-math.ts +50 -2
  42. package/src/reference/gate-prose-notation.ts +15 -1
  43. package/src/reference/katex-delimiters.mjs +125 -0
  44. package/src/reference/katex-options.mjs +4 -0
  45. package/src/reference/math-glyphs.mjs +16 -2
  46. package/src/reference/render-lab-math.ts +1 -1
  47. package/src/reference/types.ts +7 -0
@@ -1,5 +1,8 @@
1
1
  import rehypeKatex from 'rehype-katex';
2
- import { createNotationKatexOptions } from '../../reference/katex-options.mjs';
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
- const safeAttributeValue = /^[ A-Za-z0-9+.,()\-/%]*$/;
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 safeStyle = /^(?:[a-z-]+:[ A-Za-z0-9+.,()\-/%]*;)*$/;
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
- name === 'style' &&
175
- typeof value === 'string' &&
176
- safeStyle.test(value) &&
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' && typeof value === 'string' && safePathData.test(value)) {
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 class="about" data-about-panel>
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 delimiters must make their hierarchy visible.
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
- readonly outerSize: DelimiterSizeName;
34
- readonly nestedSize: DelimiterSizeName;
35
- readonly suggestion?: DelimiterSizeSuggestion;
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
- function sizeName(rank: number): DelimiterSizeName {
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: 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
- };
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 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`.
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 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) {
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, requiredRank);
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
- 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).`,
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
- return pair.requiredRank;
274
+ for (const child of pair.children) visit(child, true);
297
275
  };
298
276
 
299
- for (const root of roots) assess(root);
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
- /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
109
+ /**
110
+ * A display equation's anchor id: `eq-<key>` when it carries a
111
+ * `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
112
+ *
113
+ * @param {{ key?: string, number?: string | number }} equation - `number` is
114
+ * read only when there is no `key`, so a keyed caller may omit it.
115
+ */
110
116
  export function equationAnchorId({ key, number }) {
111
117
  return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
112
118
  }
@@ -25,7 +25,7 @@ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
25
25
  import { isBaseAbbreviation } from './base-abbreviations.mjs';
26
26
  import { pageScope } from './gate-math';
27
27
  import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
28
- import { lineOf, proseOnly } from './gate-prose-notation';
28
+ import { lessonLine, proseOnly } from './gate-prose-notation';
29
29
  import type {
30
30
  AbbreviationDefinitionInput,
31
31
  NotationRegistryInput,
@@ -117,7 +117,7 @@ export function gateAbbreviations(input: {
117
117
  context: 'abbreviation',
118
118
  severity: 'error',
119
119
  file: lesson.source.file,
120
- line: lineOf(lesson.body, index),
120
+ line: lessonLine(lesson, index),
121
121
  token,
122
122
  message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
123
123
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { stripFencedCode } from './equations.mjs';
23
23
  import type { MathGateDiagnostic } from './gate-math';
24
- import { lineOf } from './gate-prose-notation';
24
+ import { lessonLine } from './gate-prose-notation';
25
25
  import type { NotationLessonInput } from './types';
26
26
 
27
27
  export const HEADING_LENGTH_LIMIT = 65;
@@ -120,7 +120,7 @@ export function gateHeadingLength(
120
120
  context: 'structure',
121
121
  severity: 'error',
122
122
  file: lesson.source.file,
123
- line: lineOf(lesson.body, finding.index),
123
+ line: lessonLine(lesson, finding.index),
124
124
  token: finding.token,
125
125
  message: finding.message,
126
126
  });
@@ -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 { stripEquationLabels } from './equations.mjs';
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: lineOf(lesson.body, finding.index),
195
+ line: lessonLine(lesson, finding.index),
182
196
  token: finding.token,
183
197
  message: finding.message,
184
198
  });