explico 0.12.0 → 0.13.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 (279) hide show
  1. package/CHANGELOG.md +1137 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +4 -3
  4. package/dist/compiler/astro.js +45 -15
  5. package/dist/compiler/cli.d.ts +26 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +9 -1
  8. package/dist/compiler/collections.js +55 -16
  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 +14 -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 +87 -6
  51. package/dist/compiler/manifest.js +174 -31
  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 +2 -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 +2 -2
  61. package/dist/reference/base-operators.mjs +2 -2
  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/figures-validate.d.ts +1 -1
  88. package/dist/reference/gate-abbreviations.d.ts +1 -1
  89. package/dist/reference/gate-abbreviations.js +1 -1
  90. package/dist/reference/gate-course-rules.d.ts +4 -3
  91. package/dist/reference/gate-course-rules.js +9 -4
  92. package/dist/reference/gate-heading-length.d.ts +2 -0
  93. package/dist/reference/gate-heading-length.js +7 -5
  94. package/dist/reference/gate-heading-structure.js +26 -0
  95. package/dist/reference/gate-indices.d.ts +35 -0
  96. package/dist/reference/gate-indices.js +299 -0
  97. package/dist/reference/gate-math.d.ts +32 -2
  98. package/dist/reference/gate-math.js +68 -12
  99. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  100. package/dist/reference/gate-mdx-expression.js +29 -0
  101. package/dist/reference/gate-part-pages.d.ts +1 -1
  102. package/dist/reference/gate-prop-text.d.ts +28 -0
  103. package/dist/reference/gate-prop-text.js +106 -0
  104. package/dist/reference/gate-prose-notation.d.ts +3 -1
  105. package/dist/reference/gate-prose-notation.js +4 -2
  106. package/dist/reference/gate-retired-keys.d.ts +35 -0
  107. package/dist/reference/gate-retired-keys.js +58 -0
  108. package/dist/reference/gloss.d.ts +2 -0
  109. package/dist/reference/katex-measure.d.ts +17 -0
  110. package/dist/reference/katex-measure.js +223 -0
  111. package/dist/reference/keyed-float-validate.js +12 -0
  112. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  113. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  114. package/dist/reference/lab-math-scope.d.ts +1 -0
  115. package/dist/reference/lab-math-scope.js +13 -2
  116. package/dist/reference/math-glyphs.d.mts +94 -2
  117. package/dist/reference/math-glyphs.mjs +851 -46
  118. package/dist/reference/mixed-content.d.ts +8 -3
  119. package/dist/reference/mixed-content.js +4 -2
  120. package/dist/reference/page-notation.d.ts +34 -4
  121. package/dist/reference/page-notation.js +54 -2
  122. package/dist/reference/references.d.ts +11 -1
  123. package/dist/reference/references.js +6 -0
  124. package/dist/reference/registry.js +104 -10
  125. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  126. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  127. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  128. package/dist/reference/remark-citation.mjs +0 -0
  129. package/dist/reference/remark-notation.mjs +84 -20
  130. package/dist/reference/render-lab-math.d.ts +2 -0
  131. package/dist/reference/render-lab-math.js +17 -5
  132. package/dist/reference/sections-validate.d.ts +28 -3
  133. package/dist/reference/sections-validate.js +31 -4
  134. package/dist/reference/sections.d.mts +24 -6
  135. package/dist/reference/sections.mjs +92 -17
  136. package/dist/reference/tables-validate.d.ts +1 -1
  137. package/dist/reference/tables-validate.js +24 -1
  138. package/dist/reference/text-measure.d.ts +10 -0
  139. package/dist/reference/text-measure.js +41 -0
  140. package/dist/reference/text-widths.d.ts +10 -0
  141. package/dist/reference/text-widths.js +1238 -0
  142. package/dist/reference/types.d.ts +64 -8
  143. package/dist/rules/catalog.js +191 -30
  144. package/dist/rules/in-effect.d.ts +1 -1
  145. package/dist/rules/rules.schema.json +79 -24
  146. package/dist/rules/settings.d.ts +1 -1
  147. package/dist/rules/settings.js +7 -0
  148. package/dist/session/PreferenceStore.d.ts +18 -1
  149. package/dist/session/PreferenceStore.js +29 -1
  150. package/docs/authoring/README.md +32 -21
  151. package/docs/authoring/agents.md +38 -7
  152. package/docs/authoring/components.md +111 -6
  153. package/docs/authoring/migrating.md +707 -0
  154. package/docs/authoring/notation.md +281 -29
  155. package/docs/authoring/retired.md +1 -0
  156. package/docs/authoring/structure-rules.md +10 -41
  157. package/docs/authoring/structure.md +53 -2
  158. package/docs/authoring/visualizations.md +144 -18
  159. package/package.json +12 -7
  160. package/src/compiler/astro.ts +57 -17
  161. package/src/compiler/cli.ts +143 -35
  162. package/src/compiler/collections.ts +92 -19
  163. package/src/compiler/content-schema.ts +108 -0
  164. package/src/compiler/content-schemas.ts +391 -0
  165. package/src/compiler/course-config.ts +86 -15
  166. package/src/compiler/exports/combined-html.ts +669 -0
  167. package/src/compiler/exports/download-index.ts +36 -0
  168. package/src/compiler/exports/export-chapters.ts +233 -0
  169. package/src/compiler/exports/export-pages.ts +44 -0
  170. package/src/compiler/exports/html-tree.ts +62 -0
  171. package/src/compiler/exports/latex/capture.ts +651 -0
  172. package/src/compiler/exports/latex/compile.ts +168 -0
  173. package/src/compiler/exports/latex/document.ts +479 -0
  174. package/src/compiler/exports/latex/explico.cls +415 -0
  175. package/src/compiler/exports/latex/numbering.ts +74 -0
  176. package/src/compiler/exports/latex/serialize.ts +787 -0
  177. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  178. package/src/compiler/exports/latex-export.ts +255 -0
  179. package/src/compiler/exports/markdown/serialize.ts +632 -0
  180. package/src/compiler/exports/markdown-export.ts +329 -0
  181. package/src/compiler/exports/standalone-html.ts +39 -70
  182. package/src/compiler/exports/static-tree.ts +944 -0
  183. package/src/compiler/exports/zip.ts +88 -0
  184. package/src/compiler/lesson-derivation.ts +13 -8
  185. package/src/compiler/manifest.ts +299 -28
  186. package/src/compiler/render-diagnostics.ts +111 -0
  187. package/src/components/HoverPanel.astro +204 -6
  188. package/src/components/PartOverview.astro +12 -9
  189. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  190. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  191. package/src/components/assessments/AssessmentSet.astro +14 -1
  192. package/src/components/block-heading.ts +38 -0
  193. package/src/components/card-arrow.ts +18 -4
  194. package/src/components/card-carry.ts +406 -0
  195. package/src/components/card-drag.ts +1 -30
  196. package/src/components/card-fetch.ts +87 -0
  197. package/src/components/card-manager.ts +1222 -38
  198. package/src/components/card-memory.ts +5 -1
  199. package/src/components/card-placement.ts +211 -13
  200. package/src/components/card-side.ts +281 -31
  201. package/src/components/citation/CitationLayer.astro +45 -40
  202. package/src/components/diagram/Diagram.astro +123 -44
  203. package/src/components/figure/Figure.astro +11 -0
  204. package/src/components/figure/FloatCaption.astro +4 -17
  205. package/src/components/float-toolbar.ts +274 -0
  206. package/src/components/memo/Memo.astro +21 -10
  207. package/src/components/memo/ShortNote.astro +4 -0
  208. package/src/components/notation/NotationGlossary.astro +22 -17
  209. package/src/components/notation/NotationLayer.astro +321 -79
  210. package/src/components/notation/NotationMath.astro +8 -1
  211. package/src/components/notation/render-notation-math.ts +51 -5
  212. package/src/components/readable-text.ts +25 -0
  213. package/src/components/reference/FloatRefLayer.astro +162 -89
  214. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  215. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  216. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  217. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  218. package/src/components/starlight/LessonExportActions.astro +384 -310
  219. package/src/components/starlight/LessonFooter.astro +47 -9
  220. package/src/components/starlight/PrintController.astro +28 -0
  221. package/src/components/starlight/TextSizeControl.astro +137 -0
  222. package/src/components/starlight/print-controller.ts +380 -0
  223. package/src/components/starlight/section-summary.ts +13 -5
  224. package/src/components/table/Table.astro +13 -21
  225. package/src/components/table/table-truncation.ts +15 -0
  226. package/src/content-config.ts +20 -312
  227. package/src/curriculum/validation.ts +72 -24
  228. package/src/reference/base-abbreviations.mjs +2 -2
  229. package/src/reference/base-operators.mjs +2 -2
  230. package/src/reference/base-roman-labels.mjs +4 -3
  231. package/src/reference/citation-format.mjs +74 -0
  232. package/src/reference/consistency.ts +47 -61
  233. package/src/reference/curriculum-alignment.ts +4 -41
  234. package/src/reference/derived-entries.ts +68 -0
  235. package/src/reference/derived-notation.mjs +121 -0
  236. package/src/reference/diagram-fit.ts +104 -0
  237. package/src/reference/diagram-layout.ts +182 -51
  238. package/src/reference/diagrams-validate.ts +2 -1
  239. package/src/reference/equations-validate.ts +41 -2
  240. package/src/reference/equations.mjs +120 -22
  241. package/src/reference/examples-validate.ts +1 -0
  242. package/src/reference/figure-fit.ts +139 -0
  243. package/src/reference/figure-pictures.ts +96 -0
  244. package/src/reference/figures-validate.ts +2 -1
  245. package/src/reference/gate-abbreviations.ts +1 -1
  246. package/src/reference/gate-course-rules.ts +22 -7
  247. package/src/reference/gate-heading-length.ts +25 -7
  248. package/src/reference/gate-heading-structure.ts +40 -1
  249. package/src/reference/gate-indices.ts +470 -0
  250. package/src/reference/gate-math.ts +102 -13
  251. package/src/reference/gate-mdx-expression.ts +45 -0
  252. package/src/reference/gate-prop-text.ts +145 -0
  253. package/src/reference/gate-prose-notation.ts +19 -5
  254. package/src/reference/gate-retired-keys.ts +101 -0
  255. package/src/reference/gloss.ts +2 -0
  256. package/src/reference/katex-measure.ts +384 -0
  257. package/src/reference/keyed-float-validate.ts +22 -0
  258. package/src/reference/keyed-jsx-floats.mjs +11 -14
  259. package/src/reference/lab-math-scope.ts +15 -1
  260. package/src/reference/math-glyphs.mjs +851 -46
  261. package/src/reference/mixed-content.ts +11 -2
  262. package/src/reference/page-notation.ts +100 -3
  263. package/src/reference/references.ts +14 -0
  264. package/src/reference/registry.ts +154 -8
  265. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  266. package/src/reference/rehype-heading-anchors.mjs +11 -1
  267. package/src/reference/remark-citation.mjs +0 -0
  268. package/src/reference/remark-notation.mjs +84 -20
  269. package/src/reference/render-lab-math.ts +29 -5
  270. package/src/reference/sections-validate.ts +67 -3
  271. package/src/reference/sections.mjs +92 -17
  272. package/src/reference/tables-validate.ts +40 -2
  273. package/src/reference/text-measure.ts +63 -0
  274. package/src/reference/text-widths.ts +133 -0
  275. package/src/reference/types.ts +75 -8
  276. package/src/rules/catalog.ts +205 -29
  277. package/src/rules/settings.ts +10 -0
  278. package/src/session/PreferenceStore.ts +53 -0
  279. package/src/styles/global.css +291 -75
