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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,175 @@ under **Breaking** below; a PATCH never breaks.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.8.0] - 2026-09-19
18
+
19
+ ### Breaking
20
+
21
+ - **Rule N17 is inverted: a nested delimiter is sized by the engine, never by
22
+ hand.** The engine now installs its own KaTeX delimiter sizing
23
+ (`src/reference/katex-delimiters.mjs`), changing one constant in
24
+ `makeLeftRightDelim` — `delimiterExtend`, TeX's `\delimitershortfall`, from
25
+ `5.0` to `-1.0` points.
26
+ Stock TeX and stock KaTeX let a `\left … \right` pair fall up to 5pt short of
27
+ its content, so two pairs at different depths around similar content round to
28
+ the same glyph and the nesting vanishes; N17 used to repair that by making
29
+ authors hand-pick `\bigl` → `\Bigl` → `\biggl` → `\Biggl` outward. Those are
30
+ *fixed* glyphs: they stop tracking the content they wrap, they run out after
31
+ four levels, and editing an inner expression silently invalidates every size
32
+ above it. With a negative shortfall a pair is always strictly taller than its
33
+ content, so each enclosing level grows on its own, at any depth, by exactly
34
+ what it needs. `\left … \right` at every level is now the rule, and
35
+ hand-sizing a pair inside a nest is the warning.
36
+
37
+ Consequences for a course:
38
+ - The Tier-3 diagnostic code is renamed `delimiter-size-hierarchy` →
39
+ **`delimiter-manual-size`**; a `lint-ignore.yml` entry or a script matching
40
+ the old code must be updated. Its diagnostic shape changed too: the
41
+ `outerSize`/`nestedSize` fields are replaced by a single `size` (the
42
+ hand-picked size found), and `suggestion` is now always present and always
43
+ a `\left`/`\right` pair — it no longer carries a `size` field.
44
+ - Math that was N17-compliant now warns. A ladder such as
45
+ `\Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr]` reports one
46
+ finding per hand-sized level; the repair is `\left`/`\right` at every level.
47
+ A *lone* hand-sized pair that is not part of a nest — `\bigl(x,\ y\bigr)`
48
+ for a tuple — is deliberately left alone.
49
+ - **A course needs no install step for this.** `delimiterExtend` is a
50
+ function-local `const`, so nothing KaTeX exports reaches it and the change
51
+ has to happen in the module's own source. Rather than a `patches/` file —
52
+ which pnpm records per project and so cannot travel inside the npm artifact
53
+ — the engine reads KaTeX's ESM bundle, rewrites that constant, and moves
54
+ the render entry points onto the shared `katex` object. `rehype-katex`
55
+ reads `katex.renderToString` at call time and KaTeX's default export is
56
+ mutable, so lesson-body math inherits it without the plugin being forked or
57
+ aliased. `astro.config.mjs` already imports
58
+ `explico/reference/katex-options.mjs`, which installs it, so an existing
59
+ course picks this up on upgrade with no edit. The installer verifies itself
60
+ — a KaTeX upgrade that moves the constant, or anything that renders first,
61
+ fails the build with an explicit message instead of silently reverting to
62
+ stock spacing. [`docs/katex-bracket-scaling.md`](docs/katex-bracket-scaling.md)
63
+ derives the change and documents the mechanism and its trade-offs.
64
+
65
+ ### Added
66
+
67
+ - **A math diagnostic now names the equation it belongs to.** Every finding
68
+ the math gate reports from a `$$…$$` block that carries a `\label{eq:…}`
69
+ gains an `equation` field — `{ key, href }`, where `href` is the anchor the
70
+ rendered page gives that equation (`/l1-ar/ar1-basics/#eq-ar1-variance`) —
71
+ and `diagnosticLine()` appends it, so the console line, the run log, and
72
+ `pnpm content diagnostics` all read
73
+ `[code] file:line:column: message (eq:<key> -> <href>)`. A size-only
74
+ `delimiter-manual-size` finding inside a six-line `aligned` block was
75
+ otherwise anonymous: the message names delimiters, never the result they
76
+ belong to, so an author had to reconstruct which equation was meant from a
77
+ line number alone. `CliDiagnostic` carries the same optional field, so a
78
+ course's own `scripts/content.ts` gets it without change. The manifest
79
+ schema is now **v16** (additive: v15 → v16); nothing existing changed shape.
80
+
81
+ - **The About panel publishes the identity it shows.** `AboutPanel.astro` now
82
+ stamps `data-knowledge-version`, `data-knowledge-date` (omitted when the
83
+ content changelog has no date) and `data-engine-version` on its
84
+ `details[data-about-panel]` element, and marks the rendered version line with
85
+ `data-knowledge-line`. The two versions the panel exists to publish were
86
+ readable only as prose — and as a `version · date` join — so a course's build
87
+ check had to match that text, which pins the check to one version string and
88
+ turns every routine bump into a test edit; this repo's own
89
+ `playground/tests/e2e/about.spec.ts` was asserting `0.4.0` against a
90
+ playground long since at `0.5.2`. A bare version substring is not scoped to
91
+ the panel either, so the engine version printed one row below can satisfy it.
92
+ The attributes are additive: nothing rendered changed, and the existing
93
+ `data-about-panel` hook is untouched.
94
+
95
+ ### Fixed
96
+
97
+ - **A glossary heading's accessible name is the term again.** The notation
98
+ glossary's per-entry anchor link moved *inside* its `<h2>` when the entry
99
+ header was rebuilt for smooth scrolling, so the `aria-label` on that link
100
+ ("Link to “Velocity”") joined the heading's accessible name — a screen reader
101
+ announced "Velocity Link to Velocity", and any check for the heading by name
102
+ stopped matching. `NotationGlossary.astro` now wraps the heading and its
103
+ anchor in a `.glossary-heading` flex row, the shape
104
+ `rehype-heading-anchors` already gives a lesson heading (`<h2>` and
105
+ `a.sl-anchor-link` as siblings). Nothing visual changed; the anchor still
106
+ reveals on hover and focus.
107
+
108
+ - **A diagnostic's `line` now addresses the file it names.** Every gate scans
109
+ `lesson.body`, which Astro's `parseFrontmatter` hands over with the
110
+ frontmatter already removed, but each finding is reported against
111
+ `lesson.source.file` — so every `file:line:column` the engine has ever
112
+ emitted for a lesson was short by the height of that lesson's frontmatter.
113
+ It failed silently in the worst way: the number stayed plausible, so an
114
+ editor, a VS Code problem matcher, or an author following
115
+ `pnpm content diagnostics` jumped confidently to the wrong line, and a
116
+ size-only N17 finding could not be located at all. `parseDocument` now
117
+ records the body's start line, `NotationLessonInput` carries it as an
118
+ optional `bodyLine`, and the new `lessonLine()` helper applies it — so
119
+ `gate-math` (including every `delimiter-manual-size` column),
120
+ `gate-prose-notation` (N9), `gate-abbreviations` (N14), and
121
+ `gate-heading-length` (S1) are all corrected at once. A hand-built lesson
122
+ input that omits `bodyLine` behaves exactly as before.
123
+
124
+ - **`equationAnchorId({ key })` type-checks without a `number`.** Its JSDoc
125
+ required both properties although `number` is read only when there is no
126
+ `key`, so a keyed caller had to pass a value the function ignores.
127
+
128
+ - **A notation `formula` can use a manual delimiter size, an accent, a box, or
129
+ a color without failing the build.** `render-notation-math.ts` sanitizes
130
+ KaTeX's output against an attribute allowlist that had only ever grown to
131
+ cover what the corpus happened to use, so ordinary math threw
132
+ `Unexpected KaTeX property` with no author-side workaround: `\Bigl`/`\biggl`/
133
+ `\Biggl` (`minsize`, `maxsize`), `\xrightarrow` (`minsize`), `\boxed`
134
+ (`notation`), and `\colorbox` (`voffset`, `mathbackground`). Rule N17 asks
135
+ authors for exactly those delimiter commands, so a card that followed the
136
+ engine's own advice could not build. The allowlist is now KaTeX's whole inert
137
+ presentation surface — `columnlines`, `depth`, `display`, `largeop`,
138
+ `linebreak`, `linethickness`, `mathbackground`, `mathcolor`, `mathsize`,
139
+ `maxsize`, `minsize`, `notation`, `rowlines`, `valign`, and `voffset` join the
140
+ set — with `alt`, `href`, `src`, and `title` explicitly denied, and a new test
141
+ scans KaTeX's own `setAttribute` calls so a KaTeX upgrade that adds an
142
+ attribute fails here instead of in a downstream course. Hex colors are
143
+ accepted in an attribute or style value; no attribute that can carry a URL is
144
+ allowed through.
145
+
146
+ - **`\vec` (and every other accent) renders again.** The style sanitizer
147
+ required each CSS declaration to end in `;`, but KaTeX does not always
148
+ terminate the last one — `\vec` emits `style="width:0.471em"` — and the
149
+ rejection then fell through to the misleading
150
+ `Unexpected KaTeX property: style`. Declarations are now validated one at a
151
+ time, and an unsafe `style` or `d` value says so instead of reporting an
152
+ unknown property.
153
+
154
+ - **A macro in a formula no longer invents a source span.** KaTeX gives a node
155
+ it produced by expanding a macro a `loc` from the lexer over the *macro
156
+ body*, so those offsets index that body and not the source being scanned —
157
+ and they are still valid indices into a longer source, so the glyph resolver
158
+ read them as a real span over unrelated text. `\vdots` expands to
159
+ `{\varvdots\rule{0pt}{15pt}}`, whose atom claims characters 1–10: in
160
+ `M=\begin{pmatrix}…` that is `"=\begin{p"`, and because no author can wrap
161
+ that slice in an `\explain`, a matrix showing a column ellipsis failed the
162
+ build with `Unresolved notation ""`. Where the stray span instead collided
163
+ with a glyph in scope, the binding was injected at the macro body's offsets
164
+ and split the source mid-command — `\varSigma=\operatorname{Cov}(X)` became
165
+ `\varSigm\explain{cov-matrix}{a=\ope}ratorname{Cov}(X)`, a corrupt formula no
166
+ gate rejected. `nodeSpan` now believes a `loc` only when it belongs to the
167
+ string this pass parsed. 79 of KaTeX's 282 macros emit such a node, among
168
+ them `\argmax`, `\argmin`, `\plim`, `\limsup`, `\liminf`, `\bmod`, `\pmod`,
169
+ `\R`, `\N`, `\Z` and every upright `\varGamma`-style capital. A macro's
170
+ *argument* keeps the author's own locs and still binds normally. One limit
171
+ remains: a macro-expanded glyph has no authored span, so canonical matching
172
+ and the completeness gate cannot see it — annotate it with an explicit
173
+ `\explain{key}{\varSigma}`, which works and is unaffected by this change.
174
+
175
+ - **A malformed `\explain` / `\slot` key fails the build instead of reaching
176
+ the reader.** KaTeX does not treat a denied `trust` callback as a parse
177
+ error: it renders the command name in `errorColor` and continues, so
178
+ `\explain{Not A Key}{\Omega}` produced red `\htmlData` text on the page with
179
+ the build green. (Until now this was masked by `mathcolor` — the attribute
180
+ KaTeX's error rendering carries — not being in the allowlist.)
181
+ `renderNotationMath` now checks both marker macros against
182
+ `trustNotationMarker` before KaTeX runs and throws naming the offending key.
183
+ `renderNotationFormula` still degrades to inert-but-correct math rather than
184
+ costing a reader the equation.
185
+
17
186
  ## [0.7.0] - 2026-09-18
18
187
 
19
188
  ### Breaking
@@ -722,7 +891,8 @@ under **Breaking** below; a PATCH never breaks.
722
891
  system with its completeness gate, the curriculum-graph validator, the
723
892
  Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
724
893
 
725
- [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.7.0...HEAD
894
+ [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.8.0...HEAD
895
+ [0.8.0]: https://github.com/danieltuzes/explico/compare/v0.7.0...v0.8.0
726
896
  [0.7.0]: https://github.com/danieltuzes/explico/compare/v0.6.0...v0.7.0
727
897
  [0.6.0]: https://github.com/danieltuzes/explico/compare/v0.5.0...v0.6.0
728
898
  [0.5.0]: https://github.com/danieltuzes/explico/compare/v0.4.0...v0.5.0
@@ -1,4 +1,5 @@
1
1
  import { DiagnosticSeverity } from '../reference/types.js';
2
+ import { EquationLink } from '../reference/gate-math.js';
2
3
  import { Manifest, ManifestLesson } from './manifest.js';
3
4
  import '../curriculum/validation.js';
4
5
  import '../reference/consistency.js';
@@ -12,7 +13,6 @@ import '../reference/figures-validate.js';
12
13
  import '../reference/diagrams-validate.js';
13
14
  import '../reference/knowledge-checks-validate.js';
14
15
  import '../reference/raw-floats-validate.js';
15
- import '../reference/gate-math.js';
16
16
  import '../reference/mixed-content.js';
17
17
  import '../reference/gloss.js';
18
18
  import './knowledge-version.js';
@@ -61,6 +61,13 @@ interface CliDiagnostic {
61
61
  readonly file?: string;
62
62
  readonly line?: number;
63
63
  readonly column?: number;
64
+ /**
65
+ * The keyed equation the finding sits in, when the math carries a
66
+ * `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
67
+ * rendered page. A size-only finding inside a long `aligned` block is
68
+ * otherwise anonymous.
69
+ */
70
+ readonly equation?: EquationLink;
64
71
  }
65
72
  /**
66
73
  * Every manifest diagnostic that names this lesson (by id or file), normalized
@@ -76,7 +76,8 @@ function lessonDiagnostics(manifest, lesson) {
76
76
  message: diagnostic.message,
77
77
  file: diagnostic.file,
78
78
  ...diagnostic.line === void 0 ? {} : { line: diagnostic.line },
79
- ...diagnostic.column === void 0 ? {} : { column: diagnostic.column }
79
+ ...diagnostic.column === void 0 ? {} : { column: diagnostic.column },
80
+ ...diagnostic.equation === void 0 ? {} : { equation: diagnostic.equation }
80
81
  });
81
82
  }
82
83
  for (const diagnostic of manifest.diagnostics.equations) {
@@ -365,6 +366,11 @@ function contentStatus(manifest) {
365
366
  }
366
367
  };
367
368
  }
369
+ function equationLink(value) {
370
+ if (value === null || typeof value !== "object") return void 0;
371
+ const { key, href } = value;
372
+ return typeof key === "string" && typeof href === "string" ? { key, href } : void 0;
373
+ }
368
374
  function numericLocation(value) {
369
375
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
370
376
  }
@@ -394,13 +400,15 @@ function contentDiagnostics(manifest, minimumSeverity = "info") {
394
400
  const line = numericLocation(diagnostic.line) ?? numericLocation(source?.line);
395
401
  const column = numericLocation(diagnostic.column) ?? numericLocation(source?.column);
396
402
  const detail = typeof diagnostic.code === "string" ? diagnostic.code : typeof diagnostic.kind === "string" ? diagnostic.kind : "diagnostic";
403
+ const equation = equationLink(diagnostic.equation);
397
404
  diagnostics.push({
398
405
  code: `${group}:${detail}`,
399
406
  severity,
400
407
  message: String(diagnostic.message ?? detail),
401
408
  ...file === void 0 ? {} : { file },
402
409
  ...line === void 0 ? {} : { line },
403
- ...column === void 0 ? {} : { column }
410
+ ...column === void 0 ? {} : { column },
411
+ ...equation === void 0 ? {} : { equation }
404
412
  });
405
413
  }
406
414
  }
@@ -25,12 +25,24 @@ function filesBelow(directory, extensions) {
25
25
  return entry.isFile() && extensions.some((extension) => entry.name.endsWith(extension)) ? [path] : [];
26
26
  }).sort();
27
27
  }
28
+ function bodyStartLine(raw, body) {
29
+ const offset = raw.endsWith(body) ? raw.length - body.length : raw.indexOf(body);
30
+ if (offset <= 0) return 1;
31
+ let line = 1;
32
+ for (let index = 0; index < offset; index += 1) {
33
+ if (raw.charCodeAt(index) === 10) line += 1;
34
+ }
35
+ return line;
36
+ }
28
37
  function parseDocument(path) {
29
- const parsed = parseFrontmatter(readFileSync(path, "utf8"));
38
+ const raw = readFileSync(path, "utf8");
39
+ const parsed = parseFrontmatter(raw);
40
+ const body = parsed.content.replace(/^\n/, "");
30
41
  return {
31
42
  file: relative(REPOSITORY_ROOT, path),
32
43
  data: parsed.frontmatter ?? {},
33
- body: parsed.content.replace(/^\n/, "")
44
+ body,
45
+ bodyLine: bodyStartLine(raw, body)
34
46
  };
35
47
  }
36
48
  function record(value, label) {
@@ -322,7 +334,7 @@ function notationLessonEntries() {
322
334
  for (const path of filesBelow(join(CONTENT_ROOT, "docs"), [".md", ".mdx"])) {
323
335
  const slug = docSlug(path);
324
336
  if (!isLessonSlug(slug)) continue;
325
- const { file, data, body } = parseDocument(path);
337
+ const { file, data, body, bodyLine } = parseDocument(path);
326
338
  const notation = record(data.notation ?? {}, `${file} notation`);
327
339
  const local = Array.isArray(notation.local) ? notation.local : [];
328
340
  lessons.push({
@@ -333,6 +345,7 @@ function notationLessonEntries() {
333
345
  ),
334
346
  references: extractNotationReferences(body, file, void 0),
335
347
  body,
348
+ bodyLine,
336
349
  source: { file }
337
350
  });
338
351
  }
@@ -67,6 +67,11 @@ interface AnyDiagnostic {
67
67
  readonly file?: string;
68
68
  readonly line?: number;
69
69
  readonly column?: number;
70
+ /** `{ key, href }` when the finding belongs to a `\label{eq:…}` equation. */
71
+ readonly equation?: {
72
+ readonly key: string;
73
+ readonly href: string;
74
+ };
70
75
  }
71
76
  /** `manifest.diagnostics`, structurally — one array per subsystem. */
72
77
  type DiagnosticGroups = Readonly<Record<string, readonly AnyDiagnostic[]>>;
@@ -89,7 +94,12 @@ interface RunLog {
89
94
  /** Refreshes `latest` and reports what the run recorded. */
90
95
  close(): RunLogSummary;
91
96
  }
92
- /** `[code] message`, matching what the console has always printed. */
97
+ /**
98
+ * `[code] file:line:column: message`, matching what the console has always
99
+ * printed, with ` (eq:<key> -> <href>)` appended when the finding belongs to a
100
+ * keyed equation — so a size-only finding inside a long `aligned` block names
101
+ * the result it is about, not just its coordinates.
102
+ */
93
103
  declare function diagnosticLine(diagnostic: AnyDiagnostic): string;
94
104
  interface RunLogOptions {
95
105
  /** Overridden in tests; defaults to `build/logs` under the repository root. */
@@ -38,7 +38,8 @@ function diagnosticLine(diagnostic) {
38
38
  const code = diagnostic.code ?? diagnostic.kind;
39
39
  const at = [diagnostic.file, diagnostic.line, diagnostic.column].filter((part) => part !== void 0).join(":");
40
40
  const prefix = code === void 0 ? "" : `[${code}] `;
41
- return at === "" ? `${prefix}${diagnostic.message}` : `${prefix}${at}: ${diagnostic.message}`;
41
+ const equation = diagnostic.equation === void 0 ? "" : ` (eq:${diagnostic.equation.key} -> ${diagnostic.equation.href})`;
42
+ return at === "" ? `${prefix}${diagnostic.message}${equation}` : `${prefix}${at}: ${diagnostic.message}${equation}`;
42
43
  }
43
44
  function createRunLog(options = {}) {
44
45
  const root = options.root ?? LOGS_DIR;
@@ -101,7 +101,7 @@ import '../reference/keyed-float-validate.js';
101
101
  * from `warning` to `info`; their diagnostic codes and container fields stay
102
102
  * unchanged.
103
103
  */
104
- declare const MANIFEST_SCHEMA_VERSION = 15;
104
+ declare const MANIFEST_SCHEMA_VERSION = 16;
105
105
  declare const MANIFEST_PATH: string;
106
106
  interface ManifestCitation {
107
107
  readonly id: string;
@@ -75,7 +75,7 @@ import {
75
75
  isLessonSlug,
76
76
  lessonIdFromSlug
77
77
  } from "./lesson-derivation.js";
78
- const MANIFEST_SCHEMA_VERSION = 15;
78
+ const MANIFEST_SCHEMA_VERSION = 16;
79
79
  const REPOSITORY_ROOT = process.cwd();
80
80
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
81
81
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
@@ -1,15 +1,20 @@
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
6
12
  * one delimiter shape into another. N16's alternating-shape preference remains
7
13
  * a review-only authoring concern.
8
14
  */
9
- declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-size-hierarchy";
15
+ declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-manual-size";
10
16
  type DelimiterSizeName = 'automatic' | 'big' | 'Big' | 'bigg' | 'Bigg';
11
17
  interface DelimiterSizeSuggestion {
12
- readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
13
18
  readonly open: string;
14
19
  readonly close: string;
15
20
  readonly latex: string;
@@ -20,15 +25,19 @@ interface DelimiterSizeDiagnostic {
20
25
  readonly offset: number;
21
26
  readonly line: number;
22
27
  readonly column: number;
23
- readonly outerSize: DelimiterSizeName;
24
- readonly nestedSize: DelimiterSizeName;
25
- readonly suggestion?: DelimiterSizeSuggestion;
28
+ /** The hand-picked size that should become `\left`/`\right`. */
29
+ readonly size: Exclude<DelimiterSizeName, 'automatic'>;
30
+ readonly suggestion: DelimiterSizeSuggestion;
26
31
  readonly message: string;
27
32
  }
28
33
  /**
29
- * Warn when a scalable outer pair is not at least one manual size level above
30
- * everything nested inside it. `\left` and `\big` form the baseline level;
31
- * progressively larger levels are `\Big`, `\bigg`, and `\Bigg`.
34
+ * Warn when a pair that takes part in a nest of scalable delimiters is sized
35
+ * by hand instead of by `\left`/`\right`.
36
+ *
37
+ * A pair takes part in a nest when it contains, or sits inside, another pair
38
+ * this linter tracks. A lone hand-sized pair around otherwise bare content —
39
+ * `\bigl(x(t),\ y(t)\bigr)` for a tuple — is a deliberate typographic
40
+ * choice at a single level, has no hierarchy to get wrong, and is left alone.
32
41
  *
33
42
  * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
34
43
  * ignored: N15 owns that separate error, while ordinary semantic notation
@@ -1,4 +1,4 @@
1
- const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-size-hierarchy";
1
+ const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-manual-size";
2
2
  const SIZE_SPECS = [
3
3
  {
4
4
  name: "automatic",
@@ -40,9 +40,6 @@ const COMMAND_SPECS = SIZE_SPECS.flatMap((spec) => [
40
40
  { ...spec, command: spec.openCommand, tokenKind: "open" },
41
41
  { ...spec, command: spec.closeCommand, tokenKind: "close" }
42
42
  ]).sort((left, right) => right.command.length - left.command.length);
43
- const SUGGESTION_BY_RANK = new Map(
44
- SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec])
45
- );
46
43
  function delimiterAt(latex, offset, kind) {
47
44
  const candidates = kind === "open" ? ["\\{", "(", "["] : ["\\}", ")", "]"];
48
45
  for (const delimiter of candidates) {
@@ -120,55 +117,34 @@ function lineAndColumn(source, offset) {
120
117
  column: offset - lastNewline
121
118
  };
122
119
  }
123
- function sizeName(rank) {
124
- return SUGGESTION_BY_RANK.get(rank)?.name ?? "Bigg";
125
- }
126
- function suggestionFor(pair, requiredRank) {
127
- const close = pair.close;
128
- const spec = SUGGESTION_BY_RANK.get(requiredRank);
129
- if (!close || !spec || spec.name === "automatic" || spec.name === "big") {
130
- return void 0;
131
- }
132
- return {
133
- size: spec.name,
134
- open: `${spec.openCommand}${pair.open.delimiter}`,
135
- close: `${spec.closeCommand}${close.delimiter}`,
136
- latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`
137
- };
120
+ const SPEC_BY_SIZE = new Map(SIZE_SPECS.map((spec) => [spec.name, spec]));
121
+ function suggestionFor(pair) {
122
+ const open = `\\left${pair.open.delimiter}`;
123
+ const close = `\\right${pair.close?.delimiter ?? pair.open.delimiter}`;
124
+ return { open, close, latex: `${open} ... ${close}` };
138
125
  }
139
126
  function lintDelimiterSizes(latex) {
140
127
  const roots = parseDelimiterPairs(latex);
141
128
  if (!roots) return [];
142
129
  const diagnostics = [];
143
- const assess = (pair) => {
144
- let largestNestedRequirement = 0;
145
- let representativeNestedSize = "automatic";
146
- for (const child of pair.children) {
147
- const childRequired = assess(child);
148
- if (childRequired > largestNestedRequirement) {
149
- largestNestedRequirement = childRequired;
150
- representativeNestedSize = sizeName(childRequired);
151
- }
152
- }
153
- const requiredRank = largestNestedRequirement === 0 ? pair.open.rank : largestNestedRequirement + 1;
154
- pair.requiredRank = Math.max(pair.open.rank, requiredRank);
155
- if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
130
+ const visit = (pair, nested) => {
131
+ const inNest = nested || pair.children.length > 0;
132
+ if (inNest && pair.open.pairKind === "manual") {
156
133
  const location = lineAndColumn(latex, pair.open.offset);
157
- const suggestion = suggestionFor(pair, requiredRank);
134
+ const suggestion = suggestionFor(pair);
158
135
  diagnostics.push({
159
136
  code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
160
137
  severity: "warning",
161
138
  offset: pair.open.offset,
162
139
  ...location,
163
- outerSize: pair.open.size,
164
- nestedSize: representativeNestedSize,
165
- ...suggestion ? { suggestion } : {},
166
- message: suggestion ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).` : `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).`
140
+ size: pair.open.size,
141
+ suggestion,
142
+ message: `A nested delimiter pair is sized by hand with ${SPEC_BY_SIZE.get(pair.open.size)?.openCommand ?? pair.open.size}${pair.open.delimiter}. Use ${suggestion.latex} and let the engine size the nest; preserve the original opening and closing delimiter shapes (rule N17).`
167
143
  });
168
144
  }
169
- return pair.requiredRank;
145
+ for (const child of pair.children) visit(child, true);
170
146
  };
171
- for (const root of roots) assess(root);
147
+ for (const root of roots) visit(root, false);
172
148
  return diagnostics.sort((left, right) => left.offset - right.offset);
173
149
  }
174
150
  export {
@@ -35,10 +35,16 @@ export function displayEquationNumber({ prefix, offset, section, indexInSection,
35
35
  indexInSection: any;
36
36
  indexInLesson: any;
37
37
  }): string;
38
- /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
38
+ /**
39
+ * A display equation's anchor id: `eq-<key>` when it carries a
40
+ * `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
41
+ *
42
+ * @param {{ key?: string, number?: string | number }} equation - `number` is
43
+ * read only when there is no `key`, so a keyed caller may omit it.
44
+ */
39
45
  export function equationAnchorId({ key, number }: {
40
- key: any;
41
- number: any;
46
+ key?: string;
47
+ number?: string | number;
42
48
  }): string;
43
49
  export function stripFencedCode(markdown: any): string;
44
50
  /**
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { collectOperators, collectRomanLabels } from "./math-glyphs.mjs";
2
2
  import { isBaseAbbreviation } from "./base-abbreviations.mjs";
3
3
  import { pageScope } from "./gate-math.js";
4
- import { lineOf, proseOnly } from "./gate-prose-notation.js";
4
+ import { lessonLine, proseOnly } from "./gate-prose-notation.js";
5
5
  const CAPS_RUN = /\b([A-Z]{2,})(?:'s|s)?\b/g;
6
6
  const DOTTED_ABBR = /\b(?:[A-Za-z]\.){2,}/g;
7
7
  function findAbbreviationLikeTokens(text) {
@@ -51,7 +51,7 @@ function gateAbbreviations(input) {
51
51
  context: "abbreviation",
52
52
  severity: "error",
53
53
  file: lesson.source.file,
54
- line: lineOf(lesson.body, index),
54
+ line: lessonLine(lesson, index),
55
55
  token,
56
56
  message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`
57
57
  });
@@ -1,5 +1,5 @@
1
1
  import { stripFencedCode } from "./equations.mjs";
2
- import { lineOf } from "./gate-prose-notation.js";
2
+ import { lessonLine } from "./gate-prose-notation.js";
3
3
  const HEADING_LENGTH_LIMIT = 65;
4
4
  const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
5
5
  const TITLE_ATTRIBUTE_BY_TAG = {
@@ -65,7 +65,7 @@ function gateHeadingLength(lessons) {
65
65
  context: "structure",
66
66
  severity: "error",
67
67
  file: lesson.source.file,
68
- line: lineOf(lesson.body, finding.index),
68
+ line: lessonLine(lesson, finding.index),
69
69
  token: finding.token,
70
70
  message: finding.message
71
71
  });
@@ -1,5 +1,19 @@
1
1
  import { NotationRegistryInput, NotationLessonInput } from './types.js';
2
2
 
3
+ /**
4
+ * The equation a finding sits in, when its math carries a `\label{eq:…}`.
5
+ *
6
+ * A location tells you where to edit; this tells you *what you are looking
7
+ * at*. A size-only N17 finding inside a six-line `aligned` block is otherwise
8
+ * anonymous — the message names delimiters, not the result they belong to —
9
+ * so the key and the rendered page's anchor both travel with the diagnostic.
10
+ */
11
+ interface EquationLink {
12
+ /** The label's key, without the `eq:` prefix. */
13
+ readonly key: string;
14
+ /** Where the reader sees it, e.g. `/l1-ar/ar1-basics/#eq-ar1-variance`. */
15
+ readonly href: string;
16
+ }
3
17
  interface MathGateDiagnostic {
4
18
  readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
5
19
  /**
@@ -31,6 +45,8 @@ interface MathGateDiagnostic {
31
45
  readonly line?: number;
32
46
  readonly column?: number;
33
47
  readonly token?: string;
48
+ /** Present when the offending math carries a `\label{eq:…}`. */
49
+ readonly equation?: EquationLink;
34
50
  readonly message: string;
35
51
  }
36
52
  interface AssessmentItemLike {
@@ -58,6 +74,12 @@ type ScopeEntry = {
58
74
  * hand and risk drifting from what the completeness gate actually checks.
59
75
  */
60
76
  declare function pageScope(lesson: NotationLessonInput, sharedByKey: ReadonlyMap<string, ScopeEntry>): ScopeEntry[];
77
+ /**
78
+ * The `\label{eq:…}` a math source carries, as a key plus the anchor the
79
+ * rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
80
+ * reset before use — a stale one would skip the first label of the next span.
81
+ */
82
+ declare function equationLinkFor(lessonId: string, latex: string): EquationLink | undefined;
61
83
  /**
62
84
  * Resolve every rendered-math context against the page glyph table. Returns one
63
85
  * diagnostic per unresolved token; an empty array means the gate passed.
@@ -68,4 +90,4 @@ declare function gateContentMath(input: {
68
90
  readonly lessonAssessments: readonly LessonAssessmentLink[];
69
91
  }): MathGateDiagnostic[];
70
92
 
71
- export { type MathGateDiagnostic, type ScopeEntry, gateContentMath, pageScope };
93
+ export { type EquationLink, type MathGateDiagnostic, type ScopeEntry, equationLinkFor, gateContentMath, pageScope };