explico 0.5.0 → 0.7.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 (88) hide show
  1. package/CHANGELOG.md +295 -7
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/course-config.d.ts +31 -1
  8. package/dist/compiler/course-config.js +7 -0
  9. package/dist/compiler/exports/standalone-html.js +13 -0
  10. package/dist/compiler/logging.d.ts +109 -0
  11. package/dist/compiler/logging.js +107 -0
  12. package/dist/compiler/manifest.d.ts +16 -4
  13. package/dist/compiler/manifest.js +7 -3
  14. package/dist/reference/base-abbreviations.mjs +8 -2
  15. package/dist/reference/consistency.d.ts +8 -10
  16. package/dist/reference/consistency.js +3 -47
  17. package/dist/reference/delimiter-size.d.ts +39 -0
  18. package/dist/reference/delimiter-size.js +177 -0
  19. package/dist/reference/diagrams-validate.d.ts +1 -1
  20. package/dist/reference/equations-validate.d.ts +2 -2
  21. package/dist/reference/equations-validate.js +1 -1
  22. package/dist/reference/figures-validate.d.ts +1 -1
  23. package/dist/reference/gate-heading-length.d.ts +12 -0
  24. package/dist/reference/gate-heading-length.js +79 -0
  25. package/dist/reference/gate-math.d.ts +8 -2
  26. package/dist/reference/gate-math.js +30 -7
  27. package/dist/reference/keyed-float-validate.d.ts +2 -2
  28. package/dist/reference/keyed-float-validate.js +1 -1
  29. package/dist/reference/math-glyphs.d.mts +12 -0
  30. package/dist/reference/math-glyphs.mjs +183 -42
  31. package/dist/reference/references.js +2 -1
  32. package/dist/reference/registry.js +1 -1
  33. package/dist/reference/rehype-base-links.d.mts +23 -0
  34. package/dist/reference/rehype-base-links.mjs +40 -0
  35. package/dist/reference/remark-abbreviations.mjs +10 -12
  36. package/dist/reference/remark-citation.d.mts +5 -1
  37. package/dist/reference/remark-citation.mjs +0 -0
  38. package/dist/reference/render-lab-math.d.ts +18 -1
  39. package/dist/reference/render-lab-math.js +10 -3
  40. package/dist/reference/tables-validate.d.ts +1 -1
  41. package/dist/reference/types.d.ts +1 -1
  42. package/docs/VERSIONING.md +24 -4
  43. package/docs/authoring/README.md +14 -2
  44. package/docs/authoring/agents.md +7 -3
  45. package/docs/authoring/components.md +4 -1
  46. package/docs/authoring/migrating.md +477 -8
  47. package/docs/authoring/notation.md +194 -7
  48. package/docs/authoring/prose.md +64 -0
  49. package/docs/authoring/structure-rules.md +69 -0
  50. package/docs/authoring/visualizations.md +2 -2
  51. package/package.json +1 -1
  52. package/src/compiler/astro.ts +89 -0
  53. package/src/compiler/cli.ts +144 -12
  54. package/src/compiler/course-config.ts +40 -0
  55. package/src/compiler/exports/standalone-html.ts +15 -0
  56. package/src/compiler/logging.ts +248 -0
  57. package/src/compiler/manifest.ts +20 -3
  58. package/src/components/diagram/Diagram.astro +0 -55
  59. package/src/components/memo/Memo.astro +2 -5
  60. package/src/components/memo/ShortNote.astro +2 -5
  61. package/src/components/notation/NotationFormula.astro +199 -10
  62. package/src/components/notation/NotationGlossary.astro +89 -58
  63. package/src/components/notation/NotationLayer.astro +238 -156
  64. package/src/components/scroll-animation.ts +160 -0
  65. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  66. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  67. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  68. package/src/components/starlight/LessonFooter.astro +5 -1
  69. package/src/content-config.ts +16 -1
  70. package/src/reference/base-abbreviations.mjs +8 -2
  71. package/src/reference/consistency.ts +10 -66
  72. package/src/reference/delimiter-size.ts +301 -0
  73. package/src/reference/diagrams-validate.ts +1 -1
  74. package/src/reference/equations-validate.ts +3 -3
  75. package/src/reference/figures-validate.ts +1 -1
  76. package/src/reference/gate-heading-length.ts +131 -0
  77. package/src/reference/gate-math.ts +48 -8
  78. package/src/reference/keyed-float-validate.ts +3 -3
  79. package/src/reference/math-glyphs.mjs +183 -42
  80. package/src/reference/references.ts +2 -1
  81. package/src/reference/registry.ts +1 -1
  82. package/src/reference/rehype-base-links.mjs +40 -0
  83. package/src/reference/remark-abbreviations.mjs +10 -12
  84. package/src/reference/remark-citation.mjs +0 -0
  85. package/src/reference/render-lab-math.ts +24 -1
  86. package/src/reference/tables-validate.ts +1 -1
  87. package/src/reference/types.ts +1 -1
  88. package/src/styles/global.css +120 -15
