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