explico 0.4.0 → 0.6.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 (150) hide show
  1. package/CHANGELOG.md +382 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +88 -1
  10. package/dist/compiler/course-config.js +35 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +98 -6
  14. package/dist/compiler/manifest.js +154 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-heading-length.d.ts +12 -0
  31. package/dist/reference/gate-heading-length.js +79 -0
  32. package/dist/reference/gate-math.d.ts +32 -5
  33. package/dist/reference/gate-math.js +6 -2
  34. package/dist/reference/gate-prose-notation.d.ts +60 -0
  35. package/dist/reference/gate-prose-notation.js +125 -0
  36. package/dist/reference/keyed-float-validate.d.ts +5 -5
  37. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  38. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  39. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  40. package/dist/reference/knowledge-checks-validate.js +78 -0
  41. package/dist/reference/knowledge-checks.d.mts +25 -0
  42. package/dist/reference/knowledge-checks.mjs +45 -0
  43. package/dist/reference/lab-math-scope.d.ts +9 -1
  44. package/dist/reference/lab-math-scope.js +32 -4
  45. package/dist/reference/math-glyphs.d.mts +42 -0
  46. package/dist/reference/math-glyphs.mjs +548 -40
  47. package/dist/reference/mdx-attributes.d.mts +13 -0
  48. package/dist/reference/mdx-attributes.mjs +38 -0
  49. package/dist/reference/mixed-content.d.ts +17 -0
  50. package/dist/reference/mixed-content.js +29 -0
  51. package/dist/reference/page-notation.d.ts +52 -0
  52. package/dist/reference/page-notation.js +171 -0
  53. package/dist/reference/raw-floats-validate.d.ts +18 -0
  54. package/dist/reference/raw-floats-validate.js +53 -0
  55. package/dist/reference/raw-floats.d.mts +18 -0
  56. package/dist/reference/raw-floats.mjs +129 -0
  57. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  58. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  59. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  60. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  61. package/dist/reference/remark-abbreviations.d.mts +7 -0
  62. package/dist/reference/remark-abbreviations.mjs +174 -0
  63. package/dist/reference/remark-citation.d.mts +5 -1
  64. package/dist/reference/remark-citation.mjs +0 -0
  65. package/dist/reference/remark-memo.d.mts +16 -0
  66. package/dist/reference/remark-memo.mjs +339 -0
  67. package/dist/reference/remark-notation.d.mts +57 -9
  68. package/dist/reference/remark-notation.mjs +354 -42
  69. package/dist/reference/remark-short-note.d.mts +4 -0
  70. package/dist/reference/remark-short-note.mjs +237 -0
  71. package/dist/reference/types.d.ts +16 -1
  72. package/dist/reference/units-vocab.js +4 -0
  73. package/docs/VERSIONING.md +53 -4
  74. package/docs/authoring/README.md +10 -0
  75. package/docs/authoring/agents.md +17 -2
  76. package/docs/authoring/components.md +138 -0
  77. package/docs/authoring/migrating.md +275 -7
  78. package/docs/authoring/notation.md +615 -1
  79. package/docs/authoring/structure-rules.md +69 -0
  80. package/docs/authoring/structure.md +120 -8
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +208 -3
  84. package/src/compiler/cli.ts +29 -2
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +132 -0
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/manifest.ts +281 -22
  89. package/src/components/HoverPanel.astro +45 -4
  90. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  91. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  92. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  93. package/src/components/assessments/AssessmentSet.astro +98 -14
  94. package/src/components/assessments/RenderedContent.tsx +55 -0
  95. package/src/components/assessments/assessment-randomization.ts +16 -0
  96. package/src/components/diagram/Diagram.astro +409 -103
  97. package/src/components/disclosure/hover-preview.ts +202 -0
  98. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  99. package/src/components/examples/CompactExamples.astro +2 -0
  100. package/src/components/figure/Figure.astro +279 -10
  101. package/src/components/memo/Memo.astro +453 -0
  102. package/src/components/memo/ShortNote.astro +223 -0
  103. package/src/components/notation/NotationFormula.astro +18 -1
  104. package/src/components/notation/NotationLayer.astro +260 -83
  105. package/src/components/notation/render-notation-math.ts +39 -2
  106. package/src/components/notation/safe-math-to-react.ts +50 -0
  107. package/src/components/reference/FloatRefLayer.astro +233 -15
  108. package/src/components/starlight/CollapsibleSections.astro +461 -0
  109. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  110. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  111. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  112. package/src/components/starlight/LayoutHeader.astro +50 -0
  113. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  114. package/src/components/starlight/LessonExportActions.astro +436 -0
  115. package/src/components/starlight/LessonFooter.astro +30 -3
  116. package/src/components/starlight/section-parts.ts +31 -0
  117. package/src/components/table/Table.astro +293 -7
  118. package/src/components/table/TableOverflowExpander.astro +489 -0
  119. package/src/content-config.ts +36 -1
  120. package/src/reference/base-abbreviations.mjs +60 -0
  121. package/src/reference/base-operators.mjs +40 -0
  122. package/src/reference/base-roman-labels.mjs +43 -0
  123. package/src/reference/consistency.ts +73 -2
  124. package/src/reference/diagrams-validate.ts +40 -0
  125. package/src/reference/equations.mjs +67 -14
  126. package/src/reference/gate-abbreviations.ts +128 -0
  127. package/src/reference/gate-heading-length.ts +131 -0
  128. package/src/reference/gate-math.ts +32 -7
  129. package/src/reference/gate-prose-notation.ts +224 -0
  130. package/src/reference/keyed-float-validate.ts +5 -5
  131. package/src/reference/keyed-jsx-floats.mjs +50 -25
  132. package/src/reference/knowledge-checks-validate.ts +130 -0
  133. package/src/reference/knowledge-checks.mjs +45 -0
  134. package/src/reference/lab-math-scope.ts +45 -3
  135. package/src/reference/math-glyphs.mjs +548 -40
  136. package/src/reference/mdx-attributes.mjs +38 -0
  137. package/src/reference/mixed-content.ts +69 -0
  138. package/src/reference/page-notation.ts +252 -0
  139. package/src/reference/raw-floats-validate.ts +103 -0
  140. package/src/reference/raw-floats.mjs +129 -0
  141. package/src/reference/rehype-heading-anchors.mjs +107 -1
  142. package/src/reference/rehype-table-header-labels.mjs +152 -0
  143. package/src/reference/remark-abbreviations.mjs +174 -0
  144. package/src/reference/remark-citation.mjs +0 -0
  145. package/src/reference/remark-memo.mjs +339 -0
  146. package/src/reference/remark-notation.mjs +354 -42
  147. package/src/reference/remark-short-note.mjs +237 -0
  148. package/src/reference/types.ts +16 -0
  149. package/src/reference/units-vocab.ts +4 -0
  150. package/src/styles/global.css +996 -50
