@xingwangzhe/stalux 1.27.0 → 1.28.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.
Files changed (40) hide show
  1. package/README.md +6 -6
  2. package/package.json +3 -2
  3. package/src/components/stalux/archives/archivesList.astro +34 -42
  4. package/src/components/stalux/categories/categoriesList.astro +4 -4
  5. package/src/components/stalux/common/textType.astro +1 -3
  6. package/src/components/stalux/footer/FooterBadges.astro +28 -24
  7. package/src/components/stalux/footer/FooterBeian.astro +22 -26
  8. package/src/components/stalux/footer/FooterStats.astro +28 -27
  9. package/src/components/stalux/footer/FooterThemeInfo.astro +24 -28
  10. package/src/components/stalux/layout/footer.astro +5 -14
  11. package/src/components/stalux/layout/head.astro +5 -9
  12. package/src/components/stalux/layout/navs.astro +16 -20
  13. package/src/components/stalux/links/linkList.astro +4 -4
  14. package/src/components/stalux/links/socialMedia.astro +34 -34
  15. package/src/components/stalux/posts/PostTaxonomy.astro +14 -14
  16. package/src/components/stalux/posts/WalineComment.astro +20 -464
  17. package/src/components/stalux/posts/postCC.astro +7 -7
  18. package/src/components/stalux/posts/postCard.astro +41 -41
  19. package/src/components/stalux/posts/postContent.astro +46 -41
  20. package/src/components/stalux/posts/postList.astro +4 -4
  21. package/src/components/stalux/posts/postNavigation.astro +107 -119
  22. package/src/components/stalux/posts/postToc.astro +20 -20
  23. package/src/components/stalux/tags/tagsCloud.astro +15 -15
  24. package/src/components/stalux/words/wordCard.astro +27 -32
  25. package/src/components/stalux/words/wordList.astro +18 -18
  26. package/src/i18n/en.json +1 -0
  27. package/src/i18n/zh-CN.json +1 -0
  28. package/src/index.ts +82 -34
  29. package/src/internal/font-slices.ts +151 -136
  30. package/src/layouts/PostLayout.astro +6 -6
  31. package/src/layouts/Stalux.astro +4 -8
  32. package/src/pages/about.astro +6 -6
  33. package/src/pages/categories/[category].astro +5 -1
  34. package/src/pages/posts/[post].astro +2 -2
  35. package/src/plugins/feature-flags.ts +60 -8
  36. package/src/scripts/waline.ts +32 -11
  37. package/src/styles/base/init.css +2 -1
  38. package/src/styles/base/math.css +1 -1
  39. package/src/styles/components/posts/waline.css +440 -0
  40. package/src/utils/i18n.ts +16 -5
package/src/index.ts CHANGED
@@ -8,7 +8,14 @@
8
8
  * 基于 Astro 7.1.3 Integration API(astro:config:setup / injectRoute / injectScript)
9
9
  */
10
10
 
11
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
11
+ import {
12
+ copyFileSync,
13
+ existsSync,
14
+ promises as fs,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ } from "node:fs";
12
19
  import path from "node:path";
13
20
  import { fileURLToPath } from "node:url";
14
21
 
@@ -23,7 +30,11 @@ import { createIndex as pagefindCreateIndex } from "pagefind";
23
30
  import type { StaluxOptions } from "./config";
24
31
  import { expressiveCode } from "./expressive-code";
25
32
  import { staluxComponentsAlias } from "./internal/components-plugin";
26
- import { type FontSlice, runFontSlicing } from "./internal/font-slices";
33
+ import {
34
+ clearPageFontSubsets,
35
+ resolveFontInputs,
36
+ writePageFontSubset,
37
+ } from "./internal/font-slices";
27
38
  import { createInjectedRoutes } from "./internal/injected-routes";
