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.
Files changed (230) hide show
  1. package/CHANGELOG.md +1257 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +5 -3
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1152 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1467 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. 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: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
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 notation — it caps how
64
- * long a heading or a keyed float's title may run — but reuses this same
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
- readonly code?: string;
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
- function mathSpans(body: string): MathSpan[] {
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 { token } of result.unresolved) {
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. ${fix}`,
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