@@ -1,6 +1,7 @@
1
1
  import katex from 'katex';
2
2
 
3
3
  import { BASE_LIBRARY } from './base-library.mjs';
4
+ import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
4
5
 
5
6
  /**
6
7
  * Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
@@ -181,6 +182,52 @@ function readBracedGroup(value, start) {
181
182
  return undefined;
182
183
  }
183
184
 
185
+ const MARKER_COMMANDS = [EXPLAIN_COMMAND, '\\slot'];
186
+
187
+ /**
188
+ * Strip Explico's own `\explain{key}{latex}` / `\slot{key}{latex}` markers
189
+ * (see `katex-options.mjs`) down to the LaTeX they wrap, recursively. Lesson
190
+ * math — both a notation card's `formula` and an equation written directly
191
+ * in prose — carries these markers inline so every symbol is hoverable; a
192
+ * reader copying the equation out of this system wants the plain math
193
+ * underneath, not internal notation-key plumbing that means nothing to
194
+ * KaTeX, MathJax, or LaTeX anywhere else. Shared by `render-notation-math.ts`
195
+ * (a notation card's copy button) and `remark-notation.mjs` (a numbered
196
+ * display equation's copy button) — kept here, rather than in either, so
197
+ * neither module has to import the other.
198
+ */
199
+ export function stripNotationMarkers(latex) {
200
+ let result = '';
201
+ let cursor = 0;
202
+
203
+ while (cursor < latex.length) {
204
+ const command = MARKER_COMMANDS.find(
205
+ (name) =>
206
+ latex.startsWith(name, cursor) &&
207
+ !isEscaped(latex, cursor) &&
208
+ !/[A-Za-z@]/.test(latex[cursor + name.length] ?? ''),
209
+ );
210
+
211
+ const keyGroup = command
212
+ ? readBracedGroup(latex, skipWhitespace(latex, cursor + command.length))
213
+ : undefined;
214
+ const contentGroup = keyGroup
215
+ ? readBracedGroup(latex, skipWhitespace(latex, keyGroup.end))
216
+ : undefined;
217
+
218
+ if (contentGroup) {
219
+ result += stripNotationMarkers(contentGroup.content);
220
+ cursor = contentGroup.end;
221
+ continue;
222
+ }
223
+
224
+ result += latex[cursor];
225
+ cursor += 1;
226
+ }
227
+
228
+ return result;
229
+ }
230
+
184
231
  /**
185
232
  * Every `\explain{key}{latex}` in the source (the escape hatch). Each key must
186
233
  * already be in the page glyph table. The returned span points at the wrapped
@@ -322,7 +369,24 @@ function sequenceSignature(nodes) {
322
369
 
323
370
  // --- source spans -----------------------------------------------------
324
371
 
325
- function nodeSpan(node) {
372
+ /**
373
+ * KaTeX's lexer swallows trailing insignificant whitespace after a
374
+ * control-word command (`\epsilon`, `\mathrm`, …) into that token's own
375
+ * `loc.end` — including whitespace this resolver introduces itself when it
376
+ * blanks an `\explain{key}{` prefix down to spaces (`sourceWithoutExplainPrefixes`).
377
+ * A symbol's own text never legitimately ends in whitespace, so any span read
378
+ * off `loc` is trimmed back to its last non-space character before a caller
379
+ * treats it as the symbol's real boundary — otherwise a bare symbol sitting
380
+ * right before an `\explain{…}{…}` call gets a span that reaches past itself
381
+ * and into that call's own command text.
382
+ */
383
+ function trimTrailingWhitespace(latex, start, end) {
384
+ let trimmed = end;
385
+ while (trimmed > start && /\s/.test(latex[trimmed - 1])) trimmed -= 1;
386
+ return trimmed;
387
+ }
388
+
389
+ function nodeSpan(node, latex) {
326
390
  let start = Number.POSITIVE_INFINITY;
327
391
  let end = Number.NEGATIVE_INFINITY;
328
392
  const visited = new Set();
@@ -347,13 +411,12 @@ function nodeSpan(node) {
347
411
  }
348
412
 
349
413
  visit(node);
350
- return Number.isFinite(start) && Number.isFinite(end)
351
- ? { start, end }
352
- : undefined;
414
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
415
+ return { start, end: trimTrailingWhitespace(latex, start, end) };
353
416
  }