@@ -185,14 +185,71 @@ function assertPinnedKatex() {
185
185
  }
186
186
  }
187
187
 
188
+ /** The inner environment that takes a top-level LaTeX one's rows inside `$$…$$`. */
189
+ const INNER_ENVIRONMENT = {
190
+ align: 'aligned',
191
+ flalign: 'aligned',
192
+ split: 'aligned',
193
+ alignat: 'alignedat',
194
+ gather: 'gathered',
195
+ };
196
+
197
+ /**
198
+ * What to write instead of a LaTeX habit KaTeX rejects, or `undefined`.
199
+ * `replace` is set when KaTeX's own words would mislead: every span is parsed
200
+ * as inline math, so in a `$$…$$` block KaTeX says a display environment or
201
+ * `\tag` "can be used only in display mode" — but a block already is the
202
+ * display, and explico numbers it from its `\label{eq:key}`.
203
+ */
204
+ function latexHabitAdvice(message, latex) {
205
+ const NUMBERED = 'a $$…$$ block already is the display, and explico numbers it from its \\label{eq:key}';
206
+ const display = /\{(\w+)(\*?)\} can be used only in display mode/.exec(message);
207
+ if (display) {
208
+ const [, name, star] = display;
209
+ const inner = INNER_ENVIRONMENT[name];
210
+ return {
211
+ replace: true,
212
+ text: inner
213
+ ? `\\begin{${name}${star}} is a LaTeX display environment of its own; ${NUMBERED}. Write the rows in \\begin{${inner}}…\\end{${inner}}, one numbered equation per block.`
214
+ : `\\begin{${name}${star}} is a LaTeX display environment of its own; ${NUMBERED}. Drop \\begin{${name}${star}} and \\end{${name}${star}}.`,
215
+ };
216
+ }
217
+ if (/\\tag works only in display equations/.test(message)) {
218
+ return {
219
+ replace: true,
220
+ text: `\\tag prints a number of its own, but ${NUMBERED}. Drop the \\tag, and refer to the equation with [[eq-key]].`,
221
+ };
222
+ }
223
+ const command = /Undefined control sequence: \\(label|eqref|ref|autoref|cref|cite[a-z]*)\b/.exec(message)?.[1];
224
+ if (command === 'label') {
225
+ const label = /\\label\s*\{([^}]*)\}/.exec(latex)?.[1] ?? '';
226
+ return {
227
+ text: `An equation's label is \\label{eq:key}, its key lowercase kebab-case, in a $$…$$ block: \\label{${label}} is not one.`,
228
+ };
229
+ }
230
+ if (command !== undefined && command.startsWith('cite')) {
231
+ return { text: 'Math holds no citation: cite in the prose with [@source-id].' };
232
+ }
233
+ if (command !== undefined) {
234
+ const target = new RegExp(`\\\\${command}\\s*\\{([a-z]+):([^}]*)\\}`).exec(latex);
235
+ const reference = target ? `[[${target[1]}-${target[2]}]]` : '[[eq-key]]';
236
+ return { text: `Math holds no reference: write ${reference} in the prose.` };
237
+ }
238
+ return undefined;
239
+ }
240
+
188
241
  function parseMath(latex, label) {
189
242
  assertPinnedKatex();
190
243
  try {
191
244
  return katex.__parse(latex, { throwOnError: true, strict: 'error' });
192
245
  } catch (error) {
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ const advice = latexHabitAdvice(message, latex);
193
248
  fail(
194
249
  'invalid-latex',
195
- `KaTeX could not parse ${label}: ${error instanceof Error ? error.message : String(error)}`,
250
+ advice?.replace
251
+ ? `KaTeX could not parse ${label}: ${advice.text}`
252
+ : `KaTeX could not parse ${label}: ${message}${advice ? ` ${advice.text}` : ''}`,
196
253
  { label, latex },
197
254
  );
198
255
  }
@@ -265,6 +322,8 @@ const MARKER_COMMANDS = [EXPLAIN_COMMAND, '\\slot'];
265
322
  export function stripNotationMarkers(latex) {
266
323
  let result = '';
267
324
  let cursor = 0;
325
+ // True right after a stripped marker, whose edge the next character meets.
326
+ let atMarkerEdge = false;
268
327
 
269
328
  while (cursor < latex.length) {
270
329
  const command = MARKER_COMMANDS.find(
@@ -282,18 +341,38 @@ export function stripNotationMarkers(latex) {
282
341
  : undefined;
283
342
 
284
343
  if (contentGroup) {
285
- result += stripNotationMarkers(contentGroup.content);
344
+ result = joinAtMarkerEdge(
345
+ result,
346
+ stripNotationMarkers(contentGroup.content),
347
+ );
286
348
  cursor = contentGroup.end;
349
+ atMarkerEdge = true;
287
350
  continue;
288
351
  }
289
352
 
290
- result += latex[cursor];
353
+ result = atMarkerEdge
354
+ ? joinAtMarkerEdge(result, latex[cursor])
355
+ : result + latex[cursor];
356
+ atMarkerEdge = false;
291
357
  cursor += 1;
292
358
  }
293
359
 
294
360
  return result;
295
361
  }
296
362
 
363
+ /**
364
+ * Join the two sides of a stripped marker's edge. TeX reads a control word up
365
+ * to its first non-letter, so `\times` and `m` must not become `\timesm`, nor
366
+ * `\lambda` and `u` become `\lambdau`. The space is neutral: TeX skips spaces
367
+ * after a control word in every mode.
368
+ */
369
+ function joinAtMarkerEdge(before, after) {
370
+ const word = /\\[A-Za-z]+$/.exec(before);
371
+ return word && !isEscaped(before, word.index) && /^[A-Za-z]/.test(after)
372
+ ? `${before} ${after}`
373
+ : before + after;
374
+ }
375
+
297
376
  /**
298
377
  * Every `\explain{key}{latex}` in the source (the escape hatch). Each key must
299
378
  * already be in the page glyph table. The returned span points at the wrapped
@@ -385,6 +464,18 @@ function sourceWithoutExplainPrefixes(latex, calls) {
385
464
 
386
465
  // --- structural signatures --------------------------------------------
387
466
 
467
+ /**
468
+ * The letters of a declared operator's body, spacing dropped, so
469
+ * `\operatorname{arg\,max}` and `\operatorname{argmax}` sign alike — the
470
+ * same name rule N8 keys its allow list by.
471
+ */
472
+ function operatorLetters(value) {
473
+ if (Array.isArray(value)) return value.map(operatorLetters).join('');
474
+ if (!isNode(value)) return '';
475
+ if (value.type === 'mathord' || value.type === 'textord') return value.text ?? '';
476
+ return operatorLetters(value.body);
477
+ }
478
+
388
479
  function signatureParts(node) {
389
480
  if (!isNode(node)) return [];
390
481
 
@@ -399,7 +490,15 @@ function signatureParts(node) {
399
490
  return [`atom:${node.family ?? ''}:${node.text ?? ''}`];
400
491
  if (node.type === 'spacing') return [`space:${node.text ?? ''}`];
401
492
  if (node.type === 'op' || node.type === 'operatorname') {
402
- return [`operator:${node.name ?? node.type}`];
493
+ if (typeof node.name === 'string') return [`operator:${node.name}`];
494
+ // An `\operatorname{…}` (or a `\mathop{…}`) carries its name in `body`,
495
+ // not `name`. Signed by its node type alone, every declared operator
496
+ // looked alike: a page's one operator card claimed every other declared
497
+ // operator on the page, and two operator cards could not share a page.
498
+ // A body with no letters (`\mathop{\sum}`) falls through to the full
499
+ // structural signature below.
500
+ const letters = operatorLetters(node.body);
501
+ if (letters !== '') return [`operator:${node.type}:${letters}`];
403
502
  }
404
503
  if (node.type === 'text')
405
504
  return [`text:${signatureValue(node.body).join('|')}`];
@@ -722,6 +821,7 @@ function collectSourceStructure(tree, latex) {
722
821
  const identifiers = [];
723
822
  const scriptSpans = [];
724
823
  const decorations = [];
824
+ const indexed = [];
725
825
  const seenContainers = new Set();
726
826
  const seenIdentifiers = new Set();
727
827
 
@@ -773,6 +873,11 @@ function collectSourceStructure(tree, latex) {
773
873
  const span = nodeSpan(script, latex);
774
874
  if (span) scriptSpans.push(span);
775
875
  }
876
+ // A symbol with a subscript (rule N23). Not one in a distinct font's
877
+ // body: `\mathbf{x_1}` is a styled whole, like a container there.
878
+ if (!ignored && context.font === undefined && indexedForm(value)) {
879
+ indexed.push(value);
880
+ }
776
881
  }
777
882
 
778
883
  const decoration = ignored ? undefined : decorationRecord(value, latex);
@@ -808,7 +913,7 @@ function collectSourceStructure(tree, latex) {
808
913
 
809
914
  addContainer(tree);
810
915
  visitValue(tree);
811
- return { containers, identifiers, scriptSpans, decorations };
916
+ return { containers, identifiers, scriptSpans, decorations, indexed };
812
917
  }
813
918
 
814
919
  // --- base-glyph signatures ------------------------------------------
@@ -896,21 +1001,319 @@ export function decorationOfEntry(latex) {
896
1001
  }
897
1002
 
898
1003
  /**
899
- * The shorter page form a parameterized symbol also permits: the bare base
900
- * glyph of `t_k` / `r_m` / `j^{(m)}` (one significant node) or the head of a
901
- * function form `D(0,t)` / `V_0(1_t)` (head followed by `(`).
1004
+ * The bare glyph an entry lends its card to: a plain letter's own, the base of
1005
+ * a superscript form `j^{(m)}`, or the head of a function form `D(0,t)`.
1006
+ *
1007
+ * An index is part of a symbol's name (rule N23), so an entry with a
1008
+ * subscript lends to no bare glyph: `v_0` (a fixed label) is not `v`, and a
1009
+ * family `\alpha_i` gives its card to its own instances (`\alpha_1`, see
1010
+ * {@link resolveIndexedNode}) but never to a bare `\alpha`, which is a
1011
+ * different object. A function form whose head is indexed (`V_0(1_t)`) lends
1012
+ * by that head, as a whole — see {@link indexedFunctionHead}.
902
1013
  */
903
1014
  function canonicalBaseSignature(tree) {
1015
+ const head = functionFormHead(tree);
1016
+ if (head === null) return undefined;
1017
+ if (head?.type === 'supsub' && head.sub) return undefined;
1018
+ return identifierBaseSignature(head);
1019
+ }
1020
+
1021
+ /**
1022
+ * The one significant node of an entry, or the head of a function form
1023
+ * (`D(0,t)`, `V_0(1_t)`: a head followed by `(`); `null` when the entry is
1024
+ * neither.
1025
+ */
1026
+ function functionFormHead(tree) {
904
1027
  const significant = tree.filter(
905
1028
  (node) => node.type !== 'spacing' && node.type !== 'ordgroup',
906
1029
  );
907
- if (significant.length === 1) return identifierBaseSignature(significant[0]);
908
- if (significant.length < 2) return undefined;
1030
+ if (significant.length === 1) return significant[0];
1031
+ if (significant.length < 2) return null;
909
1032
  const [head, open] = significant;
910
1033
  if (open.type !== 'atom' || open.family !== 'open' || open.text !== '(') {
911
- return undefined;
1034
+ return null;
912
1035
  }
913
- return identifierBaseSignature(head);
1036
+ return head;
1037
+ }
1038
+
1039
+ /** The indexed head of a function form, `V_0` in `V_0(1_t)`, else `undefined`. */
1040
+ function indexedFunctionHead(tree) {
1041
+ const significant = tree.filter(
1042
+ (node) => node.type !== 'spacing' && node.type !== 'ordgroup',
1043
+ );
1044
+ if (significant.length < 2) return undefined;
1045
+ const head = functionFormHead(tree);
1046
+ return head && indexedForm(head) ? head : undefined;
1047
+ }
1048
+
1049
+ // --- indexed symbols (rule N23) -------------------------------------
1050
+
1051
+ /** A top-level comma in a subscript separates two index slots: `x_{i,j}`. */
1052
+ const isSlotComma = (node) =>
1053
+ node.type === 'atom' && node.family === 'punct' && node.text === ',';
1054
+
1055
+ /** The slots of a subscript, `{…}` groups flattened. */
1056
+ function indexSlots(sub) {
1057
+ const slots = [[]];
1058
+ for (const node of flattenGroup(sub)) {
1059
+ if (isSlotComma(node)) slots.push([]);
1060
+ else slots[slots.length - 1].push(node);
1061
+ }
1062
+ return slots;
1063
+ }
1064
+
1065
+ const isDigitNode = (node) =>
1066
+ (node.type === 'textord' || node.type === 'mathord') &&
1067
+ /^[0-9.]+$/u.test(node.text ?? '');
1068
+
1069
+ const isLabelNode = (node) =>
1070
+ node.type === 'text' ||
1071
+ (node.type === 'font' && node.font === ROMAN_LABEL_FONT);
1072
+
1073
+ /**
1074
+ * A running index is a lowercase Latin letter: `i`, `k`, `t`. An uppercase
1075
+ * letter in a subscript is a size (`X_N`) or another symbol (`p_X`), and a
1076
+ * Greek one a parameter (`p_\theta`), so neither runs over a set.
1077
+ */
1078
+ const RUNNING_INDEX_LETTER = /^[a-z]$/u;
1079
+
1080
+ /** True for a letter that is a running index wherever it is a whole slot. */
1081
+ export function isRunningIndexLetter(text) {
1082
+ return RUNNING_INDEX_LETTER.test(text);
1083
+ }
1084
+
1085
+ /**
1086
+ * What one index slot is: `running` (a lowercase letter), `digits`, `named`
1087
+ * (a `\mathrm{…}` / `\text{…}` label, or a subscript the page declares fixed:
1088
+ * an index entry `{}_x`, a letter card with `fixedIndex`), `symbol` (anything
1089
+ * else: `N`, `\theta`, `T_{\mathrm{fwd}}`, `t-1`), or `empty`.
1090
+ */
1091
+ function slotKind(slot, fixedSlots) {
1092
+ if (slot.length === 0) return 'empty';
1093
+ if (fixedSlots.has(sequenceSignature(slot))) return 'named';
1094
+ if (slot.every(isDigitNode)) return 'digits';
1095
+ if (slot.length === 1 && isLabelNode(slot[0])) return 'named';
1096
+ if (
1097
+ slot.length === 1 &&
1098
+ slot[0].type === 'mathord' &&
1099
+ RUNNING_INDEX_LETTER.test(slot[0].text ?? '')
1100
+ ) {
1101
+ return 'running';
1102
+ }
1103
+ return 'symbol';
1104
+ }
1105
+
1106
+ /**
1107
+ * Whether a subscript's base is a symbol an index belongs to: a letter
1108
+ * (`x`, `\theta`), a font-styled symbol (`\mathbf{v}`, `\mathbb{E}`), a roman
1109
+ * label (`\mathrm{RV}`), or a decorated one (`\hat{\beta}`, `b'`). A big
1110
+ * operator (`\sum_{i=1}`), a bracket (`\lVert x\rVert_2`), a number set
1111
+ * (`\mathbb{R}_+`) and a brace (`\underbrace{…}_{…}`) are not.
1112
+ */
1113
+ function isSymbolBase(base) {
1114
+ const items = flattenGroup(base);
1115
+ if (items.length !== 1) return false;
1116
+ const [node] = items;
1117
+ if (IGNORED_MATH_TOKENS.has(fontTokenText(node))) return false;
1118
+ if (isIdentifierNode(node)) return true;
1119
+ if (node.type === 'font') return hasBindableGlyph(node) || isLabelNode(node);
1120
+ const decoration = decorationOf(node);
1121
+ return decoration !== undefined && isSymbolBase(node[decoration.part]);
1122
+ }
1123
+
1124
+ /**
1125
+ * The parts of a symbol with a subscript, or `undefined` for any other node.
1126
+ * `sup` says what rides on top: nothing, a mark (`'`, `*`, `\dagger`: rule
1127
+ * N22) or anything else, a power (`^{2}`, `^{(m)}`).
1128
+ */
1129
+ function indexedForm(node) {
1130
+ if (!isNode(node) || node.type !== 'supsub' || !node.sub) return undefined;
1131
+ if (!isSymbolBase(node.base)) return undefined;
1132
+ return {
1133
+ baseSignature: signatureValue(node.base).join('|'),
1134
+ subSignature: signatureValue(node.sub).join('|'),
1135
+ supSignature: node.sup ? signatureValue(node.sup).join('|') : '',
1136
+ sup: !node.sup
1137
+ ? 'none'
1138
+ : scriptMarkName(node.sup) === undefined
1139
+ ? 'power'
1140
+ : 'mark',
1141
+ slots: indexSlots(node.sub),
1142
+ };
1143
+ }
1144
+
1145
+ /** The signature of a symbol with its subscript and without what rides on top. */
1146
+ const coreSignature = (form) =>
1147
+ `supsub(${form.baseSignature})_(${form.subSignature})^()`;
1148
+
1149
+ /**
1150
+ * The source span of a symbol and its subscript, without a power or mark
1151
+ * written after them: `v_0` in `v_0^{2}`. When the superscript sits between
1152
+ * them (`v^2_0`, `v'_0`) the two cannot be wrapped apart, so the whole node
1153
+ * is the span.
1154
+ */
1155
+ function coreSourceSpan(node, latex) {
1156
+ const whole = sourceSpan(node, latex);
1157
+ if (!node.sup) return whole;
1158
+ const base = sourceSpan(node.base, latex);
1159
+ const sub = sourceSpan(node.sub, latex);
1160
+ if (!whole || !base || !sub) return whole;
1161
+ return /^\s*_\s*$/u.test(latex.slice(base.end, sub.start))
1162
+ ? { start: base.start, end: sub.end }
1163
+ : whole;
1164
+ }
1165
+
1166
+ /**
1167
+ * Whether an occurrence is an instance of a family: same base, same number of
1168
+ * slots, the same superscript or none on the family, and each slot fits. A
1169
+ * running slot takes anything but a fixed label (`x_1`, `x_{k+1}`, `x_N`; not
1170
+ * `x_{\mathrm{e}}`), a `symbol` slot any symbol (`T_{\mathrm{opt}}` in place
1171
+ * of `T_{\mathrm{fwd}}`), and a fixed slot only itself.
1172
+ */
1173
+ function isFamilyInstance(family, form, fixedSlots) {
1174
+ if (family.form.baseSignature !== form.baseSignature) return false;
1175
+ if (family.form.sup !== 'none' && family.form.supSignature !== form.supSignature) {
1176
+ return false;
1177
+ }
1178
+ if (family.form.slots.length !== form.slots.length) return false;
1179
+ return family.form.slots.every((slot, index) => {
1180
+ const kind = family.kinds[index];
1181
+ const filler = form.slots[index];
1182
+ const fillerKind = slotKind(filler, fixedSlots);
1183
+ if (kind === 'running' || kind === 'symbol') {
1184
+ return fillerKind !== 'named' && fillerKind !== 'empty';
1185
+ }
1186
+ return sequenceSignature(slot) === sequenceSignature(filler);
1187
+ });
1188
+ }
1189
+
1190
+ /**
1191
+ * The key of a derived entry: a base entry composed with an index entry
1192
+ * (`\mathbf{v}_0` from `velocity-vector` and `{}_0`). The build materializes
1193
+ * it from the two (`derived-notation.mjs`); it is never authored, and an
1194
+ * authored key that equals one fails the build.
1195
+ *
1196
+ * @param {string} baseKey
1197
+ * @param {string} indexKey
1198
+ * @returns {string}
1199
+ */
1200
+ export function derivedNotationKey(baseKey, indexKey) {
1201
+ return `${baseKey}.sub.${indexKey}`;
1202
+ }
1203
+
1204
+ /**
1205
+ * The subscript an **index entry** explains — an empty group with a subscript:
1206
+ * `{}_0`, `{}_x`, `{}_{\mathrm{max}}` — as the source after `{}`, else
1207
+ * `undefined`. Such an entry binds no symbol of its own; composed with the
1208
+ * entry of the symbol it indexes, it makes a derived card (rule N23).
1209
+ *
1210
+ * @param {string} latex
1211
+ * @returns {string | undefined}
1212
+ */
1213
+ export function indexOfEntry(latex) {
1214
+ if (typeof latex !== 'string' || latex.trim() === '') return undefined;
1215
+ let tree;
1216
+ try {
1217
+ tree = parseMath(stripNotationMarkers(latex), 'index entry');
1218
+ } catch (error) {
1219
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1220
+ return undefined;
1221
+ }
1222
+ throw error;
1223
+ }
1224
+ if (!isIndexEntryTree(tree)) return undefined;
1225
+ const match = /^\s*\{\s*\}\s*(_[\s\S]*)$/u.exec(latex);
1226
+ return match ? match[1].trim() : undefined;
1227
+ }
1228
+
1229
+ function isIndexEntryTree(tree) {
1230
+ if (!Array.isArray(tree) || tree.length !== 1) return false;
1231
+ const [node] = tree;
1232
+ return (
1233
+ node.type === 'supsub' &&
1234
+ Boolean(node.sub) &&
1235
+ !node.sup &&
1236
+ isNode(node.base) &&
1237
+ node.base.type === 'ordgroup' &&
1238
+ node.base.body.length === 0
1239
+ );
1240
+ }
1241
+
1242
+ /**
1243
+ * The symbol a base entry gives a derived card: its whole `latex`, or the
1244
+ * glyph it lends to — the head of a function form (`v` of `v(t)`), the base
1245
+ * of a superscript form (`j` of `j^{(m)}`).
1246
+ *
1247
+ * @param {string} notation
1248
+ * @returns {string}
1249
+ */
1250
+ export function baseSymbolLatex(notation) {
1251
+ const source = stripNotationMarkers(notation);
1252
+ let tree;
1253
+ try {
1254
+ tree = parseMath(source, 'base symbol');
1255
+ } catch (error) {
1256
+ if (error instanceof GlyphResolutionError) return source.trim();
1257
+ throw error;
1258
+ }
1259
+ let node = functionFormHead(tree);
1260
+ if (!node) return source.trim();
1261
+ if (node.type === 'supsub' && !node.sub && scriptMarkName(node.sup) === undefined) {
1262
+ node = node.base;
1263
+ }
1264
+ const span = sourceSpan(node, source);
1265
+ return span ? source.slice(span.start, span.end) : source.trim();
1266
+ }
1267
+
1268
+ /**
1269
+ * The symbol of a derived card: the base's symbol with the index entry's
1270
+ * subscript — `\mathbf{v}` and `{}_0` make `\mathbf{v}_0`.
1271
+ *
1272
+ * @param {string} baseNotation
1273
+ * @param {string} indexNotation
1274
+ * @returns {string}
1275
+ */
1276
+ export function derivedNotationLatex(baseNotation, indexNotation) {
1277
+ return `${baseSymbolLatex(baseNotation)}${indexOfEntry(indexNotation) ?? ''}`;
1278
+ }
1279
+
1280
+ /**
1281
+ * How an index entry is shown on its own — over a placeholder square, as an
1282
+ * operator entry is (`\square_0`). Display only.
1283
+ *
1284
+ * @param {string} latex
1285
+ * @returns {string}
1286
+ */
1287
+ export function indexDisplayLatex(latex) {
1288
+ return latex.replace(/^\s*\{\s*\}/u, '\\square');
1289
+ }
1290
+
1291
+ /**
1292
+ * Whether an entry's `latex` has a subscript on a symbol anywhere — what an
1293
+ * `\explain{key}{X_i}` wrap needs from `key` before its card can explain the
1294
+ * index (rule N23, `index-swallowed`).
1295
+ *
1296
+ * @param {string} latex
1297
+ * @returns {boolean}
1298
+ */
1299
+ export function entryHasIndex(latex) {
1300
+ if (typeof latex !== 'string' || latex.trim() === '') return false;
1301
+ let tree;
1302
+ try {
1303
+ tree = parseMath(stripNotationMarkers(latex), 'indexed entry');
1304
+ } catch (error) {
1305
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1306
+ return false;
1307
+ }
1308
+ throw error;
1309
+ }
1310
+ const visit = (value) => {
1311
+ if (Array.isArray(value)) return value.some(visit);
1312
+ if (!isNode(value)) return false;
1313
+ if (value.type === 'supsub' && value.sub) return true;
1314
+ return [...SOURCE_CHILD_KEYS].some((key) => visit(value[key]));
1315
+ };
1316
+ return visit(tree);
914
1317
  }
915
1318
 
916
1319
  /**
@@ -1053,6 +1456,21 @@ function buildGlyphIndex(definitions) {
1053
1456
  const byBase = new Map();
1054
1457
  const ambiguousBase = new Set();
1055
1458
  const operators = new Map();
1459
+ // Rule N23: index entries by the subscript they explain, indexed function
1460
+ // heads (`V_0` of `V_0(1_t)`) by their signature, the subscripts the page
1461
+ // declares fixed, and the indexed whole forms that may be families.
1462
+ const indexEntries = new Map();
1463
+ const headForms = new Map();
1464
+ const fixedSlots = new Set();
1465
+ const indexedEntries = [];
1466
+ // What each bare glyph's indexed entries are, for the message of a symbol
1467
+ // that no longer borrows one's card (rule N4).
1468
+ const indexedBases = new Map();
1469
+ const noteIndexedBase = (signature, key) => {
1470
+ const keys = indexedBases.get(signature) ?? [];
1471
+ if (!keys.includes(key)) keys.push(key);
1472
+ indexedBases.set(signature, keys);
1473
+ };
1056
1474
  let maxNodeLength = 0;
1057
1475
 
1058
1476
  for (const [index, value] of definitions.entries()) {
@@ -1105,13 +1523,52 @@ function buildGlyphIndex(definitions) {
1105
1523
  continue;
1106
1524
  }
1107
1525
 
1526
+ // An index entry (`{}_0`) binds no symbol; it explains its subscript
1527
+ // wherever that composes with an explained symbol (rule N23).
1528
+ if (isIndexEntryTree(tree)) {
1529
+ const subscript = signatureValue(tree[0].sub).join('|');
1530
+ const clash = indexEntries.get(subscript);
1531
+ if (clash && clash !== key) {
1532
+ fail(
1533
+ 'ambiguous-canonical-notation',
1534
+ `Page glyphs "${clash}" and "${key}" are both index entries for the subscript in "${notation}": an index has one meaning per page (rule N4).`,
1535
+ { left: clash, right: key },
1536
+ );
1537
+ }
1538
+ indexEntries.set(subscript, key);
1539
+ fixedSlots.add(subscript);
1540
+ continue;
1541
+ }
1542
+
1543
+ const fixedIndex = value?.fixedIndex === true;
1544
+ const significant = tree.filter((node) => node.type !== 'spacing');
1545
+ if (significant.length === 1 && indexedForm(significant[0])) {
1546
+ const form = indexedForm(significant[0]);
1547
+ indexedEntries.push({ key, form, fixedIndex });
1548
+ noteIndexedBase(form.baseSignature, key);
1549
+ }
1550
+ if (fixedIndex && significant.length === 1 && isIdentifierNode(significant[0])) {
1551
+ fixedSlots.add(sequenceSignature(significant));
1552
+ }
1553
+ const indexedHead = indexedFunctionHead(tree);
1554
+ if (indexedHead) {
1555
+ noteIndexedBase(indexedForm(indexedHead).baseSignature, key);
1556
+ const head = sequenceSignature([indexedHead]);
1557
+ const clash = headForms.get(head);
1558
+ if (clash && clash !== key) ambiguousBase.add(head);
1559
+ else headForms.set(head, key);
1560
+ }
1561
+
1108
1562
  const signature = sequenceSignature(tree);
1109
1563
  if (hasBindableGlyph(tree) && tree.length > 0) {
1110
1564
  const clash = bySignature.get(signature);
1111
1565
  if (clash && clash !== key) {
1112
1566
  fail(
1113
1567
  'ambiguous-canonical-notation',
1114
- `Page glyphs "${clash}" and "${key}" have structurally identical LaTeX ("${notation}").`,
1568
+ `Page glyphs "${clash}" and "${key}" have structurally identical LaTeX ("${notation}"), ` +
1569
+ 'so a reader hovering it could not tell which is meant: a glyph has one meaning per ' +
1570
+ 'page (rule N4), and \\explain does not lift that. Write one of them differently on ' +
1571
+ 'this page (another letter, a subscript), or keep one of them off it.',
1115
1572
  { left: clash, right: key },
1116
1573
  );
1117
1574
  }
@@ -1129,18 +1586,163 @@ function buildGlyphIndex(definitions) {
1129
1586
  }
1130
1587
  }
1131
1588
 
1132
- return { byKey, bySignature, byBase, ambiguousBase, operators, maxNodeLength };
1589
+ // A family is an indexed entry with a running slot (`x_k`, `F_{t,T}`); one
1590
+ // whose every slot is fixed (`v_0`, `T_{\mathrm{opt}}`, or `fixedIndex`)
1591
+ // is a single symbol and lends its card to nothing.
1592
+ const families = new Map();
1593
+ for (const { key, form, fixedIndex } of indexedEntries) {
1594
+ if (fixedIndex) continue;
1595
+ const kinds = form.slots.map((slot) => slotKind(slot, fixedSlots));
1596
+ if (!kinds.includes('running')) continue;
1597
+ const list = families.get(form.baseSignature) ?? [];
1598
+ list.push({ key, form, kinds });
1599
+ families.set(form.baseSignature, list);
1600
+ }
1601
+
1602
+ return {
1603
+ byKey,
1604
+ bySignature,
1605
+ byBase,
1606
+ ambiguousBase,
1607
+ operators,
1608
+ indexEntries,
1609
+ headForms,
1610
+ fixedSlots,
1611
+ families,
1612
+ indexedBases,
1613
+ maxNodeLength,
1614
+ };
1615
+ }
1616
+
1617
+ /**
1618
+ * The entry that explains a symbol on its own — its whole form, or a bare
1619
+ * glyph's unique card — so that an index entry can compose with it into a
1620
+ * derived card. `undefined` when there is none, or more than one.
1621
+ */
1622
+ function baseEntryKey(base, glyphs) {
1623
+ const signature = signatureValue(base).join('|');
1624
+ const whole = glyphs.bySignature.get(signature);
1625
+ if (whole !== undefined) return whole;
1626
+ const items = flattenGroup(base);
1627
+ if (items.length !== 1 || !isIdentifierNode(items[0])) return undefined;
1628
+ if (glyphs.ambiguousBase.has(signature)) return undefined;
1629
+ return glyphs.byBase.get(signature);
1630
+ }
1631
+
1632
+ /**
1633
+ * Resolve a symbol with a subscript that no whole form matched exactly
1634
+ * (rule N23), taking the first of these that exists:
1635
+ *
1636
+ * 1. the head of a function form (`V_0` of `V_0(1_t)`), or a whole form
1637
+ * under a power or a mark — `v_0^{2}` is `v_0` squared;
1638
+ * 2. a family whose index has the same shape — `x_1` under `x_k`;
1639
+ * 3. with `derive`, an index entry for its whole subscript and an entry for
1640
+ * its base: a derived card (`\mathbf{v}_0` from `\mathbf{v}` and `{}_0`).
1641
+ *
1642
+ * Returns `{ key, span, match, derived? }`, or `undefined` to leave the
1643
+ * symbol to its base's card and its index's own.
1644
+ */
1645
+ function resolveIndexedNode(node, glyphs, source, derive) {
1646
+ const form = indexedForm(node);
1647
+ if (!form) return undefined;
1648
+ const whole = sourceSpan(node, source);
1649
+ const core = coreSourceSpan(node, source);
1650
+ if (!whole || !core) return undefined;
1651
+
1652
+ const head = sequenceSignature([node]);
1653
+ if (glyphs.headForms.has(head) && !glyphs.ambiguousBase.has(head)) {
1654
+ return { key: glyphs.headForms.get(head), span: whole, match: 'canonical' };
1655
+ }
1656
+ if (form.sup !== 'none') {
1657
+ const signature = coreSignature(form);
1658
+ const key =
1659
+ glyphs.bySignature.get(signature) ??
1660
+ (glyphs.ambiguousBase.has(signature)
1661
+ ? undefined
1662
+ : glyphs.headForms.get(signature));
1663
+ if (key !== undefined) return { key, span: core, match: 'canonical' };
1664
+ }
1665
+
1666
+ const families = (glyphs.families.get(form.baseSignature) ?? []).filter(
1667
+ (family) => isFamilyInstance(family, form, glyphs.fixedSlots),
1668
+ );
1669
+ if (families.length > 1) {
1670
+ const token = source.slice(whole.start, whole.end);
1671
+ fail(
1672
+ 'ambiguous-base-fallback',
1673
+ `"${token}" at ${whole.start}:${whole.end} is an instance of more than one page symbol (${families
1674
+ .map((family) => `"${family.key}"`)
1675
+ .join(', ')}); wrap it in \\explain{key}{${token}}.`,
1676
+ { token, start: whole.start, end: whole.end },
1677
+ );
1678
+ }
1679
+ if (families.length === 1) {
1680
+ const [family] = families;
1681
+ return {
1682
+ key: family.key,
1683
+ span: family.form.sup !== 'none' ? whole : core,
1684
+ match: 'family',
1685
+ };
1686
+ }
1687
+
1688
+ if (!derive) return undefined;
1689
+ const indexKey = glyphs.indexEntries.get(form.subSignature);
1690
+ if (indexKey === undefined) return undefined;
1691
+ const baseKey = baseEntryKey(node.base, glyphs);
1692
+ if (baseKey === undefined) return undefined;
1693
+ return {
1694
+ key: derivedNotationKey(baseKey, indexKey),
1695
+ span: core,
1696
+ match: 'derived',
1697
+ derived: { base: baseKey, index: indexKey },
1698
+ };
1133
1699
  }
1134
1700
 
1135
- /** Bind whole canonical forms: greedy longest match, left to right, per run. */
1136
- function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
1701
+ /**
1702
+ * Bind whole canonical forms: greedy longest match, left to right, per run.
1703
+ * A symbol with a subscript that no whole form matches exactly is then
1704
+ * resolved as an indexed symbol ({@link resolveIndexedNode}).
1705
+ *
1706
+ * A match inside another one's script still binds — `T_{\mathrm{opt}}` in
1707
+ * `B_{T_{\mathrm{opt}}}` keeps its own card — unless it is a lone letter
1708
+ * (`matchBaseGlyphs` binds those) or the outer match is a derived card, whose
1709
+ * index is explained by the index entry as a whole.
1710
+ */
1711
+ function matchCanonicalForms(
1712
+ latex,
1713
+ strippedSource,
1714
+ containers,
1715
+ glyphs,
1716
+ scriptSpans,
1717
+ derive,
1718
+ explicitCalls,
1719
+ ) {
1137
1720
  const bindings = [];
1138
1721
  const claimed = [];
1722
+ // An author's `\explain` claims what it wraps: a symbol inside one is not
1723
+ // resolved as an indexed symbol, so two families never fail there.
1724
+ const insideExplicit = (node) => {
1725
+ const span = sourceSpan(node, strippedSource);
1726
+ return (
1727
+ span !== undefined && explicitCalls.some((call) => contains(call, span))
1728
+ );
1729
+ };
1730
+
1731
+ const blocked = (span, nodes) =>
1732
+ claimed.some(
1733
+ (existing) =>
1734
+ sameSpan(existing, span) ||
1735
+ (contains(existing, span) &&
1736
+ (existing.match === 'derived' ||
1737
+ (nodes.length === 1 && isIdentifierNode(nodes[0])) ||
1738
+ !isSafeScriptNesting(span, existing, scriptSpans))),
1739
+ );
1139
1740
 
1140
1741
  for (const container of containers) {
1141
1742
  let cursor = 0;
1142
1743
  while (cursor < container.length) {
1143
1744
  let step = 1;
1745
+ let found;
1144
1746
  const maxLength = Math.min(
1145
1747
  glyphs.maxNodeLength,
1146
1748
  container.length - cursor,
@@ -1153,27 +1755,42 @@ function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
1153
1755
  // or `b'` wraps the decoration too, not just the letter under it.
1154
1756
  const rawSpan = sourceSpan(nodes, strippedSource);
1155
1757
  if (!rawSpan) continue;
1758
+ found = { key, nodes, rawSpan, match: 'canonical' };
1759
+ break;
1760
+ }
1761
+ // A group around one node (an `aligned` cell's) shares that node's
1762
+ // container, which is recorded once: look through it.
1763
+ let single = container[cursor];
1764
+ while (single.type === 'ordgroup' && single.body.length === 1) {
1765
+ [single] = single.body;
1766
+ }
1767
+ if (!found && !insideExplicit(single)) {
1768
+ const indexed = resolveIndexedNode(single, glyphs, strippedSource, derive);
1769
+ if (indexed) {
1770
+ found = {
1771
+ ...indexed,
1772
+ nodes: [container[cursor]],
1773
+ rawSpan: indexed.span,
1774
+ };
1775
+ }
1776
+ }
1777
+ if (found) {
1156
1778
  const span = expandFontWrapperSpan(
1157
1779
  latex,
1158
- unwrapEnclosingGroupSpan(latex, rawSpan),
1780
+ unwrapEnclosingGroupSpan(latex, found.rawSpan),
1159
1781
  );
1160
- step = length;
1161
- if (
1162
- claimed.some(
1163
- (existing) => sameSpan(existing, span) || contains(existing, span),
1164
- )
1165
- ) {
1166
- break;
1782
+ step = found.nodes.length;
1783
+ if (!blocked(span, found.nodes)) {
1784
+ bindings.push({
1785
+ key: found.key,
1786
+ ...span,
1787
+ token: latex.slice(span.start, span.end),
1788
+ level: 'scope',
1789
+ match: found.match,
1790
+ ...(found.derived ? { derived: found.derived } : {}),
1791
+ });
1792
+ claimed.push({ ...span, match: found.match });
1167
1793
  }
1168
- bindings.push({
1169
- key,
1170
- ...span,
1171
- token: latex.slice(span.start, span.end),
1172
- level: 'scope',
1173
- match: 'canonical',
1174
- });
1175
- claimed.push(span);
1176
- break;
1177
1794
  }
1178
1795
  cursor += step;
1179
1796
  }
@@ -1194,10 +1811,13 @@ function matchBaseGlyphs(latex, identifiers, canonical, glyphs, scriptSpans) {
1194
1811
  // is already accounted for by that call; never re-flag or re-bind it, even
1195
1812
  // when it also sits in a script inside a larger canonical match. (To bind a
1196
1813
  // nested glyph separately, wrap it in its own nested `\explain`.)
1814
+ // A derived card (rule N23) explains its index as a whole, so a letter in
1815
+ // that index (`x` of `v_x` under `{}_x`) gets no card of its own either.
1197
1816
  if (
1198
1817
  canonical.some(
1199
1818
  (binding) =>
1200
- binding.level === 'explicit' && contains(binding, identifier),
1819
+ (binding.level === 'explicit' || binding.match === 'derived') &&
1820
+ contains(binding, identifier),
1201
1821
  )
1202
1822
  ) {
1203
1823
  continue;
@@ -1276,17 +1896,20 @@ function injectScopeBindings(latex, bindings) {
1276
1896
  * N22). A letter under such a decoration is reported once, as the decorated
1277
1897
  * form (`b''''`, not `b`): that whole form is what needs the entry.
1278
1898
  */
1279
- function unresolvedIdentifiers(latex, identifiers, bindings, suspended) {
1899
+ function unresolvedIdentifiers(latex, identifiers, bindings, suspended, indexedBases) {
1280
1900
  const letters = identifiers
1281
1901
  .filter(
1282
1902
  (identifier) =>
1283
1903
  !bindings.some((binding) => contains(binding, identifier)) &&
1284
1904
  !suspended.some((decoration) => contains(decoration.part, identifier)),
1285
1905
  )
1286
- .map(({ start, end, font }) => {
1906
+ .map(({ start, end, font, signature }) => {
1287
1907
  // Report `\mathbb{E}`, not a bare `E`, so the fix hint points at the
1288
1908
  // whole token the author has to wrap in `\explain`.
1289
1909
  const wrapped = expandFontWrapperSpan(latex, { start, end });
1910
+ // Rule N23: an indexed entry lends its card to no bare glyph, so name
1911
+ // the ones this letter used to borrow from.
1912
+ const lenders = font === undefined ? (indexedBases?.get(signature) ?? []) : [];
1290
1913
  return {
1291
1914
  token: latex.slice(wrapped.start, wrapped.end),
1292
1915
  // A styled base-library letter (`\mathbf{e}`) is not Euler's number.
@@ -1297,6 +1920,7 @@ function unresolvedIdentifiers(latex, identifiers, bindings, suspended) {
1297
1920
  start: wrapped.start,
1298
1921
  end: wrapped.end,
1299
1922
  ...(font === undefined ? {} : { font }),
1923
+ ...(lenders.length === 0 ? {} : { lenders: [...lenders] }),
1300
1924
  };
1301
1925
  })
1302
1926
  .filter(({ libraryToken }) => !BASE_LIBRARY.has(libraryToken))
@@ -1312,6 +1936,151 @@ function unresolvedIdentifiers(latex, identifiers, bindings, suspended) {
1312
1936
  );
1313
1937
  }
1314
1938
 
1939
+ /**
1940
+ * How each symbol with a subscript is explained (rule N23), for the check in
1941
+ * `gate-indices.ts`. `how` is one of:
1942
+ *
1943
+ * - `whole-form`, `family`, `derived`: a binding of that kind is exactly the
1944
+ * symbol; `inside-form`: a larger whole form covers it (`F_k` in `CF_k`);
1945
+ * - `explicit`: an `\explain` around exactly it, whose key's `latex` has an
1946
+ * index; `swallowed`: the same, but the key's `latex` has none, so its card
1947
+ * cannot explain the index;
1948
+ * - `inside-wrap`: inside a larger `\explain`, which claims its content;
1949
+ * - `composed`: the base's card plus each slot's own — `explained` only when
1950
+ * the base has a card and every slot holds a symbol with one (`X_{t-1}`);
1951
+ * `unexplained` lists the parts that have none, the base first when
1952
+ * `baseExplained` is false;
1953
+ * - `unresolved`: a letter in it has no card at all, which rule N4 reports.
1954
+ *
1955
+ * `running` lists the running index letters (`i`, `k`, `t`) in its slots that
1956
+ * the page has not declared fixed, with the key of the letter's own card and
1957
+ * of the entry that covers the symbol, for the index-set check.
1958
+ */
1959
+ function classifyIndexed(nodes, context) {
1960
+ const { latex, source, bindings, explicitCalls, glyphs, identifiers, unresolved } =
1961
+ context;
1962
+ const tight = (span) => (span ? unwrapEnclosingGroupSpan(latex, span) : span);
1963
+ const size = (span) => span.end - span.start;
1964
+ const cards = bindings.filter((binding) => binding.match !== 'operator');
1965
+ const isUnresolved = (span) => unresolved.some((item) => contains(span, item));
1966
+ const results = [];
1967
+ const seen = new Set();
1968
+
1969
+ for (const node of nodes) {
1970
+ const whole = tight(sourceSpan(node, source));
1971
+ const core = tight(coreSourceSpan(node, source));
1972
+ const base = tight(sourceSpan(node.base, source));
1973
+ const sub = tight(sourceSpan(node.sub, source));
1974
+ if (!whole || !core || !base || !sub) continue;
1975
+ if (seen.has(`${core.start}:${core.end}`)) continue;
1976
+ seen.add(`${core.start}:${core.end}`);
1977
+
1978
+ const slots = indexSlots(node.sub).map((slot) => ({
1979
+ span: tight(sourceSpan(slot, source)),
1980
+ kind: slotKind(slot, glyphs.fixedSlots),
1981
+ }));
1982
+ const exactly = (span) => sameSpan(span, core) || sameSpan(span, whole);
1983
+
1984
+ let how;
1985
+ let key = null;
1986
+ const wrap = explicitCalls
1987
+ .filter((call) => contains(call, core))
1988
+ .sort((left, right) => size(left) - size(right))[0];
1989
+ const cover = cards
1990
+ .filter(
1991
+ (binding) =>
1992
+ binding.level === 'scope' &&
1993
+ binding.match !== 'base' &&
1994
+ contains(binding, core),
1995
+ )
1996
+ .sort((left, right) => size(left) - size(right))[0];
1997
+ if (wrap && !exactly(wrap)) {
1998
+ how = 'inside-wrap';
1999
+ } else if (wrap) {
2000
+ key = wrap.key;
2001
+ how = entryHasIndex(glyphs.byKey.get(wrap.key)?.notation)
2002
+ ? 'explicit'
2003
+ : 'swallowed';
2004
+ } else if (cover) {
2005
+ key = cover.key;
2006
+ how = !exactly(cover)
2007
+ ? 'inside-form'
2008
+ : cover.match === 'canonical'
2009
+ ? 'whole-form'
2010
+ : cover.match;
2011
+ } else if (isUnresolved(whole)) {
2012
+ how = 'unresolved';
2013
+ } else {
2014
+ how = 'composed';
2015
+ }
2016
+
2017
+ const unexplained = [];
2018
+ let baseExplained = true;
2019
+ if (how === 'composed') {
2020
+ // `\hat{\beta}` has a card when `\beta` does; the hat is rule N22's.
2021
+ if (
2022
+ !cards.some(
2023
+ (binding) => contains(binding, base) || contains(base, binding),
2024
+ )
2025
+ ) {
2026
+ baseExplained = false;
2027
+ unexplained.push(latex.slice(base.start, base.end));
2028
+ }
2029
+ for (const slot of slots) {
2030
+ if (slot.kind === 'empty' || !slot.span) continue;
2031
+ const carded = cards.some((binding) => contains(slot.span, binding));
2032
+ if (!carded) unexplained.push(latex.slice(slot.span.start, slot.span.end));
2033
+ }
2034
+ }
2035
+
2036
+ const running = [];
2037
+ // A swallowed index is already an error; its letters wait for the fix.
2038
+ if (!['inside-wrap', 'derived', 'unresolved', 'swallowed'].includes(how)) {
2039
+ for (const slot of slots) {
2040
+ if (slot.kind === 'named' || !slot.span) continue;
2041
+ for (const identifier of identifiers) {
2042
+ if (identifier.font !== undefined) continue;
2043
+ if (identifier.node.type !== 'mathord') continue;
2044
+ if (!RUNNING_INDEX_LETTER.test(identifier.node.text ?? '')) continue;
2045
+ if (!contains(slot.span, identifier)) continue;
2046
+ const own = cards
2047
+ .filter(
2048
+ (binding) =>
2049
+ contains(binding, identifier) && contains(slot.span, binding),
2050
+ )
2051
+ .sort((left, right) => size(left) - size(right))[0];
2052
+ running.push({
2053
+ letter: identifier.node.text,
2054
+ start: identifier.start,
2055
+ end: identifier.end,
2056
+ key: own?.key ?? null,
2057
+ cover: key,
2058
+ });
2059
+ }
2060
+ }
2061
+ }
2062
+
2063
+ // The form as written, markers dropped: `\mathbf{v}_0`, not
2064
+ // `\explain{v}{\mathbf{v}}_0` or the blanked `{\mathbf{v}}_0`.
2065
+ const shown = widenOverExplainPrefix(core, explicitCalls);
2066
+ results.push({
2067
+ token: stripNotationMarkers(latex.slice(shown.start, core.end)).trim(),
2068
+ start: core.start,
2069
+ end: core.end,
2070
+ base: latex.slice(base.start, base.end),
2071
+ index: latex.slice(sub.start, sub.end),
2072
+ how,
2073
+ key,
2074
+ explained:
2075
+ how === 'composed' ? unexplained.length === 0 : how !== 'swallowed',
2076
+ baseExplained,
2077
+ unexplained,
2078
+ running,
2079
+ });
2080
+ }
2081
+ return results.sort((left, right) => left.start - right.start);
2082
+ }
2083
+
1315
2084
  /**
1316
2085
  * A decoration over an `\explain{key}{…}` call starts at that call's content
1317
2086
  * group in the blanked source; widen it to the call itself, so a marker
@@ -1576,9 +2345,20 @@ function findNestedBareDelimiters(tree) {
1576
2345
  * decorated form no whole form covers is reported in `unresolved`, marked
1577
2346
  * `suspended`.
1578
2347
  *
2348
+ * A symbol with a subscript (rule N23) takes its exact whole form, else the
2349
+ * family whose index has its shape, else — with `options.derive`, which page
2350
+ * math sets (a lesson body, its assessments, labs and diagrams) — a derived
2351
+ * card from an index entry (`{}_0`) and the base's entry; each such card
2352
+ * wraps the whole indexed symbol. `derived`
2353
+ * lists the derived cards used, as `{ key, base, index }`, for the build to
2354
+ * materialize; `indices` says how each indexed symbol is explained (see
2355
+ * `classifyIndexed`). A definition may carry `fixedIndex: true`: its index
2356
+ * (or, on a letter, the letter as an index) is a fixed label, not a running
2357
+ * one, so it is no family.
2358
+ *
1579
2359
  * @param {string} latex
1580
- * @param {Array<{key: string, notation: string}>} definitions
1581
- * @param {{ suspended?: readonly string[] }} [options]
2360
+ * @param {Array<{key: string, notation: string, fixedIndex?: boolean}>} definitions
2361
+ * @param {{ suspended?: readonly string[], derive?: boolean }} [options]
1582
2362
  */
1583
2363
  export function resolveMathGlyphs(latex, definitions, options = {}) {
1584
2364
  if (typeof latex !== 'string') {
@@ -1586,6 +2366,7 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1586
2366
  }
1587
2367
 
1588
2368
  const suspended = new Set(options.suspended ?? []);
2369
+ const derive = options.derive === true;
1589
2370
  const glyphs = buildGlyphIndex(definitions);
1590
2371
  const explicitCalls = findExplainCalls(latex, glyphs.byKey);
1591
2372
  // The source KaTeX actually parses — `\explain{key}{` prefixes blanked to
@@ -1622,7 +2403,7 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1622
2403
  { violations: nestingViolations },
1623
2404
  );
1624
2405
  }
1625
- const { containers, identifiers, scriptSpans, decorations } =
2406
+ const { containers, identifiers, scriptSpans, decorations, indexed } =
1626
2407
  collectSourceStructure(tree, strippedSource);
1627
2408
 
1628
2409
  // A canonical container match can be found by seeing through transparent
@@ -1636,6 +2417,9 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1636
2417
  strippedSource,
1637
2418
  containers,
1638
2419
  glyphs,
2420
+ scriptSpans,
2421
+ derive,
2422
+ explicitCalls,
1639
2423
  ).filter(
1640
2424
  (binding) =>
1641
2425
  !explicitCalls.some(
@@ -1660,8 +2444,8 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1660
2444
  const suspendedDecorations = [];
1661
2445
  const decorationResults = decorations.map((decoration) => {
1662
2446
  const span = widenOverExplainPrefix(decoration, explicitCalls);
1663
- const wholeForm =
1664
- covered.find((binding) => contains(binding, span))?.key ?? null;
2447
+ const cover = covered.find((binding) => contains(binding, span));
2448
+ const wholeForm = cover?.key ?? null;
1665
2449
  let operator = null;
1666
2450
  if (wholeForm === null && suspended.has(decoration.name)) {
1667
2451
  suspendedDecorations.push({ ...decoration, ...span });
@@ -1688,6 +2472,9 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1688
2472
  ...span,
1689
2473
  token: latex.slice(span.start, span.end),
1690
2474
  wholeForm,
2475
+ // An author's `\explain` names the whole form; rule N22 checks that
2476
+ // its key's `latex` carries the decoration.
2477
+ wholeFormExplicit: cover?.level === 'explicit',
1691
2478
  operator,
1692
2479
  };
1693
2480
  });
@@ -1726,6 +2513,19 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1726
2513
  }
1727
2514
  }
1728
2515
 
2516
+ const unresolved = unresolvedIdentifiers(
2517
+ latex,
2518
+ identifiers,
2519
+ bindings,
2520
+ suspendedDecorations,
2521
+ glyphs.indexedBases,
2522
+ );
2523
+ const derived = new Map();
2524
+ for (const binding of bindings) {
2525
+ if (binding.match !== 'derived' || derived.has(binding.key)) continue;
2526
+ derived.set(binding.key, { key: binding.key, ...binding.derived });
2527
+ }
2528
+
1729
2529
  return {
1730
2530
  latex: injectScopeBindings(latex, bindings),
1731
2531
  bindings: bindings.map(({ key, level, match, token }) => ({
@@ -1734,13 +2534,18 @@ export function resolveMathGlyphs(latex, definitions, options = {}) {
1734
2534
  match,
1735
2535
  token,
1736
2536
  })),
1737
- unresolved: unresolvedIdentifiers(
2537
+ unresolved,
2538
+ decorations: decorationResults,
2539
+ indices: classifyIndexed(indexed, {
1738
2540
  latex,
1739
- identifiers,
2541
+ source: strippedSource,
1740
2542
  bindings,
1741
- suspendedDecorations,
1742
- ),
1743
- decorations: decorationResults,
2543
+ explicitCalls,
2544
+ glyphs,
2545
+ identifiers,
2546
+ unresolved,
2547
+ }),
2548
+ derived: [...derived.values()],
1744
2549
  };
1745
2550
  }
1746
2551