explico 0.7.0 → 0.11.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 +1409 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +54 -3
- package/dist/compiler/cli.js +80 -6
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +84 -8
- package/dist/compiler/course-config.d.ts +3 -194
- package/dist/compiler/course-config.js +78 -5
- package/dist/compiler/exports/standalone-html.d.ts +6 -1
- package/dist/compiler/knowledge-version.js +1 -1
- package/dist/compiler/lesson-derivation.d.ts +27 -10
- package/dist/compiler/lesson-derivation.js +23 -8
- package/dist/compiler/logging.d.ts +14 -1
- package/dist/compiler/logging.js +6 -2
- package/dist/compiler/manifest.d.ts +204 -31
- package/dist/compiler/manifest.js +270 -55
- package/dist/compiler/resolution-report.d.ts +6 -1
- package/dist/course-config-Dsnri9Tk.d.ts +464 -0
- package/dist/reference/base-accents.d.mts +26 -0
- package/dist/reference/base-accents.mjs +31 -0
- package/dist/reference/base-roman-labels.d.mts +2 -2
- package/dist/reference/base-roman-labels.mjs +2 -2
- package/dist/reference/consistency.d.ts +14 -2
- package/dist/reference/consistency.js +108 -4
- package/dist/reference/delimiter-size.d.ts +21 -11
- package/dist/reference/delimiter-size.js +15 -39
- package/dist/reference/diagrams-validate.d.ts +7 -2
- package/dist/reference/diagrams-validate.js +9 -1
- package/dist/reference/equations-validate.d.ts +8 -5
- package/dist/reference/equations-validate.js +1 -0
- package/dist/reference/equations.d.mts +69 -34
- package/dist/reference/equations.mjs +90 -44
- package/dist/reference/examples-validate.d.ts +31 -0
- package/dist/reference/examples-validate.js +48 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/figures-validate.js +4 -1
- package/dist/reference/folds-validate.d.ts +33 -0
- package/dist/reference/folds-validate.js +32 -0
- package/dist/reference/gate-abbreviations.d.ts +3 -1
- package/dist/reference/gate-abbreviations.js +3 -2
- package/dist/reference/gate-course-rules.d.ts +32 -0
- package/dist/reference/gate-course-rules.js +85 -0
- package/dist/reference/gate-heading-length.d.ts +5 -2
- package/dist/reference/gate-heading-length.js +47 -23
- package/dist/reference/gate-heading-structure.d.ts +41 -0
- package/dist/reference/gate-heading-structure.js +113 -0
- package/dist/reference/gate-math-punctuation.d.ts +71 -0
- package/dist/reference/gate-math-punctuation.js +251 -0
- package/dist/reference/gate-math.d.ts +44 -6
- package/dist/reference/gate-math.js +63 -10
- package/dist/reference/gate-part-pages.d.ts +50 -0
- package/dist/reference/gate-part-pages.js +118 -0
- package/dist/reference/gate-prose-notation.d.ts +32 -11
- package/dist/reference/gate-prose-notation.js +30 -8
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +138 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/keyed-float-validate.d.ts +27 -10
- package/dist/reference/keyed-float-validate.js +15 -1
- package/dist/reference/keyed-jsx-floats.d.mts +11 -5
- package/dist/reference/keyed-jsx-floats.mjs +73 -20
- package/dist/reference/knowledge-checks-validate.d.ts +1 -1
- package/dist/reference/knowledge-checks.d.mts +3 -1
- package/dist/reference/knowledge-checks.mjs +9 -4
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-slot.d.ts +34 -0
- package/dist/reference/lab-math-slot.js +6 -0
- package/dist/reference/math-glyphs.d.mts +80 -4
- package/dist/reference/math-glyphs.mjs +588 -39
- package/dist/reference/page-notation.d.ts +6 -1
- package/dist/reference/raw-floats-validate.js +1 -1
- package/dist/reference/raw-floats.mjs +1 -1
- package/dist/reference/rehype-heading-anchors.d.mts +14 -0
- package/dist/reference/rehype-heading-anchors.mjs +116 -17
- package/dist/reference/rehype-table-header-labels.d.mts +6 -1
- package/dist/reference/rehype-table-header-labels.mjs +21 -3
- package/dist/reference/remark-asides.d.mts +3 -0
- package/dist/reference/remark-asides.mjs +180 -0
- package/dist/reference/remark-memo.d.mts +3 -1
- package/dist/reference/remark-memo.mjs +42 -11
- package/dist/reference/remark-notation.d.mts +60 -10
- package/dist/reference/remark-notation.mjs +387 -36
- package/dist/reference/render-lab-math.d.ts +4 -26
- package/dist/reference/render-lab-math.js +5 -5
- package/dist/reference/sections-validate.d.ts +27 -0
- package/dist/reference/sections-validate.js +41 -0
- package/dist/reference/sections.d.mts +97 -0
- package/dist/reference/sections.mjs +306 -0
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +24 -1
- package/dist/reference/unit-words.d.ts +37 -0
- package/dist/reference/unit-words.js +170 -0
- package/dist/rules/catalog.d.ts +176 -0
- package/dist/rules/catalog.js +983 -0
- package/dist/rules/course-rules.d.ts +115 -0
- package/dist/rules/course-rules.js +292 -0
- package/dist/rules/course-rules.schema.json +138 -0
- package/dist/rules/in-effect.d.ts +53 -0
- package/dist/rules/in-effect.js +142 -0
- package/dist/rules/rules.schema.json +1072 -0
- package/dist/rules/settings.d.ts +3 -0
- package/dist/rules/settings.js +290 -0
- package/dist/rules/waivers.d.ts +53 -0
- package/dist/rules/waivers.js +132 -0
- package/dist/session/PreferenceStore.d.ts +5 -0
- package/dist/session/PreferenceStore.js +9 -0
- package/docs/VERSIONING.md +2 -2
- package/docs/authoring/README.md +232 -40
- package/docs/authoring/agents.md +55 -9
- package/docs/authoring/components.md +178 -22
- package/docs/authoring/migrating.md +1324 -2
- package/docs/authoring/notation.md +616 -344
- package/docs/authoring/prose.md +551 -0
- package/docs/authoring/retired.md +35 -0
- package/docs/authoring/structure-rules.md +422 -40
- package/docs/authoring/structure.md +445 -62
- package/docs/authoring/versioning.md +20 -28
- package/docs/authoring/visualizations.md +142 -20
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +3 -2
- package/src/compiler/cli.ts +139 -6
- package/src/compiler/collections.ts +144 -10
- package/src/compiler/course-config.ts +273 -34
- package/src/compiler/knowledge-version.ts +1 -1
- package/src/compiler/lesson-derivation.ts +54 -15
- package/src/compiler/logging.ts +21 -4
- package/src/compiler/manifest.ts +573 -104
- package/src/components/BlockHeading.astro +113 -0
- package/src/components/CourseContents.astro +221 -0
- package/src/components/CurriculumMap.astro +5 -6
- package/src/components/FloatIndex.astro +200 -0
- package/src/components/HoverPanel.astro +431 -79
- package/src/components/PartBridge.astro +27 -0
- package/src/components/PartOverview.astro +401 -0
- package/src/components/PartScope.astro +27 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
- package/src/components/assessments/AssessmentRunner.tsx +8 -5
- package/src/components/assessments/AssessmentSet.astro +23 -26
- package/src/components/block-heading.ts +146 -0
- package/src/components/card-arrow.ts +243 -0
- package/src/components/card-drag.ts +631 -0
- package/src/components/card-jump.ts +45 -0
- package/src/components/card-manager.ts +1589 -0
- package/src/components/card-memory.ts +177 -0
- package/src/components/card-placement.ts +504 -0
- package/src/components/card-side.ts +336 -0
- package/src/components/card-travel.ts +197 -0
- package/src/components/citation/CitationLayer.astro +77 -76
- package/src/components/diagram/Diagram.astro +161 -316
- package/src/components/disclosure/course-disclosure.ts +24 -0
- package/src/components/disclosure/hover-preview.ts +122 -42
- package/src/components/disclosure/persisted-disclosure.ts +3 -3
- package/src/components/disclosure/print-as-shown.ts +252 -0
- package/src/components/examples/CompactExample.astro +3 -1
- package/src/components/examples/CompactExamples.astro +101 -94
- package/src/components/figure/Figure.astro +174 -274
- package/src/components/figure/FloatCaption.astro +87 -0
- package/src/components/figure/figure-state.ts +58 -0
- package/src/components/figure/figure-tools.ts +87 -0
- package/src/components/float-name.ts +101 -0
- package/src/components/fold/Fold.astro +125 -0
- package/src/components/fold/fold-id.ts +40 -0
- package/src/components/memo/Memo.astro +162 -231
- package/src/components/memo/ShortNote.astro +6 -6
- package/src/components/notation/NotationFormula.astro +5 -1
- package/src/components/notation/NotationGlossary.astro +24 -14
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +22 -6
- package/src/components/starlight/CollapsibleSections.astro +207 -93
- package/src/components/starlight/DiagramEnhancer.astro +15 -138
- package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
- package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
- package/src/components/starlight/LayoutHeader.astro +298 -19
- package/src/components/starlight/LayoutPageSidebar.astro +99 -12
- package/src/components/starlight/LayoutPageTitle.astro +120 -0
- package/src/components/starlight/LayoutSidebar.astro +229 -11
- package/src/components/starlight/LessonExportActions.astro +53 -72
- package/src/components/starlight/LessonFooter.astro +26 -4
- package/src/components/starlight/section-parts.ts +15 -2
- package/src/components/starlight/section-summary.ts +181 -0
- package/src/components/table/Table.astro +95 -76
- package/src/components/table/TableOverflowExpander.astro +65 -28
- package/src/content-config.ts +40 -4
- package/src/reference/base-accents.mjs +31 -0
- package/src/reference/base-roman-labels.mjs +2 -2
- package/src/reference/consistency.ts +176 -5
- package/src/reference/delimiter-size.ts +40 -61
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +90 -44
- package/src/reference/examples-validate.ts +97 -0
- package/src/reference/figures-validate.ts +6 -1
- package/src/reference/folds-validate.ts +65 -0
- package/src/reference/gate-abbreviations.ts +3 -2
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +68 -37
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +119 -12
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +74 -13
- package/src/reference/katex-delimiters.mjs +138 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/keyed-float-validate.ts +45 -11
- package/src/reference/keyed-jsx-floats.mjs +73 -20
- package/src/reference/knowledge-checks-validate.ts +1 -1
- package/src/reference/knowledge-checks.mjs +9 -4
- package/src/reference/lab-math-slot.ts +36 -0
- package/src/reference/math-glyphs.mjs +588 -39
- package/src/reference/raw-floats-validate.ts +1 -1
- package/src/reference/raw-floats.mjs +1 -1
- package/src/reference/rehype-heading-anchors.mjs +116 -17
- package/src/reference/rehype-table-header-labels.mjs +21 -3
- package/src/reference/remark-asides.mjs +180 -0
- package/src/reference/remark-memo.mjs +42 -11
- package/src/reference/remark-notation.mjs +387 -36
- package/src/reference/render-lab-math.ts +17 -32
- package/src/reference/sections-validate.ts +81 -0
- package/src/reference/sections.mjs +306 -0
- package/src/reference/tables-validate.ts +2 -1
- package/src/reference/types.ts +24 -0
- package/src/reference/unit-words.ts +233 -0
- package/src/rules/catalog.ts +1298 -0
- package/src/rules/course-rules.ts +456 -0
- package/src/rules/in-effect.ts +235 -0
- package/src/rules/settings.ts +448 -0
- package/src/rules/waivers.ts +249 -0
- package/src/session/PreferenceStore.ts +15 -0
- package/src/styles/global.css +1471 -163
- package/src/styles/surfaces.css +182 -0
- package/src/components/hover-panel.ts +0 -339
- package/src/components/popup-stack.ts +0 -345
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trailing-punctuation gate (rule N12): math never ends in the sentence's
|
|
3
|
+
* own `.`, `,`, `;` or `:`. It runs from the CLI alongside the completeness
|
|
4
|
+
* gate (`gate-math.ts`) over the same spans (`mathSpans`: every `$…$` /
|
|
5
|
+
* `$$…$$` in a lesson body, including table cells and component slots, and in
|
|
6
|
+
* an assessment item's `prompt` / `explanation`), and shares its
|
|
7
|
+
* `MathGateDiagnostic` shape and output array.
|
|
8
|
+
*
|
|
9
|
+
* "Ends" is read the way a reader sees it, not the raw source: trailing
|
|
10
|
+
* spacing (`\,`, `\quad`), `\label{…}` / `\caption{…}` / `\tag{…}`, a row break `\\`, an
|
|
11
|
+
* `\end{…}`, and the invisible delimiter `\right.` are looked through, and a
|
|
12
|
+
* closing `\text{…}` / `\mathrm{…}` is looked into — so `5\ \text{m,}` and
|
|
13
|
+
* `c &= d. \end{aligned}` are both caught. Every row of a multi-line block
|
|
14
|
+
* (the text before each `\\`) is checked the same way.
|
|
15
|
+
*
|
|
16
|
+
* Two shapes are called out by name because they look like notation: a
|
|
17
|
+
* trailing decimal point (`5.`) — a precision marker that cannot be told
|
|
18
|
+
* apart from the full stop, so it is never allowed — and a typed `...`
|
|
19
|
+
* (write `\ldots`). The one exception is a dotted abbreviation set upright
|
|
20
|
+
* that is itself notation (`+ \text{h.c.}`, `X_n \to X\ \text{a.s.}`): the
|
|
21
|
+
* shipped {@link MATH_END_ABBREVIATIONS}, edited per course with
|
|
22
|
+
* `rules.N12.allow` / `deny`.
|
|
23
|
+
*/
|
|
24
|
+
import { equationLinkFor, mathSpans, type MathGateDiagnostic } from './gate-math';
|
|
25
|
+
import { lessonLine } from './gate-prose-notation';
|
|
26
|
+
import type { NotationLessonInput } from './types';
|
|
27
|
+
|
|
28
|
+
interface AssessmentItemLike {
|
|
29
|
+
readonly id?: string;
|
|
30
|
+
readonly prompt?: string;
|
|
31
|
+
readonly explanation?: string;
|
|
32
|
+
}
|
|
33
|
+
interface AssessmentLike {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly items?: readonly AssessmentItemLike[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Dotted abbreviations that are notation, so they may end math (`\text{h.c.}`). */
|
|
39
|
+
export const MATH_END_ABBREVIATIONS: readonly string[] = [
|
|
40
|
+
'a.e.',
|
|
41
|
+
'a.s.',
|
|
42
|
+
'c.c.',
|
|
43
|
+
'const.',
|
|
44
|
+
'h.c.',
|
|
45
|
+
'i.i.d.',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface MathPunctuationOptions {
|
|
49
|
+
/** Abbreviations allowed in addition to {@link MATH_END_ABBREVIATIONS}. */
|
|
50
|
+
readonly allow?: readonly string[];
|
|
51
|
+
/** Shipped abbreviations this course does not accept. */
|
|
52
|
+
readonly deny?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PunctuationFinding {
|
|
56
|
+
/** Index of the mark in the LaTeX. */
|
|
57
|
+
readonly index: number;
|
|
58
|
+
readonly mark: string;
|
|
59
|
+
readonly kind: 'sentence' | 'decimal' | 'ellipsis' | 'abbreviation';
|
|
60
|
+
/** True when the mark ends a row (text before a `\\`), not the whole span. */
|
|
61
|
+
readonly rowEnd: boolean;
|
|
62
|
+
/** The upright dotted word the mark closes (`etc.`), for `abbreviation`. */
|
|
63
|
+
readonly word?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- tokens ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
interface Token {
|
|
69
|
+
readonly kind: 'command' | 'open' | 'close' | 'space' | 'char';
|
|
70
|
+
/** A command's name without its backslash (`frac`, `,`, `\`); otherwise the character. */
|
|
71
|
+
readonly text: string;
|
|
72
|
+
readonly start: number;
|
|
73
|
+
/** For a brace, the index of its partner; -1 when unbalanced. */
|
|
74
|
+
match: number;
|
|
75
|
+
/** Index of the innermost enclosing `open` token; -1 at top level. */
|
|
76
|
+
readonly parent: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const LETTERS = /[A-Za-z]+/y;
|
|
80
|
+
|
|
81
|
+
function tokenize(latex: string): Token[] {
|
|
82
|
+
const tokens: Token[] = [];
|
|
83
|
+
const stack: number[] = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < latex.length) {
|
|
86
|
+
const ch = latex[i]!;
|
|
87
|
+
const parent = stack.length === 0 ? -1 : stack[stack.length - 1]!;
|
|
88
|
+
if (ch === '%') {
|
|
89
|
+
// A comment runs to the end of the line; `\%` is consumed below as a command.
|
|
90
|
+
const newline = latex.indexOf('\n', i);
|
|
91
|
+
i = newline === -1 ? latex.length : newline;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '\\') {
|
|
95
|
+
LETTERS.lastIndex = i + 1;
|
|
96
|
+
const letters = LETTERS.exec(latex);
|
|
97
|
+
const name = letters ? letters[0] : latex.charAt(i + 1);
|
|
98
|
+
tokens.push({ kind: 'command', text: name, start: i, match: -1, parent });
|
|
99
|
+
i += 1 + Math.max(name.length, 1);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '{') {
|
|
103
|
+
tokens.push({ kind: 'open', text: ch, start: i, match: -1, parent });
|
|
104
|
+
stack.push(tokens.length - 1);
|
|
105
|
+
} else if (ch === '}') {
|
|
106
|
+
const open = stack.pop();
|
|
107
|
+
const index = tokens.length;
|
|
108
|
+
tokens.push({
|
|
109
|
+
kind: 'close',
|
|
110
|
+
text: ch,
|
|
111
|
+
start: i,
|
|
112
|
+
match: open ?? -1,
|
|
113
|
+
parent: open === undefined ? -1 : tokens[open]!.parent,
|
|
114
|
+
});
|
|
115
|
+
if (open !== undefined) tokens[open]!.match = index;
|
|
116
|
+
} else {
|
|
117
|
+
tokens.push({ kind: /\s/.test(ch) ? 'space' : 'char', text: ch, start: i, match: -1, parent });
|
|
118
|
+
}
|
|
119
|
+
i += 1;
|
|
120
|
+
}
|
|
121
|
+
return tokens;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- reading the end of a span -----------------------------------------------
|
|
125
|
+
|
|
126
|
+
const MARKS = new Set(['.', ',', ';', ':']);
|
|
127
|
+
|
|
128
|
+
/** Commands that render no glyph at the end of math: spacing, row breaks, numbering switches. */
|
|
129
|
+
const INVISIBLE = new Set([
|
|
130
|
+
',', ';', ':', '!', ' ', '\\',
|
|
131
|
+
'quad', 'qquad', 'enspace', 'thinspace', 'medspace', 'thickspace',
|
|
132
|
+
'negthinspace', 'negmedspace', 'negthickspace',
|
|
133
|
+
'nonumber', 'notag', 'displaystyle', 'textstyle',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/** Commands whose `{…}` argument never shows as the last thing in the math. */
|
|
137
|
+
const SKIPPED_GROUP = new Set(['label', 'caption', 'tag', 'begin', 'end', 'hspace', 'vspace', 'color']);
|
|
138
|
+
|
|
139
|
+
/** Commands that set their argument upright as text — where a dotted abbreviation lives. */
|
|
140
|
+
const UPRIGHT = new Set(['text', 'textrm', 'textup', 'textnormal', 'mathrm', 'mbox', 'operatorname']);
|
|
141
|
+
|
|
142
|
+
/** `\right.` and its sized cousins: the `.` is the invisible null delimiter. */
|
|
143
|
+
const SIZER = /^(?:left|right|middle|[bB]igg?[lrm]?)$/;
|
|
144
|
+
|
|
145
|
+
/** The command a `{…}` group is the argument of, allowing `\tag*{…}`; undefined for a bare group. */
|
|
146
|
+
function groupOwner(tokens: readonly Token[], open: number, from: number): number | undefined {
|
|
147
|
+
let k = open - 1;
|
|
148
|
+
while (k >= from && tokens[k]!.kind === 'space') k -= 1;
|
|
149
|
+
if (k >= from && tokens[k]!.kind === 'char' && tokens[k]!.text === '*') k -= 1;
|
|
150
|
+
return k >= from && tokens[k]!.kind === 'command' ? k : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type Tail = { readonly mark: number } | 'clean' | 'empty';
|
|
154
|
+
|
|
155
|
+
/** The last visible token in `tokens[from, to)`: a punctuation mark, something else, or nothing. */
|
|
156
|
+
function tail(tokens: readonly Token[], from: number, to: number): Tail {
|
|
157
|
+
let j = to - 1;
|
|
158
|
+
while (j >= from) {
|
|
159
|
+
const token = tokens[j]!;
|
|
160
|
+
if (token.kind === 'space') {
|
|
161
|
+
j -= 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (token.kind === 'command' && INVISIBLE.has(token.text)) {
|
|
165
|
+
j -= 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token.kind === 'char' && token.text === '.') {
|
|
169
|
+
const before = tokens[j - 1];
|
|
170
|
+
if (j - 1 >= from && before?.kind === 'command' && SIZER.test(before.text)) {
|
|
171
|
+
j -= 2;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (token.kind === 'char' && token.text === ']') {
|
|
176
|
+
// `\\[4pt]` — a row break's optional spacing.
|
|
177
|
+
let open = j - 1;
|
|
178
|
+
while (open >= from && !(tokens[open]!.kind === 'char' && tokens[open]!.text === '[')) open -= 1;
|
|
179
|
+
const breakToken = tokens[open - 1];
|
|
180
|
+
if (open - 1 >= from && breakToken?.kind === 'command' && breakToken.text === '\\') {
|
|
181
|
+
j = open - 2;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
return 'clean';
|
|
185
|
+
}
|
|
186
|
+
if (token.kind === 'close') {
|
|
187
|
+
if (token.match < from) return 'clean';
|
|
188
|
+
const owner = groupOwner(tokens, token.match, from);
|
|
189
|
+
if (owner !== undefined && SKIPPED_GROUP.has(tokens[owner]!.text)) {
|
|
190
|
+
j = owner - 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const inner = tail(tokens, token.match + 1, j);
|
|
194
|
+
if (inner !== 'empty') return inner;
|
|
195
|
+
j = token.match - 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (token.kind === 'char' && MARKS.has(token.text)) return { mark: j };
|
|
199
|
+
return 'clean';
|
|
200
|
+
}
|
|
201
|
+
return 'empty';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function classify(
|
|
205
|
+
tokens: readonly Token[],
|
|
206
|
+
j: number,
|
|
207
|
+
rowEnd: boolean,
|
|
208
|
+
allowed: ReadonlySet<string>,
|
|
209
|
+
): PunctuationFinding | undefined {
|
|
210
|
+
const token = tokens[j]!;
|
|
211
|
+
const base = { index: token.start, mark: token.text, rowEnd };
|
|
212
|
+
if (token.text !== '.') return { ...base, kind: 'sentence' };
|
|
213
|
+
|
|
214
|
+
const isChar = (k: number, pattern: RegExp) =>
|
|
215
|
+
tokens[k]?.kind === 'char' && pattern.test(tokens[k]!.text);
|
|
216
|
+
|
|
217
|
+
if (isChar(j - 1, /^\.$/)) return { ...base, kind: 'ellipsis' };
|
|
218
|
+
|
|
219
|
+
if (isChar(j - 1, /^\d$/)) {
|
|
220
|
+
let k = j - 1;
|
|
221
|
+
while (isChar(k - 1, /^\d$/)) k -= 1;
|
|
222
|
+
// `x_2.` is a subscript followed by the full stop, not a number.
|
|
223
|
+
if (!isChar(k - 1, /^[_^]$/)) return { ...base, kind: 'decimal' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const owner = token.parent >= 0 ? groupOwner(tokens, token.parent, 0) : undefined;
|
|
227
|
+
if (owner !== undefined && UPRIGHT.has(tokens[owner]!.text)) {
|
|
228
|
+
let k = j;
|
|
229
|
+
while (isChar(k - 1, /^[A-Za-z.]$/)) k -= 1;
|
|
230
|
+
const word = tokens
|
|
231
|
+
.slice(k, j + 1)
|
|
232
|
+
.map((t) => t.text)
|
|
233
|
+
.join('');
|
|
234
|
+
if (/[A-Za-z]/.test(word)) {
|
|
235
|
+
if (allowed.has(word)) return undefined;
|
|
236
|
+
return { ...base, kind: 'abbreviation', word };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { ...base, kind: 'sentence' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function allowedAbbreviations(options: MathPunctuationOptions): ReadonlySet<string> {
|
|
243
|
+
const deny = new Set(options.deny ?? []);
|
|
244
|
+
return new Set([
|
|
245
|
+
...MATH_END_ABBREVIATIONS.filter((word) => !deny.has(word)),
|
|
246
|
+
...(options.allow ?? []),
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function findings(latex: string, allowed: ReadonlySet<string>): PunctuationFinding[] {
|
|
251
|
+
const tokens = tokenize(latex);
|
|
252
|
+
const byMark = new Map<number, PunctuationFinding>();
|
|
253
|
+
const record = (result: Tail, rowEnd: boolean) => {
|
|
254
|
+
if (typeof result !== 'object' || byMark.has(result.mark)) return;
|
|
255
|
+
const finding = classify(tokens, result.mark, rowEnd, allowed);
|
|
256
|
+
if (finding) byMark.set(result.mark, finding);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
tokens.forEach((token, k) => {
|
|
260
|
+
if (token.kind === 'command' && token.text === '\\') record(tail(tokens, token.parent + 1, k), true);
|
|
261
|
+
});
|
|
262
|
+
record(tail(tokens, 0, tokens.length), false);
|
|
263
|
+
|
|
264
|
+
return [...byMark.values()].sort((left, right) => left.index - right.index);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Every N12 finding in one span of LaTeX (the text between the `$` delimiters). */
|
|
268
|
+
export function trailingPunctuation(
|
|
269
|
+
latex: string,
|
|
270
|
+
options: MathPunctuationOptions = {},
|
|
271
|
+
): PunctuationFinding[] {
|
|
272
|
+
return findings(latex, allowedAbbreviations(options));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- diagnostics ---------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const SEE = 'See docs/authoring/notation.md#n12.';
|
|
278
|
+
|
|
279
|
+
function messageFor(finding: PunctuationFinding, snippet: string): string {
|
|
280
|
+
const where = finding.rowEnd ? 'ends a row of the math (before `\\\\`)' : 'ends the math';
|
|
281
|
+
switch (finding.kind) {
|
|
282
|
+
case 'decimal':
|
|
283
|
+
return (
|
|
284
|
+
`"${snippet}" ${where} with a trailing decimal point, which cannot be told apart from ` +
|
|
285
|
+
`the sentence's full stop. Drop the point, or write the digits that state the precision ` +
|
|
286
|
+
`(\`5.0\`); keep a full stop outside the math. ${SEE}`
|
|
287
|
+
);
|
|
288
|
+
case 'ellipsis':
|
|
289
|
+
return `"${snippet}" ${where} with a typed "...". Write \`\\ldots\`, and keep a full stop outside the math. ${SEE}`;
|
|
290
|
+
case 'abbreviation':
|
|
291
|
+
return (
|
|
292
|
+
`"${snippet}" ${where} with "${finding.word}". If that abbreviation is notation ` +
|
|
293
|
+
`(like \`\\text{h.c.}\`), add it to \`rules.N12.allow\`; if the "." is the sentence's ` +
|
|
294
|
+
`full stop, move it after the closing \`$\`. ${SEE}`
|
|
295
|
+
);
|
|
296
|
+
case 'sentence':
|
|
297
|
+
return finding.rowEnd
|
|
298
|
+
? `"${snippet}" ${where} with "${finding.mark}" — the sentence's own punctuation. Drop it; the row break already separates the lines. ${SEE}`
|
|
299
|
+
: `"${snippet}" ${where} with "${finding.mark}" — the sentence's own punctuation. Move it after the closing \`$\` (inline math) or drop it (display math). ${SEE}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The last few characters of the math up to and including the mark, on one line. */
|
|
304
|
+
function snippetOf(latex: string, index: number): string {
|
|
305
|
+
const from = Math.max(0, index - 24);
|
|
306
|
+
const text = latex.slice(from, index + 1).replace(/\s+/g, ' ').trimStart();
|
|
307
|
+
return from > 0 ? `…${text}` : text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Scan every lesson body's math for the sentence's own trailing punctuation.
|
|
312
|
+
* One error per mark, located at the mark itself, so a per-place waiver
|
|
313
|
+
* (`{/* explico-allow N12: reason *\/}`) can cover it.
|
|
314
|
+
*/
|
|
315
|
+
export function gateMathPunctuation(
|
|
316
|
+
lessons: readonly NotationLessonInput[],
|
|
317
|
+
options: MathPunctuationOptions = {},
|
|
318
|
+
): MathGateDiagnostic[] {
|
|
319
|
+
const allowed = allowedAbbreviations(options);
|
|
320
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
321
|
+
|
|
322
|
+
for (const lesson of lessons) {
|
|
323
|
+
for (const span of mathSpans(lesson.body)) {
|
|
324
|
+
const equation = equationLinkFor(lesson.lessonId, span.latex);
|
|
325
|
+
for (const finding of findings(span.latex, allowed)) {
|
|
326
|
+
const at = span.offset + finding.index;
|
|
327
|
+
const snippet = snippetOf(span.latex, finding.index);
|
|
328
|
+
diagnostics.push({
|
|
329
|
+
context: 'body',
|
|
330
|
+
code: 'math-trailing-punctuation',
|
|
331
|
+
severity: 'error',
|
|
332
|
+
file: lesson.source.file,
|
|
333
|
+
line: lessonLine(lesson, at),
|
|
334
|
+
column: at - lesson.body.lastIndexOf('\n', at - 1),
|
|
335
|
+
token: snippet,
|
|
336
|
+
...(equation === undefined ? {} : { equation }),
|
|
337
|
+
message: messageFor(finding, snippet),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return diagnostics;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The same check over every assessment item's `prompt` / `explanation`, which
|
|
348
|
+
* render through the same KaTeX pipeline as lesson math. No line number: the
|
|
349
|
+
* item is one JSON string field, so `file` names the item, the same
|
|
350
|
+
* `"<assessmentId> (<itemId>)"` shape `gate-math.ts` uses.
|
|
351
|
+
*/
|
|
352
|
+
export function gateMathPunctuationInAssessments(
|
|
353
|
+
assessments: readonly AssessmentLike[],
|
|
354
|
+
options: MathPunctuationOptions = {},
|
|
355
|
+
): MathGateDiagnostic[] {
|
|
356
|
+
const allowed = allowedAbbreviations(options);
|
|
357
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
358
|
+
|
|
359
|
+
for (const assessment of assessments) {
|
|
360
|
+
for (const item of assessment.items ?? []) {
|
|
361
|
+
for (const field of [item.prompt, item.explanation]) {
|
|
362
|
+
if (typeof field !== 'string') continue;
|
|
363
|
+
for (const span of mathSpans(field)) {
|
|
364
|
+
for (const finding of findings(span.latex, allowed)) {
|
|
365
|
+
const snippet = snippetOf(span.latex, finding.index);
|
|
366
|
+
diagnostics.push({
|
|
367
|
+
context: 'assessment',
|
|
368
|
+
code: 'math-trailing-punctuation',
|
|
369
|
+
severity: 'error',
|
|
370
|
+
file: `${assessment.id} (${item.id ?? '?'})`,
|
|
371
|
+
token: snippet,
|
|
372
|
+
message: messageFor(finding, snippet),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return diagnostics;
|
|
381
|
+
}
|
|
@@ -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,8 +25,30 @@ 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
|
-
readonly context:
|
|
44
|
+
readonly context:
|
|
45
|
+
| 'body'
|
|
46
|
+
| 'formula'
|
|
47
|
+
| 'assessment'
|
|
48
|
+
| 'prose'
|
|
49
|
+
| 'abbreviation'
|
|
50
|
+
| 'structure'
|
|
51
|
+
| 'course';
|
|
26
52
|
/**
|
|
27
53
|
* Body / component-slot math blocks the build (the render path needs every
|
|
28
54
|
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
@@ -41,17 +67,24 @@ export interface MathGateDiagnostic {
|
|
|
41
67
|
* `gate-abbreviations.ts`) is the same "ships straight as an error" story
|
|
42
68
|
* for a different shape of plain-text token — an unexplained acronym or
|
|
43
69
|
* dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
|
|
44
|
-
* (S1, `gate-heading-length.ts`) is unrelated to
|
|
45
|
-
* long a heading or a keyed float's title may run
|
|
70
|
+
* (S1, `gate-heading-length.ts`; S2, `gate-part-pages.ts`) is unrelated to
|
|
71
|
+
* notation — it caps how long a heading or a keyed float's title may run,
|
|
72
|
+
* and requires a part page's bridge and scope paragraphs — but reuses this same
|
|
46
73
|
* diagnostic shape and the one `manifest.diagnostics.math` array rather
|
|
47
|
-
* than adding a second array the CLI has to know about.
|
|
74
|
+
* than adding a second array the CLI has to know about. `course`
|
|
75
|
+
* (`gate-course-rules.ts`) is a rule the *course* wrote in `courseRules`, not
|
|
76
|
+
* one the engine ships; its severity is the course's choice, so it is the one
|
|
77
|
+
* context whose severity is not fixed here.
|
|
48
78
|
*/
|
|
49
79
|
readonly severity: 'error' | 'warning';
|
|
50
|
-
|
|
80
|
+
/** Owned by exactly one rule or invariant in `src/rules/catalog.ts`. */
|
|
81
|
+
readonly code: string;
|
|
51
82
|
readonly file: string;
|
|
52
83
|
readonly line?: number;
|
|
53
84
|
readonly column?: number;
|
|
54
85
|
readonly token?: string;
|
|
86
|
+
/** Present when the offending math carries a `\label{eq:…}`. */
|
|
87
|
+
readonly equation?: EquationLink;
|
|
55
88
|
readonly message: string;
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -62,6 +95,8 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
|
|
|
62
95
|
prose: 'error',
|
|
63
96
|
abbreviation: 'error',
|
|
64
97
|
structure: 'error',
|
|
98
|
+
// Never read: `gateCourseRules` sets the severity the course chose.
|
|
99
|
+
course: 'error',
|
|
65
100
|
};
|
|
66
101
|
|
|
67
102
|
interface AssessmentItemLike {
|
|
@@ -93,13 +128,16 @@ function lineOf(text: string, index: number): number {
|
|
|
93
128
|
return text.slice(0, index).split('\n').length;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
|
-
interface MathSpan {
|
|
131
|
+
export interface MathSpan {
|
|
97
132
|
readonly latex: string;
|
|
98
133
|
readonly line: number;
|
|
99
134
|
readonly column: number;
|
|
135
|
+
/** Index of `latex` in the text it was cut from. */
|
|
136
|
+
readonly offset: number;
|
|
100
137
|
}
|
|
101
138
|
|
|
102
|
-
|
|
139
|
+
/** Every `$$…$$` then `$…$` span in `body`, outside code, with its position. */
|
|
140
|
+
export function mathSpans(body: string): MathSpan[] {
|
|
103
141
|
const searchable = stripCode(body);
|
|
104
142
|
const spans: MathSpan[] = [];
|
|
105
143
|
for (const pattern of [DISPLAY_MATH, INLINE_MATH]) {
|
|
@@ -114,6 +152,7 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
114
152
|
latex,
|
|
115
153
|
line: lineOf(body, latexOffset),
|
|
116
154
|
column: latexOffset - lastNewline,
|
|
155
|
+
offset: latexOffset,
|
|
117
156
|
});
|
|
118
157
|
}
|
|
119
158
|
}
|
|
@@ -153,6 +192,30 @@ export function pageScope(
|
|
|
153
192
|
return scope;
|
|
154
193
|
}
|
|
155
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The `\label{eq:…}` a math source carries, as a key plus the anchor the
|
|
197
|
+
* rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
|
|
198
|
+
* reset before use — a stale one would skip the first label of the next span.
|
|
199
|
+
*/
|
|
200
|
+
export function equationLinkFor(
|
|
201
|
+
lessonId: string,
|
|
202
|
+
latex: string,
|
|
203
|
+
): EquationLink | undefined {
|
|
204
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
205
|
+
const match = EQ_LABEL_PATTERN.exec(latex);
|
|
206
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
207
|
+
if (!match) return undefined;
|
|
208
|
+
|
|
209
|
+
const key = match[1];
|
|
210
|
+
const slug = lessonId.replace(/\./g, '/');
|
|
211
|
+
return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Decorations a lesson suspends (`notation.suspend`, rule N22), by name. */
|
|
215
|
+
export function suspendedOperators(lesson: NotationLessonInput): string[] {
|
|
216
|
+
return (lesson.suspended ?? []).map((item) => item.operator);
|
|
217
|
+
}
|
|
218
|
+
|
|
156
219
|
function resolveInto(
|
|
157
220
|
latex: string,
|
|
158
221
|
scope: readonly ScopeEntry[],
|
|
@@ -162,7 +225,10 @@ function resolveInto(
|
|
|
162
225
|
column: number | undefined,
|
|
163
226
|
fix: string,
|
|
164
227
|
out: MathGateDiagnostic[],
|
|
228
|
+
equation?: EquationLink,
|
|
229
|
+
suspended: readonly string[] = [],
|
|
165
230
|
): void {
|
|
231
|
+
const at = equation === undefined ? {} : { equation };
|
|
166
232
|
for (const diagnostic of lintDelimiterSizes(latex)) {
|
|
167
233
|
const locatedLine =
|
|
168
234
|
line === undefined ? undefined : line + diagnostic.line - 1;
|
|
@@ -179,6 +245,7 @@ function resolveInto(
|
|
|
179
245
|
file,
|
|
180
246
|
...(locatedLine === undefined ? {} : { line: locatedLine }),
|
|
181
247
|
...(locatedColumn === undefined ? {} : { column: locatedColumn }),
|
|
248
|
+
...at,
|
|
182
249
|
message: diagnostic.message,
|
|
183
250
|
});
|
|
184
251
|
}
|
|
@@ -186,10 +253,18 @@ function resolveInto(
|
|
|
186
253
|
let result;
|
|
187
254
|
try {
|
|
188
255
|
// D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
|
|
189
|
-
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]
|
|
256
|
+
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope], {
|
|
257
|
+
suspended,
|
|
258
|
+
});
|
|
190
259
|
} catch (error) {
|
|
191
260
|
out.push({
|
|
192
261
|
context,
|
|
262
|
+
// The resolver's own code (`unparenthesized-function`, `invalid-latex`,
|
|
263
|
+
// …) is what the rule catalog keys on; anything else is a KaTeX crash.
|
|
264
|
+
code:
|
|
265
|
+
error instanceof GlyphResolutionError && typeof error.code === 'string'
|
|
266
|
+
? error.code
|
|
267
|
+
: 'math-compile-error',
|
|
193
268
|
severity: SEVERITY[context],
|
|
194
269
|
file,
|
|
195
270
|
line,
|
|
@@ -202,14 +277,35 @@ function resolveInto(
|
|
|
202
277
|
});
|
|
203
278
|
return;
|
|
204
279
|
}
|
|
205
|
-
for (const
|
|
280
|
+
for (const item of result.unresolved) {
|
|
281
|
+
const { token } = item;
|
|
282
|
+
if ('suspended' in item && typeof item.suspended === 'string') {
|
|
283
|
+
out.push({
|
|
284
|
+
context,
|
|
285
|
+
code: 'suspended-operator-unresolved',
|
|
286
|
+
severity: SEVERITY[context],
|
|
287
|
+
file,
|
|
288
|
+
line,
|
|
289
|
+
token,
|
|
290
|
+
...at,
|
|
291
|
+
message: `On this page ${JSON.stringify(item.suspended)} is suspended (notation.suspend), so ${JSON.stringify(token)} is not ${JSON.stringify(item.suspended)} applied to a symbol: it needs its own notation entry (latex: ${token}) or an \\explain{key}{${token}} wrap.`,
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Rule N4: a font makes a different symbol, never the plain letter.
|
|
296
|
+
const fontHint =
|
|
297
|
+
'font' in item && typeof item.font === 'string'
|
|
298
|
+
? ` ${token} is a different symbol from the plain letter: give it its own entry (latex: ${token}) or wrap it in \\explain{key}{${token}}.`
|
|
299
|
+
: '';
|
|
206
300
|
out.push({
|
|
207
301
|
context,
|
|
302
|
+
code: 'unresolved-glyph',
|
|
208
303
|
severity: SEVERITY[context],
|
|
209
304
|
file,
|
|
210
305
|
line,
|
|
211
306
|
token,
|
|
212
|
-
|
|
307
|
+
...at,
|
|
308
|
+
message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint} ${fix}`,
|
|
213
309
|
});
|
|
214
310
|
}
|
|
215
311
|
}
|
|
@@ -251,6 +347,9 @@ function reportUnknownScopeKeys(
|
|
|
251
347
|
)) {
|
|
252
348
|
out.push({
|
|
253
349
|
context: 'formula',
|
|
350
|
+
// Same failure the resolver reports for a body `\explain{key}` that
|
|
351
|
+
// names nothing — one code, one owner (`notation-references`).
|
|
352
|
+
code: 'unknown-explicit-key',
|
|
254
353
|
severity: SEVERITY.formula,
|
|
255
354
|
file,
|
|
256
355
|
token: key,
|
|
@@ -306,17 +405,25 @@ export function gateContentMath(input: {
|
|
|
306
405
|
for (const lesson of notationInput.lessons) {
|
|
307
406
|
const scope = pageScope(lesson, sharedByKey);
|
|
308
407
|
const file = lesson.source.file;
|
|
408
|
+
// Suspension (rule N22) is lesson-body scope, like `notation.local`: a
|
|
409
|
+
// formula, an assessment and a lab keep the operator reading.
|
|
410
|
+
const suspended = suspendedOperators(lesson);
|
|
309
411
|
|
|
412
|
+
// `mathSpans` indexes the body, which starts after the frontmatter, so
|
|
413
|
+
// every span line is short by the frontmatter's height until corrected.
|
|
414
|
+
const bodyOffset = (lesson.bodyLine ?? 1) - 1;
|
|
310
415
|
for (const span of mathSpans(lesson.body)) {
|
|
311
416
|
resolveInto(
|
|
312
417
|
span.latex,
|
|
313
418
|
scope,
|
|
314
419
|
'body',
|
|
315
420
|
file,
|
|
316
|
-
span.line,
|
|
421
|
+
span.line + bodyOffset,
|
|
317
422
|
span.column,
|
|
318
423
|
BODY_FIX,
|
|
319
424
|
diagnostics,
|
|
425
|
+
equationLinkFor(lesson.lessonId, span.latex),
|
|
426
|
+
suspended,
|
|
320
427
|
);
|
|
321
428
|
}
|
|
322
429
|
|