354
417
 
355
- function sequenceSpan(nodes) {
356
- const spans = nodes.map(nodeSpan).filter(Boolean);
418
+ function sequenceSpan(nodes, latex) {
419
+ const spans = nodes.map((node) => nodeSpan(node, latex)).filter(Boolean);
357
420
  if (spans.length === 0) return undefined;
358
421
  return {
359
422
  start: Math.min(...spans.map((span) => span.start)),
@@ -381,6 +444,8 @@ const contains = (outer, inner) =>
381
444
  outer.start <= inner.start && inner.end <= outer.end;
382
445
  const sameSpan = (left, right) =>
383
446
  left.start === right.start && left.end === right.end;
447
+ const overlaps = (left, right) =>
448
+ left.start < right.end && right.start < left.end;
384
449
 
385
450
  /**
386
451
  * Walk the parse tree once, collecting: every maximal run of sibling nodes
@@ -388,7 +453,7 @@ const sameSpan = (left, right) =>
388
453
  * its source span, and the spans of every sub/superscript (so a symbol nested
389
454
  * in a script can still be marked).
390
455
  */
391
- function collectSourceStructure(tree) {
456
+ function collectSourceStructure(tree, latex) {
392
457
  const containers = [];
393
458
  const identifiers = [];
394
459
  const scriptSpans = [];
@@ -398,7 +463,7 @@ function collectSourceStructure(tree) {
398
463
  function addContainer(nodes) {
399
464
  const usable = nodes.filter(isNode);
400
465
  if (usable.length === 0) return;
401
- const span = sequenceSpan(usable);
466
+ const span = sequenceSpan(usable, latex);
402
467
  if (!span) return;
403
468
  const identity = `${span.start}:${span.end}:${usable.length}:${sequenceSignature(usable)}`;
404
469
  if (seenContainers.has(identity)) return;
@@ -423,7 +488,7 @@ function collectSourceStructure(tree) {
423
488
  IGNORED_MATH_TOKENS.has(fontTokenText(value));
424
489
 
425
490
  if (!ignored && isIdentifierNode(value)) {
426
- const span = nodeSpan(value);
491
+ const span = nodeSpan(value, latex);
427
492
  if (span && !seenIdentifiers.has(`${span.start}:${span.end}`)) {
428
493
  seenIdentifiers.add(`${span.start}:${span.end}`);
429
494
  identifiers.push({
@@ -436,7 +501,7 @@ function collectSourceStructure(tree) {
436
501
 
437
502
  if (value.type === 'supsub') {
438
503
  for (const script of [value.sub, value.sup]) {
439
- const span = nodeSpan(script);
504
+ const span = nodeSpan(script, latex);
440
505
  if (span) scriptSpans.push(span);
441
506
  }
442
507
  }
@@ -485,19 +550,60 @@ function canonicalBaseSignature(tree) {
485
550
  return identifierBaseSignature(head);
486
551
  }
487
552
 
553
+ /**
554
+ * When a span sits exactly at the payload of an `\explain{key}{…}` / `\slot{key}{…}`
555
+ * marker — its whole content, brace to brace — peel the marker off, mirroring
556
+ * `stripNotationMarkers`. Signature matching sees straight through ordgroup
557
+ * *and* an already-explicit call (both are transparent wrapping for structural
558
+ * comparison), so a canonical container match can legitimately resolve to a
559
+ * span that, in the raw source, is entirely one explicit call; without this,
560
+ * `unwrapEnclosingGroupSpan` stops at the call's leading `\`, leaving a span
561
+ * that swallows the whole `\explain{key}{` command text as if it were plain
562
+ * LaTeX (see `notation.md`'s aligned-block/adjacency traps).
563
+ */
564
+ function unwrapMarkerSpan(latex, span) {
565
+ const { start, end } = span;
566
+ const command = MARKER_COMMANDS.find(
567
+ (name) =>
568
+ latex.startsWith(name, start) &&
569
+ !isEscaped(latex, start) &&
570
+ !/[A-Za-z@]/.test(latex[start + name.length] ?? ''),
571
+ );
572
+ if (!command) return undefined;
573
+
574
+ const keyGroup = readBracedGroup(latex, skipWhitespace(latex, start + command.length));
575
+ const contentGroup = keyGroup
576
+ ? readBracedGroup(latex, skipWhitespace(latex, keyGroup.end))
577
+ : undefined;
578
+ if (!contentGroup || contentGroup.end !== end) return undefined;
579
+
580
+ return { start: contentGroup.contentStart, end: contentGroup.contentEnd };
581
+ }
582
+
488
583
  /**
489
584
  * KaTeX puts source braces inside an ordgroup node's `loc`, but signatures
490
- * treat ordgroups as transparent. Peel a fully-enclosing `{…}` off a matched
491
- * span so `\frac{j}{m}` marks as `\frac{\explain{…}{j}}{…}`, not
492
- * `\frac\explain{…}{{j}}…`.
585
+ * treat ordgroups as transparent. Peel a fully-enclosing `{…}` (or an
586
+ * already-explicit `\explain{key}{…}`/`\slot{key}{…}` marker, {@link
587
+ * unwrapMarkerSpan}) off a matched span so `\frac{j}{m}` marks as
588
+ * `\frac{\explain{…}{j}}{…}`, not `\frac\explain{…}{{j}}…`.
493
589
  */
494
590
  function unwrapEnclosingGroupSpan(latex, span) {
495
591
  let { start, end } = span;
496
- while (latex[start] === '{') {
497
- const group = readBracedGroup(latex, start);
498
- if (!group || group.end !== end) break;
499
- start = group.contentStart;
500
- end = group.contentEnd;
592
+ for (;;) {
593
+ if (latex[start] === '{') {
594
+ const group = readBracedGroup(latex, start);
595
+ if (group && group.end === end) {
596
+ start = group.contentStart;
597
+ end = group.contentEnd;
598
+ continue;
599
+ }
600
+ }
601
+ const marker = unwrapMarkerSpan(latex, { start, end });
602
+ if (marker) {
603
+ ({ start, end } = marker);
604
+ continue;
605
+ }
606
+ break;
501
607
  }
502
608
  return { start, end };
503
609
  }
@@ -506,19 +612,26 @@ const FONT_WRAPPER_OPEN =
506
612
  /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
507
613
 
508
614
  /**
509
- * KaTeX puts no `loc` on a `\mathbb{…}` font node, so a span derived from one
510
- * begins at the inner letter. When a matched span sits exactly inside a
511
- * single-letter font command, widen it to wrap the whole `\mathbb{E}` token so
512
- * the injected `\explain{…}{…}` stays brace-balanced.
615
+ * KaTeX puts no `loc` on a font node (`\mathbb{E}`, `\mathrm{SST}`, …), so a
616
+ * span derived from one begins at its inner content, not its own command
617
+ * text. When a matched span sits exactly inside a font command's braces —
618
+ * covering that group's entire content, of any length — widen it to wrap the
619
+ * whole token (`\mathbb{E}`, `\mathrm{SST}`) so the injected `\explain{…}{…}`
620
+ * stays brace-balanced. (Originally restricted to a single-letter body —
621
+ * `\mathbb{E}` — which left a multi-letter roman label like `\mathrm{SST}`
622
+ * wrapped as `\mathrm{\explain{…}{SST}}` instead of the correct
623
+ * `\explain{…}{\mathrm{SST}}`.)
513
624
  */
514
625
  function expandFontWrapperSpan(latex, span) {
515
626
  const open = latex.slice(0, span.start).match(FONT_WRAPPER_OPEN);
516
627
  if (!open) return span;
517
628
  const group = readBracedGroup(latex, span.start - 1);
518
- if (!group || group.contentEnd - group.contentStart !== 1) return span;
629
+ if (!group || group.contentStart !== span.start || group.contentEnd !== span.end) {
630
+ return span;
631
+ }
519
632
  return {
520
633
  start: span.start - open[0].length,
521
- end: Math.max(span.end, group.end),
634
+ end: group.end,
522
635
  };
523
636
  }
524
637
 
@@ -532,6 +645,33 @@ function isSafeScriptNesting(inner, outer, scriptSpans) {
532
645
 
533
646
  // --- glyph table + matching -----------------------------------------
534
647
 
648
+ /**
649
+ * Whether a definition's own canonical LaTeX has content worth indexing for
650
+ * bare-occurrence matching — as opposed to a degenerate form (bare punctuation
651
+ * or a relation like `=`) that would over-match unrelated page text if
652
+ * registered. This is deliberately *not* `collectSourceStructure`'s
653
+ * `identifiers` list: that list is built for scanning *page* math, where a
654
+ * `\mathrm{…}` body is correctly treated as opaque (never a bindable atom on
655
+ * its own, [N3](../../docs/authoring/notation.md#n3)) — but reusing it here to
656
+ * gate the *definition's own* registration wrongly excluded every glyph whose
657
+ * whole declared form is a roman label (`\mathrm{SSR}`, `\mathrm{SST}`, …):
658
+ * such a definition produced zero `identifiers`, so it never entered
659
+ * `bySignature`/`byBase` and could never auto-resolve at a bare occurrence,
660
+ * regardless of where that occurrence sat on the page. A named operator
661
+ * (`\operatorname{SE}`) is real content the same way; a bare `=` or `+` is not.
662
+ */
663
+ function hasBindableGlyph(value) {
664
+ if (Array.isArray(value)) return value.some(hasBindableGlyph);
665
+ if (!isNode(value)) return false;
666
+ if (isIdentifierNode(value)) return true;
667
+ if (value.type === 'font' && FONT_COMMANDS.has(value.font)) return true;
668
+ if (value.type === 'op' || value.type === 'operatorname') return true;
669
+ for (const key of SOURCE_CHILD_KEYS) {
670
+ if (hasBindableGlyph(value[key])) return true;
671
+ }
672
+ return false;
673
+ }
674
+
535
675
  /**
536
676
  * Index the page glyph table by structural signature. Because one glyph carries
537
677
  * one meaning per page, each signature maps to exactly one key; a genuine
@@ -586,8 +726,7 @@ function buildGlyphIndex(definitions) {
586
726
  byKey.set(key, entry);
587
727
 
588
728
  const signature = sequenceSignature(tree);
589
- const hasIdentifier = collectSourceStructure(tree).identifiers.length > 0;
590
- if (hasIdentifier && tree.length > 0) {
729
+ if (hasBindableGlyph(tree) && tree.length > 0) {
591
730
  const clash = bySignature.get(signature);
592
731
  if (clash && clash !== key) {
593
732
  fail(
@@ -614,7 +753,7 @@ function buildGlyphIndex(definitions) {
614
753
  }
615
754
 
616
755
  /** Bind whole canonical forms: greedy longest match, left to right, per run. */
617
- function matchCanonicalForms(latex, containers, glyphs) {
756
+ function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
618
757
  const bindings = [];
619
758
  const claimed = [];
620
759
 
@@ -630,7 +769,7 @@ function matchCanonicalForms(latex, containers, glyphs) {
630
769
  const nodes = container.slice(cursor, cursor + length);
631
770
  const key = glyphs.bySignature.get(sequenceSignature(nodes));
632
771
  if (key === undefined) continue;
633
- const rawSpan = sequenceSpan(nodes);
772
+ const rawSpan = sequenceSpan(nodes, strippedSource);
634
773
  if (!rawSpan) continue;
635
774
  const span = expandFontWrapperSpan(
636
775
  latex,
@@ -768,6 +907,243 @@ function unresolvedIdentifiers(latex, identifiers, bindings) {
768
907
  .sort((left, right) => left.start - right.start || left.end - right.end);
769
908
  }
770
909
 
910
+ // --- N13: nonlinear functions take a parenthesized argument --------------
911
+
912
+ // Opening delimiters that visibly bracket a function's argument. A bare `{`
913
+ // (KaTeX `ordgroup`) does not count — it renders no visible mark at all, so
914
+ // `\sin{x}` reads identically to the bare `\sin x` this rule forbids.
915
+ const FUNCTION_ARG_OPENERS = new Set(['(', '[', '\\{']);
916
+
917
+ // Node types that plausibly start a bare (unparenthesized) argument right
918
+ // after a function name — a letter, a digit, an invisible `{…}` group,
919
+ // another function, an exponent/subscript structure, a fraction, a root, or
920
+ // a styled/labelled run (`\mathrm{…}`, `\color{…}{…}`, …). Anything else
921
+ // (a relation, a binary operator, closing punctuation, or nothing at all) is
922
+ // left alone: the function has no argument in this position to complain
923
+ // about.
924
+ const BARE_ARGUMENT_NODE_TYPES = new Set([
925
+ 'mathord',
926
+ 'textord',
927
+ 'ordgroup',
928
+ 'op',
929
+ 'operatorname',
930
+ 'supsub',
931
+ 'genfrac',
932
+ 'sqrt',
933
+ 'font',
934
+ 'accent',
935
+ 'accentUnder',
936
+ 'overline',
937
+ 'underline',
938
+ 'phantom',
939
+ 'raisebox',
940
+ 'href',
941
+ 'color',
942
+ 'styling',
943
+ 'mclass',
944
+ ]);
945
+
946
+ /** `\,`, `\;`, `\!`, `~`, … — spacing that sits between a function and its
947
+ * argument without being the argument itself. */
948
+ function isSpacingNode(node) {
949
+ return isNode(node) && node.type === 'kern';
950
+ }
951
+
952
+ /** True when `node` is a visible parenthesis/bracket group opening here. */
953
+ function opensFunctionArgument(node) {
954
+ if (!isNode(node)) return false;
955
+ if (node.type === 'leftright') return true;
956
+ return (
957
+ node.type === 'atom' &&
958
+ node.family === 'open' &&
959
+ FUNCTION_ARG_OPENERS.has(node.text)
960
+ );
961
+ }
962
+
963
+ /**
964
+ * The bare command name (`sin`, `log`, …) when `node` is a call to one of
965
+ * {@link PARENTHESIZED_FUNCTIONS} — either the `op` node itself, or one
966
+ * wrapped in a `supsub` for `\sin^2 x` / `\log_2 x`.
967
+ */
968
+ function parenthesizedFunctionName(node) {
969
+ if (!isNode(node)) return null;
970
+ if (node.type === 'op' && typeof node.name === 'string') {
971
+ const name = node.name.replace(/^\\/, '');
972
+ return PARENTHESIZED_FUNCTIONS.has(name) ? name : null;
973
+ }
974
+ if (node.type === 'supsub') return parenthesizedFunctionName(node.base);
975
+ return null;
976
+ }
977
+
978
+ /**
979
+ * Every `\sin x`-shaped violation of N13 in one already-parsed KaTeX tree: a
980
+ * {@link PARENTHESIZED_FUNCTIONS} name immediately followed, in its own
981
+ * sibling run, by something that reads as a bare argument rather than a
982
+ * parenthesized (or bracketed) one. Mirrors `collectOperators`'s tree walk,
983
+ * but needs sibling context a plain node visitor doesn't carry, so it walks
984
+ * array bodies explicitly instead of reusing that helper.
985
+ *
986
+ * @returns {string[]} one function name per violation, in document order.
987
+ */
988
+ function findUnparenthesizedFunctionCalls(tree) {
989
+ const violations = [];
990
+
991
+ const visitSiblings = (nodes) => {
992
+ for (let i = 0; i < nodes.length; i++) {
993
+ const name = parenthesizedFunctionName(nodes[i]);
994
+ if (!name) continue;
995
+ let j = i + 1;
996
+ while (j < nodes.length && isSpacingNode(nodes[j])) j += 1;
997
+ const next = j < nodes.length ? nodes[j] : undefined;
998
+ if (opensFunctionArgument(next)) continue;
999
+ if (isNode(next) && BARE_ARGUMENT_NODE_TYPES.has(next.type)) {
1000
+ violations.push(name);
1001
+ }
1002
+ }
1003
+ };
1004
+
1005
+ const visit = (value) => {
1006
+ if (Array.isArray(value)) {
1007
+ visitSiblings(value);
1008
+ for (const child of value) visit(child);
1009
+ return;
1010
+ }
1011
+ if (!isNode(value)) return;
1012
+ for (const key of Object.keys(value)) {
1013
+ if (key === 'loc') continue;
1014
+ visit(value[key]);
1015
+ }
1016
+ };
1017
+
1018
+ visit(tree);
1019
+ return violations;
1020
+ }
1021
+
1022
+ // --- N15: nested visible delimiters need \left/\right sizing -------------
1023
+
1024
+ // The four visible delimiter glyphs this rule regulates, keyed by their
1025
+ // KaTeX source text. `{`/`}` bare is invisible LaTeX grouping, not a visible
1026
+ // mark, so only the escaped, visible `\{`/`\}` counts — the same distinction
1027
+ // `FUNCTION_ARG_OPENERS` above already draws.
1028
+ const NESTED_DELIM_OPEN = new Map([
1029
+ ['(', 'paren'],
1030
+ ['[', 'bracket'],
1031
+ ['\\{', 'brace'],
1032
+ ]);
1033
+ const NESTED_DELIM_CLOSE = new Map([
1034
+ [')', 'paren'],
1035
+ [']', 'bracket'],
1036
+ ['\\}', 'brace'],
1037
+ ]);
1038
+ // Recognised on either side of a `\left…\right` pair so a mixed pair like
1039
+ // `\left.\right|` (an evaluation bar with an invisible left side) still
1040
+ // counts as already-sized.
1041
+ const NESTED_DELIM_CHARS = new Set(['(', ')', '[', ']', '\\{', '\\}', '|']);
1042
+
1043
+ /**
1044
+ * Every N15 violation in one already-parsed KaTeX tree: a bare `(`, `[`,
1045
+ * `\{`, or `|` that either sits inside another of these four delimiters, or
1046
+ * itself contains one, without either side switching to the `\left`/`\right`
1047
+ * auto-sizing form. A `\left…\right` pair is never itself reported — it is
1048
+ * the fix the rule asks for — but a bare delimiter one level inside it still
1049
+ * needs its own `\left`/`\right`, since sizing one level of a nest does not
1050
+ * size the rest.
1051
+ *
1052
+ * Mirrors {@link findUnparenthesizedFunctionCalls}'s tree walk, but needs one
1053
+ * stack carried across the whole recursion (nesting here is "does an
1054
+ * ancestor node in the tree still have an open delimiter", not sibling
1055
+ * adjacency), so it walks the tree itself rather than reusing a sibling-only
1056
+ * visitor.
1057
+ *
1058
+ * `|` has no distinct open/close glyph, so a bare `|` toggles: the first one
1059
+ * at the current depth opens a pipe scope, the next one closes it — matching
1060
+ * how `\lvert x \rvert`-less absolute value is actually written in this
1061
+ * corpus (N9's `\lvert…\rvert` guidance is about *outside*-math prose; a bare
1062
+ * `|` pair is unremarkable inside `$…$` on its own).
1063
+ *
1064
+ * @returns {{ type: string, char: string }[]} one entry per delimiter
1065
+ * instance that must become `\left`/`\right`, in document order.
1066
+ */
1067
+ function findNestedBareDelimiters(tree) {
1068
+ const stack = [];
1069
+ const violations = [];
1070
+
1071
+ const flag = (entry) => {
1072
+ if (entry.reported) return;
1073
+ entry.reported = true;
1074
+ violations.push({ type: entry.type, char: entry.char });
1075
+ };
1076
+
1077
+ const pushOpen = (type, char, bare) => {
1078
+ const entry = { type, char, bare, reported: false };
1079
+ if (stack.length > 0) {
1080
+ const parent = stack[stack.length - 1];
1081
+ // The parent already contains a nested delimiter: if it is bare, it
1082
+ // now needs sizing too, regardless of whether this new one is sized.
1083
+ if (parent.bare) flag(parent);
1084
+ // This one is nested inside another delimiter's span: if it is bare,
1085
+ // it needs sizing, regardless of whether the parent is already sized.
1086
+ if (bare) flag(entry);
1087
+ }
1088
+ stack.push(entry);
1089
+ };
1090
+
1091
+ const visit = (node) => {
1092
+ if (Array.isArray(node)) {
1093
+ for (const child of node) visit(child);
1094
+ return;
1095
+ }
1096
+ if (!isNode(node)) return;
1097
+
1098
+ // A visible delimiter only counts in math mode — the same character
1099
+ // inside `\text{…}` is display text, not a sizing delimiter (it parses
1100
+ // to a plain `textord` in text mode, never reaching this branch).
1101
+ if (node.type === 'atom' && node.mode === 'math') {
1102
+ if (node.family === 'open' && NESTED_DELIM_OPEN.has(node.text)) {
1103
+ pushOpen(NESTED_DELIM_OPEN.get(node.text), node.text, true);
1104
+ return;
1105
+ }
1106
+ if (node.family === 'close' && NESTED_DELIM_CLOSE.has(node.text)) {
1107
+ stack.pop();
1108
+ return;
1109
+ }
1110
+ }
1111
+ if (node.type === 'textord' && node.mode === 'math' && node.text === '|') {
1112
+ const top = stack[stack.length - 1];
1113
+ if (top && top.type === 'pipe') {
1114
+ stack.pop();
1115
+ } else {
1116
+ pushOpen('pipe', '|', true);
1117
+ }
1118
+ return;
1119
+ }
1120
+ if (node.type === 'leftright') {
1121
+ // Already sized. Still an enclosing scope for whatever nests inside it
1122
+ // (a bare delimiter one level in still needs its own sizing), but only
1123
+ // when a tracked glyph is actually on one of its two sides — an
1124
+ // unrelated pair like `\left\langle…\right\rangle` is transparent to
1125
+ // this rule, matching the "with each other" scope of the four glyphs
1126
+ // it names.
1127
+ if (NESTED_DELIM_CHARS.has(node.left) || NESTED_DELIM_CHARS.has(node.right)) {
1128
+ pushOpen('sized', node.left, false);
1129
+ visit(node.body);
1130
+ stack.pop();
1131
+ } else {
1132
+ visit(node.body);
1133
+ }
1134
+ return;
1135
+ }
1136
+
1137
+ for (const key of Object.keys(node)) {
1138
+ if (key === 'loc') continue;
1139
+ visit(node[key]);
1140
+ }
1141
+ };
1142
+
1143
+ visit(tree);
1144
+ return violations;
1145
+ }
1146
+
771
1147
  /**
772
1148
  * Resolve and annotate one KaTeX expression against a page's flat glyph table.
773
1149
  * Source offsets in the result always refer to the original, unmodified LaTeX.
@@ -782,13 +1158,62 @@ export function resolveMathGlyphs(latex, definitions) {
782
1158
 
783
1159
  const glyphs = buildGlyphIndex(definitions);
784
1160
  const explicitCalls = findExplainCalls(latex, glyphs.byKey);
785
- const tree = parseMath(
786
- sourceWithoutExplainPrefixes(latex, explicitCalls),
787
- 'lesson math',
1161
+ // The source KaTeX actually parses — `\explain{key}{` prefixes blanked to
1162
+ // spaces, same length as `latex`. Every `.loc` offset below comes from
1163
+ // parsing *this* string, so it (not `latex`) is what trailing-whitespace
1164
+ // trimming must check: a blanked prefix reads as spaces here but as real
1165
+ // command text in `latex`.
1166
+ const strippedSource = sourceWithoutExplainPrefixes(latex, explicitCalls);
1167
+ const tree = parseMath(strippedSource, 'lesson math');
1168
+
1169
+ const parenViolations = findUnparenthesizedFunctionCalls(tree);
1170
+ if (parenViolations.length > 0) {
1171
+ const names = [...new Set(parenViolations)].map((name) => `\\${name}`).join(', ');
1172
+ fail(
1173
+ 'unparenthesized-function',
1174
+ `${names} used without a parenthesized argument in ${JSON.stringify(latex)}. ` +
1175
+ `A nonlinear function always takes a parenthesized argument, even a single ` +
1176
+ `symbol — write \\sin(x), not \\sin x (rule N13).`,
1177
+ { violations: parenViolations },
1178
+ );
1179
+ }
1180
+ const nestingViolations = findNestedBareDelimiters(tree);
1181
+ if (nestingViolations.length > 0) {
1182
+ const shown = [...new Set(nestingViolations.map(({ char }) => char))]
1183
+ .map((char) => `\\left${char}`)
1184
+ .join(', ');
1185
+ fail(
1186
+ 'nested-bare-delimiter',
1187
+ `${shown} nested with another (, [, {, or | in ${JSON.stringify(latex)}. ` +
1188
+ `(, [, { and | can be used bare only when not nested — once one of ` +
1189
+ `them is nested inside another, every level of the nest must use the ` +
1190
+ `sizing form (\\left(/\\right), \\left[/\\right], \\left\\{/\\right\\}, ` +
1191
+ `\\left|/\\right|) instead of the bare character (rule N15).`,
1192
+ { violations: nestingViolations },
1193
+ );
1194
+ }
1195
+ const { containers, identifiers, scriptSpans } = collectSourceStructure(
1196
+ tree,
1197
+ strippedSource,
788
1198
  );
789
- const { containers, identifiers, scriptSpans } = collectSourceStructure(tree);
790
1199
 
791
- const canonical = matchCanonicalForms(latex, containers, glyphs);
1200
+ // A canonical container match can be found by seeing through transparent
1201
+ // ordgroup/font wrapping into a structural sibling of an explicit
1202
+ // `\explain{…}{…}` call — if it merely touches that call's span without
1203
+ // being fully nested inside it, the match is spurious (the explicit call
1204
+ // already accounts for that content) and injecting it would double-wrap or
1205
+ // straddle the call's own braces (N-partial-wrap; see `notation.md`).
1206
+ const canonical = matchCanonicalForms(
1207
+ latex,
1208
+ strippedSource,
1209
+ containers,
1210
+ glyphs,
1211
+ ).filter(
1212
+ (binding) =>
1213
+ !explicitCalls.some(
1214
+ (call) => overlaps(binding, call) && !contains(call, binding),
1215
+ ),
1216
+ );
792
1217
  const covered = [...canonical, ...explicitCalls];
793
1218
  const base = matchBaseGlyphs(
794
1219
  latex,
@@ -798,12 +1223,27 @@ export function resolveMathGlyphs(latex, definitions) {
798
1223
  scriptSpans,
799
1224
  );
800
1225
 
801
- const bindings = [...canonical, ...base, ...explicitCalls].sort(
802
- (left, right) =>
803
- left.start - right.start ||
804
- right.end - left.end ||
805
- (left.level === 'explicit' ? -1 : 1),
806
- );
1226
+ const bindings = [...canonical, ...base, ...explicitCalls]
1227
+ .sort(
1228
+ (left, right) =>
1229
+ left.start - right.start ||
1230
+ right.end - left.end ||
1231
+ (left.level === 'explicit' ? -1 : 1),
1232
+ )
1233
+ // An author-written `\explain{key}{…}` that also happens to match its own
1234
+ // canonical or base glyph form resolves twice at the identical span: once
1235
+ // as the explicit call, once as the auto-match. Both carry the same key,
1236
+ // so the ambiguous-span check below (which only objects when the keys
1237
+ // *differ*) lets both through, and `injectScopeBindings` would then wrap
1238
+ // the span in `\explain{key}{…}` a second time, nested around itself.
1239
+ // Keep only the first of a same-span-same-key run; the sort above already
1240
+ // orders the explicit binding ahead of an auto-matched one at that span.
1241
+ .filter(
1242
+ (binding, index, sorted) =>
1243
+ index === 0 ||
1244
+ !sameSpan(sorted[index - 1], binding) ||
1245
+ sorted[index - 1].key !== binding.key,
1246
+ );
807
1247
 
808
1248
  for (const [i, left] of bindings.entries()) {
809
1249
  for (const right of bindings.slice(i + 1)) {
@@ -842,6 +1282,14 @@ export function resolveMathGlyphs(latex, definitions) {
842
1282
  * Used by the `operator-explained` consistency check (rule N8); the glyph
843
1283
  * resolver still skips operator bodies whole.
844
1284
  *
1285
+ * The span handed in is raw lesson source, so it may still carry
1286
+ * `\explain{key}{…}` / `\slot{key}{…}` markers around other symbols in the
1287
+ * same expression — `\explain` is not valid KaTeX, so parsing it unstripped
1288
+ * throws and this function used to swallow that as "no operators here" for
1289
+ * the whole span, silently hiding any `\operatorname{…}` alongside the
1290
+ * marker. `stripNotationMarkers` removes just the marker wrapper first so
1291
+ * the scan sees plain, parseable math.
1292
+ *
845
1293
  * @param {string} latex
846
1294
  * @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
847
1295
  */
@@ -850,7 +1298,7 @@ export function collectOperators(latex) {
850
1298
 
851
1299
  let tree;
852
1300
  try {
853
- tree = parseMath(latex, 'operator scan');
1301
+ tree = parseMath(stripNotationMarkers(latex), 'operator scan');
854
1302
  } catch (error) {
855
1303
  // A span that will not parse is the math gate's problem, not this check's.
856
1304
  // A KaTeX version-pin failure is nobody's to swallow — let it through.
@@ -920,3 +1368,63 @@ export function collectOperators(latex) {
920
1368
  visit(tree);
921
1369
  return operators;
922
1370
  }
1371
+
1372
+ // --- named roman labels ----------------------------------------------
1373
+
1374
+ /**
1375
+ * Every multi-letter `\mathrm{…}` label in one KaTeX expression (`KE`, `PV`,
1376
+ * `opt`) — the upright-label counterpart to {@link collectOperators}. A
1377
+ * single-letter body (`\mathrm{d}`, the N7 differential) is not collected:
1378
+ * that one is a fixed typographic operator, not a named quantity a reader
1379
+ * would need explained.
1380
+ *
1381
+ * Pure read: nothing here rewrites the source. Used by the
1382
+ * `roman-label-explained` consistency check (rule N10); the glyph resolver
1383
+ * still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
1384
+ *
1385
+ * Same `stripNotationMarkers` preprocessing as {@link collectOperators}, and
1386
+ * for the same reason: an unstripped `\explain{…}{…}` elsewhere in the span
1387
+ * fails the raw KaTeX parse and used to silently drop every label in it.
1388
+ *
1389
+ * @param {string} latex
1390
+ * @returns {Array<{ name: string }>}
1391
+ */
1392
+ export function collectRomanLabels(latex) {
1393
+ if (typeof latex !== 'string' || latex.trim() === '') return [];
1394
+
1395
+ let tree;
1396
+ try {
1397
+ tree = parseMath(stripNotationMarkers(latex), 'roman-label scan');
1398
+ } catch (error) {
1399
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1400
+ return [];
1401
+ }
1402
+ throw error;
1403
+ }
1404
+
1405
+ const seen = new Set();
1406
+ const labels = [];
1407
+ const visit = (value) => {
1408
+ if (Array.isArray(value)) {
1409
+ for (const child of value) visit(child);
1410
+ return;
1411
+ }
1412
+ if (!isNode(value)) return;
1413
+
1414
+ if (value.type === 'font' && value.font === ROMAN_LABEL_FONT) {
1415
+ const name = fontBodyText(value.body);
1416
+ if (typeof name === 'string' && name.length >= 2 && !seen.has(name)) {
1417
+ seen.add(name);
1418
+ labels.push({ name });
1419
+ }
1420
+ }
1421
+
1422
+ for (const key of Object.keys(value)) {
1423
+ if (key === 'loc') continue;
1424
+ visit(value[key]);
1425
+ }
1426
+ };
1427
+
1428
+ visit(tree);
1429
+ return labels;
1430
+ }