explico 0.6.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 (95) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
@@ -0,0 +1,48 @@
1
+ /**
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.
9
+ *
10
+ * This deliberately parses only sizing syntax. It does not decide whether a
11
+ * parenthesis, bracket, or brace is semantic, and it never recommends changing
12
+ * one delimiter shape into another. N16's alternating-shape preference remains
13
+ * a review-only authoring concern.
14
+ */
15
+ declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-manual-size";
16
+ type DelimiterSizeName = 'automatic' | 'big' | 'Big' | 'bigg' | 'Bigg';
17
+ interface DelimiterSizeSuggestion {
18
+ readonly open: string;
19
+ readonly close: string;
20
+ readonly latex: string;
21
+ }
22
+ interface DelimiterSizeDiagnostic {
23
+ readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
24
+ readonly severity: 'warning';
25
+ readonly offset: number;
26
+ readonly line: number;
27
+ readonly column: number;
28
+ /** The hand-picked size that should become `\left`/`\right`. */
29
+ readonly size: Exclude<DelimiterSizeName, 'automatic'>;
30
+ readonly suggestion: DelimiterSizeSuggestion;
31
+ readonly message: string;
32
+ }
33
+ /**
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.
41
+ *
42
+ * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
43
+ * ignored: N15 owns that separate error, while ordinary semantic notation
44
+ * such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
45
+ */
46
+ declare function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[];
47
+
48
+ export { DELIMITER_SIZE_DIAGNOSTIC_CODE, type DelimiterSizeDiagnostic, type DelimiterSizeName, type DelimiterSizeSuggestion, lintDelimiterSizes };
@@ -0,0 +1,153 @@
1
+ const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-manual-size";
2
+ const SIZE_SPECS = [
3
+ {
4
+ name: "automatic",
5
+ rank: 1,
6
+ openCommand: "\\left",
7
+ closeCommand: "\\right",
8
+ kind: "automatic"
9
+ },
10
+ {
11
+ name: "big",
12
+ rank: 1,
13
+ openCommand: "\\bigl",
14
+ closeCommand: "\\bigr",
15
+ kind: "manual"
16
+ },
17
+ {
18
+ name: "Big",
19
+ rank: 2,
20
+ openCommand: "\\Bigl",
21
+ closeCommand: "\\Bigr",
22
+ kind: "manual"
23
+ },
24
+ {
25
+ name: "bigg",
26
+ rank: 3,
27
+ openCommand: "\\biggl",
28
+ closeCommand: "\\biggr",
29
+ kind: "manual"
30
+ },
31
+ {
32
+ name: "Bigg",
33
+ rank: 4,
34
+ openCommand: "\\Biggl",
35
+ closeCommand: "\\Biggr",
36
+ kind: "manual"
37
+ }
38
+ ];
39
+ const COMMAND_SPECS = SIZE_SPECS.flatMap((spec) => [
40
+ { ...spec, command: spec.openCommand, tokenKind: "open" },
41
+ { ...spec, command: spec.closeCommand, tokenKind: "close" }
42
+ ]).sort((left, right) => right.command.length - left.command.length);
43
+ function delimiterAt(latex, offset, kind) {
44
+ const candidates = kind === "open" ? ["\\{", "(", "["] : ["\\}", ")", "]"];
45
+ for (const delimiter of candidates) {
46
+ if (latex.startsWith(delimiter, offset)) {
47
+ return { delimiter, end: offset + delimiter.length };
48
+ }
49
+ }
50
+ return void 0;
51
+ }
52
+ function tokenAt(latex, offset) {
53
+ if (latex[offset] !== "\\") return void 0;
54
+ for (const spec of COMMAND_SPECS) {
55
+ if (!latex.startsWith(spec.command, offset)) continue;
56
+ const afterCommand = offset + spec.command.length;
57
+ if (/[A-Za-z]/.test(latex[afterCommand] ?? "")) continue;
58
+ let delimiterOffset = afterCommand;
59
+ while (/\s/.test(latex[delimiterOffset] ?? "")) delimiterOffset += 1;
60
+ const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
61
+ if (!delimiter) continue;
62
+ return {
63
+ kind: spec.tokenKind,
64
+ pairKind: spec.kind,
65
+ rank: spec.rank,
66
+ size: spec.name,
67
+ delimiter: delimiter.delimiter,
68
+ offset,
69
+ end: delimiter.end
70
+ };
71
+ }
72
+ return void 0;
73
+ }
74
+ function pairMatches(open, close) {
75
+ return open.kind === "open" && close.kind === "close" && open.pairKind === close.pairKind && open.rank === close.rank;
76
+ }
77
+ function isEscaped(latex, offset) {
78
+ let slashes = 0;
79
+ for (let index = offset - 1; index >= 0 && latex[index] === "\\"; index -= 1) {
80
+ slashes += 1;
81
+ }
82
+ return slashes % 2 === 1;
83
+ }
84
+ function parseDelimiterPairs(latex) {
85
+ const roots = [];
86
+ const stack = [];
87
+ for (let offset = 0; offset < latex.length; offset += 1) {
88
+ if (latex[offset] === "%" && !isEscaped(latex, offset)) {
89
+ const newline = latex.indexOf("\n", offset + 1);
90
+ if (newline === -1) break;
91
+ offset = newline;
92
+ continue;
93
+ }
94
+ const token = isEscaped(latex, offset) ? void 0 : tokenAt(latex, offset);
95
+ if (!token) continue;
96
+ offset = token.end - 1;
97
+ if (token.kind === "open") {
98
+ const pair2 = { open: token, children: [] };
99
+ const parent = stack[stack.length - 1];
100
+ if (parent) parent.children.push(pair2);
101
+ else roots.push(pair2);
102
+ stack.push(pair2);
103
+ continue;
104
+ }
105
+ const pair = stack[stack.length - 1];
106
+ if (!pair || !pairMatches(pair.open, token)) return void 0;
107
+ pair.close = token;
108
+ stack.pop();
109
+ }
110
+ return stack.length === 0 ? roots : void 0;
111
+ }
112
+ function lineAndColumn(source, offset) {
113
+ const before = source.slice(0, offset);
114
+ const lastNewline = before.lastIndexOf("\n");
115
+ return {
116
+ line: before.split("\n").length,
117
+ column: offset - lastNewline
118
+ };
119
+ }
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}` };
125
+ }
126
+ function lintDelimiterSizes(latex) {
127
+ const roots = parseDelimiterPairs(latex);
128
+ if (!roots) return [];
129
+ const diagnostics = [];
130
+ const visit = (pair, nested) => {
131
+ const inNest = nested || pair.children.length > 0;
132
+ if (inNest && pair.open.pairKind === "manual") {
133
+ const location = lineAndColumn(latex, pair.open.offset);
134
+ const suggestion = suggestionFor(pair);
135
+ diagnostics.push({
136
+ code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
137
+ severity: "warning",
138
+ offset: pair.open.offset,
139
+ ...location,
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).`
143
+ });
144
+ }
145
+ for (const child of pair.children) visit(child, true);
146
+ };
147
+ for (const root of roots) visit(root, false);
148
+ return diagnostics.sort((left, right) => left.offset - right.offset);
149
+ }
150
+ export {
151
+ DELIMITER_SIZE_DIAGNOSTIC_CODE,
152
+ lintDelimiterSizes
153
+ };
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
5
5
  type DiagramDiagnosticCode = 'dia-invalid-key' | 'dia-duplicate-key' | 'dia-ref-resolves' | 'dia-key-unused';
