explico 0.6.0 → 0.8.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 +350 -1
- 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 +30 -4
- package/dist/compiler/cli.js +79 -10
- package/dist/compiler/collections.js +16 -3
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +119 -0
- package/dist/compiler/logging.js +108 -0
- package/dist/compiler/manifest.d.ts +7 -3
- package/dist/compiler/manifest.js +1 -1
- 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 +48 -0
- package/dist/reference/delimiter-size.js +153 -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/equations.d.mts +9 -3
- package/dist/reference/equations.mjs +7 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +2 -2
- package/dist/reference/gate-heading-length.js +2 -2
- package/dist/reference/gate-math.d.ts +25 -1
- package/dist/reference/gate-math.js +50 -9
- package/dist/reference/gate-prose-notation.d.ts +12 -1
- package/dist/reference/gate-prose-notation.js +5 -1
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +125 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.mjs +16 -2
- 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.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +11 -4
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +8 -1
- package/docs/VERSIONING.md +2 -2
- package/docs/authoring/README.md +12 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/migrating.md +514 -0
- package/docs/authoring/notation.md +157 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +2 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +167 -12
- package/src/compiler/collections.ts +33 -3
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +259 -0
- package/src/compiler/manifest.ts +7 -3
- 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 +100 -58
- package/src/components/notation/NotationLayer.astro +83 -81
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AboutPanel.astro +17 -2
- 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 +2 -0
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +279 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/equations.mjs +7 -1
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-abbreviations.ts +2 -2
- package/src/reference/gate-heading-length.ts +2 -2
- package/src/reference/gate-math.ts +91 -8
- package/src/reference/gate-prose-notation.ts +15 -1
- package/src/reference/katex-delimiters.mjs +125 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +16 -2
- 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 +25 -2
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +8 -1
- package/src/styles/global.css +119 -14
|
@@ -263,14 +263,6 @@ for (const gloss of glossItems) {
|
|
|
263
263
|
</header>
|
|
264
264
|
<p data-slot-summary hidden />
|
|
265
265
|
<div data-slot-formula hidden />
|
|
266
|
-
<button
|
|
267
|
-
class="notation-popup-copy"
|
|
268
|
-
type="button"
|
|
269
|
-
data-popup-copy
|
|
270
|
-
hidden
|
|
271
|
-
>
|
|
272
|
-
Copy LaTeX
|
|
273
|
-
</button>
|
|
274
266
|
<p class="notation-units" data-slot-units hidden />
|
|
275
267
|
<a data-slot-link hidden>
|
|
276
268
|
Open the full definition
|
|
@@ -405,18 +397,7 @@ for (const gloss of glossItems) {
|
|
|
405
397
|
const units = fragment.querySelector<HTMLElement>('[data-slot-units]');
|
|
406
398
|
const link =
|
|
407
399
|
fragment.querySelector<HTMLAnchorElement>('[data-slot-link]');
|
|
408
|
-
|
|
409
|
-
'[data-popup-copy]',
|
|
410
|
-
);
|
|
411
|
-
if (
|
|
412
|
-
!el ||
|
|
413
|
-
!symbolHost ||
|
|
414
|
-
!title ||
|
|
415
|
-
!summary ||
|
|
416
|
-
!formulaHost ||
|
|
417
|
-
!units ||
|
|
418
|
-
!copyButton
|
|
419
|
-
) {
|
|
400
|
+
if (!el || !symbolHost || !title || !summary || !formulaHost || !units) {
|
|
420
401
|
return null;
|
|
421
402
|
}
|
|
422
403
|
|
|
@@ -443,34 +424,6 @@ for (const gloss of glossItems) {
|
|
|
443
424
|
);
|
|
444
425
|
formulaHost.hidden = sourceFormula === null;
|
|
445
426
|
|
|
446
|
-
// The formula's own `data-latex` carries the plain, authored LaTeX —
|
|
447
|
-
// not the `\explain{key}{…}`-wrapped source KaTeX actually rendered —
|
|
448
|
-
// so what a reader copies is what they'd write themselves, not an
|
|
449
|
-
// internal marker macro. See `render-notation-math.ts:renderNotationFormula`.
|
|
450
|
-
const formulaLatex = sourceFormula?.dataset.latex ?? '';
|
|
451
|
-
copyButton.hidden = formulaLatex.length === 0;
|
|
452
|
-
if (formulaLatex.length > 0) {
|
|
453
|
-
copyButton.addEventListener('click', (event) => {
|
|
454
|
-
// Otherwise this bubbles to the document click handler in
|
|
455
|
-
// `popup-stack.ts`, which treats any click inside a popup that
|
|
456
|
-
// isn't a trigger or a link as "close it" — that would remove the
|
|
457
|
-
// card before the "Copied!" flash below ever paints.
|
|
458
|
-
event.stopPropagation();
|
|
459
|
-
if (!navigator.clipboard) return;
|
|
460
|
-
navigator.clipboard.writeText(formulaLatex).then(
|
|
461
|
-
() => {
|
|
462
|
-
copyButton.dataset.copied = 'true';
|
|
463
|
-
window.setTimeout(() => {
|
|
464
|
-
delete copyButton.dataset.copied;
|
|
465
|
-
}, 1200);
|
|
466
|
-
},
|
|
467
|
-
() => {
|
|
468
|
-
/* Clipboard blocked: nothing more we can do here. */
|
|
469
|
-
},
|
|
470
|
-
);
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
427
|
setOptionalText(
|
|
475
428
|
units,
|
|
476
429
|
entry
|
|
@@ -547,6 +500,84 @@ for (const gloss of glossItems) {
|
|
|
547
500
|
);
|
|
548
501
|
if (details) setupNotationDisclosure(details);
|
|
549
502
|
}
|
|
503
|
+
|
|
504
|
+
// "Copy LaTeX" for a notation formula (a glossary entry's own numbered
|
|
505
|
+
// formula, or one named inline via `\explain{}` inside a popup/`Diagram`),
|
|
506
|
+
// two ways: its own button (only a numbered formula has one —
|
|
507
|
+
// `data-notation-formula-copy`, `NotationFormula.astro`), or a click
|
|
508
|
+
// anywhere else in the box that isn't the number link or an explained
|
|
509
|
+
// symbol — the same region `.notation-formula-math:hover` highlights
|
|
510
|
+
// (`NotationFormula.astro`), which includes the reserved gutter between
|
|
511
|
+
// the equation and its number, not just the rendered `.katex-html`. This
|
|
512
|
+
// listener lives here, not in `NotationFormula.astro` or
|
|
513
|
+
// `KeyedFloatEnhancer.astro`, because this component loads on every page —
|
|
514
|
+
// it's the Starlight `Footer` override — while `KeyedFloatEnhancer` is
|
|
515
|
+
// lesson-only and a formula can appear inside a hover popup mounted
|
|
516
|
+
// outside `.sl-markdown-content`.
|
|
517
|
+
document.addEventListener('click', (event) => {
|
|
518
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
519
|
+
if (!origin) return;
|
|
520
|
+
|
|
521
|
+
const ownButton = origin.closest<HTMLButtonElement>(
|
|
522
|
+
'[data-notation-formula-copy]',
|
|
523
|
+
);
|
|
524
|
+
const container = ownButton
|
|
525
|
+
? null
|
|
526
|
+
: origin.closest<HTMLElement>('[data-notation-formula]');
|
|
527
|
+
if (!ownButton) {
|
|
528
|
+
if (origin.closest('[data-notation-key], .notation-formula-number')) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!container) return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const feedbackTarget =
|
|
535
|
+
ownButton ??
|
|
536
|
+
container?.querySelector<HTMLElement>('[data-notation-formula-copy]') ??
|
|
537
|
+
container;
|
|
538
|
+
const latex =
|
|
539
|
+
ownButton?.dataset.latex ?? feedbackTarget?.dataset.latex ?? '';
|
|
540
|
+
if (!feedbackTarget || latex.length === 0 || !navigator.clipboard) return;
|
|
541
|
+
|
|
542
|
+
navigator.clipboard.writeText(latex).then(
|
|
543
|
+
() => {
|
|
544
|
+
feedbackTarget.dataset.copied = 'true';
|
|
545
|
+
window.setTimeout(() => {
|
|
546
|
+
delete feedbackTarget.dataset.copied;
|
|
547
|
+
}, 1200);
|
|
548
|
+
},
|
|
549
|
+
() => {
|
|
550
|
+
/* Clipboard blocked: nothing more we can do here. */
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// A tooltip for the click gesture above, and only for the part of the
|
|
556
|
+
// equation it actually applies to — an explained symbol has its own hover
|
|
557
|
+
// card (created above) and needs no tooltip layered on top of it, and the
|
|
558
|
+
// copy button already carries a static `title`. `mouseover`/`mouseout` (not
|
|
559
|
+
// `mouseenter`/`mouseleave`) because they bubble, which this document-level
|
|
560
|
+
// delegation needs.
|
|
561
|
+
document.addEventListener('mouseover', (event) => {
|
|
562
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
563
|
+
if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
|
|
564
|
+
if (
|
|
565
|
+
origin.closest(
|
|
566
|
+
'[data-notation-key], [data-notation-formula-copy], .notation-formula-number',
|
|
567
|
+
)
|
|
568
|
+
) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!origin.closest('[data-notation-formula]')) return;
|
|
572
|
+
origin.setAttribute('title', 'Copy LaTeX');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
document.addEventListener('mouseout', (event) => {
|
|
576
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
577
|
+
if (origin?.getAttribute('title') === 'Copy LaTeX') {
|
|
578
|
+
origin.removeAttribute('title');
|
|
579
|
+
}
|
|
580
|
+
});
|
|
550
581
|
</script>
|
|
551
582
|
|
|
552
583
|
<style>
|
|
@@ -722,6 +753,10 @@ for (const gloss of glossItems) {
|
|
|
722
753
|
font-size: var(--sl-text-sm);
|
|
723
754
|
}
|
|
724
755
|
|
|
756
|
+
/* Cursor, hover highlight, and "Copied!" flash for this plain-span
|
|
757
|
+
formula fallback (a definition with no `formulaScope`) come from the
|
|
758
|
+
generic `[data-notation-formula]` rules in `global.css` — shared with
|
|
759
|
+
every other producer of that attribute, not reimplemented here. */
|
|
725
760
|
.notation-formula {
|
|
726
761
|
overflow-x: auto;
|
|
727
762
|
/* `overflow-x: auto` alone makes the block axis compute to `auto` too
|
|
@@ -823,39 +858,6 @@ for (const gloss of glossItems) {
|
|
|
823
858
|
line-height: 1.5;
|
|
824
859
|
}
|
|
825
860
|
|
|
826
|
-
.notation-popup-copy {
|
|
827
|
-
position: relative;
|
|
828
|
-
margin-block: 0.4rem 0.6rem;
|
|
829
|
-
padding: 0.2rem 0.55rem;
|
|
830
|
-
border: 1px solid var(--sl-color-gray-5);
|
|
831
|
-
border-radius: 0.35rem;
|
|
832
|
-
color: var(--sl-color-text-accent);
|
|
833
|
-
background: var(--sl-color-bg);
|
|
834
|
-
font: inherit;
|
|
835
|
-
font-size: var(--sl-text-xs);
|
|
836
|
-
cursor: pointer;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
.notation-popup-copy:hover {
|
|
840
|
-
border-color: var(--sl-color-accent);
|
|
841
|
-
background: var(--sl-color-accent-low);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
.notation-popup-copy[data-copied]::after {
|
|
845
|
-
content: 'Copied!';
|
|
846
|
-
position: absolute;
|
|
847
|
-
inset-block-end: calc(100% + 0.35rem);
|
|
848
|
-
inset-inline-start: 50%;
|
|
849
|
-
translate: -50% 0;
|
|
850
|
-
padding: 0.15rem 0.45rem;
|
|
851
|
-
border-radius: 0.35rem;
|
|
852
|
-
background: var(--sl-color-accent-low);
|
|
853
|
-
color: var(--sl-color-text-accent);
|
|
854
|
-
font-size: var(--sl-text-xs);
|
|
855
|
-
white-space: nowrap;
|
|
856
|
-
pointer-events: none;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
861
|
.notation-panel-body [data-slot-formula] {
|
|
860
862
|
overflow-x: auto;
|
|
861
863
|
/* See `.notation-formula` above: pairs with `overflow-x` to stop the
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import rehypeKatex from 'rehype-katex';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createNotationKatexOptions,
|
|
4
|
+
trustNotationMarker,
|
|
5
|
+
} from '../../reference/katex-options.mjs';
|
|
3
6
|
import { NOTATION_KEY_PATTERN } from '../../reference/remark-notation.mjs';
|
|
4
7
|
import {
|
|
5
8
|
GlyphResolutionError,
|
|
@@ -81,32 +84,96 @@ const allowedTags = new Set([
|
|
|
81
84
|
'line',
|
|
82
85
|
]);
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every inert presentation attribute KaTeX's MathML builder can set, sorted.
|
|
89
|
+
*
|
|
90
|
+
* This list is deliberately KaTeX's whole surface rather than the attributes
|
|
91
|
+
* the corpus happens to have hit: anything missing here is a hard build error
|
|
92
|
+
* on valid, ordinary math, and the author has no way to work around it. `\Bigl`
|
|
93
|
+
* (`minsize`/`maxsize`), `\boxed` (`notation`), and `\colorbox`
|
|
94
|
+
* (`voffset`/`mathbackground`) each used to fail that way.
|
|
95
|
+
* `tests/notation/render-notation-math.test.ts` scans KaTeX's own
|
|
96
|
+
* `setAttribute` calls and fails when a release adds a name that is in neither
|
|
97
|
+
* this set nor {@link deniedAttributeNames}, so a KaTeX upgrade cannot quietly
|
|
98
|
+
* reintroduce the gap.
|
|
99
|
+
*/
|
|
84
100
|
const simpleAttributeNames = new Set([
|
|
85
101
|
'accent',
|
|
86
102
|
'accentunder',
|
|
87
103
|
'columnalign',
|
|
104
|
+
'columnlines',
|
|
88
105
|
'columnspacing',
|
|
106
|
+
'depth',
|
|
107
|
+
'display',
|
|
89
108
|
'displaystyle',
|
|
90
109
|
'fence',
|
|
91
110
|
'height',
|
|
111
|
+
'largeop',
|
|
112
|
+
'linebreak',
|
|
113
|
+
'linethickness',
|
|
92
114
|
'lspace',
|
|
115
|
+
'mathbackground',
|
|
116
|
+
'mathcolor',
|
|
117
|
+
'mathsize',
|
|
93
118
|
'mathvariant',
|
|
119
|
+
'maxsize',
|
|
120
|
+
'minsize',
|
|
121
|
+
'notation',
|
|
94
122
|
'preserveaspectratio',
|
|
123
|
+
'rowlines',
|
|
95
124
|
'rowspacing',
|
|
96
125
|
'rspace',
|
|
97
126
|
'scriptlevel',
|
|
98
127
|
'separator',
|
|
99
128
|
'stretchy',
|
|
129
|
+
'valign',
|
|
100
130
|
'viewbox',
|
|
131
|
+
'voffset',
|
|
101
132
|
'width',
|
|
102
133
|
]);
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The attributes KaTeX can set that must never reach the page, each one a
|
|
137
|
+
* navigation or external-resource target: `href` (`\href`), `src`/`alt`
|
|
138
|
+
* (`\includegraphics`), and `title` (`\htmlTitle`). Every command that
|
|
139
|
+
* produces one already needs KaTeX's `trust` callback and is refused earlier by
|
|
140
|
+
* {@link unsafeExtensionCommand}, so this set is the second line — and the
|
|
141
|
+
* reason the completeness scan above can tell "KaTeX added an attribute nobody
|
|
142
|
+
* classified" apart from "we decided against this one".
|
|
143
|
+
*/
|
|
144
|
+
const deniedAttributeNames = new Set(['alt', 'href', 'src', 'title']);
|
|
145
|
+
|
|
146
|
+
// `#` is here for `mathcolor` / `mathbackground` / a `background-color:` style,
|
|
147
|
+
// which KaTeX writes as a hex literal. No attribute reaching this point can
|
|
148
|
+
// carry a URL — `href` and `src` are denied above — so it opens no target.
|
|
149
|
+
const safeAttributeValue = /^[ A-Za-z0-9+.,()\-/%#]*$/;
|
|
105
150
|
const safeClassName = /^[A-Za-z0-9_-]+$/;
|
|
106
|
-
const
|
|
151
|
+
const safeDeclaration = /^\s*[a-z-]+:[ A-Za-z0-9+.,()\-/%#]*$/;
|
|
107
152
|
const safePathData = /^[\sA-Za-z0-9+.,\-]*$/;
|
|
108
153
|
const unsafeExtensionCommand =
|
|
109
154
|
/\\(?:href|url|includegraphics|htmlClass|htmlData|htmlId|htmlStyle)\b/;
|
|
155
|
+
// `\explain{key}{latex}` and `\slot{name}{latex}`, the two macros that expand
|
|
156
|
+
// to a trusted `\htmlData` marker. Only the first argument is captured.
|
|
157
|
+
const MARKER_MACRO = /\\(explain|slot)\{([^{}]*)\}/g;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A declaration list KaTeX generated: inert properties only, and no
|
|
161
|
+
* `url(...)`/`expression(...)` to fetch or evaluate anything.
|
|
162
|
+
*
|
|
163
|
+
* Validated per declaration rather than with one regex over the whole string,
|
|
164
|
+
* because KaTeX does not always terminate the last one — `\vec` emits
|
|
165
|
+
* `style="width:0.471em"` with no trailing `;`, which a
|
|
166
|
+
* `(?:declaration;)*`-shaped pattern rejects outright.
|
|
167
|
+
*/
|
|
168
|
+
function isSafeStyle(value: string): boolean {
|
|
169
|
+
if (/(?:expression|url)\s*\(/i.test(value)) return false;
|
|
170
|
+
return value
|
|
171
|
+
.split(';')
|
|
172
|
+
.every(
|
|
173
|
+
(declaration) =>
|
|
174
|
+
declaration.trim().length === 0 || safeDeclaration.test(declaration),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
110
177
|
|
|
111
178
|
function attributeValue(value: unknown, propertyName: string): string {
|
|
112
179
|
if (typeof value === 'boolean') return String(value);
|
|
@@ -170,22 +237,27 @@ function safeProperties(
|
|
|
170
237
|
continue;
|
|
171
238
|
}
|
|
172
239
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
!/(?:expression|url)\s*\(/i.test(value)
|
|
178
|
-
) {
|
|
240
|
+
if (name === 'style') {
|
|
241
|
+
if (typeof value !== 'string' || !isSafeStyle(value)) {
|
|
242
|
+
throw new Error('Unsafe KaTeX style value.');
|
|
243
|
+
}
|
|
179
244
|
properties.style = value;
|
|
180
245
|
continue;
|
|
181
246
|
}
|
|
182
247
|
|
|
183
|
-
if (name === 'd'
|
|
248
|
+
if (name === 'd') {
|
|
249
|
+
if (typeof value !== 'string' || !safePathData.test(value)) {
|
|
250
|
+
throw new Error('Unsafe KaTeX path data.');
|
|
251
|
+
}
|
|
184
252
|
properties.d = value;
|
|
185
253
|
continue;
|
|
186
254
|
}
|
|
187
255
|
|
|
188
256
|
const normalizedName = name.toLowerCase();
|
|
257
|
+
if (deniedAttributeNames.has(normalizedName)) {
|
|
258
|
+
throw new Error(`Disallowed KaTeX property: ${name}.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
189
261
|
if (simpleAttributeNames.has(normalizedName)) {
|
|
190
262
|
const outputName =
|
|
191
263
|
normalizedName === 'viewbox'
|
|
@@ -228,6 +300,34 @@ function sanitizeNode(node: HastNode): SafeMathNode {
|
|
|
228
300
|
// this module already imports from `remark-notation.mjs`.
|
|
229
301
|
export { stripNotationMarkers };
|
|
230
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Refuse a marker argument the trust callback would reject, before KaTeX sees
|
|
305
|
+
* it.
|
|
306
|
+
*
|
|
307
|
+
* KaTeX does not treat a denied `trust` as a parse error: it renders the
|
|
308
|
+
* command name in `errorColor` and carries on, so `\explain{Not A Key}{\Omega}`
|
|
309
|
+
* would otherwise reach a reader as red `\htmlData` text with the build still
|
|
310
|
+
* green. `\htmlData` itself is already refused by {@link
|
|
311
|
+
* unsafeExtensionCommand}, so the two marker macros are the only way into that
|
|
312
|
+
* callback and this check covers all of them. It asks
|
|
313
|
+
* {@link trustNotationMarker} rather than re-deriving the key patterns, so the
|
|
314
|
+
* answer here cannot drift from the answer KaTeX gets.
|
|
315
|
+
*/
|
|
316
|
+
function assertTrustedMarkers(latex: string): void {
|
|
317
|
+
for (const [, macro, argument] of latex.matchAll(MARKER_MACRO)) {
|
|
318
|
+
const attribute =
|
|
319
|
+
macro === 'explain' ? 'data-notation-key' : 'data-lab-slot';
|
|
320
|
+
if (
|
|
321
|
+
!trustNotationMarker({
|
|
322
|
+
command: '\\htmlData',
|
|
323
|
+
attributes: { [attribute]: argument },
|
|
324
|
+
})
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(`Notation LaTeX has an invalid \\${macro} key: ${argument}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
231
331
|
/**
|
|
232
332
|
* Render repository-authored LaTeX through the same build-time KaTeX adapter
|
|
233
333
|
* as lesson math, then expose a constrained structured tree to Astro. No HTML
|
|
@@ -240,6 +340,7 @@ export function renderNotationMath(latex: string): readonly SafeMathNode[] {
|
|
|
240
340
|
if (unsafeExtensionCommand.test(latex)) {
|
|
241
341
|
throw new Error('Notation LaTeX cannot use a trust-requiring extension.');
|
|
242
342
|
}
|
|
343
|
+
assertTrustedMarkers(latex);
|
|
243
344
|
|
|
244
345
|
const root: HastRoot = {
|
|
245
346
|
type: 'root',
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single, site-wide motion profile for "jump to an in-page anchor":
|
|
3
|
+
* `DURATION_MS`, constant-magnitude acceleration — speeding up through the
|
|
4
|
+
* first half of the trip, slowing down through the second, symmetric about
|
|
5
|
+
* the midpoint (the standard `easeInOutQuad` curve, which is exactly a
|
|
6
|
+
* constant |acceleration| profile since position is piecewise quadratic in
|
|
7
|
+
* time).
|
|
8
|
+
*
|
|
9
|
+
* The browser's own fragment navigation (a plain `<a href="#id">` click, with
|
|
10
|
+
* no `scroll-behavior: smooth` set anywhere in this site's CSS) jumps
|
|
11
|
+
* instantly — and CSS's `scroll-behavior: smooth` can't be tuned to a given
|
|
12
|
+
* duration or easing anyway. Getting an exact, consistent curve everywhere
|
|
13
|
+
* means animating the scroll ourselves. `AnchorScrollEnhancer.astro` (the
|
|
14
|
+
* generic click case, covering every same-page link on every page) and the
|
|
15
|
+
* handful of enhancers with their own reasons to move the viewport
|
|
16
|
+
* (`HeadingAnchorEnhancer.astro`, `KeyedFloatEnhancer.astro`, footnote/memo
|
|
17
|
+
* expansion) all render through this module, so a click that touches two of
|
|
18
|
+
* them at once — e.g. a heading anchor click, animated here *and* re-scrolled
|
|
19
|
+
* by that enhancer's own `hashchange` listener — collapses to a single
|
|
20
|
+
* animation via `activeAnimation` below, rather than two fighting for the
|
|
21
|
+
* scroll position.
|
|
22
|
+
*
|
|
23
|
+
* An earlier version of `AnchorScrollEnhancer.astro` let the browser's own
|
|
24
|
+
* click-triggered jump run, then tried to catch it a frame later and replay
|
|
25
|
+
* it as an animation. The trouble: the HTML spec gives no guarantee about
|
|
26
|
+
* *when*, relative to our own script, that jump actually lands — under load
|
|
27
|
+
* it can take several frames — so a single-frame check could conclude
|
|
28
|
+
* "nothing moved, there's nothing to animate" and let the browser's own
|
|
29
|
+
* un-animated jump land moments later, unopposed (this was the original,
|
|
30
|
+
* confirmed bug reports came in about). Polling for more frames narrows that
|
|
31
|
+
* window but never closes it — there's always some delay past which it gives
|
|
32
|
+
* up. `animatedScrollIntoView` below removes the dependency on that timing
|
|
33
|
+
* entirely: it measures the destination itself, synchronously, before
|
|
34
|
+
* anything has had a chance to move, so there's nothing to race.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const DURATION_MS = 200;
|
|
38
|
+
function easeInOutQuad(t: number): number {
|
|
39
|
+
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SettleReason = 'complete' | 'interrupted';
|
|
43
|
+
|
|
44
|
+
let activeAnimation: {
|
|
45
|
+
readonly target: Element;
|
|
46
|
+
readonly cancel: (reason: SettleReason) => void;
|
|
47
|
+
} | null = null;
|
|
48
|
+
|
|
49
|
+
function prefersReducedMotion(): boolean {
|
|
50
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cancel on the reader's own scroll intent — a short ride is unlikely to
|
|
54
|
+
// outlast one, but fighting the reader's wheel/touch/keyboard input, even
|
|
55
|
+
// briefly, is exactly the kind of jank this exists to avoid.
|
|
56
|
+
const CANCEL_EVENTS = ['wheel', 'touchstart', 'keydown'] as const;
|
|
57
|
+
|
|
58
|
+
function runAnimation(
|
|
59
|
+
target: Element,
|
|
60
|
+
startX: number,
|
|
61
|
+
startY: number,
|
|
62
|
+
endX: number,
|
|
63
|
+
endY: number,
|
|
64
|
+
onSettle?: (reason: SettleReason) => void,
|
|
65
|
+
): void {
|
|
66
|
+
if (activeAnimation?.target === target) return;
|
|
67
|
+
activeAnimation?.cancel('interrupted');
|
|
68
|
+
|
|
69
|
+
if (prefersReducedMotion() || (startX === endX && startY === endY)) {
|
|
70
|
+
window.scrollTo(endX, endY);
|
|
71
|
+
activeAnimation = null;
|
|
72
|
+
onSettle?.('complete');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
window.scrollTo(startX, startY);
|
|
77
|
+
|
|
78
|
+
let rafId = 0;
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
const startTime = performance.now();
|
|
81
|
+
|
|
82
|
+
const onCancelEvent = () => cancel('interrupted');
|
|
83
|
+
|
|
84
|
+
const cleanup = () => {
|
|
85
|
+
for (const type of CANCEL_EVENTS) {
|
|
86
|
+
window.removeEventListener(type, onCancelEvent);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const finish = (reason: SettleReason) => {
|
|
91
|
+
cleanup();
|
|
92
|
+
if (activeAnimation?.target === target) activeAnimation = null;
|
|
93
|
+
onSettle?.(reason);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const cancel = (reason: SettleReason) => {
|
|
97
|
+
if (cancelled) return;
|
|
98
|
+
cancelled = true;
|
|
99
|
+
cancelAnimationFrame(rafId);
|
|
100
|
+
finish(reason);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const step = (now: number) => {
|
|
104
|
+
if (cancelled) return;
|
|
105
|
+
const t = Math.min(1, (now - startTime) / DURATION_MS);
|
|
106
|
+
const eased = easeInOutQuad(t);
|
|
107
|
+
window.scrollTo(
|
|
108
|
+
startX + (endX - startX) * eased,
|
|
109
|
+
startY + (endY - startY) * eased,
|
|
110
|
+
);
|
|
111
|
+
if (t < 1) {
|
|
112
|
+
rafId = requestAnimationFrame(step);
|
|
113
|
+
} else {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
finish('complete');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const type of CANCEL_EVENTS) {
|
|
120
|
+
window.addEventListener(type, onCancelEvent, { passive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
activeAnimation = { target, cancel };
|
|
124
|
+
rafId = requestAnimationFrame(step);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Drop-in replacement for `target.scrollIntoView({ behavior: 'smooth', ... })`.
|
|
129
|
+
* Measures the destination itself — instantly, synchronously, before
|
|
130
|
+
* anything has had a chance to move — via `target.scrollIntoView({behavior:
|
|
131
|
+
* 'auto', ...})` (this respects `scroll-margin-*`, sticky headers, etc.
|
|
132
|
+
* exactly as the browser's own fragment navigation would), then jumps back
|
|
133
|
+
* and animates the trip.
|
|
134
|
+
*
|
|
135
|
+
* `onSettle`, when given, fires once: `'complete'` if the animation ran to
|
|
136
|
+
* the destination (or reduced motion meant an instant jump straight there),
|
|
137
|
+
* `'interrupted'` if the reader's own scroll/keyboard input cut it short, or
|
|
138
|
+
* — a rare, intentional no-op — never, if a newer call for the *same*
|
|
139
|
+
* target was already in flight (this one is simply dropped; the one already
|
|
140
|
+
* running owns the eventual settle).
|
|
141
|
+
*/
|
|
142
|
+
export function animatedScrollIntoView(
|
|
143
|
+
target: Element,
|
|
144
|
+
options: ScrollIntoViewOptions = { block: 'start' },
|
|
145
|
+
onSettle?: (reason: SettleReason) => void,
|
|
146
|
+
): void {
|
|
147
|
+
if (prefersReducedMotion()) {
|
|
148
|
+
target.scrollIntoView({ ...options, behavior: 'auto' });
|
|
149
|
+
onSettle?.('complete');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const startX = window.scrollX;
|
|
154
|
+
const startY = window.scrollY;
|
|
155
|
+
target.scrollIntoView({ ...options, behavior: 'auto' });
|
|
156
|
+
const endX = window.scrollX;
|
|
157
|
+
const endY = window.scrollY;
|
|
158
|
+
|
|
159
|
+
runAnimation(target, startX, startY, endX, endY, onSettle);
|
|
160
|
+
}
|
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
* It is a plain `<details>` disclosure: the browser gives keyboard toggle and
|
|
13
13
|
* `aria-expanded` for free; the inline script only adds Escape-to-close and
|
|
14
14
|
* outside-click-to-close.
|
|
15
|
+
*
|
|
16
|
+
* The panel also *publishes* the identity it displays: the `data-knowledge-*`
|
|
17
|
+
* and `data-engine-version` attributes carry the same values as the rendered
|
|
18
|
+
* text. A build check that wants to confirm the shipped page really carries the
|
|
19
|
+
* configured version would otherwise have to match the panel's prose and its
|
|
20
|
+
* `version · date` join, which pins a course's test to one version string and
|
|
21
|
+
* makes the check fail on every bump; a bare version substring is not even
|
|
22
|
+
* scoped to the panel, so the engine's own version elsewhere on the page can
|
|
23
|
+
* satisfy it. These attributes are exact, scoped, and stable across a bump.
|
|
15
24
|
*/
|
|
16
25
|
import { loadManifest } from '../../compiler/manifest';
|
|
17
26
|
|
|
@@ -32,7 +41,13 @@ const knowledgeLine = knowledge.date
|
|
|
32
41
|
: knowledge.version;
|
|
33
42
|
---
|
|
34
43
|
|
|
35
|
-
<details
|
|
44
|
+
<details
|
|
45
|
+
class="about"
|
|
46
|
+
data-about-panel
|
|
47
|
+
data-knowledge-version={knowledge.version}
|
|
48
|
+
data-knowledge-date={knowledge.date}
|
|
49
|
+
data-engine-version={engine.version}
|
|
50
|
+
>
|
|
36
51
|
<summary
|
|
37
52
|
class="about-toggle"
|
|
38
53
|
aria-label={`About ${material.name}`}
|
|
@@ -47,7 +62,7 @@ const knowledgeLine = knowledge.date
|
|
|
47
62
|
<dl class="about-facts">
|
|
48
63
|
<div>
|
|
49
64
|
<dt>Knowledge version</dt>
|
|
50
|
-
<dd>{knowledgeLine}</dd>
|
|
65
|
+
<dd data-knowledge-line>{knowledgeLine}</dd>
|
|
51
66
|
</div>
|
|
52
67
|
<div>
|
|
53
68
|
<dt>Engine</dt>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Site-wide "jump to an in-page anchor" animation (Step F3 follow-up). Loads
|
|
4
|
+
* unconditionally, on every page — not just lessons — since it has to catch
|
|
5
|
+
* every same-page `<a href="#…">`: a heading, a keyed equation/table/figure
|
|
6
|
+
* number, a footnote/memo marker, and plain content links with no dedicated
|
|
7
|
+
* enhancer of their own at all (the notation glossary's term/anchor links,
|
|
8
|
+
* its "see also" list, a page's table of contents, …).
|
|
9
|
+
*
|
|
10
|
+
* This calls `preventDefault()` on a qualifying click — which looks like the
|
|
11
|
+
* one thing `HeadingAnchorEnhancer.astro` and `KeyedFloatEnhancer.astro` both
|
|
12
|
+
* carry a comment warning against, since `:target`-driven CSS (the
|
|
13
|
+
* heading/equation "flash") and the sticky-header `scroll-margin` offset only
|
|
14
|
+
* fire on a genuine same-document fragment navigation — never on a
|
|
15
|
+
* `history.pushState`. That warning is about *silently* replacing the
|
|
16
|
+
* navigation with `pushState` and never actually re-triggering it. This does
|
|
17
|
+
* something safer: it measures the destination and animates there itself
|
|
18
|
+
* (`animatedScrollIntoView`, `scroll-animation.ts` — the same call
|
|
19
|
+
* `HeadingAnchorEnhancer`/`KeyedFloatEnhancer` use for their own supplementary
|
|
20
|
+
* scrolls), and only once the viewport is actually *at* that destination does
|
|
21
|
+
* it assign `location.hash` — a real fragment navigation, not `pushState`,
|
|
22
|
+
* so `:target` matches and `hashchange` fires exactly as a plain click's
|
|
23
|
+
* default action would have. Because the assignment happens after arrival,
|
|
24
|
+
* the browser's own resulting "scroll to the fragment" pass finds nothing
|
|
25
|
+
* left to move and is an invisible no-op, whenever it happens to run.
|
|
26
|
+
*
|
|
27
|
+
* An earlier version instead let the native click run and tried to catch its
|
|
28
|
+
* jump a frame later — see the note atop `scroll-animation.ts` for why that
|
|
29
|
+
* was racy in a way no amount of polling fixed, and had to be replaced by
|
|
30
|
+
* this preventDefault-and-replay approach instead.
|
|
31
|
+
*
|
|
32
|
+
* If the reader scrolls away mid-animation (`'interrupted'` below), the URL
|
|
33
|
+
* still gets a plain `pushState` — reflecting the click without dragging
|
|
34
|
+
* them back to a destination they've already scrolled past, and without the
|
|
35
|
+
* `:target` flash firing for a section they're no longer looking at.
|
|
36
|
+
*/
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
import { animatedScrollIntoView } from '../scroll-animation';
|
|
41
|
+
|
|
42
|
+
function sameDocumentFragmentId(link: HTMLAnchorElement): string | null {
|
|
43
|
+
if (link.target && link.target !== '_self') return null;
|
|
44
|
+
let url: URL;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(link.href, location.href);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
url.origin !== location.origin ||
|
|
52
|
+
url.pathname !== location.pathname ||
|
|
53
|
+
url.search !== location.search ||
|
|
54
|
+
!url.hash
|
|
55
|
+
) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return url.hash.slice(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
document.addEventListener('click', (event) => {
|
|
62
|
+
if (
|
|
63
|
+
event.defaultPrevented ||
|
|
64
|
+
event.button !== 0 ||
|
|
65
|
+
event.metaKey ||
|
|
66
|
+
event.ctrlKey ||
|
|
67
|
+
event.shiftKey ||
|
|
68
|
+
event.altKey
|
|
69
|
+
) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
74
|
+
const link = origin?.closest('a[href]');
|
|
75
|
+
if (!(link instanceof HTMLAnchorElement)) return;
|
|
76
|
+
|
|
77
|
+
const rawId = sameDocumentFragmentId(link);
|
|
78
|
+
if (!rawId) return;
|
|
79
|
+
|
|
80
|
+
let id = rawId;
|
|
81
|
+
try {
|
|
82
|
+
id = decodeURIComponent(rawId);
|
|
83
|
+
} catch {
|
|
84
|
+
/* keep the raw fragment */
|
|
85
|
+
}
|
|
86
|
+
const target = document.getElementById(id);
|
|
87
|
+
if (!target) return; // no matching element: let the browser's own no-op stand
|
|
88
|
+
|
|
89
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
90
|
+
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
animatedScrollIntoView(target, { block: 'start' }, (reason) => {
|
|
93
|
+
const hash = `#${id}`;
|
|
94
|
+
if (location.hash === hash) return;
|
|
95
|
+
if (reason === 'complete') {
|
|
96
|
+
location.hash = id;
|
|
97
|
+
} else {
|
|
98
|
+
history.pushState(null, '', hash);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
</script>
|