@xingwangzhe/stalux 1.35.15 → 1.36.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.
@@ -0,0 +1,117 @@
1
+ import { parseFragment } from "parse5";
2
+ import { defineHastPlugin, type HastNode } from "satteri";
3
+
4
+ declare module "satteri" {
5
+ interface DataMap {
6
+ staluxCaptionExclusions?: Set<number>;
7
+ }
8
+ }
9
+
10
+ type Element = Extract<HastNode, { type: "element" }>;
11
+
12
+ function onlyElement(node: Element): Element | undefined {
13
+ const children = node.children.filter(
14
+ (child) => child.type !== "text" || child.value.trim().length > 0,
15
+ );
16
+ return children.length === 1 && children[0]?.type === "element" ? children[0] : undefined;
17
+ }
18
+
19
+ // Raw HTML wrappers remain sibling raw nodes in Markdown HAST. Parse a small
20
+ // structural shell to recognize paragraphs inside those wrappers as well.
21
+ function rawExclusions(root: Readonly<HastNode>): Set<number> {
22
+ const excluded = new Set<number>();
23
+ function scan(node: Readonly<HastNode>) {
24
+ if (!("children" in node)) return;
25
+ const children = node.children;
26
+ if (children.some((child) => child.type === "raw")) {
27
+ const shell = children
28
+ .map((child, index) =>
29
+ child.type === "raw"
30
+ ? child.value
31
+ : `<span data-caption-index="${index}"></span>`,
32
+ )
33
+ .join("\n");
34
+ const fragment = parseFragment(shell);
35
+ function visit(
36
+ current: typeof fragment | (typeof fragment.childNodes)[number],
37
+ protectedParent = false,
38
+ ) {
39
+ const protectedNode =
40
+ protectedParent ||
41
+ ("tagName" in current &&
42
+ ["figure", "figcaption", "pre", "code", "template"].includes(
43
+ current.tagName,
44
+ ));
45
+ if (protectedNode && "attrs" in current) {
46
+ const index = current.attrs.find((attr) => attr.name === "data-caption-index");
47
+ const child = index ? children[Number(index.value)] : undefined;
48
+ const offset = child?.position?.start.offset;
49
+ if (offset !== undefined) excluded.add(offset);
50
+ }
51
+ if ("childNodes" in current)
52
+ for (const child of current.childNodes) visit(child, protectedNode);
53
+ if ("content" in current) visit(current.content, protectedNode);
54
+ }
55
+ visit(fragment);
56
+ }
57
+ for (const child of children) scan(child);
58
+ }
59
+ scan(root);
60
+ return excluded;
61
+ }
62
+
63
+ /** Runs after PhotoSwipe so its link and all image properties remain intact. */
64
+ export const imageCaptions = defineHastPlugin({
65
+ name: "stalux-image-captions",
66
+ options: { position: true },
67
+ before(root, ctx) {
68
+ ctx.data.staluxCaptionExclusions = rawExclusions(root);
69
+ },
70
+ after(_root, ctx) {
71
+ delete ctx.data.staluxCaptionExclusions;
72
+ },
73
+ element: {
74
+ filter: ["p"],
75
+ visit(node, ctx) {
76
+ const offset = node.position?.start.offset;
77
+ if (offset !== undefined && ctx.data.staluxCaptionExclusions?.has(offset)) return;
78
+ for (
79
+ let parent: Readonly<HastNode> | undefined = ctx.parent(node);
80
+ parent;
81
+ parent = ctx.parent(parent)
82
+ ) {
83
+ if (
84
+ parent.type === "element" &&
85
+ ["figure", "figcaption", "pre", "code", "template"].includes(parent.tagName)
86
+ ) {
87
+ return;
88
+ }
89
+ }
90
+
91
+ const content = onlyElement(node);
92
+ if (!content) return;
93
+ const image = content.tagName === "a" ? onlyElement(content) : content;
94
+ if (image?.tagName !== "img") return;
95
+ const caption = String(image.properties.alt ?? "").trim();
96
+ if (!caption) return;
97
+
98
+ ctx.replaceNode(node, {
99
+ type: "element",
100
+ tagName: "figure",
101
+ properties: {
102
+ ...node.properties,
103
+ "data-stalux-image": "",
104
+ },
105
+ children: [
106
+ content,
107
+ {
108
+ type: "element",
109
+ tagName: "figcaption",
110
+ properties: {},
111
+ children: [{ type: "text", value: caption }],
112
+ },
113
+ ],
114
+ });
115
+ },
116
+ },
117
+ });
@@ -1,124 +1,139 @@
1
- /**
2
- * Keep analytics trackers in Partytown. This small main-thread bridge owns the
3
- * Astro navigation lifecycle and tells the worker about each completed page.
4
- */
1
+ /** Async native SDKs keep live DOM events out of worker proxies during Astro swaps. */
2
+
3
+ import { createClientLogger } from "./logger";
4
+
5
+ const logger = createClientLogger("analytics");
6
+
5
7
  declare global {
6
8
  interface Window {
7
9
  __staluxAnalyticsRuntime?: {
8
10
  installed?: boolean;
11
+ native?: boolean;
12
+ revision?: number;
13
+ scripts?: Map<string, Promise<void>>;
9
14
  googleId?: string;
10
15
  googleInitialized?: boolean;
11
16
  clarityId?: string;
12
- umamiKey?: string;
13
17
  previousPage?: string;
14
18
  };
15
19
  }
16
20
  }
