@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.
- package/README.md +7 -5
- package/package.json +2 -2
- package/src/components/stalux/archives/archivesList.astro +2 -13
- package/src/components/stalux/layout/head.astro +3 -3
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/config.ts +1 -1
- package/src/env.d.ts +9 -0
- package/src/expressive-code.ts +9 -2
- package/src/index.ts +22 -14
- package/src/internal/html-output.ts +4 -1
- package/src/internal/html-styles.ts +39 -0
- package/src/internal/offscreen-layout.ts +117 -0
- package/src/internal/project-mode.ts +7 -0
- package/src/internal/search-style.ts +29 -0
- package/src/internal/style-names.generated.ts +13 -14
- package/src/internal/webmcp-mutations.ts +37 -0
- package/src/plugins/image-captions.ts +117 -0
- package/src/scripts/analytics-runtime.ts +94 -103
- package/src/scripts/archive-descriptions.ts +25 -4
- package/src/scripts/background.ts +40 -12
- package/src/scripts/code-accessibility.ts +31 -0
- package/src/scripts/navigation-font.ts +36 -10
- package/src/scripts/webmcp-mutations.ts +27 -0
- package/src/styles/base.css +10 -0
- package/src/styles/content.css +8 -0
- package/src/styles/generated-archives.css +1 -1
- package/src/styles/generated-article.css +1 -1
- package/src/styles/generated-prose.css +1 -1
- package/src/styles/generated.css +1 -1
- package/src/styles/motion.css +0 -11
|
@@ -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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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.
|
|
24
|
-
script.
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
88
|
-
//
|
|
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-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
+
}
|