explico 0.5.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.
@@ -369,7 +369,24 @@ function sequenceSignature(nodes) {
369
369
 
370
370
  // --- source spans -----------------------------------------------------
371
371
 
372
- 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) {
373
390
  let start = Number.POSITIVE_INFINITY;
374
391
  let end = Number.NEGATIVE_INFINITY;
375
392
  const visited = new Set();
@@ -394,13 +411,12 @@ function nodeSpan(node) {
394
411
  }
395
412
 
396
413
  visit(node);
397
- return Number.isFinite(start) && Number.isFinite(end)
398
- ? { start, end }
399
- : undefined;
414
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
415
+ return { start, end: trimTrailingWhitespace(latex, start, end) };
400
416
  }
401
417
 
402
- function sequenceSpan(nodes) {
403
- const spans = nodes.map(nodeSpan).filter(Boolean);
418
+ function sequenceSpan(nodes, latex) {
419
+ const spans = nodes.map((node) => nodeSpan(node, latex)).filter(Boolean);
404
420
  if (spans.length === 0) return undefined;
405
421
  return {
406
422
  start: Math.min(...spans.map((span) => span.start)),
@@ -428,6 +444,8 @@ const contains = (outer, inner) =>
428
444
  outer.start <= inner.start && inner.end <= outer.end;
429
445
  const sameSpan = (left, right) =>
430
446
  left.start === right.start && left.end === right.end;
447
+ const overlaps = (left, right) =>
448
+ left.start < right.end && right.start < left.end;
431
449
 
432
450
  /**
433
451
  * Walk the parse tree once, collecting: every maximal run of sibling nodes
@@ -435,7 +453,7 @@ const sameSpan = (left, right) =>
435
453
  * its source span, and the spans of every sub/superscript (so a symbol nested
436
454
  * in a script can still be marked).
437
455
  */
438
- function collectSourceStructure(tree) {
456
+ function collectSourceStructure(tree, latex) {
439
457
  const containers = [];
440
458
  const identifiers = [];
441
459
  const scriptSpans = [];
@@ -445,7 +463,7 @@ function collectSourceStructure(tree) {
445
463
  function addContainer(nodes) {
446
464
  const usable = nodes.filter(isNode);
447
465
  if (usable.length === 0) return;
448
- const span = sequenceSpan(usable);
466
+ const span = sequenceSpan(usable, latex);
449
467
  if (!span) return;
450
468
  const identity = `${span.start}:${span.end}:${usable.length}:${sequenceSignature(usable)}`;
451
469
  if (seenContainers.has(identity)) return;
@@ -470,7 +488,7 @@ function collectSourceStructure(tree) {
470
488
  IGNORED_MATH_TOKENS.has(fontTokenText(value));
471
489
 
472
490
  if (!ignored && isIdentifierNode(value)) {
473
- const span = nodeSpan(value);
491
+ const span = nodeSpan(value, latex);
474
492
  if (span && !seenIdentifiers.has(`${span.start}:${span.end}`)) {
475
493
  seenIdentifiers.add(`${span.start}:${span.end}`);
476
494
  identifiers.push({
@@ -483,7 +501,7 @@ function collectSourceStructure(tree) {
483
501
 
484
502
  if (value.type === 'supsub') {
485
503
  for (const script of [value.sub, value.sup]) {
486
- const span = nodeSpan(script);
504
+ const span = nodeSpan(script, latex);
487
505
  if (span) scriptSpans.push(span);
488
506
  }
489
507
  }
@@ -532,19 +550,60 @@ function canonicalBaseSignature(tree) {
532
550
  return identifierBaseSignature(head);
533
551
  }
534
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
+
535
583
  /**
536
584
  * KaTeX puts source braces inside an ordgroup node's `loc`, but signatures
537
- * treat ordgroups as transparent. Peel a fully-enclosing `{…}` off a matched
538
- * span so `\frac{j}{m}` marks as `\frac{\explain{…}{j}}{…}`, not
539
- * `\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}}…`.
540
589
  */
541
590
  function unwrapEnclosingGroupSpan(latex, span) {
542
591
  let { start, end } = span;
543
- while (latex[start] === '{') {
544
- const group = readBracedGroup(latex, start);
545
- if (!group || group.end !== end) break;
546
- start = group.contentStart;
547
- 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;
548
607
  }
549
608
  return { start, end };
550
609
  }
@@ -553,19 +612,26 @@ const FONT_WRAPPER_OPEN =
553
612
  /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
554
613
 
555
614
  /**
556
- * KaTeX puts no `loc` on a `\mathbb{…}` font node, so a span derived from one
557
- * begins at the inner letter. When a matched span sits exactly inside a
558
- * single-letter font command, widen it to wrap the whole `\mathbb{E}` token so
559
- * 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}}`.)
560
624
  */
561
625
  function expandFontWrapperSpan(latex, span) {
562
626
  const open = latex.slice(0, span.start).match(FONT_WRAPPER_OPEN);
563
627
  if (!open) return span;
564
628
  const group = readBracedGroup(latex, span.start - 1);
565
- if (!group || group.contentEnd - group.contentStart !== 1) return span;
629
+ if (!group || group.contentStart !== span.start || group.contentEnd !== span.end) {
630
+ return span;
631
+ }
566
632
  return {
567
633
  start: span.start - open[0].length,
568
- end: Math.max(span.end, group.end),
634
+ end: group.end,
569
635
  };
570
636
  }
571
637
 
@@ -579,6 +645,33 @@ function isSafeScriptNesting(inner, outer, scriptSpans) {
579
645
 
580
646
  // --- glyph table + matching -----------------------------------------
581
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
+
582
675
  /**
583
676
  * Index the page glyph table by structural signature. Because one glyph carries
584
677
  * one meaning per page, each signature maps to exactly one key; a genuine
@@ -633,8 +726,7 @@ function buildGlyphIndex(definitions) {
633
726
  byKey.set(key, entry);
634
727
 
635
728
  const signature = sequenceSignature(tree);
636
- const hasIdentifier = collectSourceStructure(tree).identifiers.length > 0;
637
- if (hasIdentifier && tree.length > 0) {
729
+ if (hasBindableGlyph(tree) && tree.length > 0) {
638
730
  const clash = bySignature.get(signature);
639
731
  if (clash && clash !== key) {
640
732
  fail(
@@ -661,7 +753,7 @@ function buildGlyphIndex(definitions) {
661
753
  }
662
754
 
663
755
  /** Bind whole canonical forms: greedy longest match, left to right, per run. */
664
- function matchCanonicalForms(latex, containers, glyphs) {
756
+ function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
665
757
  const bindings = [];
666
758
  const claimed = [];
667
759
 
@@ -677,7 +769,7 @@ function matchCanonicalForms(latex, containers, glyphs) {
677
769
  const nodes = container.slice(cursor, cursor + length);
678
770
  const key = glyphs.bySignature.get(sequenceSignature(nodes));
679
771
  if (key === undefined) continue;
680
- const rawSpan = sequenceSpan(nodes);
772
+ const rawSpan = sequenceSpan(nodes, strippedSource);
681
773
  if (!rawSpan) continue;
682
774
  const span = expandFontWrapperSpan(
683
775
  latex,
@@ -1066,10 +1158,13 @@ export function resolveMathGlyphs(latex, definitions) {
1066
1158
 
1067
1159
  const glyphs = buildGlyphIndex(definitions);
1068
1160
  const explicitCalls = findExplainCalls(latex, glyphs.byKey);
1069
- const tree = parseMath(
1070
- sourceWithoutExplainPrefixes(latex, explicitCalls),
1071
- 'lesson math',
1072
- );
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');
1073
1168
 
1074
1169
  const parenViolations = findUnparenthesizedFunctionCalls(tree);
1075
1170
  if (parenViolations.length > 0) {
@@ -1097,9 +1192,28 @@ export function resolveMathGlyphs(latex, definitions) {
1097
1192
  { violations: nestingViolations },
1098
1193
  );
1099
1194
  }
1100
- const { containers, identifiers, scriptSpans } = collectSourceStructure(tree);
1195
+ const { containers, identifiers, scriptSpans } = collectSourceStructure(
1196
+ tree,
1197
+ strippedSource,
1198
+ );
1101
1199
 
1102
- 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
+ );
1103
1217
  const covered = [...canonical, ...explicitCalls];
1104
1218
  const base = matchBaseGlyphs(
1105
1219
  latex,
@@ -1109,12 +1223,27 @@ export function resolveMathGlyphs(latex, definitions) {
1109
1223
  scriptSpans,
1110
1224
  );
1111
1225
 
1112
- const bindings = [...canonical, ...base, ...explicitCalls].sort(
1113
- (left, right) =>
1114
- left.start - right.start ||
1115
- right.end - left.end ||
1116
- (left.level === 'explicit' ? -1 : 1),
1117
- );
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
+ );
1118
1247
 
1119
1248
  for (const [i, left] of bindings.entries()) {
1120
1249
  for (const right of bindings.slice(i + 1)) {
@@ -1153,6 +1282,14 @@ export function resolveMathGlyphs(latex, definitions) {
1153
1282
  * Used by the `operator-explained` consistency check (rule N8); the glyph
1154
1283
  * resolver still skips operator bodies whole.
1155
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
+ *
1156
1293
  * @param {string} latex
1157
1294
  * @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
1158
1295
  */
@@ -1161,7 +1298,7 @@ export function collectOperators(latex) {
1161
1298
 
1162
1299
  let tree;
1163
1300
  try {
1164
- tree = parseMath(latex, 'operator scan');
1301
+ tree = parseMath(stripNotationMarkers(latex), 'operator scan');
1165
1302
  } catch (error) {
1166
1303
  // A span that will not parse is the math gate's problem, not this check's.
1167
1304
  // A KaTeX version-pin failure is nobody's to swallow — let it through.
@@ -1245,6 +1382,10 @@ export function collectOperators(latex) {
1245
1382
  * `roman-label-explained` consistency check (rule N10); the glyph resolver
1246
1383
  * still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
1247
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
+ *
1248
1389
  * @param {string} latex
1249
1390
  * @returns {Array<{ name: string }>}
1250
1391
  */
@@ -1253,7 +1394,7 @@ export function collectRomanLabels(latex) {
1253
1394
 
1254
1395
  let tree;
1255
1396
  try {
1256
- tree = parseMath(latex, 'roman-label scan');
1397
+ tree = parseMath(stripNotationMarkers(latex), 'roman-label scan');
1257
1398
  } catch (error) {
1258
1399
  if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1259
1400
  return [];
Binary file
@@ -1813,7 +1813,7 @@ body {
1813
1813
  .assessment-actions,
1814
1814
  .assessment-chevron,
1815
1815
  .assessment-feedback.is-idle,
1816
- .notation-pin,
1816
+ .notation-list-anchor,
1817
1817
  .notation-enhancement-help,
1818
1818
  .lab-controls {
1819
1819
  display: none !important;