explico 0.11.1 → 0.13.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 (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Rule N23 — an index is part of the symbol. A subscript on a symbol is
3
+ * explained by the indexed symbol's own entry (`v_0`), by a family entry
4
+ * whose index runs (`x_k` explains `x_1`), or by an index entry for the
5
+ * subscript (`{}_0`, which makes the derived card of `\mathbf{v}_0`); a card
6
+ * on the base alone does not explain it. Arithmetic on an index letter that
7
+ * has its own card is explained (`X_{t-1}`), and a subscript on an operator or
8
+ * a bracket is not an index (`\sum_{i=1}`). A running index — a lowercase
9
+ * letter the page does not declare fixed — states the set it runs over, on
10
+ * its card (`range`) or after a `\quad` on the first display line that uses
11
+ * it.
12
+ *
13
+ * The same pass collects the derived cards each page's body uses, which the
14
+ * manifest materializes (`derived-entries.ts`) whether or not the rule is on:
15
+ * the render path writes their keys either way.
16
+ *
17
+ * Lesson bodies, notation formulas and assessments are checked here; a lab's
18
+ * and a diagram's math, which only exists as they render, is checked there
19
+ * (`render-lab-math.ts`, `Diagram.astro`, through {@link indexFindings}). An
20
+ * indexed symbol takes a derived card wherever page math renders — a body,
21
+ * an assessment, a lab, a diagram — and never in a notation formula, which
22
+ * resolves against its own card's table. A span the resolver rejects is the
23
+ * completeness gate's to report. A body finding is located, so `explico-allow
24
+ * N23` waives it at one place.
25
+ */
26
+ import { stripEquationLabels } from './equations.mjs';
27
+ import { resolveMathGlyphs, stripNotationMarkers } from './math-glyphs.mjs';
28
+ import {
29
+ equationLinkFor,
30
+ mathSpans,
31
+ pageScope,
32
+ suspendedOperators,
33
+ type AssessmentLike,
34
+ type LessonAssessmentLink,
35
+ type MathGateDiagnostic,
36
+ type ScopeEntry,
37
+ } from './gate-math';
38
+ import { formulaGlyphScope, type GlyphScopeEntry, type ResolvedGloss } from './gloss';
39
+ import { indexOfEntry } from './math-glyphs.mjs';
40
+ import type { DerivedUse, IndexedSymbol, NotationLessonInput } from './types';
41
+
42
+ export type { DerivedUse } from './types';
43
+
44
+ /** What the check needs of an entry besides its glyph-table row. */
45
+ export interface IndexEntryFacts extends ScopeEntry {
46
+ readonly range?: string;
47
+ readonly formula?: string;
48
+ readonly glosses?: readonly ResolvedGloss[];
49
+ readonly source?: { readonly file: string };
50
+ }
51
+
52
+ /** Where a math source sits: what the message calls it, and its fix. */
53
+ type Place = 'body' | 'formula' | 'assessment';
54
+
55
+ /** How to explain an index where the source sits. */
56
+ const INDEX_FIX: Record<Place, (token: string, subscript: string) => string> = {
57
+ body: (token, subscript) =>
58
+ `Give the indexed symbol its own entry (latex: ${token}), a family entry whose index ` +
59
+ `runs (latex: x_k), or an index entry for the subscript (latex: "{}_${subscript}", ` +
60
+ `label and meaning), introduced with [[key]] or in notation.local`,
61
+ assessment: (token, subscript) =>
62
+ `Give the indexed symbol its own entry (latex: ${token}), a family entry whose index ` +
63
+ `runs (latex: x_k), or an index entry for the subscript (latex: "{}_${subscript}"), ` +
64
+ `introduced in the lesson's prose with [[key]] or in its notation.local`,
65
+ formula: (token, subscript) =>
66
+ `In a formula, wrap the symbol in \\explain{key}{${token}} with an entry for the indexed ` +
67
+ `symbol, declare it under glosses: (latex: ${token}), or wrap the subscript in an index ` +
68
+ `entry's key (\\explain{key}{${subscript}})`,
69
+ };
70
+
71
+ interface Occurrence {
72
+ readonly letter: string;
73
+ readonly line: number;
74
+ readonly display: boolean;
75
+ /** The math line (between `\\` breaks) the letter is an index on. */
76
+ readonly mathLine: string;
77
+ readonly token: string;
78
+ readonly ownKey: string | null;
79
+ readonly coverKey: string | null;
80
+ }
81
+
82
+ const DISPLAY_OPEN = /\$\$\s*$/;
83
+ const RANGE_RELATION =
84
+ /(?:=|<|>|\\(?:in|le|leq|ge|geq|lt|gt|dots|ldots|cdots|to|subseteq|subset)(?![A-Za-z]))/;
85
+
86
+ /** The text after each `\quad` on a math line — never `\qquad` (rule N18's reason slot). */
87
+ function quadClauses(mathLine: string): string[] {
88
+ return mathLine
89
+ .split(/\\quad(?![A-Za-z])/)
90
+ .slice(1)
91
+ .map((clause) => stripNotationMarkers(clause));
92
+ }
93
+
94
+ /** Whether a clause states the set `letter` runs over: `\quad i = 1, \dots, n`. */
95
+ function statesRange(clause: string, letter: string): boolean {
96
+ const named = new RegExp(`(?<![A-Za-z\\\\])${letter}(?![A-Za-z])`);
97
+ return named.test(clause) && RANGE_RELATION.test(clause);
98
+ }
99
+
100
+ /** The `\\`-separated line of `latex` that holds `offset`. */
101
+ function mathLineAt(latex: string, offset: number): string {
102
+ const before = latex.slice(0, offset).split(/\\\\/);
103
+ const after = latex.slice(offset).split(/\\\\/);
104
+ return `${before[before.length - 1] ?? ''}${after[0] ?? ''}`;
105
+ }
106
+
107
+ function message(
108
+ lessonId: string,
109
+ code: 'index-explained' | 'index-swallowed' | 'index-range' | 'derived-key-taken',
110
+ details: Record<string, string>,
111
+ place: Place = 'body',
112
+ ): string {
113
+ switch (code) {
114
+ case 'index-explained': {
115
+ const index = details.index ?? '';
116
+ const subscript = index.length === 1 ? index : `{${index}}`;
117
+ const what =
118
+ details.base === 'false'
119
+ ? `has no card, and neither does its base (${details.parts})`
120
+ : `has a subscript no card explains (${details.parts})`;
121
+ return (
122
+ `${lessonId}: ${JSON.stringify(details.token)} ${what}. ` +
123
+ `${INDEX_FIX[place](details.token ?? '', subscript)}; a letter in a subscript is an ` +
124
+ `index, so a base-library i, e or d there needs a card (rule N23).`
125
+ );
126
+ }
127
+ case 'index-swallowed':
128
+ return (
129
+ `${lessonId}: \\explain{${details.key}}{${details.token}} wraps an indexed symbol in ` +
130
+ `${details.key}, whose latex (${details.notation}) has no index, so its card cannot ` +
131
+ `explain ${JSON.stringify(details.index)}. Drop the wrap and give the subscript an index ` +
132
+ `entry (latex: "{}_${details.index}"), or wrap it in an entry for the indexed symbol (rule N23).`
133
+ );
134
+ case 'index-range':
135
+ return (
136
+ `${lessonId}: the running index ${details.letter} (first in ${JSON.stringify(details.token)}) ` +
137
+ `has no stated set. Give ${details.where} a range (range: "1, \\\\dots, n"), state it after a ` +
138
+ `\\quad on the first display line that uses it (\\quad ${details.letter} = 1, \\dots, n), or ` +
139
+ `mark the card fixedIndex: true if ${details.letter} is a label there, not a running index (rule N23).`
140
+ );
141
+ case 'derived-key-taken':
142
+ return (
143
+ `${lessonId}: ${JSON.stringify(details.token)} composes the derived card ${details.key}, ` +
144
+ `but an authored entry has that key. Rename the entry: a key of the form ` +
145
+ `<symbol>.sub.<index> is the build's (rule N23).`
146
+ );
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Run rule N23 over every lesson (and non-chapter page) body, every notation
152
+ * formula and every assessment, and collect the derived cards each body
153
+ * uses. `entries` holds every shared entry by key; a lesson's own come from
154
+ * its `localDefinitions`. The index-set check (`index-range`) reads lesson
155
+ * bodies only: a letter's set is stated on its card or on the page.
156
+ */
157
+ export function gateIndices(
158
+ lessons: readonly NotationLessonInput[],
159
+ entries: ReadonlyMap<string, IndexEntryFacts>,
160
+ extras: {
161
+ readonly assessments?: readonly AssessmentLike[];
162
+ readonly lessonAssessments?: readonly LessonAssessmentLink[];
163
+ } = {},
164
+ ): {
165
+ diagnostics: MathGateDiagnostic[];
166
+ derivedByLesson: Map<string, DerivedUse[]>;
167
+ } {
168
+ const diagnostics: MathGateDiagnostic[] = [];
169
+ const derivedByLesson = new Map<string, DerivedUse[]>();
170
+
171
+ for (const lesson of lessons) {
172
+ const file = lesson.source.file;
173
+ const bodyOffset = (lesson.bodyLine ?? 1) - 1;
174
+ const scope = pageScope(lesson, entries);
175
+ const local = new Map<string, IndexEntryFacts>(
176
+ lesson.localDefinitions.map((definition) => [definition.key, definition]),
177
+ );
178
+ const factsOf = (key: string | null): IndexEntryFacts | undefined =>
179
+ key === null ? undefined : (local.get(key) ?? entries.get(key));
180
+ const suspended = suspendedOperators(lesson);
181
+ const derived = new Map<string, DerivedUse>();
182
+ const reported = new Set<string>();
183
+ const occurrences: Occurrence[] = [];
184
+
185
+ const spans = mathSpans(lesson.body).sort((left, right) => left.offset - right.offset);
186
+ for (const span of spans) {
187
+ const latex = stripEquationLabels(span.latex);
188
+ let result;
189
+ try {
190
+ result = resolveMathGlyphs(latex, scope, { suspended, derive: true });
191
+ } catch {
192
+ continue;
193
+ }
194
+ const line = span.line + bodyOffset;
195
+ const equation = equationLinkFor(lesson.lessonId, span.latex);
196
+ const at = {
197
+ context: 'body' as const,
198
+ severity: 'error' as const,
199
+ file,
200
+ line,
201
+ ...(equation === undefined ? {} : { equation }),
202
+ };
203
+ const display = DISPLAY_OPEN.test(lesson.body.slice(0, span.offset));
204
+
205
+ for (const use of result.derived) {
206
+ if (!derived.has(use.key)) derived.set(use.key, use);
207
+ if (factsOf(use.key) !== undefined && !reported.has(`taken:${use.key}`)) {
208
+ reported.add(`taken:${use.key}`);
209
+ diagnostics.push({
210
+ ...at,
211
+ code: 'derived-key-taken',
212
+ token: use.key,
213
+ message: message(lesson.lessonId, 'derived-key-taken', {
214
+ key: use.key,
215
+ token: use.key,
216
+ }),
217
+ });
218
+ }
219
+ }
220
+
221
+ for (const item of result.indices) {
222
+ pushFindings(item, at, lesson.lessonId, 'body', factsOf, reported, diagnostics);
223
+ for (const running of item.running) {
224
+ // A letter with no card at all is `index-explained`'s, or N4's.
225
+ if (running.key === null && running.cover === null) continue;
226
+ occurrences.push({
227
+ letter: running.letter,
228
+ line,
229
+ display,
230
+ mathLine: mathLineAt(latex, running.start),
231
+ token: item.token,
232
+ ownKey: running.key,
233
+ coverKey: running.cover,
234
+ });
235
+ }
236
+ }
237
+ }
238
+
239
+ // `index-range`: once per running letter per page.
240
+ const byLetter = new Map<string, Occurrence[]>();
241
+ for (const occurrence of occurrences) {
242
+ const list = byLetter.get(occurrence.letter) ?? [];
243
+ list.push(occurrence);
244
+ byLetter.set(occurrence.letter, list);
245
+ }
246
+ for (const [letter, list] of byLetter) {
247
+ const declared = list.some((occurrence) =>
248
+ [occurrence.ownKey, occurrence.coverKey].some((key) => {
249
+ const facts = factsOf(key);
250
+ return facts?.range !== undefined || facts?.fixedIndex === true;
251
+ }),
252
+ );
253
+ if (declared) continue;
254
+ const firstDisplay = list.find((occurrence) => occurrence.display);
255
+ if (
256
+ firstDisplay &&
257
+ quadClauses(firstDisplay.mathLine).some((clause) => statesRange(clause, letter))
258
+ ) {
259
+ continue;
260
+ }
261
+ const [first] = list;
262
+ if (!first) continue;
263
+ const card = first.ownKey ?? first.coverKey;
264
+ diagnostics.push({
265
+ context: 'body',
266
+ severity: 'error',
267
+ code: 'index-range',
268
+ file,
269
+ line: first.line,
270
+ token: letter,
271
+ message: message(lesson.lessonId, 'index-range', {
272
+ letter,
273
+ token: first.token,
274
+ where: card === null ? `the card of ${letter}` : `the card ${card}`,
275
+ }),
276
+ });
277
+ }
278
+
279
+ if (derived.size > 0) derivedByLesson.set(lesson.lessonId, [...derived.values()]);
280
+
281
+ // A notation.local formula, against the table the formula gate uses.
282
+ for (const definition of lesson.localDefinitions) {
283
+ if (!definition.formula || indexOfEntry(definition.notation) !== undefined) continue;
284
+ checkFormula(
285
+ definition.formula,
286
+ dedupeRows([...scope, ...definition.glosses]),
287
+ `${lesson.lessonId}: notation.local ${definition.key} formula`,
288
+ file,
289
+ factsOf,
290
+ reported,
291
+ diagnostics,
292
+ );
293
+ }
294
+ }
295
+
296
+ // A shared entry's formula, against its own table (D15). Each formula is
297
+ // its own place: a form two formulas leave unexplained is reported in both.
298
+ const cards = new Map<string, GlyphScopeEntry>(entries);
299
+ for (const entry of entries.values()) {
300
+ if (!entry.formula || indexOfEntry(entry.notation) !== undefined) continue;
301
+ checkFormula(
302
+ entry.formula,
303
+ formulaGlyphScope({
304
+ key: entry.key,
305
+ notation: entry.notation,
306
+ formula: entry.formula,
307
+ glosses: entry.glosses ?? [],
308
+ cardsByKey: cards,
309
+ }),
310
+ `notation ${entry.key} formula`,
311
+ entry.source?.file ?? `content/notation/${entry.key}.md`,
312
+ (key) => (key === null ? undefined : entries.get(key)),
313
+ new Set<string>(),
314
+ diagnostics,
315
+ );
316
+ }
317
+
318
+ // Assessment math renders on its lesson's page, with that page's table.
319
+ const lessonById = new Map(lessons.map((lesson) => [lesson.lessonId, lesson]));
320
+ const assessmentById = new Map(
321
+ (extras.assessments ?? []).map((assessment) => [assessment.id, assessment]),
322
+ );
323
+ for (const link of extras.lessonAssessments ?? []) {
324
+ const lesson = lessonById.get(link.lessonId);
325
+ if (!lesson) continue;
326
+ const scope = pageScope(lesson, entries);
327
+ const local = new Map<string, IndexEntryFacts>(
328
+ lesson.localDefinitions.map((definition) => [definition.key, definition]),
329
+ );
330
+ const factsOf = (key: string | null): IndexEntryFacts | undefined =>
331
+ key === null ? undefined : (local.get(key) ?? entries.get(key));
332
+ for (const assessmentId of link.assessmentIds) {
333
+ const assessment = assessmentById.get(assessmentId);
334
+ const reported = new Set<string>();
335
+ for (const item of assessment?.items ?? []) {
336
+ for (const field of [
337
+ item.prompt,
338
+ item.explanation,
339
+ ...(item.options ?? []).map((option) => option.label),
340
+ ]) {
341
+ if (typeof field !== 'string') continue;
342
+ for (const span of mathSpans(field)) {
343
+ let result;
344
+ try {
345
+ result = resolveMathGlyphs(span.latex, scope, { derive: true });
346
+ } catch {
347
+ continue;
348
+ }
349
+ const at = {
350
+ context: 'assessment' as const,
351
+ severity: 'error' as const,
352
+ file: `${assessmentId} (${item.id ?? '?'})`,
353
+ line: span.line,
354
+ };
355
+ for (const indexed of result.indices) {
356
+ pushFindings(
357
+ indexed,
358
+ at,
359
+ `${link.lessonId}: assessment ${assessmentId}`,
360
+ 'assessment',
361
+ factsOf,
362
+ reported,
363
+ diagnostics,
364
+ );
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ return { diagnostics, derivedByLesson };
373
+ }
374
+
375
+ /** `index-explained` or `index-swallowed` for one indexed symbol, once per form. */
376
+ function pushFindings(
377
+ item: IndexedSymbol,
378
+ at: Omit<MathGateDiagnostic, 'code' | 'message'>,
379
+ owner: string,
380
+ place: Place,
381
+ factsOf: (key: string | null) => IndexEntryFacts | undefined,
382
+ reported: Set<string>,
383
+ diagnostics: MathGateDiagnostic[],
384
+ ): void {
385
+ const token = item.token.replace(/\s+/g, '');
386
+ if (item.how === 'swallowed' && !reported.has(`swallowed:${token}`)) {
387
+ reported.add(`swallowed:${token}`);
388
+ diagnostics.push({
389
+ ...at,
390
+ code: 'index-swallowed',
391
+ token: item.token,
392
+ message: message(owner, 'index-swallowed', {
393
+ key: item.key ?? '',
394
+ token: item.token,
395
+ notation: factsOf(item.key)?.notation ?? '',
396
+ index: item.index,
397
+ }),
398
+ });
399
+ } else if (item.how === 'composed' && !item.explained && !reported.has(`explained:${token}`)) {
400
+ reported.add(`explained:${token}`);
401
+ diagnostics.push({
402
+ ...at,
403
+ code: 'index-explained',
404
+ token: item.token,
405
+ message: message(
406
+ owner,
407
+ 'index-explained',
408
+ {
409
+ token: item.token,
410
+ index: item.index.replace(/^\{([\s\S]*)\}$/, '$1'),
411
+ parts: item.unexplained.map((part) => JSON.stringify(part)).join(', '),
412
+ base: String(item.baseExplained),
413
+ },
414
+ place,
415
+ ),
416
+ });
417
+ }
418
+ }
419
+
420
+ /** Rule N23 in one notation formula: derived cards do not compose there. */
421
+ function checkFormula(
422
+ formula: string,
423
+ scope: readonly ScopeEntry[],
424
+ owner: string,
425
+ file: string,
426
+ factsOf: (key: string | null) => IndexEntryFacts | undefined,
427
+ reported: Set<string>,
428
+ diagnostics: MathGateDiagnostic[],
429
+ ): void {
430
+ let result;
431
+ try {
432
+ result = resolveMathGlyphs(formula, [...scope]);
433
+ } catch {
434
+ return;
435
+ }
436
+ const at = { context: 'formula' as const, severity: 'error' as const, file };
437
+ for (const item of result.indices) {
438
+ pushFindings(item, at, owner, 'formula', factsOf, reported, diagnostics);
439
+ }
440
+ }
441
+
442
+ /** A glyph table with each key once, the first row winning. */
443
+ function dedupeRows(rows: readonly ScopeEntry[]): ScopeEntry[] {
444
+ const seen = new Set<string>();
445
+ return rows.filter((row) => !seen.has(row.key) && Boolean(seen.add(row.key)));
446
+ }
447
+
448
+ /**
449
+ * Rule N23's findings in one math source rendered outside a lesson body — a
450
+ * lab's or a diagram's, which fail the build as they render — as one line
451
+ * each: the form, what explains none of it, and the fix.
452
+ */
453
+ export function indexFindings(indices: readonly IndexedSymbol[]): string[] {
454
+ return indices.flatMap((item) => {
455
+ if (item.how === 'swallowed') {
456
+ return [
457
+ `${item.token}: \\explain{${item.key ?? ''}}{…} names an entry whose latex has no ` +
458
+ `index, so its card cannot explain ${JSON.stringify(item.index)}; give the subscript an ` +
459
+ `index entry ({}_${item.index.length === 1 ? item.index : `{${item.index}}`}) and drop the wrap, or name an entry for the indexed symbol`,
460
+ ];
461
+ }
462
+ if (item.how !== 'composed' || item.explained) return [];
463
+ return [
464
+ `${item.token}: no card explains ${item.unexplained
465
+ .map((part) => JSON.stringify(part))
466
+ .join(', ')}; give the indexed symbol its own entry, a family entry (x_k), or an ` +
467
+ `index entry for the subscript ({}_0) in the page's notation`,
468
+ ];
469
+ });
470
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  NotationRegistryInput,
25
25
  SharedNotationDefinitionInput,
26
26
  } from './types';
27
+ import { blankPropStrings } from './gate-prop-text';
27
28
 
28
29
  /**
29
30
  * The equation a finding sits in, when its math carries a `\label{eq:…}`.
@@ -99,16 +100,18 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
99
100
  course: 'error',
100
101
  };
101
102
 
102
- interface AssessmentItemLike {
103
+ export interface AssessmentItemLike {
103
104
  readonly id?: string;
104
105
  readonly prompt?: string;
105
106
  readonly explanation?: string;
107
+ /** A choice item's options; a label's math renders like the prompt's. */
108
+ readonly options?: readonly { readonly label?: string }[];
106
109
  }
107
- interface AssessmentLike {
110
+ export interface AssessmentLike {
108
111
  readonly id: string;
109
112
  readonly items?: readonly AssessmentItemLike[];
110
113
  }
111
- interface LessonAssessmentLink {
114
+ export interface LessonAssessmentLink {
112
115
  readonly lessonId: string;
113
116
  readonly assessmentIds: readonly string[];
114
117
  }
@@ -136,9 +139,13 @@ export interface MathSpan {
136
139
  readonly offset: number;
137
140
  }
138
141
 
139
- /** Every `$$…$$` then `$…$` span in `body`, outside code, with its position. */
142
+ /**
143
+ * Every `$$…$$` then `$…$` span in `body`, outside code and quoted JSX props
144
+ * (plain text that never reaches KaTeX; invariant `prop-text`), with its
145
+ * position.
146
+ */
140
147
  export function mathSpans(body: string): MathSpan[] {
141
- const searchable = stripCode(body);
148
+ const searchable = stripCode(blankPropStrings(body));
142
149
  const spans: MathSpan[] = [];
143
150
  for (const pattern of [DISPLAY_MATH, INLINE_MATH]) {
144
151
  pattern.lastIndex = 0;
@@ -159,7 +166,24 @@ export function mathSpans(body: string): MathSpan[] {
159
166
  return spans;
160
167
  }
161
168
 
162
- export type ScopeEntry = { key: string; notation: string };
169
+ /**
170
+ * One row of a page's glyph table. `fixedIndex` (rule N23) tells the
171
+ * resolver that an entry's index is a fixed label, so it is no family.
172
+ */
173
+ export type ScopeEntry = { key: string; notation: string; fixedIndex?: true };
174
+
175
+ /** A definition as a glyph-table row, `fixedIndex` kept. */
176
+ export function scopeEntry(definition: {
177
+ readonly key: string;
178
+ readonly notation: string;
179
+ readonly fixedIndex?: true;
180
+ }): ScopeEntry {
181
+ return {
182
+ key: definition.key,
183
+ notation: definition.notation,
184
+ ...(definition.fixedIndex ? { fixedIndex: true as const } : {}),
185
+ };
186
+ }
163
187
 
164
188
  /**
165
189
  * A lesson's flat glyph table: its own `notation.local` entries plus every
@@ -172,10 +196,7 @@ export function pageScope(
172
196
  lesson: NotationLessonInput,
173
197
  sharedByKey: ReadonlyMap<string, ScopeEntry>,
174
198
  ): ScopeEntry[] {
175
- const local = lesson.localDefinitions.map((definition) => ({
176
- key: definition.key,
177
- notation: definition.notation,
178
- }));
199
+ const local = lesson.localDefinitions.map(scopeEntry);
179
200
  const localKeys = new Set(local.map((entry) => entry.key));
180
201
 
181
202
  const referenced = new Set<string>();
@@ -187,7 +208,7 @@ export function pageScope(
187
208
  for (const key of referenced) {
188
209
  if (localKeys.has(key)) continue;
189
210
  const shared = sharedByKey.get(key);
190
- if (shared) scope.push({ key: shared.key, notation: shared.notation });
211
+ if (shared) scope.push(scopeEntry(shared));
191
212
  }
192
213
  return scope;
193
214
  }
@@ -227,6 +248,7 @@ function resolveInto(
227
248
  out: MathGateDiagnostic[],
228
249
  equation?: EquationLink,
229
250
  suspended: readonly string[] = [],
251
+ derive = false,
230
252
  ): void {
231
253
  const at = equation === undefined ? {} : { equation };
232
254
  for (const diagnostic of lintDelimiterSizes(latex)) {
@@ -255,6 +277,7 @@ function resolveInto(
255
277
  // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
256
278
  result = resolveMathGlyphs(stripEquationLabels(latex), [...scope], {
257
279
  suspended,
280
+ derive,
258
281
  });
259
282
  } catch (error) {
260
283
  out.push({
@@ -297,6 +320,11 @@ function resolveInto(
297
320
  'font' in item && typeof item.font === 'string'
298
321
  ? ` ${token} is a different symbol from the plain letter: give it its own entry (latex: ${token}) or wrap it in \\explain{key}{${token}}.`
299
322
  : '';
323
+ // Rule N23: an indexed entry with this base explains no bare letter.
324
+ const lenderHint =
325
+ 'lenders' in item && Array.isArray(item.lenders) && item.lenders.length > 0
326
+ ? ` ${item.lenders.join(', ')} ${item.lenders.length === 1 ? 'has' : 'have'} ${token} with a subscript, and an indexed entry explains its own instances only, never a bare ${token} (rule N4). If ${token} is a different object, give it its own entry (latex: ${token}); if it is shorthand for that entry, wrap it in \\explain{${item.lenders[0]}}{${token}}.`
327
+ : '';
300
328
  out.push({
301
329
  context,
302
330
  code: 'unresolved-glyph',
@@ -305,11 +333,45 @@ function resolveInto(
305
333
  line,
306
334
  token,
307
335
  ...at,
308
- message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint} ${fix}`,
336
+ message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint}${lenderHint} ${fix}`,
309
337
  });
310
338
  }
311
339
  }
312
340
 
341
+ /**
342
+ * The `$…$` pairs remark-math reads as math that hold an amount of money
343
+ * instead: `$5 to $6`, `$5–$10`, `$5, and $x`. remark-math pairs any two
344
+ * single `$` on a line, left to right; `mathSpans` follows Pandoc (no space
345
+ * before a closing `$`, no digit after it) and skips them, so such a pair was
346
+ * neither checked as math nor shown as money — the page printed "5 to 6", or
347
+ * the build failed on the letters of "and". A pair is money when its content
348
+ * starts with a digit and either ends in a space or runs straight into a
349
+ * digit after the closing `$`.
350
+ */
351
+ export function currencyDollars(body: string): MathSpan[] {
352
+ const searchable = stripCode(blankPropStrings(body)).replace(DISPLAY_MATH, (block) =>
353
+ block.replace(/[^\n]/g, ' '),
354
+ );
355
+ const spans: MathSpan[] = [];
356
+ let lineStart = 0;
357
+ searchable.split('\n').forEach((text, index) => {
358
+ const dollars: number[] = [];
359
+ for (let at = 0; at < text.length; at += 1) {
360
+ if (text[at] !== '$' || text[at - 1] === '\\' || text[at - 1] === '$' || text[at + 1] === '$') continue;
361
+ dollars.push(at);
362
+ }
363
+ for (let pair = 0; pair + 1 < dollars.length; pair += 2) {
364
+ const open = dollars[pair]!;
365
+ const close = dollars[pair + 1]!;
366
+ const latex = text.slice(open + 1, close);
367
+ if (!/^\d/.test(latex) || !(/\s$/.test(latex) || /^\d/.test(text.slice(close + 1)))) continue;
368
+ spans.push({ latex, line: index + 1, column: open + 2, offset: lineStart + open + 1 });
369
+ }
370
+ lineStart += text.length + 1;
371
+ });
372
+ return spans;
373
+ }
374
+
313
375
  const BODY_FIX =
314
376
  'Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.';
315
377
  function dedupeScope(scope: readonly GlyphScopeEntry[]): GlyphScopeEntry[] {
@@ -424,9 +486,28 @@ export function gateContentMath(input: {
424
486
  diagnostics,
425
487
  equationLinkFor(lesson.lessonId, span.latex),
426
488
  suspended,
489
+ // Derived cards (rule N23) are lesson-body math, like the render path.
490
+ true,
427
491
  );
428
492
  }
429
493
 
494
+ for (const span of currencyDollars(lesson.body)) {
495
+ const shown = span.latex.replace(/\s+$/, '');
496
+ diagnostics.push({
497
+ context: 'body',
498
+ code: 'currency-dollar',
499
+ severity: SEVERITY.body,
500
+ file,
501
+ line: span.line + bodyOffset,
502
+ column: span.column,
503
+ token: `$${span.latex}$`,
504
+ message:
505
+ `"$${span.latex}$" reads as math: remark-math pairs any two dollars on a line, so the ` +
506
+ `page shows "${shown}" without them. Write a currency dollar as \\$ (\\$5 to \\$6), ` +
507
+ 'or the amount in words.',
508
+ });
509
+ }
510
+
430
511
  for (const definition of lesson.localDefinitions) {
431
512
  if (!definition.formula) continue;
432
513
  // `scope` already carries every page-local entry, including this one, so
@@ -491,7 +572,11 @@ export function gateContentMath(input: {
491
572
  const assessment = assessmentById.get(assessmentId);
492
573
  if (!assessment?.items) continue;
493
574
  for (const item of assessment.items) {
494
- for (const field of [item.prompt, item.explanation]) {
575
+ for (const field of [
576
+ item.prompt,
577
+ item.explanation,
578
+ ...(item.options ?? []).map((option) => option.label),
579
+ ]) {
495
580
  if (typeof field !== 'string') continue;
496
581
  for (const span of mathSpans(field)) {
497
582
  resolveInto(
@@ -503,6 +588,10 @@ export function gateContentMath(input: {
503
588
  span.column,
504
589
  ASSESSMENT_FIX,
505
590
  diagnostics,
591
+ undefined,
592
+ [],
593
+ // Page math, rendered with derived cards (rule N23).
594
+ true,
506
595
  );
507
596
  }
508
597
  }