6
6
  interface DiagramDiagnostic {
7
7
  readonly code: DiagramDiagnosticCode;
8
- readonly severity: 'error' | 'warning';
8
+ readonly severity: 'error' | 'warning' | 'info';
9
9
  readonly message: string;
10
10
  readonly file?: string;
11
11
  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` (Tier 3, warning): a labelled equation never referenced.
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.
@@ -26,7 +26,7 @@ interface EquationRefRecord {
26
26
  type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
27
27
  interface EquationDiagnostic {
28
28
  readonly code: EquationDiagnosticCode;
29
- readonly severity: 'error' | 'warning';
29
+ readonly severity: 'error' | 'warning' | 'info';
30
30
  readonly message: string;
31
31
  readonly file?: string;
32
32
  readonly lessonId?: string;
@@ -73,7 +73,7 @@ function validateEquations(lessons) {
73
73
  if (referenced.has(`${label.slug}#${label.key}`)) continue;
74
74
  diagnostics.push({
75
75
  code: "eq-key-unused",
76
- severity: "warning",
76
+ severity: "info",
77
77
  message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
78
78
  file: label.slug,
79
79
  lessonId: label.lessonId,
@@ -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
  }
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
5
5
  type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused';
6
6
  interface FigureDiagnostic {
7
7
  readonly code: FigureDiagnosticCode;
8
- readonly severity: 'error' | 'warning';
8
+ readonly severity: 'error' | 'warning' | 'info';
9
9
  readonly message: string;
10
10
  readonly file?: string;
11
11
  readonly lessonId?: string;
@@ -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
  /**
@@ -26,9 +40,13 @@ interface MathGateDiagnostic {
26
40
  * than adding a second array the CLI has to know about.
27
41
  */
28
42
  readonly severity: 'error' | 'warning';
43
+ readonly code?: string;
29
44
  readonly file: string;
30
45
  readonly line?: number;
46
+ readonly column?: number;
31
47
  readonly token?: string;
48
+ /** Present when the offending math carries a `\label{eq:…}`. */
49
+ readonly equation?: EquationLink;
32
50
  readonly message: string;
33
51
  }
34
52
  interface AssessmentItemLike {
@@ -56,6 +74,12 @@ type ScopeEntry = {
56
74
  * hand and risk drifting from what the completeness gate actually checks.
57
75
  */
58
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;
59
83
  /**
60
84
  * Resolve every rendered-math context against the page glyph table. Returns one
61
85
  * diagnostic per unresolved token; an empty array means the gate passed.
@@ -66,4 +90,4 @@ declare function gateContentMath(input: {
66
90
  readonly lessonAssessments: readonly LessonAssessmentLink[];
67
91
  }): MathGateDiagnostic[];
68
92
 
69
- export { type MathGateDiagnostic, type ScopeEntry, gateContentMath, pageScope };
93
+ export { type EquationLink, type MathGateDiagnostic, type ScopeEntry, equationLinkFor, gateContentMath, pageScope };
@@ -1,5 +1,10 @@
1
1
  import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
2
- import { stripEquationLabels } from "./equations.mjs";
2
+ import {
3
+ EQ_LABEL_PATTERN,
4
+ equationAnchorId,
5
+ stripEquationLabels
6
+ } from "./equations.mjs";
7
+ import { lintDelimiterSizes } from "./delimiter-size.js";
3
8
  import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
4
9
  const SEVERITY = {
5
10
  body: "error",
@@ -26,10 +31,14 @@ function mathSpans(body) {
26
31
  pattern.lastIndex = 0;
27
32
  let match;
28
33
  while (match = pattern.exec(searchable)) {
34
+ const latex = match[1] ?? "";
35
+ const latexOffset = match.index + match[0].indexOf(latex);
36
+ const before = body.slice(0, latexOffset);
37
+ const lastNewline = before.lastIndexOf("\n");
29
38
  spans.push({
30
- // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
31
- latex: stripEquationLabels(match[1] ?? ""),
32
- line: lineOf(body, match.index)
39
+ latex,
40
+ line: lineOf(body, latexOffset),
41
+ column: latexOffset - lastNewline
33
42
  });
34
43
  }
35
44
  }
@@ -53,10 +62,34 @@ function pageScope(lesson, sharedByKey) {
53
62
  }
54
63
  return scope;
55
64
  }
56
- function resolveInto(latex, scope, context, file, line, fix, out) {
65
+ function equationLinkFor(lessonId, latex) {
66
+ EQ_LABEL_PATTERN.lastIndex = 0;
67
+ const match = EQ_LABEL_PATTERN.exec(latex);
68
+ EQ_LABEL_PATTERN.lastIndex = 0;
69
+ if (!match) return void 0;
70
+ const key = match[1];
71
+ const slug = lessonId.replace(/\./g, "/");
72
+ return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
73
+ }
74
+ function resolveInto(latex, scope, context, file, line, column, fix, out, equation) {
75
+ const at = equation === void 0 ? {} : { equation };
76
+ for (const diagnostic of lintDelimiterSizes(latex)) {
77
+ const locatedLine = line === void 0 ? void 0 : line + diagnostic.line - 1;
78
+ const locatedColumn = column === void 0 ? void 0 : diagnostic.line === 1 ? column + diagnostic.column - 1 : diagnostic.column;
79
+ out.push({
80
+ context,
81
+ code: diagnostic.code,
82
+ severity: "warning",
83
+ file,
84
+ ...locatedLine === void 0 ? {} : { line: locatedLine },
85
+ ...locatedColumn === void 0 ? {} : { column: locatedColumn },
86
+ ...at,
87
+ message: diagnostic.message
88
+ });
89
+ }
57
90
  let result;
58
91
  try {
59
- result = resolveMathGlyphs(latex, [...scope]);
92
+ result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
60
93
  } catch (error) {
61
94
  out.push({
62
95
  context,
@@ -74,6 +107,7 @@ function resolveInto(latex, scope, context, file, line, fix, out) {
74
107
  file,
75
108
  line,
76
109
  token,
110
+ ...at,
77
111
  message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`
78
112
  });
79
113
  }
@@ -138,15 +172,18 @@ function gateContentMath(input) {
138
172
  for (const lesson of notationInput.lessons) {
139
173
  const scope = pageScope(lesson, sharedByKey);
140
174
  const file = lesson.source.file;
175
+ const bodyOffset = (lesson.bodyLine ?? 1) - 1;
141
176
  for (const span of mathSpans(lesson.body)) {
142
177
  resolveInto(
143
178
  span.latex,
144
179
  scope,
145
180
  "body",
146
181
  file,
147
- span.line,
182
+ span.line + bodyOffset,
183
+ span.column,
148
184
  BODY_FIX,
149
- diagnostics
185
+ diagnostics,
186
+ equationLinkFor(lesson.lessonId, span.latex)
150
187
  );
151
188
  }
152
189
  for (const definition of lesson.localDefinitions) {
@@ -163,6 +200,7 @@ function gateContentMath(input) {
163
200
  "formula",
164
201
  file,
165
202
  void 0,
203
+ void 0,
166
204
  FORMULA_FIX,
167
205
  diagnostics
168
206
  );
@@ -183,6 +221,7 @@ function gateContentMath(input) {
183
221
  "formula",
184
222
  definition.source.file,
185
223
  void 0,
224
+ void 0,
186
225
  SHARED_FORMULA_FIX,
187
226
  diagnostics
188
227
  );
@@ -209,7 +248,8 @@ function gateContentMath(input) {
209
248
  scope,
210
249
  "assessment",
211
250
  `${assessmentId} (${item.id ?? "?"})`,
212
- void 0,
251
+ span.line,
252
+ span.column,
213
253
  ASSESSMENT_FIX,
214
254
  diagnostics
215
255
  );
@@ -221,6 +261,7 @@ function gateContentMath(input) {
221
261
  return diagnostics;
222
262
  }
223
263
  export {
264
+ equationLinkFor,
224
265
  gateContentMath,
225
266
  pageScope
226
267
  };
@@ -39,6 +39,17 @@ interface AssessmentLike {
39
39
  declare function proseOnly(body: string): string;
40
40
  /** Exported for the same reason as {@link proseOnly}. */
41
41
  declare function lineOf(text: string, index: number): number;
42
+ /**
43
+ * {@link lineOf} corrected to the file the lesson came from. A gate scans
44
+ * `lesson.body`, which starts *after* the frontmatter, so a raw body line is
45
+ * short by the frontmatter's height and points at the wrong source line —
46
+ * silently, because the number is still plausible. Every gate that reports a
47
+ * `line` against `lesson.source.file` must go through this.
48
+ */
49
+ declare function lessonLine(lesson: {
50
+ readonly body: string;
51
+ readonly bodyLine?: number;
52
+ }, index: number): number;
42
53
  /**
43
54
  * Scan every lesson body for plain-text math notation outside `$…$`. Returns
44
55
  * one `MathGateDiagnostic` (context `'prose'`, always an error — N9 has no
@@ -57,4 +68,4 @@ declare function gateProseNotation(lessons: readonly NotationLessonInput[]): Mat
57
68
  */
58
69
  declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[]): MathGateDiagnostic[];
59
70
 
60
- export { gateProseNotation, gateProseNotationInAssessments, lineOf, proseOnly };
71
+ export { gateProseNotation, gateProseNotationInAssessments, lessonLine, lineOf, proseOnly };
@@ -12,6 +12,9 @@ function proseOnly(body) {
12
12
  function lineOf(text, index) {
13
13
  return text.slice(0, index).split("\n").length;
14
14
  }
15
+ function lessonLine(lesson, index) {
16
+ return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
17
+ }
15
18
  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`;
16
19
  const NUMERAL_UNIT = new RegExp(
17
20
  String.raw`(?<![\w.])-?\d+(?:\.\d+)?\s?(?:${UNIT})(?![A-Za-z])`,
@@ -89,7 +92,7 @@ function gateProseNotation(lessons) {
89
92
  context: "prose",
90
93
  severity: "error",
91
94
  file: lesson.source.file,
92
- line: lineOf(lesson.body, finding.index),
95
+ line: lessonLine(lesson, finding.index),
93
96
  token: finding.token,
94
97
  message: finding.message
95
98
  });
@@ -120,6 +123,7 @@ function gateProseNotationInAssessments(assessments) {
120
123
  export {
121
124
  gateProseNotation,
122
125
  gateProseNotationInAssessments,
126
+ lessonLine,
123
127
  lineOf,
124
128
  proseOnly
125
129
  };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
3
+ * moves the constant, and a leftover `patches/` file, are the two ways this
4
+ * stops being able to install itself.
5
+ */
6
+ export function rewriteBundle(source: any): any;
7
+ /**
8
+ * Render a known nest and confirm the outer pair really is larger than the
9
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
10
+ * refactor, a frozen export, an import that beat us to it — into one loud
11
+ * failure at the point of installation.
12
+ */
13
+ export function assertDelimiterGrowth(instance?: typeof katex): void;
14
+ export default katex;
15
+ import katex from 'katex';