17
21
 
22
+ type AnalyticsHost = Window & {
23
+ dataLayer?: unknown[];
24
+ gtag?: (...args: unknown[]) => void;
25
+ clarity?: ((...args: unknown[]) => void) & { q: unknown[] };
26
+ umami?: { track: (payload: Record<string, unknown>) => unknown };
27
+ };
28
+ const host = window as AnalyticsHost;
18
29
  window.__staluxAnalyticsRuntime ??= {};
19
30
  const state = window.__staluxAnalyticsRuntime;
31
+ state.scripts ??= new Map();
20
32
 
21
- function appendWorkerInline(code: string): void {
33
+ /** Never await analytics in a router callback; failed loads can retry on a later page. */
34
+ function loadTrackerScript(src: string, attributes: Record<string, string> = {}): Promise<void> {
35
+ const existing = state.scripts?.get(src);
36
+ if (existing) return existing;
22
37
  const script = document.createElement("script");
23
- script.type = "text/partytown";
24
- script.textContent = code;
25
- document.head.appendChild(script);
26
- window.dispatchEvent(new CustomEvent("ptupdate"));
27
- }
28
-
29
- function appendWorkerScript(src: string, attributes: Record<string, string> = {}): void {
30
- const script = document.createElement("script");
31
- script.type = "text/partytown";
38
+ script.async = true;
39
+ script.fetchPriority = "low";
32
40
  script.src = src;
33
41
  for (const [name, value] of Object.entries(attributes)) script.setAttribute(name, value);
34
- document.head.appendChild(script);
35
- window.dispatchEvent(new CustomEvent("ptupdate"));
42
+ const loaded = new Promise<void>((resolve, reject) => {
43
+ const finish = (error?: Error) => {
44
+ clearTimeout(timer);
45
+ script.onload = null;
46
+ script.onerror = null;
47
+ if (error) {
48
+ script.remove();
49
+ reject(error);
50
+ } else resolve();
51
+ };
52
+ const timer = setTimeout(() => finish(new Error("Analytics SDK load timed out")), 15_000);
53
+ script.onload = () => finish();
54
+ script.onerror = () => finish(new Error("Analytics SDK load failed"));
55
+ document.head.appendChild(script);
56
+ });
57
+ state.scripts?.set(src, loaded);
58
+ void loaded.catch(() => {
59
+ if (state.scripts?.get(src) === loaded) state.scripts.delete(src);
60
+ });
61
+ return loaded;
36
62
  }
37
63
 
38
64
  function initializeTrackers(): void {
39
65
  const config = document.body?.dataset;
40
66
  if (!config) return;
41
-
42
67
  if (config.staluxGaId) {
68
+ if (state.googleId !== config.staluxGaId) state.googleInitialized = false;
43
69
  state.googleId = config.staluxGaId;
70
+ host.dataLayer ??= [];
71
+ host.gtag ??= function (..._args: unknown[]) {
72
+ // biome-ignore lint/complexity/noArguments: Match the official gtag queue format.
73
+ host.dataLayer?.push(arguments);
74
+ };
75
+ if (!state.googleInitialized) {
76
+ state.googleInitialized = true;
77
+ host.gtag("js", new Date());
78
+ host.gtag("config", state.googleId, { send_page_view: false });
79
+ }
80
+ void loadTrackerScript(
81
+ `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(state.googleId)}`,
82
+ { "data-stalux-google": "true" },
83
+ ).catch(() => logger.debug("Google SDK unavailable; queued events retained"));
44
84
  }
45
-
46
85
  if (config.staluxClarityId && state.clarityId !== config.staluxClarityId) {
47
- state.clarityId = config.staluxClarityId;
48
- appendWorkerInline(`
49
- window.clarity = window.clarity || function () {
50
- (window.clarity.q = window.clarity.q || []).push(arguments);
51
- };
52
- window.clarity.q = window.clarity.q || [];
53
- `);
54
- appendWorkerScript(`https://www.clarity.ms/tag/${encodeURIComponent(state.clarityId)}`, {
86
+ host.clarity ??= Object.assign(
87
+ function (..._args: unknown[]) {
88
+ // biome-ignore lint/complexity/noArguments: Match the official Clarity queue format.
89
+ host.clarity?.q.push(arguments);
90
+ },
91
+ { q: [] as unknown[] },
92
+ );
93
+ const id = config.staluxClarityId;
94
+ state.clarityId = id;
95
+ void loadTrackerScript(`https://www.clarity.ms/tag/${encodeURIComponent(id)}`, {
55
96
  "data-stalux-clarity": "true",
56
- });
57
- }
58
-
59
- if (
60
- config.staluxUmamiId &&
61
- config.staluxUmamiUrl &&
62
- state.umamiKey !== `${config.staluxUmamiId}\n${config.staluxUmamiUrl}`
63
- ) {
64
- state.umamiKey = `${config.staluxUmamiId}\n${config.staluxUmamiUrl}`;
65
- appendWorkerScript(config.staluxUmamiUrl, {
66
- "data-stalux-umami": "true",
67
- "data-website-id": config.staluxUmamiId,
68
- "data-auto-pageview": "false",
97
+ }).catch(() => {
98
+ if (state.clarityId === id) state.clarityId = undefined;
99
+ logger.debug("Clarity SDK unavailable; a later navigation can retry");
69
100
  });
70
101
  }
71
102
  }
72
103
 
73
104
  function trackGooglePage(): void {
74
105
  if (!state.googleId) return;
75
- const page = {
106
+ host.gtag?.("event", "page_view", {
76
107
  page_location: window.location.href,
77
108
  page_path: window.location.pathname + window.location.search,
78
109
  page_title: document.title,
79
- };
80
- const initialize = !state.googleInitialized;
81
- state.googleInitialized = true;
82
- appendWorkerInline(`
83
- // Partytown 0.14 serializes HTMLCollection as its worker NodeList.
84
- // GA user-data detection checks instanceof HTMLCollection before sending page_view.
85
- window.HTMLCollection = window.HTMLCollection || window.NodeList;
86
- window.dataLayer = window.dataLayer || [];
87
- window.gtag = function () { window.dataLayer.push(arguments); };
88
- ${initialize ? `window.gtag("js", new Date());window.gtag("config", ${JSON.stringify(state.googleId)}, { send_page_view: false });` : ""}
89
- window.gtag("event", "page_view", ${JSON.stringify(page)});
90
- `);
91
- if (initialize)
92
- appendWorkerScript(
93
- `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(state.googleId)}`,
94
- { "data-stalux-google": "true" },
95
- );
110
+ });
96
111
  }
97
112
 
98
113
  function trackUmamiPage(): void {
99
- const id = document.body?.dataset.staluxUmamiId;
100
- if (!id || !state.umamiKey) return;
101
- const payload = JSON.stringify({
102
- website: id,
114
+ const config = document.body?.dataset;
115
+ if (!config?.staluxUmamiId || !config.staluxUmamiUrl) return;
116
+ // Snapshot now: a slow SDK must not attribute every queued visit to the last URL.
117
+ const payload = {
118
+ website: config.staluxUmamiId,
103
119
  url: window.location.href,
104
120
  title: document.title,
105
121
  hostname: window.location.hostname,
106
122
  screen: `${window.screen.width}x${window.screen.height}`,
107
123
  language: navigator.language,
108
124
  referrer: state.previousPage ?? document.referrer,
109
- });
110
- appendWorkerInline(`
111
- (function send(attempt) {
112
- if (window.umami?.track) window.umami.track(${payload});
113
- else if (attempt < 50) setTimeout(() => send(attempt + 1), 100);
114
- })(0);
115
- `);
125
+ };
126
+ void loadTrackerScript(config.staluxUmamiUrl, {
127
+ "data-stalux-umami": "true",
128
+ "data-website-id": config.staluxUmamiId,
129
+ // Keep SDK click/performance features; only pageviews belong to Astro's lifecycle.
130
+ "data-auto-pageview": "false",
131
+ })
132
+ .then(() => host.umami?.track(payload))
133
+ .catch(() => logger.debug("Umami SDK unavailable; navigation remains usable"));
116
134
  }
117
135
 
118
136
  function trackVercountPage(): void {
119
- // Visible counters need the current window's URL, cookie and DOM nodes.
120
- // Avoid worker startup and synchronous DOM bridges; bind fresh nodes once
121
- // per completed Astro navigation using the small upstream async script.
122
137
  document.querySelector("script[data-stalux-vercount]")?.remove();
123
138
  const script = document.createElement("script");
124
139
  script.async = true;
@@ -127,36 +142,14 @@ function trackVercountPage(): void {
127
142
  document.head.appendChild(script);
128
143
  }
129
144
 
130
- if (!state.installed) {
145
+ if (state.installed && (!state.native || state.revision !== 2)) {
146
+ // An open tab can cross a deployment boundary. Its old anonymous worker
147
+ // listeners cannot be detached safely; reload once to install the new runtime.
148
+ window.location.reload();
149
+ } else if (!state.installed) {
150
+ state.native = true;
151
+ state.revision = 2;
131
152
  state.installed = true;
132
- let sandbox: HTMLIFrameElement | undefined;
133
- // Moving an iframe with append() reloads its browsing context. Park it outside
134
- // the replaced body with moveBefore(), which preserves the running worker.
135
- // Capture runs before @astrojs/partytown's iframe recreation handler.
136
- document.addEventListener(
137
- "astro:before-swap",
138
- () => {
139
- const frame = document.body.querySelector<HTMLIFrameElement>(
140
- 'iframe[src*="~partytown/"]',
141
- );
142
- if (frame && typeof document.documentElement.moveBefore === "function") {
143
- sandbox = frame;
144
- document.documentElement.moveBefore(frame, null);
145
- } else if (!window.crossOriginIsolated) {
146
- // Atomics mode has no sandbox iframe and keeps its worker alive.
147
- state.googleInitialized = false;
148
- state.clarityId = undefined;
149
- state.umamiKey = undefined;
150
- }
151
- },
152
- true,
153
- );
154
- document.addEventListener("astro:after-swap", () => {
155
- if (sandbox) {
156
- document.body.moveBefore(sandbox, null);
157
- sandbox = undefined;
158
- }
159
- });
160
153
  document.addEventListener("astro:page-load", () => {
161
154
  initializeTrackers();
162
155
  trackGooglePage();
@@ -165,5 +158,3 @@ if (!state.installed) {
165
158
  state.previousPage = window.location.href;
166
159
  });
167
160
  }
168
-
169
- export {};
@@ -12,13 +12,24 @@ export function getArchiveDescriptionPosition(
12
12
  height: number,
13
13
  viewportWidth: number,
14
14
  viewportHeight: number,
15
- ) {
15
+ ): { left: number; top: number; maxHeight?: number } {
16
16
  const fitsRight = anchor.right + 12 + width <= viewportWidth - 16;
17
17
  const left = fitsRight ? anchor.right + 12 : anchor.left;
18
- const top = fitsRight ? anchor.top : anchor.bottom + 8;
18
+ let top = fitsRight ? anchor.top : anchor.bottom + 8;
19
+ let maxHeight: number | undefined;
20
+ if (!fitsRight && top + height > viewportHeight - 16) {
21
+ const above = Math.max(0, anchor.top - 24);
22
+ const below = Math.max(0, viewportHeight - anchor.bottom - 24);
23
+ const useBelow = below >= above;
24
+ const available = useBelow ? below : above;
25
+ const visibleHeight = Math.min(height, available);
26
+ top = useBelow ? anchor.bottom + 8 : anchor.top - visibleHeight - 8;
27
+ if (visibleHeight < height) maxHeight = visibleHeight;
28
+ }
19
29
  return {
20
30
  left: Math.max(16, Math.min(left, viewportWidth - width - 16)),
21
- top: Math.max(16, Math.min(top, viewportHeight - height - 16)),
31
+ top: Math.max(16, fitsRight ? Math.min(top, viewportHeight - height - 16) : top),
32
+ ...(maxHeight === undefined ? {} : { maxHeight }),
22
33
  };
23
34
  }
24
35
 
@@ -71,7 +82,8 @@ export function mountArchiveDescriptions() {
71
82
  tooltip.showPopover();
72
83
  popoverOpen = true;
73
84
  }
74
- const { left, top } = getArchiveDescriptionPosition(
85
+ tooltip.style.maxHeight = "";
86
+ const { left, top, maxHeight } = getArchiveDescriptionPosition(
75
87
  link.getBoundingClientRect(),
76
88
  tooltip.offsetWidth,
77
89
  tooltip.offsetHeight,
@@ -80,6 +92,7 @@ export function mountArchiveDescriptions() {
80
92
  );
81
93
  tooltip.style.left = `${left}px`;
82
94
  tooltip.style.top = `${top}px`;
95
+ if (maxHeight !== undefined) tooltip.style.maxHeight = `${maxHeight}px`;
83
96
  };
84
97
  const linkFrom = (target: EventTarget | null) =>
85
98
  target instanceof Element
@@ -88,6 +101,9 @@ export function mountArchiveDescriptions() {
88
101
  const enter = (event: Event) => {
89
102
  if (event.type === "pointerover" && !hover.matches) return;
90
103
  const link = linkFrom(event.target);
104
+ // Touch/mouse focus must not put a top-layer popover under the pending click.
105
+ // Keyboard focus still exposes the same accessible preview.
106
+ if (event.type === "focusin" && !link?.matches(":focus-visible")) return;
91
107
  if (link && root.contains(link)) show(link);
92
108
  };
93
109
  const leave = (event: Event) => {
@@ -102,6 +118,9 @@ export function mountArchiveDescriptions() {
102
118
  const handleEscape = (event: KeyboardEvent) => {
103
119
  if (event.key === "Escape") hide();
104
120
  };
121
+ const navigate = (event: Event) => {
122
+ if (linkFrom(event.target)) hide();
123
+ };
105
124
  const scroll = (event: Event) => {
106
125
  if (event.target instanceof Node && tooltip.contains(event.target)) return;
107
126
  hide();
@@ -113,6 +132,7 @@ export function mountArchiveDescriptions() {
113
132
  root.addEventListener("pointerout", leave);
114
133
  root.addEventListener("focusin", enter);
115
134
  root.addEventListener("focusout", leave);
135
+ root.addEventListener("click", navigate, true);
116
136
  tooltip.addEventListener("pointerenter", clearClose);
117
137
  tooltip.addEventListener("pointerleave", hide);
118
138
  document.addEventListener("keydown", handleEscape);
@@ -127,6 +147,7 @@ export function mountArchiveDescriptions() {
127
147
  root.removeEventListener("pointerout", leave);
128
148
  root.removeEventListener("focusin", enter);
129
149
  root.removeEventListener("focusout", leave);
150
+ root.removeEventListener("click", navigate, true);
130
151
  tooltip.removeEventListener("pointerenter", clearClose);
131
152
  tooltip.removeEventListener("pointerleave", hide);
132
153
  document.removeEventListener("keydown", handleEscape);
@@ -10,7 +10,7 @@
10
10
  * - SSR 只渲染灰墙底色(--stalux-bg-color),不渲染任何背景图,构建产物确定。
11
11
  * - 首次加载与 VT 软导航每次都会随机选一张背景:astro:page-load 在
12
12
  * window load(首访)与 swap 完成后、过渡动画期间(软导航)触发,
13
- * 此时设置背景,过渡动画中旧图淡出、新图淡入,自然 crossfade 无跳变。
13
+ * 先低优先级解码目标背景,等待内容过渡完成后再淡入,不与内容快照争抢绘制。
14
14
  */
15
15
  import { createClientLogger } from "./logger";
16
16
 
@@ -57,13 +57,9 @@ function pickBackgroundIndex(previous: number): number {
57
57
  return index;
58
58
  }
59
59
 
60
- function crossfadeBackground(): void {
61
- if (backgroundImages.length === 0) return;
62
-
60
+ function crossfadeBackground(index: number): void {
63
61
  const activeLayer = getActiveLayer();
64
62
  const nextLayer: "a" | "b" = activeLayer === "a" ? "b" : "a";
65
- const previousIndex = Number(document.body.dataset.staluxBgIndex ?? -1);
66
- const index = pickBackgroundIndex(previousIndex);
67
63
  const next = getLayerEl(nextLayer);
68
64
  if (!next || !backgroundImages[index]) {
69
65
  logger.debug("background layer unavailable; skipped");
@@ -78,17 +74,49 @@ function crossfadeBackground(): void {
78
74
  document.body.dataset.staluxBgLayer = nextLayer;
79
75
  }
80
76
 
77
+ let generation = 0;
78
+ let prepared: Promise<number | undefined> | undefined;
79
+ let transitionFinished: Promise<unknown> = Promise.resolve();
80
+
81
+ function prepareBackground(): Promise<number | undefined> {
82
+ const previousIndex = Number(document.body.dataset.staluxBgIndex ?? -1);
83
+ const index = pickBackgroundIndex(previousIndex);
84
+ const image = new Image();
85
+ image.decoding = "async";
86
+ image.fetchPriority = "low";
87
+ image.src = backgroundImages[index] ?? "";
88
+ return image.decode().then(
89
+ () => index,
90
+ () => undefined,
91
+ );
92
+ }
93
+
81
94
  function initializeBackground(): void {
82
95
  const active = getLayerEl(getActiveLayer());
83
- if (active?.style.backgroundImage) return;
84
- crossfadeBackground();
96
+ if (!prepared && active?.style.backgroundImage) return;
97
+ const current = generation;
98
+ const ready = prepared ?? prepareBackground();
99
+ prepared = undefined;
100
+ // Decode off the navigation path, and keep the full-screen background repaint
101
+ // out of the content's View Transition. A superseded navigation never commits.
102
+ void Promise.all([ready, transitionFinished]).then(([index]) => {
103
+ if (current === generation && index !== undefined) crossfadeBackground(index);
104
+ });
85
105
  }
86
106
 
87
- // before-swap 在 View Transition 的快照之后、DOM 替换之前触发;背景层使用
88
- // transition:persist 保留,因此可以在页面内容交换期间完成双层 crossfade。
89
- // 非 VT 浏览器也会触发该生命周期,fallback 同样保持平滑。
107
+ // 背景层跨页保留;before-swap 只记录过渡完成信号,不阻塞或修改内容快照。
108
+ // page-load 再提交已解码的背景,非 VT 浏览器也采用同一清理/去重流程。
90
109
  if (!(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__) {
91
110
  (window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__ = true;
92
- document.addEventListener("astro:before-swap", crossfadeBackground);
111
+ document.addEventListener("astro:before-preparation", () => {
112
+ generation++;
113
+ const connection = (navigator as Navigator & { connection?: { saveData?: boolean } })
114
+ .connection;
115
+ prepared = connection?.saveData ? undefined : prepareBackground();
116
+ });
117
+ document.addEventListener("astro:before-swap", (event) => {
118
+ const transition = (event as Event & { viewTransition?: ViewTransition }).viewTransition;
119
+ transitionFinished = transition?.finished.catch(() => undefined) ?? Promise.resolve();
120
+ });
93
121
  document.addEventListener("astro:page-load", initializeBackground);
94
122
  }
@@ -0,0 +1,31 @@
1
+ /** Preserve EC's keyboard scroll access when an offscreen code subtree becomes rendered. */
2
+ export function mountCodeAccessibility(target: Document = document): (() => void) | undefined {
3
+ const selector = "code[data-stalux-offscreen-code]";
4
+ const codes = [...target.querySelectorAll<HTMLElement>(selector)];
5
+ if (!codes.length) return;
6
+ const update = (code: HTMLElement) => {
7
+ if (!code.isConnected) return;
8
+ const rendered =
9
+ typeof code.checkVisibility === "function"
10
+ ? code.checkVisibility({ contentVisibilityAuto: true })
11
+ : true;
12
+ if (!rendered) return;
13
+ const pre = code.parentElement;
14
+ if (
15
+ pre?.tagName === "PRE" &&
16
+ pre.scrollWidth > pre.clientWidth &&
17
+ !pre.hasAttribute("tabindex")
18
+ ) {
19
+ pre.setAttribute("tabindex", "0");
20
+ if (!pre.hasAttribute("role")) pre.setAttribute("role", "region");
21
+ }
22
+ };
23
+ const reveal = (event: Event) => {
24
+ if ((event as Event & { skipped?: boolean }).skipped) return;
25
+ if (event.target instanceof HTMLElement && event.target.matches(selector))
26
+ update(event.target);
27
+ };
28
+ for (const code of codes) update(code);
29
+ target.addEventListener("contentvisibilityautostatechange", reveal, true);
30
+ return () => target.removeEventListener("contentvisibilityautostatechange", reveal, true);
31
+ }
@@ -1,20 +1,46 @@
1
- /** Warm the destination font while the old page is still visible. Never delay navigation indefinitely. */
1
+ // Retain decoded faces across back/forward visits, without installing them in the old document.
2
+ // Bound this session cache: a blog reader can visit hundreds of distinct page subsets.
3
+ const fonts = new Map<string, { face: FontFace; ready: Promise<FontFace> }>();
4
+ const MAX_FONTS = 16;
5
+
6
+ function warmFont(href: string): Promise<FontFace> {
7
+ const cached = fonts.get(href);
8
+ if (cached) {
9
+ fonts.delete(href);
10
+ fonts.set(href, cached);
11
+ return cached.ready;
12
+ }
13
+ const face = new FontFace("LXGW WenKai-Page Subset", `url(${JSON.stringify(href)})`, {
14
+ style: "normal",
15
+ weight: "100 900",
16
+ });
17
+ const ready = face.load();
18
+ fonts.set(href, { face, ready });
19
+ if (fonts.size > MAX_FONTS) fonts.delete(fonts.keys().next().value as string);
20
+ void ready.catch(() => {
21
+ if (fonts.get(href)?.ready === ready) fonts.delete(href);
22
+ });
23
+ return ready;
24
+ }
25
+
26
+ /** Give a cached font a short head start; a slow font must not hold the destination page hostage. */
2
27
  export async function prepareNavigationFont(
3
28
  nextDocument: Document,
4
29
  signal: AbortSignal,
5
- timeoutMs = 1000,
30
+ timeoutMs = 150,
6
31
  ): Promise<void> {
7
32
  const href = nextDocument
8
33
  .querySelector<HTMLLinkElement>("link[data-stalux-page-font-preload]")
9
34
  ?.getAttribute("href");
10
35
  if (!href || signal.aborted || typeof FontFace === "undefined") return;
11
- const url = new URL(href, window.location.href);
12
- if (url.origin !== window.location.origin) return;
13
- // Loading a detached face fills the font cache without replacing the old page's font.
14
- const face = new FontFace("LXGW WenKai-Page Subset", `url(${JSON.stringify(url.href)})`, {
15
- style: "normal",
16
- weight: "100 900",
17
- });
36
+ let ready: Promise<FontFace>;
37
+ try {
38
+ const url = new URL(href, window.location.href);
39
+ if (url.origin !== window.location.origin) return;
40
+ ready = warmFont(url.href);
41
+ } catch {
42
+ return;
43
+ }
18
44
  await new Promise<void>((resolve) => {
19
45
  const finish = () => {
20
46
  clearTimeout(timer);
@@ -23,6 +49,6 @@ export async function prepareNavigationFont(
23
49
  };
24
50
  const timer = setTimeout(finish, timeoutMs);
25
51
  signal.addEventListener("abort", finish, { once: true });
26
- void face.load().then(finish, finish);
52
+ void ready.then(finish, finish);
27
53
  });
28
54
  }
@@ -0,0 +1,27 @@
1
+ /** A clock tick or background fade cannot change declarative tool definitions. */
2
+ export function affectsDeclarativeTools(records: MutationRecord[]): boolean {
3
+ const selector = "form[toolname],[form],label,slot";
4
+ const containsTools = (node: Node): boolean => {
5
+ if (!(node instanceof Element)) return false;
6
+ if (Element.prototype.matches.call(node, selector)) return true;
7
+ if (Element.prototype.querySelector.call(node, selector)) return true;
8
+ // An already-created open shadow tree can be inserted along with its host.
9
+ for (const element of [node, ...Element.prototype.querySelectorAll.call(node, "*")]) {
10
+ if (Reflect.get(Element.prototype, "shadowRoot", element)) return true;
11
+ }
12
+ return false;
13
+ };
14
+ return records.some((record) => {
15
+ if (
16
+ record.type === "attributes" &&
17
+ (record.attributeName === "form" || record.attributeName === "toolname")
18
+ )
19
+ return true;
20
+ const element =
21
+ record.target instanceof Element ? record.target : record.target.parentElement;
22
+ if (element && Element.prototype.closest.call(element, selector)) return true;
23
+ if (record.type === "childList")
24
+ return [...record.addedNodes, ...record.removedNodes].some(containsTools);
25
+ return false;
26
+ });
27
+ }