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.
- package/CHANGELOG.md +295 -7
- package/README.md +16 -2
- package/dist/compiler/astro.d.ts +13 -2
- package/dist/compiler/astro.js +39 -0
- package/dist/compiler/cli.d.ts +22 -3
- package/dist/compiler/cli.js +71 -10
- package/dist/compiler/course-config.d.ts +31 -1
- package/dist/compiler/course-config.js +7 -0
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +109 -0
- package/dist/compiler/logging.js +107 -0
- package/dist/compiler/manifest.d.ts +16 -4
- package/dist/compiler/manifest.js +7 -3
- package/dist/reference/base-abbreviations.mjs +8 -2
- package/dist/reference/consistency.d.ts +8 -10
- package/dist/reference/consistency.js +3 -47
- package/dist/reference/delimiter-size.d.ts +39 -0
- package/dist/reference/delimiter-size.js +177 -0
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +2 -2
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +8 -2
- package/dist/reference/gate-math.js +30 -7
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.d.mts +12 -0
- package/dist/reference/math-glyphs.mjs +183 -42
- package/dist/reference/references.js +2 -1
- package/dist/reference/registry.js +1 -1
- package/dist/reference/rehype-base-links.d.mts +23 -0
- package/dist/reference/rehype-base-links.mjs +40 -0
- package/dist/reference/remark-abbreviations.mjs +10 -12
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +10 -3
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +1 -1
- package/docs/VERSIONING.md +24 -4
- package/docs/authoring/README.md +14 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/components.md +4 -1
- package/docs/authoring/migrating.md +477 -8
- package/docs/authoring/notation.md +194 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/visualizations.md +2 -2
- package/package.json +1 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +144 -12
- package/src/compiler/course-config.ts +40 -0
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +248 -0
- package/src/compiler/manifest.ts +20 -3
- package/src/components/diagram/Diagram.astro +0 -55
- package/src/components/memo/Memo.astro +2 -5
- package/src/components/memo/ShortNote.astro +2 -5
- package/src/components/notation/NotationFormula.astro +199 -10
- package/src/components/notation/NotationGlossary.astro +89 -58
- package/src/components/notation/NotationLayer.astro +238 -156
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
- package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
- package/src/components/starlight/LessonFooter.astro +5 -1
- package/src/content-config.ts +16 -1
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +301 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +48 -8
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +183 -42
- package/src/reference/references.ts +2 -1
- package/src/reference/registry.ts +1 -1
- package/src/reference/rehype-base-links.mjs +40 -0
- package/src/reference/remark-abbreviations.mjs +10 -12
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/render-lab-math.ts +24 -1
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +1 -1
- package/src/styles/global.css +120 -15
|
@@ -369,7 +369,24 @@ function sequenceSignature(nodes) {
|
|
|
369
369
|
|
|
370
370
|
// --- source spans -----------------------------------------------------
|
|
371
371
|
|
|
372
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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 `{…}`
|
|
538
|
-
*
|
|
539
|
-
* `\frac
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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{
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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(
|
|
1195
|
+
const { containers, identifiers, scriptSpans } = collectSourceStructure(
|
|
1196
|
+
tree,
|
|
1197
|
+
strippedSource,
|
|
1198
|
+
);
|
|
1101
1199
|
|
|
1102
|
-
|
|
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]
|
|
1113
|
-
(
|
|
1114
|
-
left
|
|
1115
|
-
|
|
1116
|
-
|
|
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 [];
|
|
@@ -28,7 +28,8 @@ function compareDiagnostics(left, right) {
|
|
|
28
28
|
return severityRank(left.severity) - severityRank(right.severity) || left.code.localeCompare(right.code) || (left.lessonId ?? "").localeCompare(right.lessonId ?? "") || (left.key ?? "").localeCompare(right.key ?? "") || (left.definitionId ?? "").localeCompare(right.definitionId ?? "") || compareSourceSpans(left.source, right.source) || left.message.localeCompare(right.message);
|
|
29
29
|
}
|
|
30
30
|
function severityRank(severity) {
|
|
31
|
-
|
|
31
|
+
if (severity === "error") return 0;
|
|
32
|
+
return severity === "warning" ? 1 : 2;
|
|
32
33
|
}
|
|
33
34
|
export {
|
|
34
35
|
NOTATION_KEY_PATTERN,
|
|
@@ -144,7 +144,7 @@ function buildNotationRegistry(rawInput) {
|
|
|
144
144
|
if (!reachableDefinitions.has(candidate.id)) {
|
|
145
145
|
diagnostics.push({
|
|
146
146
|
code: "unused-definition",
|
|
147
|
-
severity: "
|
|
147
|
+
severity: "info",
|
|
148
148
|
message: `${candidate.id} is not reachable from any lesson reference`,
|
|
149
149
|
key: candidate.input.key,
|
|
150
150
|
lessonId: candidate.kind === "local" ? candidate.lesson.lessonId : void 0,
|
|
@@ -0,0 +1,23 @@
|
|
|
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: any): (tree: any) => void;
|
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @param {{
|
|
2
|
+
* @param {{
|
|
3
|
+
* sources?: Map<string, object> | readonly object[] | (() => Map<string, object> | readonly object[]),
|
|
4
|
+
* defaultOpen?: boolean,
|
|
5
|
+
* }} [options]
|
|
3
6
|
*/
|
|
4
7
|
export default function remarkCitation(options?: {
|
|
5
8
|
sources?: Map<string, object> | readonly object[] | (() => Map<string, object> | readonly object[]);
|
|
9
|
+
defaultOpen?: boolean;
|
|
6
10
|
}): (tree: any, file: any) => void;
|
|
Binary file
|
|
@@ -9,7 +9,24 @@ interface RenderedLabMath {
|
|
|
9
9
|
readonly slots: readonly string[];
|
|
10
10
|
/** Notation keys the template resolved to, for diagnostics. */
|
|
11
11
|
readonly keys: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Plain, copyable LaTeX — every explained glyph resolved to its bare
|
|
14
|
+
* symbol, every `\slot{name}{…}` replaced by `slotPlaceholder(name)` — so
|
|
15
|
+
* the island can substitute each slot's *current* live value in and keep
|
|
16
|
+
* `[data-notation-formula]`'s `data-latex` accurate as the reader drags a
|
|
17
|
+
* control, the same "click the equation to copy it" gesture a static
|
|
18
|
+
* lesson or glossary equation gets (`NotationLayer.astro`).
|
|
19
|
+
*/
|
|
20
|
+
readonly plainLatexTemplate: string;
|
|
12
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* A slot's placeholder inside `plainLatexTemplate` — distinct from the
|
|
24
|
+
* numeric sentinel `sentinelFor` below uses internally (that one only has to
|
|
25
|
+
* survive a single notation-binding pass at build time; this one is the
|
|
26
|
+
* stable, name-keyed token an island substitutes with a live value at
|
|
27
|
+
* runtime, so it's exported for that purpose).
|
|
28
|
+
*/
|
|
29
|
+
declare function slotPlaceholder(name: string): string;
|
|
13
30
|
/**
|
|
14
31
|
* Compile a developer-authored lab math template the same way lesson `$$`
|
|
15
32
|
* math is compiled: every identifier must resolve to a notation key in the
|
|
@@ -18,4 +35,4 @@ interface RenderedLabMath {
|
|
|
18
35
|
*/
|
|
19
36
|
declare function renderLabMath(template: string, scope: readonly LabMathScopeDefinition[]): RenderedLabMath;
|
|
20
37
|
|
|
21
|
-
export { type LabMathScopeDefinition, type RenderedLabMath, renderLabMath };
|
|
38
|
+
export { type LabMathScopeDefinition, type RenderedLabMath, renderLabMath, slotPlaceholder };
|
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
function slotPlaceholder(name) {
|
|
5
|
+
return `\u27E6${name}\u27E7`;
|
|
6
|
+
}
|
|
4
7
|
const SLOT_PATTERN = /\\slot\{([a-z][a-z0-9]*)\}\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
5
8
|
const sentinelFor = (index) => `909909${index}.909909`;
|
|
6
9
|
function renderLabMath(template, scope) {
|
|
@@ -30,6 +33,7 @@ function renderLabMath(template, scope) {
|
|
|
30
33
|
);
|
|
31
34
|
}
|
|
32
35
|
let bound = result.latex;
|
|
36
|
+
let copyBound = result.latex;
|
|
33
37
|
slots.forEach(({ name, fallback }, index) => {
|
|
34
38
|
const sentinel = sentinelFor(index);
|
|
35
39
|
if (!bound.includes(sentinel)) {
|
|
@@ -42,6 +46,7 @@ function renderLabMath(template, scope) {
|
|
|
42
46
|
sentinel,
|
|
43
47
|
`\\htmlData{lab-slot=${name}}{${fallback}}`
|
|
44
48
|
);
|
|
49
|
+
copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
|
|
45
50
|
});
|
|
46
51
|
const html = katex.renderToString(
|
|
47
52
|
bound,
|
|
@@ -50,9 +55,11 @@ function renderLabMath(template, scope) {
|
|
|
50
55
|
return {
|
|
51
56
|
html,
|
|
52
57
|
slots: slots.map(({ name }) => name),
|
|
53
|
-
keys: [...new Set(result.bindings.map(({ key }) => key))]
|
|
58
|
+
keys: [...new Set(result.bindings.map(({ key }) => key))],
|
|
59
|
+
plainLatexTemplate: stripNotationMarkers(copyBound)
|
|
54
60
|
};
|
|
55
61
|
}
|
|
56
62
|
export {
|
|
57
|
-
renderLabMath
|
|
63
|
+
renderLabMath,
|
|
64
|
+
slotPlaceholder
|
|
58
65
|
};
|
|
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
|
|
|
5
5
|
type TableDiagnosticCode = 'tab-invalid-key' | 'tab-duplicate-key' | 'tab-ref-resolves' | 'tab-key-unused';
|
|
6
6
|
interface TableDiagnostic {
|
|
7
7
|
readonly code: TableDiagnosticCode;
|
|
8
|
-
readonly severity: 'error' | 'warning';
|
|
8
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
9
9
|
readonly message: string;
|
|
10
10
|
readonly file?: string;
|
|
11
11
|
readonly lessonId?: string;
|
|
@@ -100,7 +100,7 @@ interface AbbreviationDefinitionInput {
|
|
|
100
100
|
readonly source: SourceSpan;
|
|
101
101
|
}
|
|
102
102
|
type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state' | 'gloss-collides-with-card';
|
|
103
|
-
type DiagnosticSeverity = 'error' | 'warning';
|
|
103
|
+
type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
104
104
|
interface NotationDiagnostic {
|
|
105
105
|
readonly code: NotationDiagnosticCode;
|
|
106
106
|
readonly severity: DiagnosticSeverity;
|