@wxxb789/anc 0.0.1
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/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hover and focus link previews.
|
|
3
|
+
*
|
|
4
|
+
* A preview is a **projection lookup, not a page fetch**. The slug in the link's
|
|
5
|
+
* path is looked up in the page's bound SQLite snapshot through the shared lazy
|
|
6
|
+
* Worker, which returns the note's title, excerpt, author-ordered aliases, and
|
|
7
|
+
* effective language. A link whose target is not in the snapshot previews
|
|
8
|
+
* nothing, and there is no code path that could read anything else: no page is
|
|
9
|
+
* fetched, no markup is parsed, and no field outside the snapshot's
|
|
10
|
+
* `nodes`/`aliases` rows exists to show. That is the
|
|
11
|
+
* property worth keeping — a scrape-the-target design previews whatever the
|
|
12
|
+
* anchor happens to point at, which on a privacy projection is a hole rather
|
|
13
|
+
* than an inconsistency.
|
|
14
|
+
*
|
|
15
|
+
* Everything the panel shows is already on the page it links to and in the
|
|
16
|
+
* public snapshot, so a preview adds speed and never information (requirements
|
|
17
|
+
* section 14: it never replaces the underlying link). A reader with no pointer,
|
|
18
|
+
* no scripting, or no interest loses nothing.
|
|
19
|
+
*
|
|
20
|
+
* The placement arithmetic and the payload reader live in `preview-model.ts` so
|
|
21
|
+
* they can be tested without a browser; this file is the event wiring.
|
|
22
|
+
*
|
|
23
|
+
* No motion, by construction: the panel appears and disappears with no
|
|
24
|
+
* transition and no animation, so a reduced-motion reader has nothing to opt out
|
|
25
|
+
* of. The global `@media (prefers-reduced-motion: reduce)` block in `global.css`
|
|
26
|
+
* covers anything a later change adds.
|
|
27
|
+
*/
|
|
28
|
+
import { noteSlugFromPath } from "../lib/route-path.js";
|
|
29
|
+
import { placePreview, previewFragment, previewTitle } from "../lib/preview-model.js";
|
|
30
|
+
import { requestPreview } from "./snapshot-client.js";
|
|
31
|
+
/**
|
|
32
|
+
* Hover intent. Long enough that crossing a link on the way somewhere else costs
|
|
33
|
+
* nothing, short enough that a reader who paused is not kept waiting.
|
|
34
|
+
*/
|
|
35
|
+
const OPEN_DELAY_MS = 120;
|
|
36
|
+
/**
|
|
37
|
+
* The grace period between leaving the link and the panel closing.
|
|
38
|
+
*
|
|
39
|
+
* WCAG 2.2 SC 1.4.13 requires hover-triggered content to be *hoverable* — the
|
|
40
|
+
* pointer must be able to reach it, which means crossing the gap between the
|
|
41
|
+
* link and the panel without it vanishing. It applies only once the panel is
|
|
42
|
+
* visible; before that, leaving cancels outright, which is what the ticket asks
|
|
43
|
+
* for and what stops a panel appearing for a link the reader already left.
|
|
44
|
+
*/
|
|
45
|
+
const CLOSE_DELAY_MS = 160;
|
|
46
|
+
const panel = document.querySelector('#link-preview');
|
|
47
|
+
if (panel)
|
|
48
|
+
install(panel);
|
|
49
|
+
/**
|
|
50
|
+
* The previewable note behind a link, or nothing.
|
|
51
|
+
*
|
|
52
|
+
* The origin comparison, rather than an `href^="/"` selector, is what makes
|
|
53
|
+
* "same site" true instead of merely likely: `href="//example.com/notes/x/"`
|
|
54
|
+
* starts with `/` and has a perfectly good-looking note `pathname`, so a prefix
|
|
55
|
+
* test would hand a third-party origin a preview of one of *our* notes.
|
|
56
|
+
* Asking for `.origin` puts the question to the browser's own URL parser, and
|
|
57
|
+
* it also admits an absolute same-origin href, which is public and previewable
|
|
58
|
+
* and which the prefix selector silently excluded.
|
|
59
|
+
*
|
|
60
|
+
* A link to the page already open is **not** previewable, and that guard is
|
|
61
|
+
* load-bearing rather than tidy. Every `href="#section"` on a note page
|
|
62
|
+
* resolves to that note's own `pathname`, so without it the table of contents,
|
|
63
|
+
* all fifteen heading anchors, and — worst — the skip link all preview the note
|
|
64
|
+
* the reader is currently reading. On the published note page that is 29 such
|
|
65
|
+
* anchors, and the *first* Tab on the page opened a panel describing the page
|
|
66
|
+
* itself and pointed the skip link's `aria-describedby` at it. A preview of the
|
|
67
|
+
* open page is not information, it is noise in front of the one control a
|
|
68
|
+
* keyboard reader meets first. `tests/rendered-page.test.ts` gates it over the
|
|
69
|
+
* real anchors on real note pages — counted there rather than trusted from
|
|
70
|
+
* here, and it fails when this line is deleted.
|
|
71
|
+
*
|
|
72
|
+
* The slug comes back with the link rather than being re-derived at display
|
|
73
|
+
* time. One call to `noteSlugFromPath` is what makes this the single decision
|
|
74
|
+
* about whether a link is previewable — a second copy downstream would make
|
|
75
|
+
* each of them look optional, and a check nothing depends on is a check that
|
|
76
|
+
* gets deleted.
|
|
77
|
+
*/
|
|
78
|
+
function previewTarget(node) {
|
|
79
|
+
const link = node instanceof Element ? node.closest('a[href]') : undefined;
|
|
80
|
+
if (!(link instanceof HTMLAnchorElement) || link.origin !== location.origin)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (link.pathname === location.pathname)
|
|
83
|
+
return undefined;
|
|
84
|
+
// A link inside a `<dialog>` — the modal search dialog — is never previewed.
|
|
85
|
+
// The panel lives behind the modal, so a preview would be covered by it,
|
|
86
|
+
// `aria-describedby` would point at an element outside the reader's reach,
|
|
87
|
+
// and arrowing through results would download the whole SQLite runtime for
|
|
88
|
+
// nothing. `tests/preview-intent.test.ts` gates both effects.
|
|
89
|
+
if (link.closest('dialog') !== null)
|
|
90
|
+
return undefined;
|
|
91
|
+
// Only a note route carries a previewable slug. Reading the route through the
|
|
92
|
+
// shared helper is what keeps this from previewing `/tags/<tag>/` as though
|
|
93
|
+
// the tag were a note, and it means the route model moves in one place.
|
|
94
|
+
const slug = noteSlugFromPath(link.pathname);
|
|
95
|
+
return slug === undefined ? undefined : { link, slug };
|
|
96
|
+
}
|
|
97
|
+
function hide(state) {
|
|
98
|
+
clearTimeout(state.openTimer);
|
|
99
|
+
clearTimeout(state.closeTimer);
|
|
100
|
+
state.openTimer = undefined;
|
|
101
|
+
state.closeTimer = undefined;
|
|
102
|
+
// Only while the panel is showing. A description pointing at a hidden element
|
|
103
|
+
// announces nothing, so leaving the attribute behind would tell a screen
|
|
104
|
+
// reader the link has a description that is not there.
|
|
105
|
+
state.current?.removeAttribute('aria-describedby');
|
|
106
|
+
state.current = undefined;
|
|
107
|
+
state.panel.hidden = true;
|
|
108
|
+
}
|
|
109
|
+
/** Whether an event's target or `relatedTarget` is the panel or inside it. */
|
|
110
|
+
function isInPanel(panel, node) {
|
|
111
|
+
return node instanceof Node && panel.contains(node);
|
|
112
|
+
}
|
|
113
|
+
/** The pointer came back to the link, or reached the panel. */
|
|
114
|
+
function keep(state) {
|
|
115
|
+
clearTimeout(state.closeTimer);
|
|
116
|
+
state.closeTimer = undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The pointer left. A visible panel gets the grace period; a pending one does
|
|
120
|
+
* not, so a reader who brushes past a link never sees it open at all.
|
|
121
|
+
*/
|
|
122
|
+
function leave(state) {
|
|
123
|
+
if (state.current === undefined)
|
|
124
|
+
return;
|
|
125
|
+
if (state.panel.hidden)
|
|
126
|
+
hide(state);
|
|
127
|
+
else if (state.closeTimer === undefined)
|
|
128
|
+
state.closeTimer = setTimeout(() => hide(state), CLOSE_DELAY_MS);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Fill and place the panel for a previewable link, if it is still the one
|
|
132
|
+
* wanted.
|
|
133
|
+
*
|
|
134
|
+
* The re-check after the await is the reason this is not written inline: the
|
|
135
|
+
* index fetch can resolve long after the pointer has moved on, and without it a
|
|
136
|
+
* reader who brushed past a link gets a panel for it seconds later, anchored to
|
|
137
|
+
* a rectangle that may have scrolled away in the meantime.
|
|
138
|
+
*/
|
|
139
|
+
async function show(state, { link, slug }) {
|
|
140
|
+
let entry;
|
|
141
|
+
try {
|
|
142
|
+
entry = (await requestPreview(slug)).preview;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// A failed Worker, fetch, digest, or schema falls back to the static page:
|
|
146
|
+
// the link stays usable and nothing stale is shown. The client clears its
|
|
147
|
+
// failed initialization so a later intent can retry.
|
|
148
|
+
entry = null;
|
|
149
|
+
}
|
|
150
|
+
if (state.current !== link)
|
|
151
|
+
return;
|
|
152
|
+
if (entry === null) {
|
|
153
|
+
// Nothing to show: a failed index load, or a slug the projection does not
|
|
154
|
+
// carry. `current` is released because it means "the link whose preview is
|
|
155
|
+
// showing or scheduled", and after a miss neither is true. Leaving it set
|
|
156
|
+
// makes the next `pointerover` on that same link take the "already here"
|
|
157
|
+
// branch instead of retrying, and the preview never comes back.
|
|
158
|
+
//
|
|
159
|
+
// The gesture that reaches it is **moving between a link's own child
|
|
160
|
+
// elements**. `pointerout` is correctly swallowed — the pointer has not
|
|
161
|
+
// left the link — but crossing the internal boundary fires a fresh
|
|
162
|
+
// `pointerover` for the same anchor, and that is the one this releases for.
|
|
163
|
+
// An earlier version of this comment claimed no such gesture existed; it
|
|
164
|
+
// was wrong, and the mistake was testing jitter *within* one element, which
|
|
165
|
+
// fires no events at all. Reproduced on `/notes/accessibility-baseline/`
|
|
166
|
+
// moving between the collection pager's two spans: without this line the
|
|
167
|
+
// preview stays dead, with it the crossing re-arms.
|
|
168
|
+
//
|
|
169
|
+
// Not hypothetical markup: the collection pager in
|
|
170
|
+
// `src/pages/notes/[slug].astro` wraps `<span class="pager-direction">`
|
|
171
|
+
// and `<span class="pager-title">` in every anchor, and the fixture corpus
|
|
172
|
+
// builds **50** of them. `tests/rendered-page.test.ts` gates it on those
|
|
173
|
+
// real anchors.
|
|
174
|
+
state.current = undefined;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
renderPanel(state.panel, entry, link);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fill the panel with a preview entry and place it beside its link.
|
|
181
|
+
*
|
|
182
|
+
* Stateless: it reads only its arguments and the document's own geometry and
|
|
183
|
+
* language, and the caller has already decided the entry is still wanted.
|
|
184
|
+
*/
|
|
185
|
+
function renderPanel(panel, entry, link) {
|
|
186
|
+
// Text nodes throughout — `textContent`, never `innerHTML` — so every string
|
|
187
|
+
// from the snapshot stays data and is never parsed as markup.
|
|
188
|
+
const pageLanguage = document.documentElement.lang || 'en';
|
|
189
|
+
const markLanguage = (element, language) => {
|
|
190
|
+
if (language.toLowerCase() !== pageLanguage.toLowerCase())
|
|
191
|
+
element.lang = language;
|
|
192
|
+
};
|
|
193
|
+
const title = document.createElement('strong');
|
|
194
|
+
title.textContent = previewTitle(entry);
|
|
195
|
+
markLanguage(title, entry.language);
|
|
196
|
+
const excerpt = document.createElement('p');
|
|
197
|
+
excerpt.textContent = entry.excerpt;
|
|
198
|
+
markLanguage(excerpt, entry.language);
|
|
199
|
+
const fragment = previewFragment(link.hash);
|
|
200
|
+
if (fragment === undefined)
|
|
201
|
+
panel.replaceChildren(title, excerpt);
|
|
202
|
+
else {
|
|
203
|
+
// A heading-target link says which section it lands on. The projection
|
|
204
|
+
// carries nothing per heading, so this is the fragment itself rather than a
|
|
205
|
+
// de-slugged guess at the heading's prose — see `previewFragment`.
|
|
206
|
+
const section = document.createElement('span');
|
|
207
|
+
section.className = 'preview-fragment';
|
|
208
|
+
section.textContent = fragment;
|
|
209
|
+
panel.replaceChildren(title, section, excerpt);
|
|
210
|
+
}
|
|
211
|
+
// Unhidden before measuring, because a `display: none` element has no size to
|
|
212
|
+
// measure. Both happen inside this one task, so the browser paints the placed
|
|
213
|
+
// panel and never the provisional position.
|
|
214
|
+
panel.hidden = false;
|
|
215
|
+
const { left, top } = placePreview(link.getBoundingClientRect(), panel.getBoundingClientRect(), {
|
|
216
|
+
// Not `window.innerWidth`, which counts a classic scrollbar the panel
|
|
217
|
+
// cannot occupy. `clientWidth`/`clientHeight` are the box a fixed-position
|
|
218
|
+
// element is laid out in, so the arithmetic and the CSS agree on the edge.
|
|
219
|
+
width: document.documentElement.clientWidth,
|
|
220
|
+
height: document.documentElement.clientHeight,
|
|
221
|
+
});
|
|
222
|
+
panel.style.left = `${left}px`;
|
|
223
|
+
panel.style.top = `${top}px`;
|
|
224
|
+
link.setAttribute('aria-describedby', panel.id);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Arm the intent delay for a link, replacing whatever was pending.
|
|
228
|
+
*
|
|
229
|
+
* The `current === link` branch is the pointer arriving somewhere it already
|
|
230
|
+
* counts as being — onto a `<code>` or `<em>` inside the link it is already
|
|
231
|
+
* over, or back from the panel — so it cancels a pending close rather than
|
|
232
|
+
* restarting the open. Restarting would let a pointer drifting across a link's
|
|
233
|
+
* inner elements postpone the preview indefinitely.
|
|
234
|
+
*/
|
|
235
|
+
function open(state, target) {
|
|
236
|
+
if (state.current === target.link) {
|
|
237
|
+
keep(state);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
hide(state);
|
|
241
|
+
state.current = target.link;
|
|
242
|
+
state.openTimer = setTimeout(() => {
|
|
243
|
+
state.openTimer = undefined;
|
|
244
|
+
void show(state, target);
|
|
245
|
+
}, OPEN_DELAY_MS);
|
|
246
|
+
}
|
|
247
|
+
function install(panel) {
|
|
248
|
+
const state = { panel, current: undefined, openTimer: undefined, closeTimer: undefined };
|
|
249
|
+
document.addEventListener('pointerover', (event) => {
|
|
250
|
+
// Touch raises `pointerover` as part of the tap it precedes, so without this
|
|
251
|
+
// a tap would flash a panel over the page it is navigating to. A pen hovers
|
|
252
|
+
// like a mouse and is left alone.
|
|
253
|
+
if (event.pointerType === 'touch')
|
|
254
|
+
return;
|
|
255
|
+
const target = previewTarget(event.target);
|
|
256
|
+
if (target)
|
|
257
|
+
open(state, target);
|
|
258
|
+
else if (isInPanel(panel, event.target))
|
|
259
|
+
keep(state);
|
|
260
|
+
else
|
|
261
|
+
leave(state);
|
|
262
|
+
});
|
|
263
|
+
document.addEventListener('pointerout', (event) => {
|
|
264
|
+
if (event.pointerType === 'touch')
|
|
265
|
+
return;
|
|
266
|
+
// Moving deeper into the same link — a `<code>` or `<em>` inside it, or the
|
|
267
|
+
// collection pager's two spans — is not leaving it, and neither is crossing
|
|
268
|
+
// onto the panel. `relatedTarget` is the element being entered, and is null
|
|
269
|
+
// when the pointer leaves the window.
|
|
270
|
+
//
|
|
271
|
+
// Belt and braces with `open`'s `keep()`, deliberately. In the ordering
|
|
272
|
+
// browsers actually use — `pointerout` for the parent, then `pointerover`
|
|
273
|
+
// for the child — either mechanism alone suffices, and mutation testing
|
|
274
|
+
// confirms removing either one keeps the gate green. The pair matters for
|
|
275
|
+
// the opposite ordering: `pointerover` first would call `keep()` with no
|
|
276
|
+
// timer to clear, and the `pointerout` behind it would then arm a close
|
|
277
|
+
// while the pointer was still inside the link. One line against an ordering
|
|
278
|
+
// assumption is cheaper than the flicker it prevents.
|
|
279
|
+
if (previewTarget(event.relatedTarget)?.link === state.current)
|
|
280
|
+
return;
|
|
281
|
+
if (isInPanel(panel, event.relatedTarget))
|
|
282
|
+
return;
|
|
283
|
+
leave(state);
|
|
284
|
+
});
|
|
285
|
+
// `focusin`/`focusout` rather than `focus`/`blur`: the latter do not bubble, so
|
|
286
|
+
// a document-level listener would never see them. This is the half Quartz does
|
|
287
|
+
// not have at all — a keyboard reader tabbing through a backlinks list gets the
|
|
288
|
+
// same preview a pointer does.
|
|
289
|
+
//
|
|
290
|
+
// `:focus-visible` is what keeps this from firing on touch. A tap focuses the
|
|
291
|
+
// link too, so an unqualified `focusin` would flash a panel over the page the
|
|
292
|
+
// tap is navigating to — the same defect the `pointerType` guards close on the
|
|
293
|
+
// pointer path, arriving by the other door. The browser already decides whether
|
|
294
|
+
// a focus deserves a visible indicator, and that decision is exactly the one
|
|
295
|
+
// this needs: keyboard yes, touch and mouse no. Reusing it beats tracking the
|
|
296
|
+
// last input type by hand, and it is the same signal the stylesheet's focus
|
|
297
|
+
// ring uses, so a reader who sees the ring is the reader who gets the preview.
|
|
298
|
+
document.addEventListener('focusin', (event) => {
|
|
299
|
+
const target = previewTarget(event.target);
|
|
300
|
+
if (target && target.link.matches(':focus-visible'))
|
|
301
|
+
open(state, target);
|
|
302
|
+
else
|
|
303
|
+
hide(state);
|
|
304
|
+
});
|
|
305
|
+
document.addEventListener('focusout', (event) => {
|
|
306
|
+
if (state.current !== undefined && previewTarget(event.target)?.link === state.current)
|
|
307
|
+
hide(state);
|
|
308
|
+
});
|
|
309
|
+
document.addEventListener('keydown', (event) => {
|
|
310
|
+
// Not `preventDefault`: Escape belongs to the reader. Consuming it would
|
|
311
|
+
// break the search dialog's own dismissal on a page where both are open, and
|
|
312
|
+
// dismissing a tooltip is not a reason to take the key away from everything
|
|
313
|
+
// else.
|
|
314
|
+
if (event.key === 'Escape' && state.current !== undefined)
|
|
315
|
+
hide(state);
|
|
316
|
+
});
|
|
317
|
+
// `capture`, so a scroll inside any container dismisses rather than leaving the
|
|
318
|
+
// panel pinned to a rectangle that has moved. Dismissing is cheaper and
|
|
319
|
+
// steadier than re-placing on every scroll frame, and it is what a reader
|
|
320
|
+
// expects: they moved the page, so the transient thing went away.
|
|
321
|
+
//
|
|
322
|
+
// A *pending* preview is deliberately left armed, and that is not a shortcut.
|
|
323
|
+
// Tabbing to a link below the fold makes the browser scroll it into view, and
|
|
324
|
+
// the scroll event lands after `focusin` — so dismissing unconditionally would
|
|
325
|
+
// cancel the open timer and a keyboard reader would never see a preview for any
|
|
326
|
+
// link that was off screen. Nothing is stale either way: the panel is placed
|
|
327
|
+
// from a rectangle read when it is shown, not when it was scheduled.
|
|
328
|
+
const dismissIfShowing = () => {
|
|
329
|
+
if (!panel.hidden)
|
|
330
|
+
hide(state);
|
|
331
|
+
};
|
|
332
|
+
window.addEventListener('scroll', dismissIfShowing, { passive: true, capture: true });
|
|
333
|
+
window.addEventListener('resize', dismissIfShowing, { passive: true });
|
|
334
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The math runtime, in the mode that does not have one.
|
|
3
|
+
*
|
|
4
|
+
* `astro.config.mjs` aliases `src/scripts/math.ts` to this file whenever
|
|
5
|
+
* `MATH_MODE` is `'build-time'`. The mode that ships is `'client'`
|
|
6
|
+
* (`src/lib/math-mode.ts`), so this stub is bundled only when that constant is
|
|
7
|
+
* flipped. The page's
|
|
8
|
+
* `<script>` is bundled eagerly by Astro regardless of any condition around it,
|
|
9
|
+
* so the only way to keep Temml out of `dist/` is to make the module it hangs
|
|
10
|
+
* from import nothing.
|
|
11
|
+
*
|
|
12
|
+
* An empty module rather than a deleted `<script>`: the tag still exists, so the
|
|
13
|
+
* two modes emit the same page shape, and the difference between them is exactly
|
|
14
|
+
* one aliased import instead of a divergence in the template. The sibling of
|
|
15
|
+
* `src/scripts/diagram-disabled.ts`, which documents the same arrangement for
|
|
16
|
+
* diagrams.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-mode math rendering.
|
|
3
|
+
*
|
|
4
|
+
* Loaded only when `MATH_MODE` is `'client'` and only on pages that contain an
|
|
5
|
+
* expression. In the shipped mode (`'build-time'`) this file is never imported,
|
|
6
|
+
* so none of it — and none of Temml — reaches any page. See
|
|
7
|
+
* `src/lib/math-mode.ts` for what the two modes cost.
|
|
8
|
+
*
|
|
9
|
+
* ## What this deliberately does *not* copy from `src/scripts/diagram.ts`
|
|
10
|
+
*
|
|
11
|
+
* That file is the template, and two thirds of its machinery is unnecessary
|
|
12
|
+
* here. Both omissions are measurements rather than judgements:
|
|
13
|
+
*
|
|
14
|
+
* 1. **No theme re-rendering.** Mermaid bakes its palette into the SVG at render
|
|
15
|
+
* time, so a theme toggle leaves a diagram frozen in the old colours and the
|
|
16
|
+
* runtime has to re-run `initialize` and redraw. MathML does not: it inherits
|
|
17
|
+
* CSS colour like any other element, and measured, Temml's output carries no
|
|
18
|
+
* colour attribute at all except `\rule`'s hardcoded `black` — which
|
|
19
|
+
* `src/lib/math.ts` already rewrites to `currentColor` at build time and
|
|
20
|
+
* which {@link currentColorRule} applies here for the same reason. A
|
|
21
|
+
* `MutationObserver` on `data-theme` would be complexity for nothing.
|
|
22
|
+
* 2. **No serialized render passes.** The diagram runtime chains its renders
|
|
23
|
+
* because Mermaid's `render()` is async and two overlapping passes can
|
|
24
|
+
* interleave a half-painted figure. `temml.renderToString` is **synchronous**
|
|
25
|
+
* — measured, it returns a `string` rather than a `Promise` — so there is no
|
|
26
|
+
* interleaving to prevent and nothing to serialize.
|
|
27
|
+
*
|
|
28
|
+
* ## What it does keep, because these are the properties that matter
|
|
29
|
+
*
|
|
30
|
+
* 1. **Nothing loads until it is needed.** The import is deferred behind an
|
|
31
|
+
* `IntersectionObserver`, so a reader who never scrolls to an expression
|
|
32
|
+
* never fetches the ~59 KB gzip.
|
|
33
|
+
* 2. **The source stays until the MathML replaces it.** The `<code>` the
|
|
34
|
+
* renderer emitted is removed only after a successful render, so a failure at
|
|
35
|
+
* any point leaves the reader with the TeX rather than an empty span. This is
|
|
36
|
+
* load-bearing in a way it is not for diagrams: `src/lib/math.ts` sets
|
|
37
|
+
* `throwOnError: true` and a malformed expression aborts the *build*, so
|
|
38
|
+
* under client rendering there is no build-time gate at all and a throw would
|
|
39
|
+
* otherwise leave the element empty — measured.
|
|
40
|
+
*/
|
|
41
|
+
import { currentColorRules, extractStyles } from "../lib/math-output.js";
|
|
42
|
+
/** Every span the client branch of `mathPlugin` emits, display and inline. */
|
|
43
|
+
const MATH_SELECTOR = 'span.math-display > code.language-math, span.math-inline > code.language-math';
|
|
44
|
+
/** Temml is imported at most once per page, on the first expression in view. */
|
|
45
|
+
let runtime;
|
|
46
|
+
function loadTemml() {
|
|
47
|
+
runtime ??= import('temml').then((module) => module.default);
|
|
48
|
+
return runtime;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render one expression in place, leaving the source alone if anything fails.
|
|
52
|
+
*
|
|
53
|
+
* **Parsed rather than assigned through `innerHTML` on the live element**, so a
|
|
54
|
+
* failure cannot half-replace the source: the MathML is built in a detached
|
|
55
|
+
* container first, and only a container that actually produced a `<math>` root
|
|
56
|
+
* is swapped in. A `parsererror` document still has a root element, so the check
|
|
57
|
+
* is for the element this expects rather than for "something parsed".
|
|
58
|
+
*/
|
|
59
|
+
function renderInto(host, temml) {
|
|
60
|
+
const code = host.querySelector('code.language-math');
|
|
61
|
+
if (code === null)
|
|
62
|
+
return;
|
|
63
|
+
const tex = code.textContent ?? '';
|
|
64
|
+
if (tex.trim() === '')
|
|
65
|
+
return;
|
|
66
|
+
let html;
|
|
67
|
+
try {
|
|
68
|
+
// `throwOnError` is **off** here, and that is the opposite of the build-time
|
|
69
|
+
// setting for a reason the two modes do not share. At build time a throw
|
|
70
|
+
// aborts the build and names the expression, which is the strongest possible
|
|
71
|
+
// signal. In the browser there is nobody to tell: a throw would leave the
|
|
72
|
+
// element empty and the reader would lose the expression entirely.
|
|
73
|
+
//
|
|
74
|
+
// **And it is not sufficient on its own**, which is why the build-time
|
|
75
|
+
// validation in `mathPlugin` matters more than this setting does. Measured:
|
|
76
|
+
// `x^`, `x_`, `^` and `a^^` throw a `TypeError` out of Temml *regardless* of
|
|
77
|
+
// `throwOnError`, which only governs its own `ParseError` path. Under client
|
|
78
|
+
// mode those never reach a reader, because `mathPlugin` runs `renderMath` —
|
|
79
|
+
// with `throwOnError: true` — over every expression at build time, so a
|
|
80
|
+
// corpus containing one fails the build exactly as it does today.
|
|
81
|
+
//
|
|
82
|
+
// What remains for this `catch` is the case that setting cannot cover: a
|
|
83
|
+
// Temml version whose browser build disagrees with the one that validated,
|
|
84
|
+
// or an expression that renders at build time and throws in a browser. In
|
|
85
|
+
// that case the reader keeps the TeX, which is the whole point of the
|
|
86
|
+
// fallback.
|
|
87
|
+
html = temml.renderToString(tex, {
|
|
88
|
+
displayMode: host.classList.contains('math-display'),
|
|
89
|
+
throwOnError: false,
|
|
90
|
+
trust: false,
|
|
91
|
+
maxExpand: 1000,
|
|
92
|
+
maxSize: [500, 500],
|
|
93
|
+
annotate: false,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const container = document.createElement('span');
|
|
100
|
+
// **`extractStyles` before `innerHTML`, and it is the difference between 0 CSP
|
|
101
|
+
// violations and one per styled element.** At build time the sanitizer drops
|
|
102
|
+
// every `style` attribute that survives this call; here there is no sanitizer,
|
|
103
|
+
// so an unextracted `style` becomes a real attribute and `style-src 'self'`
|
|
104
|
+
// blocks it. Measured in Chromium under the shipped policy without this call:
|
|
105
|
+
// `\begin{pmatrix}` violated 4 times, `\begin{aligned}` and `\begin{array}` 4
|
|
106
|
+
// each, `\boxed` once — none of which are the two commands the received
|
|
107
|
+
// account named. With it, all four are silent.
|
|
108
|
+
container.innerHTML = currentColorRules(extractStyles(html));
|
|
109
|
+
const math = container.querySelector('math');
|
|
110
|
+
if (math === null)
|
|
111
|
+
return;
|
|
112
|
+
// The source is removed only now, which is what makes every `return` above a
|
|
113
|
+
// safe exit: the reader keeps the TeX in each of them.
|
|
114
|
+
code.remove();
|
|
115
|
+
host.append(math);
|
|
116
|
+
}
|
|
117
|
+
const hosts = [...document.querySelectorAll(MATH_SELECTOR)].map((code) => code.parentElement);
|
|
118
|
+
if (hosts.length > 0) {
|
|
119
|
+
const observer = new IntersectionObserver((entries) => {
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.isIntersecting)
|
|
122
|
+
continue;
|
|
123
|
+
observer.unobserve(entry.target);
|
|
124
|
+
const host = entry.target;
|
|
125
|
+
void loadTemml().then((temml) => renderInto(host, temml));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
// A margin, so an expression just below the fold is typeset before it
|
|
129
|
+
// arrives rather than visibly swapping under the reader.
|
|
130
|
+
{ rootMargin: '200px' });
|
|
131
|
+
for (const host of hosts)
|
|
132
|
+
observer.observe(host);
|
|
133
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme and reader-mode toggles.
|
|
3
|
+
*
|
|
4
|
+
* Bundled by Astro as an external module, so the built HTML carries no inline
|
|
5
|
+
* script and no inline event handler. `theme-init.js` has already applied the
|
|
6
|
+
* stored preferences before paint; this module only handles later changes.
|
|
7
|
+
*
|
|
8
|
+
* Both preferences are expressed as one attribute on `<html>` that CSS already
|
|
9
|
+
* understands, so the toggles are the only JavaScript involved and their
|
|
10
|
+
* absence degrades to the system theme rather than to a broken page.
|
|
11
|
+
*/
|
|
12
|
+
/* Astro concatenates these scripts into one bundle. An import makes this file a
|
|
13
|
+
module with its own top-level scope, so TK-06 and TK-07 can each declare
|
|
14
|
+
`root`, `dialog`, or `preview` without colliding with the other's file. It was
|
|
15
|
+
a bare `export {}` until TK-16 gave the file a real import. */
|
|
16
|
+
import { THEME_DATASET, THEME_NAMES } from "../lib/translations.js";
|
|
17
|
+
/*
|
|
18
|
+
* These two keys are also read by `src/scripts/theme-init.js`, which cannot
|
|
19
|
+
* import them: it must stay a classic, import-free script to load render-
|
|
20
|
+
* blocking before first paint. Renaming either key here without changing it
|
|
21
|
+
* there silently orphans the stored preference, so `tests/design-tokens.test.ts`
|
|
22
|
+
* asserts the two files agree.
|
|
23
|
+
*
|
|
24
|
+
* **The prefix names the thing stored, not the tool that stored it**, which is
|
|
25
|
+
* plan decision D2 applied to the one identifier a reader's own browser keeps.
|
|
26
|
+
* It was this project's own name and a colon, written into the local storage
|
|
27
|
+
* of every visitor to every site built with this tool, visible in any
|
|
28
|
+
* devtools pane. Nothing needed it: `localStorage` is partitioned by origin, so
|
|
29
|
+
* two sites' keys are already distinct whatever they are called, and the prefix
|
|
30
|
+
* was buying isolation the platform provides while carrying an identity the
|
|
31
|
+
* site does not have. `publish:` describes what wrote the value and belongs to
|
|
32
|
+
* nobody.
|
|
33
|
+
*
|
|
34
|
+
* **Deliberately not configurable**, for the reason the plan gives at §4.2:
|
|
35
|
+
* making it a config key would buy nothing the origin partition does not
|
|
36
|
+
* already give, and would cost the invariant the gate below holds — that these
|
|
37
|
+
* two files agree on a literal.
|
|
38
|
+
*/
|
|
39
|
+
const THEME_KEY = 'publish:theme';
|
|
40
|
+
const READER_KEY = 'publish:reader';
|
|
41
|
+
/**
|
|
42
|
+
* `system` is the absence of a stored value, which is what lets CSS decide.
|
|
43
|
+
*
|
|
44
|
+
* Imported rather than restated. This is a *client* module, so an import here
|
|
45
|
+
* ships whatever it pulls in — but `THEME_NAMES` is a three-string array with no
|
|
46
|
+
* dependencies, and Rollup tree-shakes the locale tables beside it out of the
|
|
47
|
+
* bundle. `tests/built-routes.test.ts` proves no chrome string reaches a shipped
|
|
48
|
+
* script, so the claim is measured rather than assumed.
|
|
49
|
+
*/
|
|
50
|
+
const THEMES = THEME_NAMES;
|
|
51
|
+
const root = document.documentElement;
|
|
52
|
+
/** Storage can be unavailable or full; a failed write must not break the toggle. */
|
|
53
|
+
function persist(key, value) {
|
|
54
|
+
try {
|
|
55
|
+
if (value === null)
|
|
56
|
+
localStorage.removeItem(key);
|
|
57
|
+
else
|
|
58
|
+
localStorage.setItem(key, value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* Preference is not persisted; the current page still reflects the choice. */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function currentTheme() {
|
|
65
|
+
const value = root.dataset.theme;
|
|
66
|
+
return THEMES.includes(value) ? value : 'system';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The label carries the state in text, not only in the rendered colours.
|
|
70
|
+
*
|
|
71
|
+
* The three labels are read off the button's own `data-label-*` attributes,
|
|
72
|
+
* which `Layout.astro` filled from the translation resolved for *this document*
|
|
73
|
+
* — so a Chinese note's toggle cycles "主题:跟随系统 / 浅色 / 深色" and an
|
|
74
|
+
* English one cycles "Theme: system / light / dark", from one shared script.
|
|
75
|
+
* TK-16 could have shipped a locale table in the bundle instead; that would put
|
|
76
|
+
* both languages on the wire for every reader and grow with each language added,
|
|
77
|
+
* to say the same words the server already knew. The fallback keeps the toggle
|
|
78
|
+
* operable rather than blank if the markup and this file ever disagree.
|
|
79
|
+
*/
|
|
80
|
+
function showTheme(theme, button) {
|
|
81
|
+
button.textContent = button.dataset[THEME_DATASET[theme]] ?? theme;
|
|
82
|
+
}
|
|
83
|
+
function showReader(on, button) {
|
|
84
|
+
button.setAttribute('aria-pressed', String(on));
|
|
85
|
+
}
|
|
86
|
+
function applyTheme(theme, button) {
|
|
87
|
+
if (theme === 'system')
|
|
88
|
+
delete root.dataset.theme;
|
|
89
|
+
else
|
|
90
|
+
root.dataset.theme = theme;
|
|
91
|
+
persist(THEME_KEY, theme === 'system' ? null : theme);
|
|
92
|
+
showTheme(theme, button);
|
|
93
|
+
}
|
|
94
|
+
function applyReader(on, button) {
|
|
95
|
+
if (on)
|
|
96
|
+
root.dataset.reader = 'on';
|
|
97
|
+
else
|
|
98
|
+
delete root.dataset.reader;
|
|
99
|
+
persist(READER_KEY, on ? 'on' : null);
|
|
100
|
+
showReader(on, button);
|
|
101
|
+
}
|
|
102
|
+
/*
|
|
103
|
+
* The initial sync only reads. `currentTheme()` reflects the DOM, not storage,
|
|
104
|
+
* so if `theme-init.js` failed to load — a stale cached page against a rotated
|
|
105
|
+
* asset hash, an extension, a CDN miss — it reports `system`. Routing that
|
|
106
|
+
* through `applyTheme` would call `removeItem` and destroy a preference the
|
|
107
|
+
* user actually set, rather than merely failing to apply it this once.
|
|
108
|
+
*/
|
|
109
|
+
const themeButton = document.querySelector('#theme-toggle');
|
|
110
|
+
if (themeButton) {
|
|
111
|
+
showTheme(currentTheme(), themeButton);
|
|
112
|
+
themeButton.addEventListener('click', () => {
|
|
113
|
+
const next = THEMES[(THEMES.indexOf(currentTheme()) + 1) % THEMES.length] ?? 'system';
|
|
114
|
+
applyTheme(next, themeButton);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const readerButton = document.querySelector('#reader-toggle');
|
|
118
|
+
if (readerButton) {
|
|
119
|
+
showReader(root.dataset.reader === 'on', readerButton);
|
|
120
|
+
readerButton.addEventListener('click', () => {
|
|
121
|
+
applyReader(root.dataset.reader !== 'on', readerButton);
|
|
122
|
+
});
|
|
123
|
+
}
|