@xingwangzhe/stalux 1.35.11 → 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.
Files changed (43) hide show
  1. package/README.md +24 -11
  2. package/package.json +5 -2
  3. package/src/components/stalux/common/Typewriter.astro +32 -1
  4. package/src/components/stalux/posts/postContent.astro +7 -1
  5. package/src/index.ts +51 -104
  6. package/src/internal/bounded-tasks.ts +28 -0
  7. package/src/internal/compact-styles.ts +55 -13
  8. package/src/internal/font-slices.ts +150 -47
  9. package/src/internal/html-images.ts +2 -0
  10. package/src/internal/html-output.ts +98 -0
  11. package/src/internal/image-dimensions.ts +99 -74
  12. package/src/internal/page-output-cache.ts +26 -9
  13. package/src/internal/public-assets.ts +59 -0
  14. package/src/internal/runtime-cache-key.ts +14 -3
  15. package/src/internal/style-names.generated.ts +27 -26
  16. package/src/layouts/PostLayout.astro +1 -0
  17. package/src/layouts/Stalux.astro +3 -1
  18. package/src/pages/api/post.abbrlink.json.ts +0 -3
  19. package/src/pages/api/posts.json.ts +16 -19
  20. package/src/pages/openapi.json.ts +3 -44
  21. package/src/pages/posts/[post].md.ts +4 -40
  22. package/src/plugins/feature-flags.ts +8 -1
  23. package/src/scripts/navigation-font.ts +28 -0
  24. package/src/scripts/navigation.ts +4 -2
  25. package/src/scripts/page-runtime.ts +20 -6
  26. package/src/scripts/pagefind-api.ts +63 -0
  27. package/src/scripts/photoswipe.ts +13 -3
  28. package/src/scripts/random-posts.ts +12 -10
  29. package/src/scripts/search-dialog.ts +2 -1
  30. package/src/scripts/view-transitions.ts +12 -1
  31. package/src/scripts/webmcp.ts +397 -141
  32. package/src/scripts/word-list.ts +13 -3
  33. package/src/styles/base.css +7 -0
  34. package/src/styles/generated.css +1 -1
  35. package/src/utils/collections-stats.ts +9 -22
  36. package/src/utils/content-index.ts +13 -4
  37. package/src/utils/dayjs.ts +26 -11
  38. package/src/utils/feed.ts +30 -29
  39. package/src/utils/post-lookup.ts +51 -0
  40. package/src/utils/post-markdown.ts +31 -0
  41. package/src/utils/site-path.ts +4 -0
  42. package/src/utils/toc-scrollspy.ts +20 -6
  43. package/src/utils/word-count-utils.ts +45 -41
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { promises as fs } from "node:fs";
2
+ import { createReadStream, promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  export interface CachedPageOutput {
@@ -24,9 +24,27 @@ export async function createPageOutputCache(
24
24
  }
25
25
  const namespace = path.join(directory, hash.digest("hex"));
26
26
  await fs.mkdir(namespace, { recursive: true });
27
- const assetDigests = new Map<string, string>();
28
- for (const asset of assets) assetDigests.set(asset, digest(await fs.readFile(asset)));
29
- const key = (entry: string, html: string) => digest(`${entry}\0${html}`);
27
+ const knownAssets = new Set(assets);
28
+ const assetDigests = new Map<string, Promise<string | null>>();
29
+ const assetDigest = (asset: string): Promise<string | null> => {
30
+ if (!knownAssets.has(asset)) return Promise.resolve(null);
31
+ let pending = assetDigests.get(asset);
32
+ if (!pending) {
33
+ pending = (async () => {
34
+ try {
35
+ const hash = createHash("sha256");
36
+ for await (const chunk of createReadStream(asset)) hash.update(chunk);
37
+ return hash.digest("hex");
38
+ } catch {
39
+ return null;
40
+ }
41
+ })();
42
+ assetDigests.set(asset, pending);
43
+ }
44
+ return pending;
45
+ };
46
+ const key = (entry: string, html: string) =>
47
+ createHash("sha256").update(entry).update("\0").update(html).digest("hex");
30
48
  return {
31
49
  async get(entry: string, html: string): Promise<CachedPageOutput | undefined> {
32
50
  try {
@@ -37,7 +55,7 @@ export async function createPageOutputCache(
37
55
  return undefined;
38
56
  if (!Array.isArray(record.assets)) return undefined;
39
57
  for (const [asset, expected] of record.assets) {
40
- if ((assetDigests.get(asset) ?? null) !== expected) return undefined;
58
+ if ((await assetDigest(asset)) !== expected) return undefined;
41
59
  }
42
60
  if (record.font) {
43
61
  if (
@@ -66,10 +84,9 @@ export async function createPageOutputCache(
66
84
  JSON.stringify({
67
85
  ...output,
68
86
  digest: digest(output.html),
69
- assets: referencedAssets.map((asset) => [
70
- asset,
71
- assetDigests.get(asset) ?? null,
72
- ]),
87
+ assets: await Promise.all(
88
+ referencedAssets.map(async (asset) => [asset, await assetDigest(asset)]),
89
+ ),
73
90
  }),
74
91
  );
75
92
  await fs.rename(temporary, target);
@@ -0,0 +1,59 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // 背景 SVG 判别式同步
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * 把主题包的 public/background/*.svg 同步到用户项目 public/background/。
10
+ *
11
+ * 判别式(非无条件覆盖):
12
+ * - 同名文件:内容一致则跳过(幂等);内容不同才覆盖(主题更新了 SVG)
13
+ * - 用户新增的文件:保留,不删除(避免误删用户自定义背景)
14
+ * - 不做整目录 force 复制
15
+ */
16
+ export function syncBackgroundSvgs(
17
+ srcDir: string,
18
+ publicDir: string,
19
+ ): { copied: number; skipped: number } {
20
+ const srcBg = path.join(srcDir, "../public/background");
21
+ if (!existsSync(srcBg)) return { copied: 0, skipped: 0 };
22
+
23
+ const destBg = path.join(publicDir, "background");
24
+ mkdirSync(destBg, { recursive: true });
25
+
26
+ let copied = 0;
27
+ let skipped = 0;
28
+ for (const name of readdirSync(srcBg)) {
29
+ if (!name.endsWith(".svg")) continue;
30
+ const srcFile = path.join(srcBg, name);
31
+ const destFile = path.join(destBg, name);
32
+ if (existsSync(destFile)) {
33
+ const srcBuf = readFileSync(srcFile);
34
+ const destBuf = readFileSync(destFile);
35
+ if (srcBuf.equals(destBuf)) {
36
+ skipped++; // 内容一致,跳过
37
+ continue;
38
+ }
39
+ }
40
+ copyFileSync(srcFile, destFile);
41
+ copied++;
42
+ }
43
+ return { copied, skipped };
44
+ }
45
+
46
+ /** Sync packaged math fonts and licensing notices; Astro does not merge integration public directories. */
47
+ export function syncFontAssets(srcDir: string, publicDir: string): void {
48
+ const source = path.join(srcDir, "../public/fonts");
49
+ if (!existsSync(source)) return;
50
+ const destination = path.join(publicDir, "fonts");
51
+ mkdirSync(destination, { recursive: true });
52
+ for (const name of readdirSync(source)) {
53
+ if (!/\.(?:woff2|txt)$/u.test(name)) continue;
54
+ const from = path.join(source, name);
55
+ const to = path.join(destination, name);
56
+ if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) continue;
57
+ copyFileSync(from, to);
58
+ }
59
+ }
@@ -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(directory: string, root = directory): Array<readonly [string, string]> {
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-text": "s5c",
197
- "stalux-u-flex": "s5d",
198
- "stalux-u-flex-col": "s5e",
199
- "stalux-u-flex-wrap": "s5f",
200
- "stalux-u-items-center": "s5g",
201
- "stalux-u-justify-center": "s5h",
202
- "stalux-u-min-w-0": "s5i",
203
- "stalux-u-text-center": "s5j",
204
- "stalux-u-w-full": "s5k",
205
- "stalux-weather": "s5l",
206
- "stalux-weather-footer": "s5m",
207
- "stalux-weather-heading": "s5n",
208
- "stalux-weather-location": "s5o",
209
- "stalux-weather-main": "s5p",
210
- "stalux-weather-source": "s5q",
211
- "stalux-weather-status": "s5r",
212
- "stalux-word-body": "s5s",
213
- "stalux-word-card": "s5t",
214
- "stalux-word-date": "s5u",
215
- "stalux-word-footer": "s5v",
216
- "stalux-word-list-item": "s5w",
217
- "stalux-word-list-list": "s5x",
218
- "stalux-word-source-date": "s5y",
219
- "stalux-word-source-link": "s5z",
220
- "stalux-word-source-meta": "s60",
221
- "stalux-word-source-text": "s61"
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",
@@ -58,6 +58,7 @@ const walineConfig = walineSection?.serverURL
58
58
 
59
59
  <!-- Main Content -->
60
60
  <PostContent
61
+ postId={props.postId}
61
62
  title={props.title}
62
63
  description={props.description}
63
64
  titleTransitionName={props.postId === undefined
@@ -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:setup 时把 SVG 复制到用户项目 public/background/。
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,5 +1,6 @@
1
- import { getCollection } from "astro:content";
2
- import { analyzeFeatureFlags } from "@plugins/feature-flags";
1
+ import { getPostContentIndex } from "@utils/content-index";
2
+ import { sitePath } from "@utils/site-path";
3
+ import { getPostDescriptions } from "@utils/word-count-utils";
3
4
  import type { APIRoute } from "astro";
4
5
 
5
6
  export const prerender = true;
@@ -11,30 +12,26 @@ 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 getCollection("posts", ({ data }) => !data.draft);
15
+ const { posts } = await getPostContentIndex();
15
16
 
16
- const payload = await Promise.all(
17
- posts.map(async (post) => ({
18
- title: post.data.title,
19
- abbrlink: String(post.data.abbrlink),
20
- date: post.data.date ?? undefined,
21
- updated: post.data.updated ?? undefined,
22
- tags: post.data.tags ?? [],
23
- categories: post.data.categories ?? [],
24
- desc: post.data.desc ?? "",
25
- wordCount: (await analyzeFeatureFlags(post.body, logger)).wordCount,
26
- url: `/posts/${post.data.abbrlink}/`,
27
- })),
28
- );
17
+ const descriptions = await getPostDescriptions(logger);
18
+ const payload = posts.map((post) => ({
19
+ title: post.data.title,
20
+ abbrlink: String(post.data.abbrlink),
21
+ date: post.data.date ?? undefined,
22
+ updated: post.data.updated ?? undefined,
23
+ tags: post.data.tags ?? [],
24
+ categories: post.data.categories ?? [],
25
+ desc: post.data.desc ?? "",
26
+ wordCount: descriptions.get(String(post.data.abbrlink))?.wordCount ?? 0,
27
+ url: sitePath(`posts/${encodeURIComponent(String(post.data.abbrlink))}/`),
28
+ }));
29
29
 
30
30
  return new Response(JSON.stringify(payload), {
31
31
  status: 200,
32
32
  headers: {
33
33
  "Content-Type": "application/json; charset=utf-8",
34
34
  "Cache-Control": "public, max-age=0, must-revalidate",
35
- "RateLimit-Limit": "60",
36
- "RateLimit-Remaining": "59",
37
- "RateLimit-Reset": "60",
38
35
  },
39
36
  });
40
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: "Returns the normalized Markdown source for a published post.",
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
- "Static exports publish RateLimit-* documentation headers but cannot enforce a dynamic quota or emit Retry-After without a server runtime.",
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);
@@ -290,7 +290,14 @@ export function analyzeFeatureFlags(
290
290
  }
291
291
  logDetail(logger, "markdown-analysis", "cache miss; analyzing feature flags");
292
292
 
293
- const resultPromise = analyzeFeatureFlagsUncached(content);
293
+ if (import.meta.env.DEV && analysisCache.size >= 128) {
294
+ const oldest = analysisCache.keys().next().value;
295
+ if (oldest !== undefined) analysisCache.delete(oldest);
296
+ }
297
+ const resultPromise = analyzeFeatureFlagsUncached(content).catch((error: unknown) => {
298
+ if (analysisCache.get(content) === resultPromise) analysisCache.delete(content);
299
+ throw error;
300
+ });
294
301
  analysisCache.set(content, resultPromise);
295
302
  return resultPromise;
296
303
  }
@@ -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
+ }
@@ -10,7 +10,7 @@ interface SearchDialog extends HTMLElement {
10
10
  close?: () => void;
11
11
  }
12
12
 
13
- async function openSearchDialog(): Promise<void> {
13
+ async function openSearchDialog(signal: AbortSignal): Promise<void> {
14
14
  logger.debug("loading search UI");
15
15
  await Promise.all([
16
16
  import("@pagefind/component-ui"),
@@ -18,6 +18,7 @@ async function openSearchDialog(): Promise<void> {
18
18
  document.querySelector<HTMLLinkElement>("link[data-stalux-search-style]"),
19
19
  ),
20
20
  ]);
21
+ if (signal.aborted) return;
21
22
  const dialog = document.querySelector<SearchDialog>("pagefind-modal#search");
22
23
  await upgradeAndOpenSearchDialog(
23
24
  {
@@ -25,6 +26,7 @@ async function openSearchDialog(): Promise<void> {
25
26
  upgrade: (element) => customElements.upgrade(element),
26
27
  },
27
28
  dialog,
29
+ signal,
28
30
  );
29
31
  }
30
32
 
@@ -105,7 +107,7 @@ registerPageLifecycle("navigation", () => {
105
107
  event.preventDefault();
106
108
  closeNav();
107
109
  try {
108
- await openSearchDialog();
110
+ await openSearchDialog(controller.signal);
109
111
  logger.debug("search dialog opened");
110
112
  } catch (error) {
111
113
  logger.error("search dialog failed to open; next click can retry", error);
@@ -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 = () => disposePage();
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
- disposePage();
98
- target.removeEventListener("astro:page-load", handlePageLoad);
99
- target.removeEventListener("astro:before-swap", handleBeforeSwap);
100
- registry.delete(key);
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") queueTask(mountPage);
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
  }