28
39
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
29
40
  import {
@@ -32,36 +43,23 @@ import {
32
43
  prepareSatteriProcessor,
33
44
  } from "./internal/satteri-config";
34
45
  import { createViteAliases } from "./internal/vite-aliases";
35
- import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
46
+ import {
47
+ applyHtmlImageLoadingPolicy,
48
+ featureFlagsHast,
49
+ featureFlagsMdast,
50
+ } from "./plugins/feature-flags";
36
51
  import { temml } from "./plugins/satteri-temml";
37
52
  import { describeError } from "./utils/diagnostics";
38
53
 
39
- // 字体配置(Astro Fonts API)通过 updateConfig 注入,两种模式(源码模板/npm 插件)都生效。
40
- // 官方 local provider 完全本地读文件(readFile),不联网;
41
- // 7.2.2 已修复 Fonts API 随机端口污染 incrementalBuild dependencyHash 的问题(PR #17659)。
42
- function buildFontsConfig(slices: FontSlice[], codeNormal: string, codeItalic?: string) {
54
+ // 代码字体通过 Astro Fonts API 注入;正文字体由 native N-API 按页面生成子集。
55
+ function buildFontsConfig(codeNormal: string, codeItalic?: string) {
43
56
  return [
44
- {
45
- // 正文:LXGW WenKai 按 unicode-range 分片,浏览器只下载命中区间的分片
46
- provider: fontProviders.local(),
47
- name: "LXGW WenKai",
48
- cssVariable: "--font-body",
49
- fallbacks: ["Noto Sans SC", "Noto Sans CJK SC", "system-ui", "sans-serif"],
50
- optimizedFallbacks: true,
51
- options: {
52
- variants: slices.map((slice) => ({
53
- weight: 400,
54
- style: "normal",
55
- src: [slice.src],
56
- unicodeRange: slice.unicodeRange,
57
- })),
58
- },
59
- },
60
57
  {
61
58
  // 代码:Google Sans Code 可变字体,原样注册,不切片
62
59
  provider: fontProviders.local(),
63
60
  name: "Google Sans Code",
64
61
  cssVariable: "--font-code",
62
+ display: "optional" as const,
65
63
  fallbacks: ["JetBrains Mono", "Fira Code", "Consolas", "Courier New", "monospace"],
66
64
  optimizedFallbacks: true,
67
65
  options: {
@@ -150,6 +148,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
150
148
  // 探测是否为插件模式(通过 npm 安装)vs 源码开发模式
151
149
  // 安装在 node_modules 中时,页面的文件路由不可用,需要通过 injectRoute 注入
152
150
  const isPluginMode = import.meta.url.includes("node_modules");
151
+ let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
153
152
 
154
153
  const coreIntegration: AstroIntegration = {
155
154
  name: "stalux",
@@ -330,20 +329,20 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
330
329
  }
331
330
  }
332
331
 
333
- // 7. 字体:构建期把正文切成 unicode-range 分片,通过官方 Fonts API 注入
334
- // (config:setup 阶段执行,保证 dev/build 都能生成;local provider 纯本地读文件)
332
+ // 7. 字体:解析本地字体输入;正文字体由 native N-API 在 build:done 阶段逐页生成。
335
333
  try {
336
- const sliced = await runFontSlicing(process.cwd(), fontsLogger);
334
+ const sliced = await resolveFontInputs(process.cwd(), fontsLogger);
337
335
  if (sliced) {
336
+ pageFontData = {
337
+ fontBuffer: sliced.fontBuffer,
338
+ outputDir: path.join(fileURLToPath(config.outDir), "_astro", "fonts"),
339
+ cacheDir: sliced.pageSubsetDir,
340
+ };
338
341
  updateConfig({
339
- fonts: buildFontsConfig(
340
- sliced.body,
341
- sliced.codeNormal,
342
- sliced.codeItalic,
343
- ),
342
+ fonts: buildFontsConfig(sliced.codeNormal, sliced.codeItalic),
344
343
  });
345
344
  fontsLogger.debug(
346
- `injected ${sliced.body.length} body font chunks + code font via Fonts API`,
345
+ "injected code font via Astro Fonts API; page body subsets use native N-API",
347
346
  );
348
347
  }
349
348
  } catch (error) {
@@ -353,9 +352,58 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
353
352
  },
354
353
 
355
354
  "astro:build:done": async ({ dir, logger: rootLogger }) => {
356
- const logger = rootLogger.fork("stalux/pagefind");
355
+ const logger = rootLogger.fork("stalux/build");
357
356
  const started = performance.now();
358
- const outDir = fileURLToPath(dir);
357
+ const outDir = pageFontData
358
+ ? path.dirname(path.dirname(pageFontData.outputDir))
359
+ : fileURLToPath(new URL(String(dir)));
360
+
361
+ // Astro's Markdown image transform drops arbitrary lazy-loading hints
362
+ // added during HAST processing. Reapply the policy to emitted HTML.
363
+ const htmlFiles = await fs.readdir(outDir, { recursive: true });
364
+ let optimizedImagePages = 0;
365
+ let pageFontPages = 0;
366
+ if (pageFontData) await fs.mkdir(pageFontData.outputDir, { recursive: true });
367
+ if (pageFontData) await fs.mkdir(pageFontData.cacheDir, { recursive: true });
368
+ const referencedPageFonts = new Set<string>();
369
+ for (const entry of htmlFiles) {
370
+ if (typeof entry !== "string" || !entry.endsWith(".html")) continue;
371
+ const output = path.join(outDir, entry);
372
+ const before = await fs.readFile(output, "utf8");
373
+ const withoutPageFont = before.replace(
374
+ /<style data-stalux-page-font>[\s\S]*?<\/style>/gi,
375
+ "",
376
+ );
377
+ let after = applyHtmlImageLoadingPolicy(withoutPageFont);
378
+ if (pageFontData) {
379
+ const pageFont = writePageFontSubset(
380
+ after,
381
+ pageFontData.fontBuffer,
382
+ pageFontData.cacheDir,
383
+ "/_astro/fonts/",
384
+ );
385
+ if (pageFont) {
386
+ after = pageFont.html;
387
+ pageFontPages++;
388
+ referencedPageFonts.add(pageFont.filename);
389
+ copyFileSync(
390
+ pageFont.sourcePath,
391
+ path.join(pageFontData.outputDir, pageFont.filename),
392
+ );
393
+ }
394
+ }
395
+ if (after !== before) {
396
+ await fs.writeFile(output, after);
397
+ optimizedImagePages++;
398
+ }
399
+ }
400
+ if (pageFontData) {
401
+ clearPageFontSubsets(pageFontData.outputDir, referencedPageFonts);
402
+ }
403
+ logger.info(`applied image loading hints to ${optimizedImagePages} HTML pages`);
404
+ logger.info(
405
+ `generated page-specific body font subsets for ${pageFontPages} HTML pages`,
406
+ );
359
407
 
360
408
  // 后处理:Pagefind 搜索索引。启用时任何失败都必须让构建失败,
361
409
  // 避免发布一个页面正常但搜索已损坏的产物。
@@ -1,29 +1,25 @@
1
- import { describeError } from "../utils/diagnostics";
2
1
  /**
3
- * Build-time CJK font slicing engine (Astro Fonts API variant).
2
+ * Build-time CJK font subsetting engine.
4
3
  *
5
- * Slices the full-size LXGW WenKai font (25 MB TTF) into ~20 woff2 chunks
6
- * by unicode-range, registered through the official `fontProviders.local()`
7
- * pipeline (`updateConfig({ fonts })`). The browser downloads only the chunks
8
- * whose unicode-range matches characters on the page.
4
+ * LXGW WenKai is subset per rendered page with the native N-API package. Only
5
+ * the independent Google Sans Code font uses Astro's local Fonts API.
9
6
  *
10
7
  * Astro's local provider reads font files from disk (`readFile`), so no
11
8
  * network access is involved at build time — identical behavior on GitHub
12
- * Actions and CN mirrors. Slices are emitted to `node_modules/.astro/`
13
- * (the Astro cacheDir) so they stay out of the repo and out of `dist/fonts`.
9
+ * Actions and CN mirrors. Page subset cache is emitted to `node_modules/.astro/`
10
+ * so it stays out of the repo and out of `dist/fonts`.
14
11
  *
15
12
  * Output layout:
16
- * node_modules/.astro/stalux-fonts/
17
- * lxgw-wenkai-slice-{n}-{hash}.woff2 — unicode-range chunk (content-addressed)
13
+ * node_modules/.astro/stalux-page-fonts/
14
+ * page-{hash}.woff2 — page-specific content-addressed CJK subset
18
15
  */
19
16
 
20
- import { createHash } from "node:crypto";
21
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
22
18
  import { dirname, join, resolve } from "node:path";
23
19
  import { fileURLToPath } from "node:url";
24
-
20
+ import { subsetFont } from "@xingwangzhe/cjk-font-split-native";
25
21
  import type { AstroIntegrationLogger } from "astro";
26
- import subsetFont from "subset-font";
22
+ import { type DefaultTreeAdapterMap, parse } from "parse5";
27
23
 
28
24
  // ---------------------------------------------------------------------------
29
25
  // Configuration
@@ -36,71 +32,22 @@ const FONT_INPUT = "src/assets/fonts/LXGWWenKai-Regular.ttf";
36
32
  const CODE_FONT_INPUT = "src/assets/fonts/GoogleSansCode.woff2";
37
33
  const CODE_FONT_ITALIC_INPUT = "src/assets/fonts/GoogleSansCode-Italic.woff2";
38
34
 
39
- /** Output directory for generated slices (under the Astro cacheDir) */
40
- const SLICE_OUT_DIR = "node_modules/.astro/stalux-fonts";
41
-
42
- /** Chunk size (code points) for the CJK Unified Ideographs block */
43
- const CJK_CHUNK_SIZE = 1050;
35
+ const PAGE_SLICE_OUT_DIR = "node_modules/.astro/stalux-page-fonts";
44
36
 
45
37
  // ---------------------------------------------------------------------------
46
38
  // Types
47
39
  // ---------------------------------------------------------------------------
48
40
 
49
- export interface FontSlice {
50
- /** Absolute path to the generated woff2 file */
51
- src: string;
52
- /** Unicode ranges for the @font-face unicode-range descriptor */
53
- unicodeRange: string[];
54
- }
55
-
56
- export interface SlicedFonts {
57
- /** Body font slices (LXGW WenKai), one variant per chunk */
58
- body: FontSlice[];
41
+ export interface FontInputs {
42
+ /** Build-time per-page subset cache, outside dist so incremental builds can reuse it. */
43
+ pageSubsetDir: string;
44
+ fontBuffer: Buffer;
59
45
  /** Absolute path to the code font woff2 */
60
46
  codeNormal: string;
61
47
  /** Absolute path to the code italic woff2, if present */
62
48
  codeItalic?: string;
63
49
  }
64
50
 
65
- interface SliceDef {
66
- /** Ordinal used in the output filename */
67
- id: number;
68
- /** [start, end] code-point ranges covered by this slice */
69
- ranges: Array<[number, number]>;
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // Slice definitions
74
- // ---------------------------------------------------------------------------
75
-
76
- /**
77
- * CJK Unified Ideographs (U+4E00–U+9FFF) split into fixed-size chunks.
78
- * Chunk boundaries are content-independent, so slices are deterministic
79
- * across builds (no per-page character scanning).
80
- */
81
- function buildCjkChunks(start = 0x4e00, end = 0x9fff, size = CJK_CHUNK_SIZE): SliceDef[] {
82
- const chunks: SliceDef[] = [];
83
- for (let s = start, id = 2; s <= end; s += size, id++) {
84
- chunks.push({ id, ranges: [[s, Math.min(s + size - 1, end)]] });
85
- }
86
- return chunks;
87
- }
88
-
89
- const SLICES: SliceDef[] = [
90
- // ASCII printable + Latin-1 supplement
91
- { id: 0, ranges: [[0x20, 0x7e]] },
92
- // CJK punctuation / fullwidth forms / general punctuation
93
- {
94
- id: 1,
95
- ranges: [
96
- [0x2000, 0x206f],
97
- [0x3000, 0x303f],
98
- [0xff00, 0xffef],
99
- ],
100
- },
101
- ...buildCjkChunks(),
102
- ];
103
-
104
51
  // ---------------------------------------------------------------------------
105
52
  // Helpers
106
53
  // ---------------------------------------------------------------------------
@@ -115,43 +62,22 @@ function findFont(projectRoot: string, rel: string): string | undefined {
115
62
  return candidates.find((p) => existsSync(p));
116
63
  }
117
64
 
118
- /** Render a [start, end] range as a CSS unicode-range value. */
119
- export function toUnicodeRange(ranges: Array<[number, number]>): string[] {
120
- const hex = (codePoint: number) => codePoint.toString(16).toUpperCase().padStart(4, "0");
121
- return ranges.map(([start, end]) =>
122
- start === end ? `U+${hex(start)}` : `U+${hex(start)}-${hex(end)}`,
123
- );
124
- }
125
-
126
- /** Collect every code point in the ranges as a deduped, sorted string. */
127
- function rangesToChars(ranges: Array<[number, number]>): string {
128
- const set = new Set<string>();
129
- for (const [s, e] of ranges) {
130
- for (let cp = s; cp <= e; cp++) {
131
- set.add(String.fromCodePoint(cp));
132
- }
133
- }
134
- return [...set].sort().join("");
135
- }
136
-
137
65
  // ---------------------------------------------------------------------------
138
66
  // Main entry
139
67
  // ---------------------------------------------------------------------------
140
68
 
141
69
  /**
142
- * Slice the body font into unicode-range woff2 chunks and resolve the code
143
- * fonts, all from local disk. Returns `null` when the body font is missing so
70
+ * Resolve the body-font source for native page subsetting and code fonts for
71
+ * Astro's local provider. Returns `null` when a required font is missing so
144
72
  * the caller can skip font injection (pages fall back to system fonts).
145
73
  *
146
74
  * Called from the `astro:config:setup` hook before `updateConfig({ fonts })`.
147
75
  */
148
- export async function runFontSlicing(
76
+ export async function resolveFontInputs(
149
77
  projectRoot: string,
150
78
  logger: AstroIntegrationLogger,
151
- ): Promise<SlicedFonts | null> {
79
+ ): Promise<FontInputs | null> {
152
80
  const started = performance.now();
153
- let cached = 0;
154
- let generated = 0;
155
81
  logger.debug("locating font inputs");
156
82
  // 1. Locate fonts (check project root first, then stalux package dir)
157
83
  const fontPath = findFont(projectRoot, FONT_INPUT);
@@ -167,55 +93,144 @@ export async function runFontSlicing(
167
93
  const codeItalic = findFont(projectRoot, CODE_FONT_ITALIC_INPUT);
168
94
 
169
95
  const fontBuffer = readFileSync(fontPath);
170
- const outDir = resolve(projectRoot, SLICE_OUT_DIR);
171
- mkdirSync(outDir, { recursive: true });
172
-
173
- // 2. Slice the body font into unicode-range chunks.
174
- // Filename is content-addressed (md5 of the character set): identical
175
- // input chars → identical bytes → identical name across builds, keeping
176
- // the incremental-build dependency graph stable.
177
- const body: FontSlice[] = [];
178
- for (const def of SLICES) {
179
- const chars = rangesToChars(def.ranges);
180
- if (!chars) continue;
181
- const hash = createHash("md5").update(chars).digest("hex").slice(0, 12);
182
- const filename = `lxgw-wenkai-slice-${def.id}-${hash}.woff2`;
183
- const outPath = join(outDir, filename);
184
-
185
- if (existsSync(outPath)) {
186
- cached++;
187
- logger.debug(`body slice ${def.id}: cache hit`);
188
- } else {
189
- try {
190
- const data = await subsetFont(fontBuffer, chars, { targetFormat: "woff2" });
191
- if (data.length > 0) {
192
- writeFileSync(outPath, data);
193
- generated++;
194
- logger.debug(
195
- ` body slice ${def.id}: ${(data.length / 1024).toFixed(1)} KB → ${filename}`,
196
- );
197
- }
198
- } catch (error) {
199
- logger.warn(` body slice ${def.id} failed: ${describeError(error)}`);
200
- }
201
- }
96
+ logger.debug(`font inputs located in ${(performance.now() - started).toFixed(1)}ms`);
97
+ logger.info(
98
+ `Native page-font source ready (LXGW ${(fontBuffer.length / 1024 / 1024).toFixed(1)} MB)`,
99
+ );
100
+ return {
101
+ pageSubsetDir: resolve(projectRoot, PAGE_SLICE_OUT_DIR),
102
+ fontBuffer,
103
+ codeNormal,
104
+ codeItalic,
105
+ };
106
+ }
107
+
108
+ export interface PageFontSubset {
109
+ html: string;
110
+ filename: string;
111
+ sourcePath: string;
112
+ cacheHit: boolean;
113
+ }
202
114
 
203
- if (existsSync(outPath)) {
204
- body.push({ src: outPath, unicodeRange: toUnicodeRange(def.ranges) });
115
+ type HtmlNode = DefaultTreeAdapterMap["node"];
116
+
117
+ const NON_RENDERED_ELEMENTS = new Set(["script", "style", "template", "title"]);
118
+ const VISUALLY_HIDDEN_CLASSES = new Set([
119
+ "agent-home-summary",
120
+ "screen-reader-only",
121
+ "sr-only",
122
+ "visually-hidden",
123
+ ]);
124
+
125
+ function findBody(node: HtmlNode): HtmlNode | undefined {
126
+ if ("tagName" in node && node.tagName === "body") return node;
127
+ if ("childNodes" in node) {
128
+ for (const child of node.childNodes) {
129
+ const body = findBody(child);
130
+ if (body) return body;
205
131
  }
206
132
  }
133
+ return undefined;
134
+ }
207
135
 
208
- if (body.length === 0) {
209
- logger.warn("Body font slicing produced no chunks, skipping font injection");
210
- return null;
136
+ function isHiddenElement(attrs: Array<{ name: string; value: string }>): boolean {
137
+ const attributes = new Map(attrs.map(({ name, value }) => [name, value]));
138
+ if (attributes.has("hidden") || attributes.has("inert")) return true;
139
+
140
+ const classes = (attributes.get("class") ?? "").split(/\s+/u);
141
+ if (classes.some((name) => VISUALLY_HIDDEN_CLASSES.has(name))) return true;
142
+
143
+ const style = attributes.get("style") ?? "";
144
+ return /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden|content-visibility\s*:\s*hidden)\s*(?:;|$)/iu.test(
145
+ style,
146
+ );
147
+ }
148
+
149
+ /** Collect renderable body text nodes, including content inside collapsed controls. */
150
+ function collectBodyText(node: HtmlNode, output: string[]): void {
151
+ if (node.nodeName === "#text" && "value" in node) {
152
+ output.push(node.value);
153
+ return;
154
+ }
155
+
156
+ if (!("childNodes" in node)) return;
157
+ if (
158
+ "tagName" in node &&
159
+ (NON_RENDERED_ELEMENTS.has(node.tagName) || isHiddenElement(node.attrs))
160
+ ) {
161
+ return;
211
162
  }
212
163
 
213
- logger.debug(
214
- `generated=${generated}; cached=${cached}; elapsed=${(performance.now() - started).toFixed(1)}ms`,
164
+ for (const child of node.childNodes) collectBodyText(child, output);
165
+ }
166
+
167
+ /** Create a cached body-font subset containing precisely the page's CJK code points. */
168
+ export function writePageFontSubset(
169
+ html: string,
170
+ fontBuffer: Buffer,
171
+ cacheDir: string,
172
+ publicUrlPrefix = "/_astro/fonts/",
173
+ ): PageFontSubset | undefined {
174
+ const document = parse(html);
175
+ const body = findBody(document);
176
+ if (!body) return undefined;
177
+
178
+ const bodyText: string[] = [];
179
+ collectBodyText(body, bodyText);
180
+ const chars = [
181
+ ...new Set(
182
+ [...bodyText.join("")].filter((char) =>
183
+ /[\u2000-\u206f\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]/u.test(char),
184
+ ),
185
+ ),
186
+ ]
187
+ .sort()
188
+ .join("");
189
+ if (!chars) return undefined;
190
+
191
+ const result = subsetFont(fontBuffer, chars, cacheDir);
192
+ const filename = `page-${result.hash}.woff2`;
193
+
194
+ const rel = `${publicUrlPrefix}${filename}`;
195
+ // Keep Astro's broad unicode-range font out of the fallback stack; otherwise
196
+ // a character outside this page's subset would download multiple 300 KB slices.
197
+ const originalFontStack = html.match(/--font-body:\s*([^;}]+)/u)?.[1]?.trim();
198
+ const fallbackStack =
199
+ originalFontStack || '"Noto Sans SC","Noto Sans CJK SC",system-ui,sans-serif';
200
+ const originalCodeStack = html.match(/--font-code:\s*([^;}]+)/u)?.[1]?.trim();
201
+ const codeStack = originalCodeStack
202
+ ? `${originalCodeStack},"LXGW WenKai-Page Subset"`
203
+ : `"LXGW WenKai-Page Subset",${fallbackStack}`;
204
+ const css = `@font-face{font-family:"LXGW WenKai-Page Subset";src:url("${rel}") format("woff2");font-style:normal;font-weight:400;font-display:swap;unicode-range:${[...chars].map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase()}`).join(",")}}:root{--font-body:"LXGW WenKai-Page Subset",${fallbackStack};--font-code:${codeStack}}`;
205
+ const withoutBroadBodyFonts = html.replace(
206
+ /@font-face\{[^}]*font-family:"LXGW WenKai-[^"]+"[^}]*\}/g,
207
+ (face) => {
208
+ const unicodeRange = face.match(/unicode-range:([^;}]+)/i)?.[1] ?? "";
209
+ const hasCjkRange = unicodeRange.split(",").some((range) => {
210
+ const start = range.match(/U\+([\da-f]+)/i)?.[1];
211
+ if (!start) return false;
212
+ const codePoint = Number.parseInt(start, 16);
213
+ return codePoint >= 0x4e00 && codePoint <= 0x9fff;
214
+ });
215
+ return hasCjkRange ? "" : face;
216
+ },
215
217
  );
216
- logger.info(
217
- `Font slicing done: ${body.length} body chunks + code font ` +
218
- `(source ${(fontBuffer.length / 1024 / 1024).toFixed(1)} MB → woff2 chunks)`,
218
+ const outputHtml = withoutBroadBodyFonts.replace(
219
+ /<\/head\s*>/i,
220
+ `<style data-stalux-page-font>${css}</style></head>`,
219
221
  );
220
- return { body, codeNormal, codeItalic };
222
+ return { html: outputHtml, filename, sourcePath: result.path, cacheHit: result.cacheHit };
223
+ }
224
+
225
+ /** Remove stale page subsets so deploy artifacts only contain the current build's referenced files. */
226
+ export function clearPageFontSubsets(
227
+ outputDir: string,
228
+ referencedFiles: Set<string> = new Set(),
229
+ ): void {
230
+ if (!existsSync(outputDir)) return;
231
+ for (const name of readdirSync(outputDir)) {
232
+ if (name.startsWith("page-") && name.endsWith(".woff2") && !referencedFiles.has(name)) {
233
+ unlinkSync(join(outputDir, name));
234
+ }
235
+ }
221
236
  }
@@ -67,12 +67,12 @@ const walineConfig = walineSection?.serverURL
67
67
  <PostCC title={props.title} author={author} url={Astro.url.href} cc={props.cc} />
68
68
  <PostNavigation prev={props.prev} next={props.next} />
69
69
  {commentEnabled && walineConfig?.serverURL && (
70
- <WalineComment
71
- {...buildWalineProps(walineConfig)}
72
- path={Astro.url.pathname}
73
- title={props.title}
74
- />
75
- )}
70
+ <WalineComment
71
+ {...buildWalineProps(walineConfig)}
72
+ path={Astro.url.pathname}
73
+ title={props.title}
74
+ />
75
+ )}
76
76
  </PostContent>
77
77
 
78
78
  <!-- Right Sidebar -->
@@ -73,11 +73,7 @@ const routeAnimation = getRouteAnimation(routeKind);
73
73
  import "../scripts/background.ts";
74
74
  import "../scripts/view-transitions.ts";
75
75
  </script>
76
- {aiConfig?.conformance !== "disabled" && (
77
- <script>
78
- import "../scripts/webmcp.ts";
79
- </script>
80
- )}
76
+ {aiConfig?.conformance !== "disabled" && <script>import "../scripts/webmcp.ts";</script>}
81
77
  <body
82
78
  class="stalux-root dark"
83
79
  data-stalux-bg-layer="a"
@@ -90,10 +86,10 @@ const routeAnimation = getRouteAnimation(routeKind);
90
86
  data-stalux-clarity-id={headConfig?.bingClarityId}
91
87
  >
92
88
  {llmPromote && (
93
- <section class="stalux-ai-prompt" data-stalux-ai-prompt aria-label="AI site context">
89
+ <section class="stalux-ai-prompt" data-stalux-ai-prompt aria-label="AI site context">
94
90
  <p>{llmPromote}</p>
95
- </section>
96
- )}
91
+ </section>
92
+ )}
97
93
  <div
98
94
  class="bg-layer bg-a"
99
95
  style="opacity: 1;"
@@ -47,12 +47,12 @@ const author = authorConfig.name;
47
47
  cc="CC-BY-NC-SA-4.0"
48
48
  />
49
49
  {commentEnabled && walineConfig?.serverURL && (
50
- <WalineComment
51
- {...buildWalineProps(walineConfig)}
52
- path={Astro.url.pathname}
53
- title={aboutPage.data.title}
54
- />
55
- )}
50
+ <WalineComment
51
+ {...buildWalineProps(walineConfig)}
52
+ path={Astro.url.pathname}
53
+ title={aboutPage.data.title}
54
+ />
55
+ )}
56
56
  </PostContent>
57
57
  </div>
58
58
  </Stalux>
@@ -27,7 +27,11 @@ const props = Astro.props as Props;
27
27
 
28
28
  <Stalux
29
29
  title={`${t("taxonomy.category")}: ${props.category} | ${siteConfig.title}`}
30
- description={t("taxonomy.browseCategory", { title: siteConfig.title, category: props.category, count: props.count })}
30
+ description={t("taxonomy.browseCategory", {
31
+ title: siteConfig.title,
32
+ category: props.category,
33
+ count: props.count,
34
+ })}
31
35
  >
32
36
  <div style="height: 3em;"></div>
33
37
  <Navs />
@@ -72,8 +72,8 @@ export async function getStaticPaths() {
72
72
  date={props.post.data.date}
73
73
  updated={props.post.data.updated}
74
74
  readingMinutes={flags.readingMinutes}
75
- tags={props.post.data.tags||[]}
76
- categories={props.post.data.categories||[]}
75
+ tags={props.post.data.tags || []}
76
+ categories={props.post.data.categories || []}
77
77
  headings={headings}
78
78
  author={authorConfig.name}
79
79
  authorAvatar={authorConfig.avatar}