@@ -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 [];
@@ -78,5 +78,6 @@ export function compareDiagnostics(
78
78
  }
79
79
 
80
80
  function severityRank(severity: NotationDiagnostic['severity']): number {
81
- return severity === 'error' ? 0 : 1;
81
+ if (severity === 'error') return 0;
82
+ return severity === 'warning' ? 1 : 2;
82
83
  }
@@ -214,7 +214,7 @@ export function buildNotationRegistry(
214
214
  if (!reachableDefinitions.has(candidate.id)) {
215
215
  diagnostics.push({
216
216
  code: 'unused-definition',
217
- severity: 'warning',
217
+ severity: 'info',
218
218
  message: `${candidate.id} is not reachable from any lesson reference`,
219
219
  key: candidate.input.key,
220
220
  lessonId:
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Prefixes root-relative `<a href="/...">` links in rendered lesson content
3
+ * with the course's Astro `base` path.
4
+ *
5
+ * A course deployed under a subpath (any GitHub Pages *project* site, e.g.
6
+ * `base: '/my-course'`) gets that prefix applied automatically to Starlight's
7
+ * own generated navigation — sidebar, pagination, breadcrumbs — because those
8
+ * are rendered by Starlight's own `.astro` components, which call its
9
+ * internal `pathWithBase()` helper. They are **not** applied to anything
10
+ * produced by the Markdown/MDX → rehype pipeline: a lesson's own prose links
11
+ * (`[text](/other-lesson/)`), `remark-notation.mjs`'s equation cross-
12
+ * references, and `remark-citation.mjs`'s citation links are all root-
13
+ * relative absolute paths written or generated without the base in mind, so
14
+ * without this plugin every one of them 404s on a subpath deployment. A
15
+ * lesson's Starlight frontmatter (e.g. `hero.actions[].link`) is untouched
16
+ * either way — that YAML never enters this pipeline — so write those as
17
+ * paths relative to the current page instead.
18
+ *
19
+ * A no-op when the course has no base (`base` omitted or `'/'`, e.g. local
20
+ * dev or a GitHub *user/org* Pages site), so a course can wire this in
21
+ * unconditionally and it only does anything on the deployments that need it.
22
+ */
23
+ export default function rehypeBaseLinks(base) {
24
+ const prefix = base && base !== '/' ? base.replace(/\/+$/, '') : '';
25
+
26
+ return function transform(tree) {
27
+ if (!prefix) return;
28
+ visit(tree);
29
+ };
30
+
31
+ function visit(node) {
32
+ if (node.type === 'element' && node.tagName === 'a') {
33
+ const href = node.properties?.href;
34
+ if (typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')) {
35
+ node.properties.href = prefix + href;
36
+ }
37
+ }
38
+ if (Array.isArray(node.children)) node.children.forEach(visit);
39
+ }
40
+ }
@@ -5,12 +5,15 @@
5
5
  * requires every abbreviation-shaped word in prose to either be universal or
6
6
  * carry a `content/abbreviations/*.md` entry, so the literal written form
7
7
  * (`abbr`, or one of its `aliases`) is itself the trigger. This plugin scans
8
- * ordinary prose for that literal text and wraps the **first** occurrence per
9
- * document in a real link (`/glossary/#abbr-<key>`, so it still navigates
10
- * with no JS) carrying the `data-abbr-key` an island script reads to render a
11
- * hover/click card, exactly the way `remarkCitation` wraps `[@id]` markers.
12
- * Later occurrences of the same term on the same page stay plain text, so a
13
- * common short word isn't turned into a link everywhere it appears.
8
+ * ordinary prose for that literal text and wraps **every** occurrence in a
9
+ * real link (`/glossary/#abbr-<key>`, so it still navigates with no JS)
10
+ * carrying the `data-abbr-key` an island script reads to render a hover/click
11
+ * card, exactly the way `remarkCitation` wraps `[@id]` markers. A reader who
12
+ * deep-links into a mid-page heading — the sidebar TOC, a heading anchor, an
13
+ * external link — sees the same hoverable cue no matter which occurrence they
14
+ * land on first, rather than only the earliest one in document order.
15
+ * `AbbreviationLayer.astro` already resolves every marker independently by
16
+ * `data-abbr-key`, so wrapping every occurrence needs no island-side change.
14
17
  *
15
18
  * Matches only ever come from the known table by construction, so unlike
16
19
  * citation's `[@id]` there is no "unknown id" failure path here.
@@ -118,10 +121,6 @@ export default function remarkAbbreviations(options = {}) {
118
121
  const { regex, keyByMatch } = buildMatcher(suppliedAbbreviations);
119
122
  if (!regex) return;
120
123
 
121
- // Fresh per file, same lifetime as citation's own `pairs` Map: only the
122
- // first occurrence of a given key on this page gets wrapped.
123
- const usedKeys = new Set();
124
-
125
124
  const replaceText = (value) => {
126
125
  regex.lastIndex = 0;
127
126
  const output = [];
@@ -131,13 +130,12 @@ export default function remarkAbbreviations(options = {}) {
131
130
 
132
131
  while ((match = regex.exec(value))) {
133
132
  const key = keyByMatch.get(match[0]);
134
- if (!key || usedKeys.has(key)) continue;
133
+ if (!key) continue;
135
134
 
136
135
  if (match.index > cursor) {
137
136
  output.push({ type: 'text', value: value.slice(cursor, match.index) });
138
137
  }
139
138
  output.push(termNode(key, match[0], base));
140
- usedKeys.add(key);
141
139
  changed = true;
142
140
  cursor = match.index + match[0].length;
143
141
  }
Binary file
@@ -1,5 +1,5 @@
1
1
  import katex from 'katex';
2
- import { resolveMathGlyphs } from './math-glyphs.mjs';
2
+ import { resolveMathGlyphs, stripNotationMarkers } from './math-glyphs.mjs';
3
3
  import { createNotationKatexOptions } from './katex-options.mjs';
4
4
 
5
5
  export interface LabMathScopeDefinition {
@@ -14,6 +14,26 @@ export interface RenderedLabMath {
14
14
  readonly slots: readonly string[];
15
15
  /** Notation keys the template resolved to, for diagnostics. */
16
16
  readonly keys: readonly string[];
17
+ /**
18
+ * Plain, copyable LaTeX — every explained glyph resolved to its bare
19
+ * symbol, every `\slot{name}{…}` replaced by `slotPlaceholder(name)` — so
20
+ * the island can substitute each slot's *current* live value in and keep
21
+ * `[data-notation-formula]`'s `data-latex` accurate as the reader drags a
22
+ * control, the same "click the equation to copy it" gesture a static
23
+ * lesson or glossary equation gets (`NotationLayer.astro`).
24
+ */
25
+ readonly plainLatexTemplate: string;
26
+ }
27
+
28
+ /**
29
+ * A slot's placeholder inside `plainLatexTemplate` — distinct from the
30
+ * numeric sentinel `sentinelFor` below uses internally (that one only has to
31
+ * survive a single notation-binding pass at build time; this one is the
32
+ * stable, name-keyed token an island substitutes with a live value at
33
+ * runtime, so it's exported for that purpose).
34
+ */
35
+ export function slotPlaceholder(name: string): string {
36
+ return `⟦${name}⟧`;
17
37
  }
18
38
 
19
39
  interface BindResult {
@@ -72,6 +92,7 @@ export function renderLabMath(
72
92
  }
73
93
 
74
94
  let bound = result.latex;
95
+ let copyBound = result.latex;
75
96
  slots.forEach(({ name, fallback }, index) => {
76
97
  const sentinel = sentinelFor(index);
77
98
  if (!bound.includes(sentinel)) {
@@ -84,6 +105,7 @@ export function renderLabMath(
84
105
  sentinel,
85
106
  `\\htmlData{lab-slot=${name}}{${fallback}}`,
86
107
  );
108
+ copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
87
109
  });
88
110
 
89
111
  const html = katex.renderToString(
@@ -95,5 +117,6 @@ export function renderLabMath(
95
117
  html,
96
118
  slots: slots.map(({ name }) => name),
97
119
  keys: [...new Set(result.bindings.map(({ key }) => key))],
120
+ plainLatexTemplate: stripNotationMarkers(copyBound),
98
121
  };
99
122
  }
@@ -15,7 +15,7 @@ export type TableDiagnosticCode =
15
15
 
16
16
  export interface TableDiagnostic {
17
17
  readonly code: TableDiagnosticCode;
18
- readonly severity: 'error' | 'warning';
18
+ readonly severity: 'error' | 'warning' | 'info';
19
19
  readonly message: string;
20
20
  readonly file?: string;
21
21
  readonly lessonId?: string;
@@ -127,7 +127,7 @@ export type NotationDiagnosticCode =
127
127
  | 'alignment-review-state'
128
128
  | 'gloss-collides-with-card';
129
129
 
130
- export type DiagnosticSeverity = 'error' | 'warning';
130
+ export type DiagnosticSeverity = 'error' | 'warning' | 'info';
131
131
 
132
132
  export interface NotationDiagnostic {
133
133
  readonly code: NotationDiagnosticCode;