@xingwangzhe/stalux 1.35.12 → 1.35.13
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 +24 -11
- package/package.json +2 -1
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +49 -101
- package/src/internal/font-slices.ts +125 -31
- package/src/internal/html-images.ts +2 -0
- package/src/internal/html-output.ts +98 -0
- package/src/internal/image-dimensions.ts +99 -74
- package/src/internal/page-output-cache.ts +10 -2
- package/src/internal/public-assets.ts +59 -0
- package/src/internal/runtime-cache-key.ts +14 -3
- package/src/internal/style-names.generated.ts +27 -26
- package/src/layouts/PostLayout.astro +1 -0
- package/src/layouts/Stalux.astro +3 -1
- package/src/pages/api/post.abbrlink.json.ts +0 -3
- package/src/pages/api/posts.json.ts +4 -6
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +385 -136
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/post-lookup.ts +23 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
declare const __STALUX_RUNTIME_CACHE_KEY__: string;
|
|
@@ -17,10 +17,15 @@ export function hashRuntimeSources(sources: ReadonlyArray<readonly [string, stri
|
|
|
17
17
|
return hash.digest("hex").slice(0, 16);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function collectFiles(
|
|
20
|
+
function collectFiles(
|
|
21
|
+
directory: string,
|
|
22
|
+
root = directory,
|
|
23
|
+
include = (_name: string) => true,
|
|
24
|
+
): Array<readonly [string, string]> {
|
|
21
25
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
22
26
|
const target = path.join(directory, entry.name);
|
|
23
|
-
if (entry.isDirectory()) return collectFiles(target, root);
|
|
27
|
+
if (entry.isDirectory()) return collectFiles(target, root, include);
|
|
28
|
+
if (!include(entry.name)) return [];
|
|
24
29
|
return [[path.relative(root, target), readFileSync(target, "utf8")] as const];
|
|
25
30
|
});
|
|
26
31
|
}
|
|
@@ -30,9 +35,15 @@ export function createRuntimeCacheKey(
|
|
|
30
35
|
packageJson: string,
|
|
31
36
|
stylesDirectory?: string,
|
|
32
37
|
styleMapping?: string,
|
|
38
|
+
configDirectory?: string,
|
|
33
39
|
): string {
|
|
34
40
|
return hashRuntimeSources([
|
|
35
41
|
...collectFiles(scriptsDirectory),
|
|
42
|
+
...(configDirectory && existsSync(configDirectory)
|
|
43
|
+
? collectFiles(configDirectory, configDirectory, (name) => /\.ya?ml$/iu.test(name)).map(
|
|
44
|
+
([name, contents]) => [`config/${name}`, contents] as const,
|
|
45
|
+
)
|
|
46
|
+
: []),
|
|
36
47
|
...(stylesDirectory
|
|
37
48
|
? readdirSync(stylesDirectory)
|
|
38
49
|
.filter((name) => /^generated(?:-[a-z]+)?\.css$/u.test(name))
|
|
@@ -193,32 +193,33 @@ export const styleNames: { classes: Record<string, string>; variables: Record<st
|
|
|
193
193
|
"stalux-toc-title": "s59",
|
|
194
194
|
"stalux-typewriter": "s5a",
|
|
195
195
|
"stalux-typewriter-accessible": "s5b",
|
|
196
|
-
"stalux-typewriter-
|
|
197
|
-
"stalux-
|
|
198
|
-
"stalux-u-flex
|
|
199
|
-
"stalux-u-flex-
|
|
200
|
-
"stalux-u-
|
|
201
|
-
"stalux-u-
|
|
202
|
-
"stalux-u-
|
|
203
|
-
"stalux-u-
|
|
204
|
-
"stalux-u-
|
|
205
|
-
"stalux-
|
|
206
|
-
"stalux-weather
|
|
207
|
-
"stalux-weather-
|
|
208
|
-
"stalux-weather-
|
|
209
|
-
"stalux-weather-
|
|
210
|
-
"stalux-weather-
|
|
211
|
-
"stalux-weather-
|
|
212
|
-
"stalux-
|
|
213
|
-
"stalux-word-
|
|
214
|
-
"stalux-word-
|
|
215
|
-
"stalux-word-
|
|
216
|
-
"stalux-word-
|
|
217
|
-
"stalux-word-list-
|
|
218
|
-
"stalux-word-
|
|
219
|
-
"stalux-word-source-
|
|
220
|
-
"stalux-word-source-
|
|
221
|
-
"stalux-word-source-
|
|
196
|
+
"stalux-typewriter-reserve": "s5c",
|
|
197
|
+
"stalux-typewriter-text": "s5d",
|
|
198
|
+
"stalux-u-flex": "s5e",
|
|
199
|
+
"stalux-u-flex-col": "s5f",
|
|
200
|
+
"stalux-u-flex-wrap": "s5g",
|
|
201
|
+
"stalux-u-items-center": "s5h",
|
|
202
|
+
"stalux-u-justify-center": "s5i",
|
|
203
|
+
"stalux-u-min-w-0": "s5j",
|
|
204
|
+
"stalux-u-text-center": "s5k",
|
|
205
|
+
"stalux-u-w-full": "s5l",
|
|
206
|
+
"stalux-weather": "s5m",
|
|
207
|
+
"stalux-weather-footer": "s5n",
|
|
208
|
+
"stalux-weather-heading": "s5o",
|
|
209
|
+
"stalux-weather-location": "s5p",
|
|
210
|
+
"stalux-weather-main": "s5q",
|
|
211
|
+
"stalux-weather-source": "s5r",
|
|
212
|
+
"stalux-weather-status": "s5s",
|
|
213
|
+
"stalux-word-body": "s5t",
|
|
214
|
+
"stalux-word-card": "s5u",
|
|
215
|
+
"stalux-word-date": "s5v",
|
|
216
|
+
"stalux-word-footer": "s5w",
|
|
217
|
+
"stalux-word-list-item": "s5x",
|
|
218
|
+
"stalux-word-list-list": "s5y",
|
|
219
|
+
"stalux-word-source-date": "s5z",
|
|
220
|
+
"stalux-word-source-link": "s60",
|
|
221
|
+
"stalux-word-source-meta": "s61",
|
|
222
|
+
"stalux-word-source-text": "s62"
|
|
222
223
|
},
|
|
223
224
|
"variables": {
|
|
224
225
|
"--accent-20p": "--s0",
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -36,7 +36,7 @@ const aiConfig = getAiDiscoveryData(configEntries);
|
|
|
36
36
|
const htmlLang = siteConfig.lang || "zh-CN";
|
|
37
37
|
|
|
38
38
|
// 背景 SVG 以静态 URL 硬编码在 background.ts(纯客户端),
|
|
39
|
-
// 集成在 config:
|
|
39
|
+
// 集成在 config:done 时把 SVG 复制到用户项目最终的 publicDir/background/。
|
|
40
40
|
// SSR 不渲染背景图,默认露出 .bg-layer 的灰墙底色(--stalux-bg-color),
|
|
41
41
|
// 客户端运行时随机选图;不经过 import.meta.glob 资源管线,
|
|
42
42
|
// 保证增量构建依赖图 hash 稳定。
|
|
@@ -77,6 +77,8 @@ const routeAnimation = getRouteAnimation(routeKind);
|
|
|
77
77
|
data-stalux-site-url={siteConfig.url}
|
|
78
78
|
data-stalux-site-description={siteConfig.description}
|
|
79
79
|
data-stalux-site-lang={siteConfig.lang}
|
|
80
|
+
data-stalux-markdown-export={String(promoteConfig?.export_md === true)}
|
|
81
|
+
data-stalux-webmcp-enabled={String(aiConfig?.conformance !== "disabled")}
|
|
80
82
|
data-stalux-ga-id={headConfig?.googleAnalyticsId}
|
|
81
83
|
data-stalux-clarity-id={headConfig?.bingClarityId}
|
|
82
84
|
data-stalux-umami-id={headConfig?.umami?.id}
|
|
@@ -16,9 +16,6 @@ export const GET: APIRoute = async () => {
|
|
|
16
16
|
headers: {
|
|
17
17
|
"Content-Type": "application/json; charset=utf-8",
|
|
18
18
|
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
19
|
-
"RateLimit-Limit": "60",
|
|
20
|
-
"RateLimit-Remaining": "59",
|
|
21
|
-
"RateLimit-Reset": "60",
|
|
22
19
|
},
|
|
23
20
|
});
|
|
24
21
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getPostContentIndex } from "@utils/content-index";
|
|
2
|
+
import { sitePath } from "@utils/site-path";
|
|
2
3
|
import { getPostDescriptions } from "@utils/word-count-utils";
|
|
3
4
|
import type { APIRoute } from "astro";
|
|
4
5
|
|
|
@@ -11,7 +12,7 @@ export const prerender = true;
|
|
|
11
12
|
* 是 stalux_get_post / stalux_random_post 等 WebMCP 工具的廉价数据源。
|
|
12
13
|
*/
|
|
13
14
|
export const GET: APIRoute = async ({ logger }) => {
|
|
14
|
-
const posts = await
|
|
15
|
+
const { posts } = await getPostContentIndex();
|
|
15
16
|
|
|
16
17
|
const descriptions = await getPostDescriptions(logger);
|
|
17
18
|
const payload = posts.map((post) => ({
|
|
@@ -23,7 +24,7 @@ export const GET: APIRoute = async ({ logger }) => {
|
|
|
23
24
|
categories: post.data.categories ?? [],
|
|
24
25
|
desc: post.data.desc ?? "",
|
|
25
26
|
wordCount: descriptions.get(String(post.data.abbrlink))?.wordCount ?? 0,
|
|
26
|
-
url:
|
|
27
|
+
url: sitePath(`posts/${encodeURIComponent(String(post.data.abbrlink))}/`),
|
|
27
28
|
}));
|
|
28
29
|
|
|
29
30
|
return new Response(JSON.stringify(payload), {
|
|
@@ -31,9 +32,6 @@ export const GET: APIRoute = async ({ logger }) => {
|
|
|
31
32
|
headers: {
|
|
32
33
|
"Content-Type": "application/json; charset=utf-8",
|
|
33
34
|
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
34
|
-
"RateLimit-Limit": "60",
|
|
35
|
-
"RateLimit-Remaining": "59",
|
|
36
|
-
"RateLimit-Reset": "60",
|
|
37
35
|
},
|
|
38
36
|
});
|
|
39
37
|
};
|
|
@@ -7,40 +7,6 @@ const responseSchema = {
|
|
|
7
7
|
items: { type: "object", additionalProperties: true },
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
const problemSchema = {
|
|
11
|
-
type: "object",
|
|
12
|
-
required: ["type", "title", "status", "code", "detail"],
|
|
13
|
-
properties: {
|
|
14
|
-
type: { type: "string", format: "uri", description: "Stable problem type URI." },
|
|
15
|
-
title: { type: "string" },
|
|
16
|
-
status: { type: "integer", minimum: 400, maximum: 599 },
|
|
17
|
-
code: { type: "string", description: "Machine-readable error code." },
|
|
18
|
-
detail: { type: "string" },
|
|
19
|
-
resolution: { type: "string" },
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const rateLimitHeaders = {
|
|
24
|
-
"RateLimit-Limit": {
|
|
25
|
-
description: "Maximum requests in the advertised window.",
|
|
26
|
-
schema: { type: "integer" },
|
|
27
|
-
},
|
|
28
|
-
"RateLimit-Remaining": {
|
|
29
|
-
description: "Remaining requests in the advertised window.",
|
|
30
|
-
schema: { type: "integer" },
|
|
31
|
-
},
|
|
32
|
-
"RateLimit-Reset": {
|
|
33
|
-
description: "Seconds until the advertised window resets.",
|
|
34
|
-
schema: { type: "integer" },
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const problemResponse = (description: string, status: number) => ({
|
|
39
|
-
description,
|
|
40
|
-
content: { "application/problem+json": { schema: problemSchema } },
|
|
41
|
-
headers: status === 429 ? { "Retry-After": { schema: { type: "integer" } } } : undefined,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
10
|
export const GET: APIRoute = async ({ site }) => {
|
|
45
11
|
const server = site?.toString().replace(/\/$/, "") || "";
|
|
46
12
|
const document = {
|
|
@@ -68,10 +34,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
68
34
|
"200": {
|
|
69
35
|
description: "Published post metadata",
|
|
70
36
|
content: { "application/json": { schema: responseSchema } },
|
|
71
|
-
headers: rateLimitHeaders,
|
|
72
37
|
},
|
|
73
|
-
"400": problemResponse("Invalid request.", 400),
|
|
74
|
-
"429": problemResponse("Rate limit exceeded.", 429),
|
|
75
38
|
},
|
|
76
39
|
},
|
|
77
40
|
},
|
|
@@ -85,10 +48,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
85
48
|
"200": {
|
|
86
49
|
description: "Published post identifiers",
|
|
87
50
|
content: { "application/json": { schema: responseSchema } },
|
|
88
|
-
headers: rateLimitHeaders,
|
|
89
51
|
},
|
|
90
|
-
"400": problemResponse("Invalid request.", 400),
|
|
91
|
-
"429": problemResponse("Rate limit exceeded.", 429),
|
|
92
52
|
},
|
|
93
53
|
},
|
|
94
54
|
},
|
|
@@ -97,7 +57,8 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
97
57
|
tags: ["Posts"],
|
|
98
58
|
operationId: "readPostMarkdown",
|
|
99
59
|
summary: "Read one post as Markdown",
|
|
100
|
-
description:
|
|
60
|
+
description:
|
|
61
|
+
"Returns normalized Markdown when promote.export_md is enabled. Otherwise this file is absent; HTTP errors and their body format are controlled by the static host.",
|
|
101
62
|
parameters: [
|
|
102
63
|
{
|
|
103
64
|
name: "abbrlink",
|
|
@@ -112,8 +73,6 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
112
73
|
description: "Markdown post content",
|
|
113
74
|
content: { "text/markdown": { schema: { type: "string" } } },
|
|
114
75
|
},
|
|
115
|
-
"404": problemResponse("Post not found.", 404),
|
|
116
|
-
"429": problemResponse("Rate limit exceeded.", 429),
|
|
117
76
|
},
|
|
118
77
|
},
|
|
119
78
|
},
|
|
@@ -124,7 +83,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
124
83
|
deprecation:
|
|
125
84
|
"Breaking changes are announced in the documentation and a Deprecation/Sunset response header may be added before removal.",
|
|
126
85
|
rateLimits:
|
|
127
|
-
"
|
|
86
|
+
"These files are static. Stalux does not enforce per-client quotas or generate dynamic rate-limit/error responses; any hosting limits belong to the deployment provider.",
|
|
128
87
|
},
|
|
129
88
|
};
|
|
130
89
|
|
|
@@ -1,49 +1,13 @@
|
|
|
1
1
|
import { type CollectionEntry, getCollection } from "astro:content";
|
|
2
|
+
import { getRuntimeCacheKey } from "@internal/runtime-cache-key";
|
|
2
3
|
import { type ConfigMap, isMarkdownExportEnabled, loadConfig } from "@utils/ai-discovery";
|
|
3
4
|
import { buildCCLink, buildCCName } from "@utils/cc";
|
|
4
5
|
import { createTranslator } from "@utils/i18n";
|
|
6
|
+
import { reconstructMarkdown } from "@utils/post-markdown";
|
|
5
7
|
import type { APIRoute, GetStaticPaths } from "astro";
|
|
6
8
|
|
|
7
9
|
export const prerender = true;
|
|
8
10
|
|
|
9
|
-
function reconstructMarkdown(post: CollectionEntry<"posts">): string {
|
|
10
|
-
const d = post.data;
|
|
11
|
-
const lines: string[] = ["---"];
|
|
12
|
-
|
|
13
|
-
lines.push(`title: ${yamlScalar(d.title)}`);
|
|
14
|
-
if (d.tags?.length)
|
|
15
|
-
lines.push(`tags:\n${d.tags.map((t) => ` - ${yamlScalar(t)}`).join("\n")}`);
|
|
16
|
-
if (d.categories?.length)
|
|
17
|
-
lines.push(`categories:\n${d.categories.map((c) => ` - ${yamlScalar(c)}`).join("\n")}`);
|
|
18
|
-
lines.push(`date: ${yamlScalar(d.date)}`);
|
|
19
|
-
if (d.updated) lines.push(`updated: ${yamlScalar(d.updated)}`);
|
|
20
|
-
lines.push(`desc: ${yamlScalar(d.desc)}`);
|
|
21
|
-
lines.push(`abbrlink: ${yamlScalar(String(d.abbrlink))}`);
|
|
22
|
-
if (d.draft) lines.push(`draft: true`);
|
|
23
|
-
if (d.cc && d.cc !== "CC-BY-NC-SA-4.0") lines.push(`cc: ${yamlScalar(d.cc)}`);
|
|
24
|
-
if (d.cover) lines.push(`cover: ${yamlScalar(d.cover)}`);
|
|
25
|
-
|
|
26
|
-
lines.push("---", "");
|
|
27
|
-
const body = typeof post.body === "string" ? post.body : "";
|
|
28
|
-
return lines.join("\n") + body + (body.endsWith("\n") ? "" : "\n");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function yamlScalar(value: string): string {
|
|
32
|
-
if (
|
|
33
|
-
value === "" ||
|
|
34
|
-
/[:#{}[\],&*?|>!%@`]/.test(value) ||
|
|
35
|
-
value.includes("\n") ||
|
|
36
|
-
value.startsWith(" ") ||
|
|
37
|
-
value.endsWith(" ") ||
|
|
38
|
-
value === "true" ||
|
|
39
|
-
value === "false" ||
|
|
40
|
-
value === "null"
|
|
41
|
-
) {
|
|
42
|
-
return JSON.stringify(value);
|
|
43
|
-
}
|
|
44
|
-
return value;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
11
|
/** 生成 Markdown 格式的版权脚注 */
|
|
48
12
|
function generateCCFooter(post: CollectionEntry<"posts">, config: ConfigMap): string {
|
|
49
13
|
const siteConfig = config.get("site") ?? {};
|
|
@@ -75,7 +39,7 @@ export const getStaticPaths = (async () => {
|
|
|
75
39
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
76
40
|
return posts.map((post) => ({
|
|
77
41
|
params: { post: String(post.data.abbrlink) },
|
|
78
|
-
cacheKey: post.data.updated ?? post.data.date
|
|
42
|
+
cacheKey: `${post.data.updated ?? post.data.date}|${getRuntimeCacheKey()}`,
|
|
79
43
|
props: { post },
|
|
80
44
|
}));
|
|
81
45
|
}) satisfies GetStaticPaths;
|
|
@@ -88,7 +52,7 @@ export const GET: APIRoute = async ({ props }) => {
|
|
|
88
52
|
|
|
89
53
|
// 内容集合已经提供解析后的 frontmatter 和正文,导出一份内容等价的 Markdown。
|
|
90
54
|
// 不读取 post.filePath,避免把页面端点绑定到 Node 文件系统。
|
|
91
|
-
let markdown = reconstructMarkdown(post);
|
|
55
|
+
let markdown = reconstructMarkdown(post.data, post.body);
|
|
92
56
|
|
|
93
57
|
// 追加版权脚注(不修改内容源文件)
|
|
94
58
|
const footer = generateCCFooter(post, config);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Warm the destination font while the old page is still visible. Never delay navigation indefinitely. */
|
|
2
|
+
export async function prepareNavigationFont(
|
|
3
|
+
nextDocument: Document,
|
|
4
|
+
signal: AbortSignal,
|
|
5
|
+
timeoutMs = 1000,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
const href = nextDocument
|
|
8
|
+
.querySelector<HTMLLinkElement>("link[data-stalux-page-font-preload]")
|
|
9
|
+
?.getAttribute("href");
|
|
10
|
+
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
|
+
});
|
|
18
|
+
await new Promise<void>((resolve) => {
|
|
19
|
+
const finish = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
signal.removeEventListener("abort", finish);
|
|
22
|
+
resolve();
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
25
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
26
|
+
void face.load().then(finish, finish);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -55,6 +55,7 @@ export function registerPageLifecycle(
|
|
|
55
55
|
logger.debug("lifecycle registered");
|
|
56
56
|
|
|
57
57
|
let active = true;
|
|
58
|
+
let pageGeneration = 0;
|
|
58
59
|
const registration: RuntimeRegistration = {
|
|
59
60
|
unregister: () => undefined,
|
|
60
61
|
};
|
|
@@ -72,6 +73,7 @@ export function registerPageLifecycle(
|
|
|
72
73
|
};
|
|
73
74
|
const mountPage = () => {
|
|
74
75
|
if (!active) return;
|
|
76
|
+
pageGeneration++;
|
|
75
77
|
disposePage();
|
|
76
78
|
const started = performance.now();
|
|
77
79
|
logger.debug("mount started");
|
|
@@ -86,7 +88,10 @@ export function registerPageLifecycle(
|
|
|
86
88
|
}
|
|
87
89
|
};
|
|
88
90
|
const handlePageLoad = () => mountPage();
|
|
89
|
-
const handleBeforeSwap = () =>
|
|
91
|
+
const handleBeforeSwap = () => {
|
|
92
|
+
pageGeneration++;
|
|
93
|
+
disposePage();
|
|
94
|
+
};
|
|
90
95
|
|
|
91
96
|
target.addEventListener("astro:page-load", handlePageLoad);
|
|
92
97
|
target.addEventListener("astro:before-swap", handleBeforeSwap);
|
|
@@ -94,15 +99,24 @@ export function registerPageLifecycle(
|
|
|
94
99
|
registration.unregister = () => {
|
|
95
100
|
if (!active) return;
|
|
96
101
|
active = false;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
try {
|
|
103
|
+
disposePage();
|
|
104
|
+
} finally {
|
|
105
|
+
target.removeEventListener("astro:page-load", handlePageLoad);
|
|
106
|
+
target.removeEventListener("astro:before-swap", handleBeforeSwap);
|
|
107
|
+
registry.delete(key);
|
|
108
|
+
}
|
|
101
109
|
};
|
|
102
110
|
registry.set(key, registration);
|
|
103
111
|
|
|
104
112
|
// Late-loaded modules can miss Astro's initial page-load event.
|
|
105
|
-
if (target.readyState === "complete")
|
|
113
|
+
if (target.readyState === "complete") {
|
|
114
|
+
const scheduledGeneration = pageGeneration;
|
|
115
|
+
queueTask(() => {
|
|
116
|
+
// A real page-load or swap supersedes this late-module fallback.
|
|
117
|
+
if (pageGeneration === scheduledGeneration) mountPage();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
106
120
|
|
|
107
121
|
return registration.unregister;
|
|
108
122
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sitePath } from "../utils/site-path";
|
|
2
|
+
import { createClientLogger } from "./logger";
|
|
3
|
+
|
|
4
|
+
export interface PagefindData {
|
|
5
|
+
url: string;
|
|
6
|
+
meta?: { title?: string };
|
|
7
|
+
plain_excerpt?: string;
|
|
8
|
+
content?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PagefindApi {
|
|
12
|
+
search: (
|
|
13
|
+
query: string | null,
|
|
14
|
+
options?: { filters?: Record<string, string> },
|
|
15
|
+
) => Promise<{
|
|
16
|
+
results: Array<{ data: () => Promise<PagefindData> }>;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Share successful imports and in-flight work; a failed attempt remains retryable. */
|
|
21
|
+
export function createPagefindLoader(
|
|
22
|
+
importModule: (url: string) => Promise<unknown> = (url) => import(/* @vite-ignore */ url),
|
|
23
|
+
origin: () => string = () => location.origin,
|
|
24
|
+
base = import.meta.env.BASE_URL,
|
|
25
|
+
): () => Promise<PagefindApi | null> {
|
|
26
|
+
let pending: Promise<PagefindApi | null> | undefined;
|
|
27
|
+
let failedAttempts = 0;
|
|
28
|
+
const logger = createClientLogger("webmcp/pagefind");
|
|
29
|
+
return () => {
|
|
30
|
+
if (!pending) {
|
|
31
|
+
pending = (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(sitePath("pagefind/pagefind.js", base), origin());
|
|
34
|
+
// Browsers can retain a failed ESM fetch; retry with a fresh module URL.
|
|
35
|
+
if (failedAttempts)
|
|
36
|
+
url.searchParams.set("stalux_retry", String(failedAttempts));
|
|
37
|
+
const loaded = await importModule(url.href);
|
|
38
|
+
const namespace = loaded as {
|
|
39
|
+
default?: PagefindApi;
|
|
40
|
+
search?: PagefindApi["search"];
|
|
41
|
+
};
|
|
42
|
+
const api = namespace.default ?? namespace;
|
|
43
|
+
if (typeof api.search !== "function")
|
|
44
|
+
throw new Error("Invalid Pagefind module");
|
|
45
|
+
return api as PagefindApi;
|
|
46
|
+
} catch {
|
|
47
|
+
failedAttempts++;
|
|
48
|
+
logger.warn(
|
|
49
|
+
"Pagefind index unavailable; build the static search index and retry",
|
|
50
|
+
);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
const attempt = pending;
|
|
55
|
+
void attempt.then((result) => {
|
|
56
|
+
if (!result && pending === attempt) pending = undefined;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return pending;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const loadPagefind = createPagefindLoader();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getRouteKind } from "@utils/public-routes";
|
|
2
2
|
import { createClientLogger } from "./logger";
|
|
3
|
+
import { prepareNavigationFont } from "./navigation-font";
|
|
3
4
|
|
|
4
5
|
const logger = createClientLogger("view-transitions");
|
|
5
6
|
|
|
@@ -19,6 +20,9 @@ interface NavigationEvent {
|
|
|
19
20
|
from: URL;
|
|
20
21
|
to: URL;
|
|
21
22
|
direction: string;
|
|
23
|
+
loader: () => Promise<void>;
|
|
24
|
+
newDocument: Document;
|
|
25
|
+
signal: AbortSignal;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
function setNavigationState(event: NavigationEvent): void {
|
|
@@ -35,7 +39,14 @@ if (!transitionWindow.__STALUX_VT_LISTENER__) {
|
|
|
35
39
|
transitionWindow.__STALUX_VT_LISTENER__ = true;
|
|
36
40
|
|
|
37
41
|
document.addEventListener("astro:before-preparation", (event) => {
|
|
38
|
-
|
|
42
|
+
const navigation = event as unknown as NavigationEvent;
|
|
43
|
+
setNavigationState(navigation);
|
|
44
|
+
const loader = navigation.loader;
|
|
45
|
+
navigation.loader = async () => {
|
|
46
|
+
await loader();
|
|
47
|
+
if (!event.defaultPrevented)
|
|
48
|
+
await prepareNavigationFont(navigation.newDocument, navigation.signal);
|
|
49
|
+
};
|
|
39
50
|
});
|
|
40
51
|
|
|
41
52
|
document.addEventListener("astro:after-swap", () => {
|