explico 0.8.0 → 0.11.1
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 +1257 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +46 -2
- package/dist/compiler/cli.js +70 -4
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +68 -5
- 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 +4 -1
- package/dist/compiler/logging.js +4 -1
- 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 +3 -2
- 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 +60 -31
- package/dist/reference/equations.mjs +83 -43
- 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 +1 -0
- 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 +45 -21
- 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 +22 -6
- package/dist/reference/gate-math.js +42 -8
- 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 +21 -11
- package/dist/reference/gate-prose-notation.js +25 -7
- package/dist/reference/katex-delimiters.mjs +20 -7
- 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 +572 -37
- 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 +4 -4
- 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 +17 -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 +5 -3
- 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 +1152 -2
- package/docs/authoring/notation.md +576 -322
- 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/package.json +2 -2
- package/src/compiler/cli.ts +116 -6
- package/src/compiler/collections.ts +111 -7
- 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 +8 -2
- 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 +3 -4
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +5 -4
- 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 +3 -2
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +83 -43
- 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 +1 -0
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +66 -35
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +69 -10
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +59 -12
- package/src/reference/katex-delimiters.mjs +20 -7
- 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 +572 -37
- 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 +16 -31
- 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 +17 -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 +1467 -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
|
+
}
|
|
@@ -41,7 +41,14 @@ export interface EquationLink {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export interface MathGateDiagnostic {
|
|
44
|
-
readonly context:
|
|
44
|
+
readonly context:
|
|
45
|
+
| 'body'
|
|
46
|
+
| 'formula'
|
|
47
|
+
| 'assessment'
|
|
48
|
+
| 'prose'
|
|
49
|
+
| 'abbreviation'
|
|
50
|
+
| 'structure'
|
|
51
|
+
| 'course';
|
|
45
52
|
/**
|
|
46
53
|
* Body / component-slot math blocks the build (the render path needs every
|
|
47
54
|
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
@@ -60,13 +67,18 @@ export interface MathGateDiagnostic {
|
|
|
60
67
|
* `gate-abbreviations.ts`) is the same "ships straight as an error" story
|
|
61
68
|
* for a different shape of plain-text token — an unexplained acronym or
|
|
62
69
|
* dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
|
|
63
|
-
* (S1, `gate-heading-length.ts`) is unrelated to
|
|
64
|
-
* 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
|
|
65
73
|
* diagnostic shape and the one `manifest.diagnostics.math` array rather
|
|
66
|
-
* 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.
|
|
67
78
|
*/
|
|
68
79
|
readonly severity: 'error' | 'warning';
|
|
69
|
-
|
|
80
|
+
/** Owned by exactly one rule or invariant in `src/rules/catalog.ts`. */
|
|
81
|
+
readonly code: string;
|
|
70
82
|
readonly file: string;
|
|
71
83
|
readonly line?: number;
|
|
72
84
|
readonly column?: number;
|
|
@@ -83,6 +95,8 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
|
|
|
83
95
|
prose: 'error',
|
|
84
96
|
abbreviation: 'error',
|
|
85
97
|
structure: 'error',
|
|
98
|
+
// Never read: `gateCourseRules` sets the severity the course chose.
|
|
99
|
+
course: 'error',
|
|
86
100
|
};
|
|
87
101
|
|
|
88
102
|
interface AssessmentItemLike {
|
|
@@ -114,13 +128,16 @@ function lineOf(text: string, index: number): number {
|
|
|
114
128
|
return text.slice(0, index).split('\n').length;
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
interface MathSpan {
|
|
131
|
+
export interface MathSpan {
|
|
118
132
|
readonly latex: string;
|
|
119
133
|
readonly line: number;
|
|
120
134
|
readonly column: number;
|
|
135
|
+
/** Index of `latex` in the text it was cut from. */
|
|
136
|
+
readonly offset: number;
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
|
|
139
|
+
/** Every `$$…$$` then `$…$` span in `body`, outside code, with its position. */
|
|
140
|
+
export function mathSpans(body: string): MathSpan[] {
|
|
124
141
|
const searchable = stripCode(body);
|
|
125
142
|
const spans: MathSpan[] = [];
|
|
126
143
|
for (const pattern of [DISPLAY_MATH, INLINE_MATH]) {
|
|
@@ -135,6 +152,7 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
135
152
|
latex,
|
|
136
153
|
line: lineOf(body, latexOffset),
|
|
137
154
|
column: latexOffset - lastNewline,
|
|
155
|
+
offset: latexOffset,
|
|
138
156
|
});
|
|
139
157
|
}
|
|
140
158
|
}
|
|
@@ -193,6 +211,11 @@ export function equationLinkFor(
|
|
|
193
211
|
return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
|
|
194
212
|
}
|
|
195
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
|
+
|
|
196
219
|
function resolveInto(
|
|
197
220
|
latex: string,
|
|
198
221
|
scope: readonly ScopeEntry[],
|
|
@@ -203,6 +226,7 @@ function resolveInto(
|
|
|
203
226
|
fix: string,
|
|
204
227
|
out: MathGateDiagnostic[],
|
|
205
228
|
equation?: EquationLink,
|
|
229
|
+
suspended: readonly string[] = [],
|
|
206
230
|
): void {
|
|
207
231
|
const at = equation === undefined ? {} : { equation };
|
|
208
232
|
for (const diagnostic of lintDelimiterSizes(latex)) {
|
|
@@ -229,10 +253,18 @@ function resolveInto(
|
|
|
229
253
|
let result;
|
|
230
254
|
try {
|
|
231
255
|
// D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
|
|
232
|
-
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]
|
|
256
|
+
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope], {
|
|
257
|
+
suspended,
|
|
258
|
+
});
|
|
233
259
|
} catch (error) {
|
|
234
260
|
out.push({
|
|
235
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',
|
|
236
268
|
severity: SEVERITY[context],
|
|
237
269
|
file,
|
|
238
270
|
line,
|
|
@@ -245,15 +277,35 @@ function resolveInto(
|
|
|
245
277
|
});
|
|
246
278
|
return;
|
|
247
279
|
}
|
|
248
|
-
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
|
+
: '';
|
|
249
300
|
out.push({
|
|
250
301
|
context,
|
|
302
|
+
code: 'unresolved-glyph',
|
|
251
303
|
severity: SEVERITY[context],
|
|
252
304
|
file,
|
|
253
305
|
line,
|
|
254
306
|
token,
|
|
255
307
|
...at,
|
|
256
|
-
message: `Unresolved notation ${JSON.stringify(token)} in ${context} math
|
|
308
|
+
message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint} ${fix}`,
|
|
257
309
|
});
|
|
258
310
|
}
|
|
259
311
|
}
|
|
@@ -295,6 +347,9 @@ function reportUnknownScopeKeys(
|
|
|
295
347
|
)) {
|
|
296
348
|
out.push({
|
|
297
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',
|
|
298
353
|
severity: SEVERITY.formula,
|
|
299
354
|
file,
|
|
300
355
|
token: key,
|
|
@@ -350,6 +405,9 @@ export function gateContentMath(input: {
|
|
|
350
405
|
for (const lesson of notationInput.lessons) {
|
|
351
406
|
const scope = pageScope(lesson, sharedByKey);
|
|
352
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);
|
|
353
411
|
|
|
354
412
|
// `mathSpans` indexes the body, which starts after the frontmatter, so
|
|
355
413
|
// every span line is short by the frontmatter's height until corrected.
|
|
@@ -365,6 +423,7 @@ export function gateContentMath(input: {
|
|
|
365
423
|
BODY_FIX,
|
|
366
424
|
diagnostics,
|
|
367
425
|
equationLinkFor(lesson.lessonId, span.latex),
|
|
426
|
+
suspended,
|
|
368
427
|
);
|
|
369
428
|
}
|
|
370
429
|
|