@xingwangzhe/stalux 1.22.7 → 1.23.1
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 +5 -10
- package/package.json +4 -5
- package/src/components/stalux/layout/head.astro +5 -3
- package/src/components/stalux/layout/listPage.astro +2 -3
- package/src/index.ts +69 -42
- package/src/internal/font-slices.ts +207 -0
- package/src/layouts/Stalux.astro +0 -41
- package/src/pages/about.astro +0 -1
- package/src/pages/archives.astro +1 -1
- package/src/pages/categories/[category].astro +0 -1
- package/src/pages/categories/index.astro +0 -1
- package/src/pages/index.astro +1 -1
- package/src/pages/links.astro +5 -6
- package/src/pages/posts/[post].astro +0 -1
- package/src/pages/tags/[tag].astro +0 -1
- package/src/pages/tags/index.astro +0 -1
- package/src/pages/words.astro +1 -1
- package/src/styles/base/init.css +1 -1
- package/src/internal/font-subset-dev.ts +0 -325
- package/src/internal/font-subset.ts +0 -619
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**[stalux.needhelp.icu](https://stalux.needhelp.icu)**
|
|
10
10
|
|
|
11
|
-
A dark-themed, high-performance Astro blog theme with elegant glassmorphism design,
|
|
11
|
+
A dark-themed, high-performance Astro blog theme with elegant glassmorphism design, unicode-range font slicing, and a focus on content-first reading experience.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -76,7 +76,7 @@ bun run dev
|
|
|
76
76
|
## ✨ Features
|
|
77
77
|
|
|
78
78
|
- 🌙 **Dark mode** with elegant glassmorphism design
|
|
79
|
-
- 🔤 **
|
|
79
|
+
- 🔤 **Unicode-range font slicing** — 25 MB font → ~22 woff2 chunks, browser downloads only what matches the page
|
|
80
80
|
- 🔍 **Full-text search** via Pagefind (auto-indexed on build)
|
|
81
81
|
- 📡 **RSS & Atom feeds**
|
|
82
82
|
- 🖼️ **PhotoSwipe** image lightbox
|
|
@@ -95,16 +95,11 @@ bun run dev
|
|
|
95
95
|
|
|
96
96
|
## 🔤 Font Optimization
|
|
97
97
|
|
|
98
|
-
Stalux ships with a 25 MB Chinese font (LXGW WenKai). Instead of loading the full
|
|
98
|
+
Stalux ships with a 25 MB Chinese font (LXGW WenKai) and a variable code font (Google Sans Code). Instead of loading the full files, the build slices the body font into ~22 woff2 chunks by `unicode-range` (via the official Astro Fonts API, `fontProviders.local()`), and the browser downloads only the chunks whose range matches characters on the page — typically 1–2 chunks (~200–600 KB each) for the above-the-fold content.
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
| --------- | --------- | ------------------------------------------ |
|
|
102
|
-
| Common | ~350 KB | UI text, nav, i18n, shared post characters |
|
|
103
|
-
| Per-route | ~0.5–3 KB | Unique characters for each page |
|
|
100
|
+
Every page emits `@font-face` rules with continuous `unicode-range` descriptors from the `<Font />` component; chunk filenames are content-addressed and deterministic across builds, keeping `experimental.incrementalBuild` caches stable (Astro ≥ 7.2.2).
|
|
104
101
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
Powered by `subset-font` (Harfbuzz WASM), running at build time and on-demand in dev mode.
|
|
102
|
+
Powered by `subset-font` (Harfbuzz WASM), slicing at build time into `node_modules/.astro/stalux-fonts/`; the local provider reads files from disk with no network access.
|
|
108
103
|
|
|
109
104
|
---
|
|
110
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.1",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -82,15 +82,14 @@
|
|
|
82
82
|
"@mcp-b/webmcp-polyfill": "^4.0.0",
|
|
83
83
|
"@pagefind/component-ui": "^1.5.2",
|
|
84
84
|
"@waline/client": "^3.15.2",
|
|
85
|
-
"@xingwangzhe/satteri-mermaid": "0.7.
|
|
86
|
-
"@xingwangzhe/satteri-photoswipe": "^0.2.
|
|
87
|
-
"@xingwangzhe/tags-cloud": "^1.2.
|
|
85
|
+
"@xingwangzhe/satteri-mermaid": "0.7.6",
|
|
86
|
+
"@xingwangzhe/satteri-photoswipe": "^0.2.3",
|
|
87
|
+
"@xingwangzhe/tags-cloud": "^1.2.6",
|
|
88
88
|
"animejs": "^4.5.0",
|
|
89
89
|
"astro-expressive-code": "^0.44.1",
|
|
90
90
|
"astro-seo": "^1.1.0",
|
|
91
91
|
"astro-typewriter": "^1.2.0",
|
|
92
92
|
"badge-maker": "^6.0.0",
|
|
93
|
-
"js-yaml": "^5.3.0",
|
|
94
93
|
"pagefind": "^1.5.2",
|
|
95
94
|
"photoswipe": "^5.4.4",
|
|
96
95
|
"satteri": "^0.9.5",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { Font } from "astro:assets";
|
|
2
3
|
import { SEO } from "astro-seo";
|
|
3
4
|
import { ClientRouter } from "astro:transitions";
|
|
4
5
|
import { getCollection } from "astro:content";
|
|
@@ -217,9 +218,10 @@ const ldJson = JSON.stringify(schema, null, 2).replace(/<\//g, "<\\/");
|
|
|
217
218
|
meta: extendedMeta,
|
|
218
219
|
}}
|
|
219
220
|
/>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<
|
|
221
|
+
<!-- 字体:由 Astro Fonts API 注入(--font-body 分片 / --font-code 可变字体),
|
|
222
|
+
<Font /> 输出 @font-face 样式;浏览器按 unicode-range 按需下载正文分片 -->
|
|
223
|
+
<Font cssVariable="--font-body" />
|
|
224
|
+
<Font cssVariable="--font-code" />
|
|
223
225
|
{
|
|
224
226
|
(headConfig?.anyhead) && (
|
|
225
227
|
<Fragment set:html={headConfig.anyhead} />
|
|
@@ -10,15 +10,14 @@ interface Props {
|
|
|
10
10
|
description: string;
|
|
11
11
|
heading: string;
|
|
12
12
|
subheading?: string;
|
|
13
|
-
fontSubsetId?: string;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const { title, description, heading, subheading
|
|
15
|
+
const { title, description, heading, subheading } = Astro.props;
|
|
17
16
|
const configEntries = await getCollection("config");
|
|
18
17
|
const siteConfig = getSiteData(configEntries);
|
|
19
18
|
---
|
|
20
19
|
|
|
21
|
-
<Stalux title={title + " | " + siteConfig.title} description={description}
|
|
20
|
+
<Stalux title={title + " | " + siteConfig.title} description={description}>
|
|
22
21
|
<div style="height: 3em;"></div>
|
|
23
22
|
<Navs />
|
|
24
23
|
<h1 class={styles.title}>{heading}</h1>
|
package/src/index.ts
CHANGED
|
@@ -10,20 +10,64 @@
|
|
|
10
10
|
|
|
11
11
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { resolve } from "node:path";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
15
14
|
|
|
16
15
|
import type { AstroIntegration } from "astro";
|
|
16
|
+
import { fontProviders } from "astro/config";
|
|
17
17
|
// pagefind 是 ESM-only 包,需要在模块顶层导入
|
|
18
18
|
// 因为 astro:build:done 钩子中 Vite module runner 已关闭,无法动态 import
|
|
19
19
|
import { createIndex as pagefindCreateIndex } from "pagefind";
|
|
20
20
|
|
|
21
21
|
import type { StaluxOptions } from "./config";
|
|
22
22
|
import { staluxComponentsAlias } from "./internal/components-plugin";
|
|
23
|
-
import {
|
|
23
|
+
import { runFontSlicing, type FontSlice } from "./internal/font-slices";
|
|
24
24
|
import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
|
|
25
25
|
import { temml } from "./plugins/satteri-temml";
|
|
26
26
|
|
|
27
|
+
// 字体配置(Astro Fonts API)通过 updateConfig 注入,两种模式(源码模板/npm 插件)都生效。
|
|
28
|
+
// 官方 local provider 完全本地读文件(readFile),不联网;
|
|
29
|
+
// 7.2.2 已修复 Fonts API 随机端口污染 incrementalBuild dependencyHash 的问题(PR #17659)。
|
|
30
|
+
function buildFontsConfig(slices: FontSlice[], codeNormal: string, codeItalic?: string) {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
// 正文:LXGW WenKai 按 unicode-range 分片,浏览器只下载命中区间的分片
|
|
34
|
+
provider: fontProviders.local(),
|
|
35
|
+
name: "LXGW WenKai",
|
|
36
|
+
cssVariable: "--font-body",
|
|
37
|
+
fallbacks: ["Noto Sans SC", "Noto Sans CJK SC", "system-ui", "sans-serif"],
|
|
38
|
+
optimizedFallbacks: true,
|
|
39
|
+
options: {
|
|
40
|
+
variants: slices.map((slice) => ({
|
|
41
|
+
weight: 400,
|
|
42
|
+
style: "normal",
|
|
43
|
+
src: [slice.src],
|
|
44
|
+
unicodeRange: slice.unicodeRange,
|
|
45
|
+
})),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
// 代码:Google Sans Code 可变字体,原样注册,不切片
|
|
50
|
+
provider: fontProviders.local(),
|
|
51
|
+
name: "Google Sans Code",
|
|
52
|
+
cssVariable: "--font-code",
|
|
53
|
+
fallbacks: ["JetBrains Mono", "Fira Code", "Consolas", "Courier New", "monospace"],
|
|
54
|
+
optimizedFallbacks: true,
|
|
55
|
+
options: {
|
|
56
|
+
variants: [
|
|
57
|
+
{
|
|
58
|
+
weight: "100 900",
|
|
59
|
+
style: "normal",
|
|
60
|
+
src: [codeNormal],
|
|
61
|
+
},
|
|
62
|
+
...(codeItalic
|
|
63
|
+
? [{ weight: "100 900", style: "italic", src: [codeItalic] }]
|
|
64
|
+
: []),
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
// ---------------------------------------------------------------------------
|
|
28
72
|
// 页面条目定义
|
|
29
73
|
// ---------------------------------------------------------------------------
|
|
@@ -326,54 +370,37 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration {
|
|
|
326
370
|
logger.debug("Stalux: site.yml not found, skip site sync");
|
|
327
371
|
}
|
|
328
372
|
|
|
329
|
-
// 7.
|
|
373
|
+
// 7. 字体:构建期把正文切成 unicode-range 分片,通过官方 Fonts API 注入
|
|
374
|
+
// (config:setup 阶段执行,保证 dev/build 都能生成;local provider 纯本地读文件)
|
|
330
375
|
try {
|
|
331
|
-
const
|
|
332
|
-
|
|
376
|
+
const sliced = await runFontSlicing(process.cwd(), logger);
|
|
377
|
+
if (sliced) {
|
|
378
|
+
updateConfig({
|
|
379
|
+
fonts: buildFontsConfig(
|
|
380
|
+
sliced.body,
|
|
381
|
+
sliced.codeNormal,
|
|
382
|
+
sliced.codeItalic,
|
|
383
|
+
),
|
|
384
|
+
});
|
|
385
|
+
logger.info(
|
|
386
|
+
`Stalux: injected ${sliced.body.length} body font chunks + code font via Fonts API`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
333
389
|
} catch (error) {
|
|
334
|
-
logger.
|
|
390
|
+
logger.warn(`Stalux: font injection failed: ${String(error)}`);
|
|
335
391
|
}
|
|
336
392
|
},
|
|
337
393
|
|
|
338
394
|
"astro:build:start": async ({ logger }) => {
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
await runFontSubsetting(projectRoot, logger);
|
|
343
|
-
} catch (error) {
|
|
344
|
-
logger.warn(`Font subsetting skipped: ${String(error)}`);
|
|
345
|
-
}
|
|
395
|
+
// 字体分片已在 astro:config:setup 阶段生成并注入,这里无额外处理。
|
|
396
|
+
// 保留钩子为空实现,便于将来在此处追加构建期逻辑。
|
|
397
|
+
logger.debug("Stalux: build start (font slices handled in config:setup)");
|
|
346
398
|
},
|
|
347
399
|
|
|
348
|
-
"astro:server:setup": async ({
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// 清除旧的字体缓存
|
|
353
|
-
const oldFontDir = resolve(projectRoot, "public/fonts");
|
|
354
|
-
if (existsSync(oldFontDir)) {
|
|
355
|
-
// 删除旧的 subset-*.css 和 common-*.woff2,让按需中间件重新生成
|
|
356
|
-
for (const f of readdirSync(oldFontDir)) {
|
|
357
|
-
if (f.startsWith("subset-") || f.startsWith("common-")) continue;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
// 先生成 common 子集
|
|
361
|
-
await runFontSubsetting(projectRoot, logger, true);
|
|
362
|
-
// 再添加按需路由切片中间件(放在最前面,优先拦截字体请求)
|
|
363
|
-
const { createDevFontMiddleware } = await import(
|
|
364
|
-
/* @vite-ignore */
|
|
365
|
-
new URL("./internal/font-subset-dev.ts", import.meta.url).href
|
|
366
|
-
);
|
|
367
|
-
const fontMiddleware = createDevFontMiddleware(projectRoot, logger);
|
|
368
|
-
server.middlewares.use((req: any, res: any, next: any) => {
|
|
369
|
-
if (req.url?.startsWith("/fonts/")) {
|
|
370
|
-
return fontMiddleware(req, res, next);
|
|
371
|
-
}
|
|
372
|
-
next();
|
|
373
|
-
});
|
|
374
|
-
} catch (error) {
|
|
375
|
-
logger.debug(`Font subsetting deferred in dev: ${String(error)}`);
|
|
376
|
-
}
|
|
400
|
+
"astro:server:setup": async ({ logger }) => {
|
|
401
|
+
// 字体分片在 config:setup 阶段已生成,dev 模式由官方 Fonts API 的
|
|
402
|
+
// dev 中间件按需提供,无需自研按需切分中间件。
|
|
403
|
+
logger.debug("Stalux: dev server ready (fonts served by Astro Fonts API)");
|
|
377
404
|
},
|
|
378
405
|
|
|
379
406
|
"astro:build:done": async ({ dir, logger }) => {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time CJK font slicing engine (Astro Fonts API variant).
|
|
3
|
+
*
|
|
4
|
+
* Slices the full-size LXGW WenKai font (25 MB TTF) into ~20 woff2 chunks
|
|
5
|
+
* by unicode-range, registered through the official `fontProviders.local()`
|
|
6
|
+
* pipeline (`updateConfig({ fonts })`). The browser downloads only the chunks
|
|
7
|
+
* whose unicode-range matches characters on the page.
|
|
8
|
+
*
|
|
9
|
+
* Astro's local provider reads font files from disk (`readFile`), so no
|
|
10
|
+
* network access is involved at build time — identical behavior on GitHub
|
|
11
|
+
* Actions and CN mirrors. Slices are emitted to `node_modules/.astro/`
|
|
12
|
+
* (the Astro cacheDir) so they stay out of the repo and out of `dist/fonts`.
|
|
13
|
+
*
|
|
14
|
+
* Output layout:
|
|
15
|
+
* node_modules/.astro/stalux-fonts/
|
|
16
|
+
* lxgw-wenkai-slice-{n}-{hash}.woff2 — unicode-range chunk (content-addressed)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import type { AstroIntegrationLogger } from "astro";
|
|
25
|
+
import subsetFont from "subset-font";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Configuration
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Full font input path (relative to project root or package) */
|
|
32
|
+
const FONT_INPUT = "src/assets/fonts/LXGWWenKai-Regular.ttf";
|
|
33
|
+
|
|
34
|
+
/** Code font input paths (variable fonts, registered as-is, no slicing) */
|
|
35
|
+
const CODE_FONT_INPUT = "src/assets/fonts/GoogleSansCode.woff2";
|
|
36
|
+
const CODE_FONT_ITALIC_INPUT = "src/assets/fonts/GoogleSansCode-Italic.woff2";
|
|
37
|
+
|
|
38
|
+
/** Output directory for generated slices (under the Astro cacheDir) */
|
|
39
|
+
const SLICE_OUT_DIR = "node_modules/.astro/stalux-fonts";
|
|
40
|
+
|
|
41
|
+
/** Chunk size (code points) for the CJK Unified Ideographs block */
|
|
42
|
+
const CJK_CHUNK_SIZE = 1050;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Types
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface FontSlice {
|
|
49
|
+
/** Absolute path to the generated woff2 file */
|
|
50
|
+
src: string;
|
|
51
|
+
/** Unicode ranges for the @font-face unicode-range descriptor */
|
|
52
|
+
unicodeRange: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SlicedFonts {
|
|
56
|
+
/** Body font slices (LXGW WenKai), one variant per chunk */
|
|
57
|
+
body: FontSlice[];
|
|
58
|
+
/** Absolute path to the code font woff2 */
|
|
59
|
+
codeNormal: string;
|
|
60
|
+
/** Absolute path to the code italic woff2, if present */
|
|
61
|
+
codeItalic?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SliceDef {
|
|
65
|
+
/** Ordinal used in the output filename */
|
|
66
|
+
id: number;
|
|
67
|
+
/** [start, end] code-point ranges covered by this slice */
|
|
68
|
+
ranges: Array<[number, number]>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Slice definitions
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* CJK Unified Ideographs (U+4E00–U+9FFF) split into fixed-size chunks.
|
|
77
|
+
* Chunk boundaries are content-independent, so slices are deterministic
|
|
78
|
+
* across builds (no per-page character scanning).
|
|
79
|
+
*/
|
|
80
|
+
function buildCjkChunks(start = 0x4e00, end = 0x9fff, size = CJK_CHUNK_SIZE): SliceDef[] {
|
|
81
|
+
const chunks: SliceDef[] = [];
|
|
82
|
+
for (let s = start, id = 2; s <= end; s += size, id++) {
|
|
83
|
+
chunks.push({ id, ranges: [[s, Math.min(s + size - 1, end)]] });
|
|
84
|
+
}
|
|
85
|
+
return chunks;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SLICES: SliceDef[] = [
|
|
89
|
+
// ASCII printable + Latin-1 supplement
|
|
90
|
+
{ id: 0, ranges: [[0x20, 0x7e]] },
|
|
91
|
+
// CJK punctuation / fullwidth forms / general punctuation
|
|
92
|
+
{
|
|
93
|
+
id: 1,
|
|
94
|
+
ranges: [
|
|
95
|
+
[0x2000, 0x206f],
|
|
96
|
+
[0x3000, 0x303f],
|
|
97
|
+
[0xff00, 0xffef],
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
...buildCjkChunks(),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Locate a font file: project root → stalux package → this package. */
|
|
108
|
+
function findFont(projectRoot: string, rel: string): string | undefined {
|
|
109
|
+
const candidates = [
|
|
110
|
+
resolve(projectRoot, rel),
|
|
111
|
+
resolve(projectRoot, "node_modules", "@xingwangzhe", "stalux", rel),
|
|
112
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", rel),
|
|
113
|
+
];
|
|
114
|
+
return candidates.find((p) => existsSync(p));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render a [start, end] range as a CSS unicode-range value. */
|
|
118
|
+
function toUnicodeRange(ranges: Array<[number, number]>): string[] {
|
|
119
|
+
const fmt = (cp: number) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
120
|
+
return ranges.map(([s, e]) => (s === e ? fmt(s) : `${fmt(s)}-${fmt(e)}`));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Collect every code point in the ranges as a deduped, sorted string. */
|
|
124
|
+
function rangesToChars(ranges: Array<[number, number]>): string {
|
|
125
|
+
const set = new Set<string>();
|
|
126
|
+
for (const [s, e] of ranges) {
|
|
127
|
+
for (let cp = s; cp <= e; cp++) {
|
|
128
|
+
set.add(String.fromCodePoint(cp));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...set].sort().join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Main entry
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Slice the body font into unicode-range woff2 chunks and resolve the code
|
|
140
|
+
* fonts, all from local disk. Returns `null` when the body font is missing so
|
|
141
|
+
* the caller can skip font injection (pages fall back to system fonts).
|
|
142
|
+
*
|
|
143
|
+
* Called from the `astro:config:setup` hook before `updateConfig({ fonts })`.
|
|
144
|
+
*/
|
|
145
|
+
export async function runFontSlicing(
|
|
146
|
+
projectRoot: string,
|
|
147
|
+
logger: AstroIntegrationLogger,
|
|
148
|
+
): Promise<SlicedFonts | null> {
|
|
149
|
+
// 1. Locate fonts (check project root first, then stalux package dir)
|
|
150
|
+
const fontPath = findFont(projectRoot, FONT_INPUT);
|
|
151
|
+
if (!fontPath) {
|
|
152
|
+
logger.warn(`Body font not found at ${FONT_INPUT}, skipping font injection`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const codeNormal = findFont(projectRoot, CODE_FONT_INPUT);
|
|
156
|
+
if (!codeNormal) {
|
|
157
|
+
logger.warn(`Code font not found at ${CODE_FONT_INPUT}, skipping font injection`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const codeItalic = findFont(projectRoot, CODE_FONT_ITALIC_INPUT);
|
|
161
|
+
|
|
162
|
+
const fontBuffer = readFileSync(fontPath);
|
|
163
|
+
const outDir = resolve(projectRoot, SLICE_OUT_DIR);
|
|
164
|
+
mkdirSync(outDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
// 2. Slice the body font into unicode-range chunks.
|
|
167
|
+
// Filename is content-addressed (md5 of the character set): identical
|
|
168
|
+
// input chars → identical bytes → identical name across builds, keeping
|
|
169
|
+
// the incremental-build dependency graph stable.
|
|
170
|
+
const body: FontSlice[] = [];
|
|
171
|
+
for (const def of SLICES) {
|
|
172
|
+
const chars = rangesToChars(def.ranges);
|
|
173
|
+
if (!chars) continue;
|
|
174
|
+
const hash = createHash("md5").update(chars).digest("hex").slice(0, 12);
|
|
175
|
+
const filename = `lxgw-wenkai-slice-${def.id}-${hash}.woff2`;
|
|
176
|
+
const outPath = join(outDir, filename);
|
|
177
|
+
|
|
178
|
+
if (!existsSync(outPath)) {
|
|
179
|
+
try {
|
|
180
|
+
const data = await subsetFont(fontBuffer, chars, { targetFormat: "woff2" });
|
|
181
|
+
if (data.length > 0) {
|
|
182
|
+
writeFileSync(outPath, data);
|
|
183
|
+
logger.debug(
|
|
184
|
+
` body slice ${def.id}: ${(data.length / 1024).toFixed(1)} KB → ${filename}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn(` body slice ${def.id} failed: ${String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (existsSync(outPath)) {
|
|
193
|
+
body.push({ src: outPath, unicodeRange: toUnicodeRange(def.ranges) });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (body.length === 0) {
|
|
198
|
+
logger.warn("Body font slicing produced no chunks, skipping font injection");
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
logger.info(
|
|
203
|
+
`Font slicing done: ${body.length} body chunks + code font ` +
|
|
204
|
+
`(source ${(fontBuffer.length / 1024 / 1024).toFixed(1)} MB → woff2 chunks)`,
|
|
205
|
+
);
|
|
206
|
+
return { body, codeNormal, codeItalic };
|
|
207
|
+
}
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -7,9 +7,6 @@ import "@styles/shared/stagger.css";
|
|
|
7
7
|
import "@styles/shared/view-transitions.css";
|
|
8
8
|
import { getAuthorData, getAiDiscoveryData, getPromoteData, getSiteData } from "@utils/config-utils";
|
|
9
9
|
import { getRouteAnimation, getRouteKind } from "@utils/view-transitions";
|
|
10
|
-
// font-subset.ts 构建期生成的 manifest 副本(进入 Astro 依赖图,
|
|
11
|
-
// manifest 变化 → 页面重建 → 增量构建 restored 的页面不会引用旧字体资源)
|
|
12
|
-
import fontsManifest from "../internal/generated/fonts-manifest.json";
|
|
13
10
|
const props = Astro.props as Props;
|
|
14
11
|
|
|
15
12
|
interface Props {
|
|
@@ -23,8 +20,6 @@ interface Props {
|
|
|
23
20
|
categories?: string[];
|
|
24
21
|
author?: string;
|
|
25
22
|
noindex?: boolean;
|
|
26
|
-
/** Font subset ID for per-route font optimization */
|
|
27
|
-
fontSubsetId?: string;
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
const configEntries = await getCollection("config");
|
|
@@ -34,39 +29,6 @@ const promoteConfig = getPromoteData(configEntries);
|
|
|
34
29
|
const aiConfig = getAiDiscoveryData(configEntries);
|
|
35
30
|
const htmlLang = siteConfig.lang || "zh-CN";
|
|
36
31
|
|
|
37
|
-
// Resolve font subset CSS URLs(内容寻址,避免浏览器缓存旧 CSS 引用已删除 TTF → 404)
|
|
38
|
-
// 生产构建:font-subset.ts 生成带 hash 的 CSS 文件名并写入 manifest(副本 import 至此),
|
|
39
|
-
// 这里从 manifest 取文件名,内容变化必然换名、浏览器自动拉新。
|
|
40
|
-
// dev 模式:manifest 的 routes 为空(skipPerRoute),回退固定名 /fonts/common.css 与
|
|
41
|
-
// /fonts/subset-<id>.css,由 dev 按需中间件生成。
|
|
42
|
-
const fontManifest = fontsManifest as
|
|
43
|
-
| {
|
|
44
|
-
commonCss?: string;
|
|
45
|
-
routes?: Record<string, string>;
|
|
46
|
-
}
|
|
47
|
-
| null
|
|
48
|
-
| undefined;
|
|
49
|
-
|
|
50
|
-
let commonFontCssUrl = "/fonts/common.css";
|
|
51
|
-
let fontSubsetUrl: string | undefined;
|
|
52
|
-
if (props.fontSubsetId) {
|
|
53
|
-
fontSubsetUrl = `/fonts/subset-${props.fontSubsetId}.css`;
|
|
54
|
-
}
|
|
55
|
-
if (fontManifest?.commonCss) {
|
|
56
|
-
commonFontCssUrl = `/fonts/${fontManifest.commonCss}`;
|
|
57
|
-
}
|
|
58
|
-
if (props.fontSubsetId && fontManifest?.routes) {
|
|
59
|
-
// route 键可能是 posts/xxx(页面对应 posts-xxx)或含原始 unicode(tag/category),
|
|
60
|
-
// 统一归一化比较:route.replace("/", "-") === decodeURIComponent(fontSubsetId)
|
|
61
|
-
const want = decodeURIComponent(props.fontSubsetId);
|
|
62
|
-
for (const [route, cssFile] of Object.entries(fontManifest.routes)) {
|
|
63
|
-
if (route.replace("/", "-") === want) {
|
|
64
|
-
fontSubsetUrl = `/fonts/${encodeURIComponent(cssFile)}`;
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
32
|
// 背景 SVG 以静态 URL 硬编码在 background.ts(纯客户端),
|
|
71
33
|
// 集成在 config:setup 时把 SVG 复制到用户项目 public/background/。
|
|
72
34
|
// SSR 不渲染背景图,默认露出 .bg-layer 的灰墙底色(--stalux-bg-color),
|
|
@@ -96,9 +58,6 @@ const routeAnimation = getRouteAnimation(routeKind);
|
|
|
96
58
|
author={props.author}
|
|
97
59
|
noindex={props.noindex}
|
|
98
60
|
/>
|
|
99
|
-
<!-- Per-route font subset (generated at build time) -->
|
|
100
|
-
<link rel="stylesheet" href={commonFontCssUrl} />
|
|
101
|
-
{fontSubsetUrl && <link rel="stylesheet" href={fontSubsetUrl} />}
|
|
102
61
|
<script>
|
|
103
62
|
import "../scripts/background.ts";
|
|
104
63
|
import "../scripts/view-transitions.ts";
|
package/src/pages/about.astro
CHANGED
package/src/pages/archives.astro
CHANGED
|
@@ -12,7 +12,7 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
|
|
|
12
12
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
<Stalux title={t("archives.title") + " | " + siteConfig.title} description={t("archives.description", { title: siteConfig.title })}
|
|
15
|
+
<Stalux title={t("archives.title") + " | " + siteConfig.title} description={t("archives.description", { title: siteConfig.title })}>
|
|
16
16
|
<div style="height: 3em;"></div>
|
|
17
17
|
<Navs />
|
|
18
18
|
<h1 class={styles.h1}>{t("archives.heading")}</h1>
|
|
@@ -27,7 +27,6 @@ const props = Astro.props as Props;
|
|
|
27
27
|
<Stalux
|
|
28
28
|
title={t("taxonomy.category") + ": " + props.category + " | " + siteConfig.title}
|
|
29
29
|
description={t("taxonomy.browseCategory", { title: siteConfig.title, category: props.category, count: props.count })}
|
|
30
|
-
fontSubsetId={props.posts.length > 0 ? `categories-${encodeURIComponent(props.category)}` : undefined}
|
|
31
30
|
>
|
|
32
31
|
<div style="height: 3em;"></div>
|
|
33
32
|
<Navs />
|
|
@@ -16,7 +16,6 @@ const hasContent = posts.length > 0;
|
|
|
16
16
|
<Stalux
|
|
17
17
|
title={t("categories.title") + " | " + siteConfig.title}
|
|
18
18
|
description={t("categories.description", { title: siteConfig.title })}
|
|
19
|
-
fontSubsetId={hasContent ? "categories" : undefined}
|
|
20
19
|
>
|
|
21
20
|
<div style="height: 3em;"></div>
|
|
22
21
|
<Navs />
|
package/src/pages/index.astro
CHANGED
|
@@ -17,7 +17,7 @@ import ToArchive from "@components/stalux/archives/toArchive.astro";
|
|
|
17
17
|
import { getSiteData, getAuthorData, getHeadData, getNavsData, getLinksData, getCommentData, getFooterData, getPromoteData, getTypetextsData, getMediaLinksData } from "@utils/config-utils";
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
<Stalux title={siteTitle} description={siteDescription}
|
|
20
|
+
<Stalux title={siteTitle} description={siteDescription}>
|
|
21
21
|
<div class={styles.homeBody}>
|
|
22
22
|
<div class={styles.homeNavs}>
|
|
23
23
|
<Navs />
|
package/src/pages/links.astro
CHANGED
|
@@ -14,11 +14,10 @@ const linksDescription = linksConfig.description;
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
<ListPage
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
>
|
|
17
|
+
title={siteTitle}
|
|
18
|
+
description={siteDescription}
|
|
19
|
+
heading={linksTitle}
|
|
20
|
+
subheading={linksDescription}
|
|
21
|
+
>
|
|
23
22
|
<LinkList />
|
|
24
23
|
</ListPage>
|
|
@@ -68,7 +68,6 @@ export async function getStaticPaths() {
|
|
|
68
68
|
tags={props.post.data.tags}
|
|
69
69
|
categories={props.post.data.categories}
|
|
70
70
|
author={authorConfig.name}
|
|
71
|
-
fontSubsetId={`posts-${props.post.data.abbrlink}`}
|
|
72
71
|
>
|
|
73
72
|
<div style="height:3em"></div>
|
|
74
73
|
<Navs /><div style="height:3em"></div>
|
|
@@ -29,7 +29,6 @@ const props = Astro.props as Props;
|
|
|
29
29
|
title={t("taxonomy.tag") + ": " + props.tag + " | " + siteConfig.title}
|
|
30
30
|
description={t("taxonomy.browseTag", { title: siteConfig.title, tag: props.tag, count: props.count })}
|
|
31
31
|
noindex={props.count < 3}
|
|
32
|
-
fontSubsetId={props.posts.length > 0 ? `tags-${encodeURIComponent(props.tag)}` : undefined}
|
|
33
32
|
>
|
|
34
33
|
<div style="height: 3em;"></div>
|
|
35
34
|
<Navs />
|
|
@@ -16,7 +16,6 @@ const hasContent = posts.length > 0;
|
|
|
16
16
|
<Stalux
|
|
17
17
|
title={t("tags.title") + " | " + siteConfig.title}
|
|
18
18
|
description={t("tags.description", { title: siteConfig.title })}
|
|
19
|
-
fontSubsetId={hasContent ? "tags" : undefined}
|
|
20
19
|
>
|
|
21
20
|
<div style="height: 3em;"></div>
|
|
22
21
|
<Navs />
|
package/src/pages/words.astro
CHANGED
|
@@ -13,7 +13,7 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
|
|
|
13
13
|
const entries = await getCollection("words", ({ data }) => !data.draft);
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
<Stalux title={t("words.title") + " | " + siteConfig.title} description={t("words.description")}
|
|
16
|
+
<Stalux title={t("words.title") + " | " + siteConfig.title} description={t("words.description")}>
|
|
17
17
|
<div style="height: 3em;"></div>
|
|
18
18
|
<Navs />
|
|
19
19
|
<h1 class={styles.title}>{t("words.heading")}</h1>
|
package/src/styles/base/init.css
CHANGED
|
@@ -221,7 +221,7 @@ ul {
|
|
|
221
221
|
--border-color-light: #e5e5e5;
|
|
222
222
|
|
|
223
223
|
/* 字体族栈 */
|
|
224
|
-
/* --font-body 由 Astro Fonts API
|
|
224
|
+
/* --font-body 由 Astro Fonts API 提供(--font-code 同理,见集成注入的 fonts 配置) */
|
|
225
225
|
--font-body-serif: "Noto Serif CJK SC", "Source Han Serif SC", "STSong", Georgia, serif;
|
|
226
226
|
|
|
227
227
|
/* 字体大小系统 */
|