@xingwangzhe/stalux 1.22.7 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- 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
|
@@ -1,619 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-route font subsetting engine.
|
|
3
|
-
*
|
|
4
|
-
* Scans each content file individually, extracts its unique character set,
|
|
5
|
-
* and generates a minimal TTF subset per route. Also generates a "common"
|
|
6
|
-
* subset for UI text (navs, i18n, layout).
|
|
7
|
-
*
|
|
8
|
-
* Output layout (under public/fonts/):
|
|
9
|
-
* fonts/
|
|
10
|
-
* common-{hash}.ttf — UI text subset (ASCII, nav, i18n)
|
|
11
|
-
* subset-{hash}.ttf — per-route subset (shared by routes with same chars)
|
|
12
|
-
* common.css — @font-face for common subset
|
|
13
|
-
* subset-{route}.css — per-route @font-face
|
|
14
|
-
* manifest.json — route → subset file mapping
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { createHash } from "node:crypto";
|
|
18
|
-
import {
|
|
19
|
-
existsSync,
|
|
20
|
-
mkdirSync,
|
|
21
|
-
readFileSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
readdirSync,
|
|
24
|
-
unlinkSync,
|
|
25
|
-
} from "node:fs";
|
|
26
|
-
import { resolve, basename, extname, join, dirname } from "node:path";
|
|
27
|
-
import { fileURLToPath } from "node:url";
|
|
28
|
-
|
|
29
|
-
import type { AstroIntegrationLogger } from "astro";
|
|
30
|
-
// 与 Astro 内容集合同款的 YAML 解析器(astro 依赖 js-yaml 解析 frontmatter,
|
|
31
|
-
// 见 node_modules/astro/dist/content/loaders/file.js)。用它解析 frontmatter
|
|
32
|
-
// 得到的结果与 getCollection 的 data 一致:引号剥离、类型还原、结构完整。
|
|
33
|
-
import { load as parseYaml } from "js-yaml";
|
|
34
|
-
import subsetFont from "subset-font";
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Configuration
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/** Content root directory (relative to project root) */
|
|
41
|
-
const CONTENT_ROOT = "stalux";
|
|
42
|
-
|
|
43
|
-
/** Full font input path (relative to project root or package) */
|
|
44
|
-
const FONT_INPUT = "src/assets/fonts/LXGWWenKai-Regular.ttf";
|
|
45
|
-
|
|
46
|
-
/** Code font input path (Google Sans Code, deterministic static @font-face) */
|
|
47
|
-
const CODE_FONT_INPUT = "src/assets/fonts/GoogleSansCode.woff2";
|
|
48
|
-
|
|
49
|
-
/** Output directory for generated subsets */
|
|
50
|
-
const FONT_OUT_DIR = "public/fonts";
|
|
51
|
-
|
|
52
|
-
/** Characters always included in common subset */
|
|
53
|
-
const ALWAYS_INCLUDE_CHARS = new Set(
|
|
54
|
-
// ASCII printable
|
|
55
|
-
[...Array(0x7e - 0x20 + 1)]
|
|
56
|
-
.map((_, i) => String.fromCodePoint(0x20 + i))
|
|
57
|
-
.join("")
|
|
58
|
-
// Basic CJK punctuation
|
|
59
|
-
.concat(
|
|
60
|
-
"\u3000\u3001\u3002\u3008\u3009\u300a\u300b\u300c\u300d\u3010\u3011" +
|
|
61
|
-
"\uff01\uff08\uff09\uff0c\uff0e\uff1a\uff1b\uff1f\uff5e\u2013\u2014" +
|
|
62
|
-
"\u2018\u2019\u201c\u201d\u2026\uff09\uff08\u00b7",
|
|
63
|
-
),
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Types
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
interface RouteCharSet {
|
|
71
|
-
route: string;
|
|
72
|
-
chars: string;
|
|
73
|
-
hash: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface FontManifest {
|
|
77
|
-
common: string; // filename of common subset TTF
|
|
78
|
-
commonCss: string; // filename of common subset CSS (content-addressed)
|
|
79
|
-
routes: Record<string, string>; // route → subset filename
|
|
80
|
-
/** Per-post mapping: abbrlink → subset filename */
|
|
81
|
-
posts: Record<string, string>;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// Character extraction
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
function extractChars(text: string): string {
|
|
89
|
-
const set = new Set<string>();
|
|
90
|
-
for (const ch of text) set.add(ch);
|
|
91
|
-
return [...set].sort().join("");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function hashChars(chars: string): string {
|
|
95
|
-
return createHash("md5").update(chars).digest("hex").slice(0, 12);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
// Content scanning
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
|
|
102
|
-
interface ContentFile {
|
|
103
|
-
route: string;
|
|
104
|
-
filePath: string;
|
|
105
|
-
abbrlink?: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* 用 js-yaml(内容集合同款解析器)解析 frontmatter。
|
|
110
|
-
*
|
|
111
|
-
* 结果与 getCollection("posts") 的 data 一致:
|
|
112
|
-
* - abbrlink:number 转 string、引号剥离(YAML 解析天然处理)
|
|
113
|
-
* - tags/categories:单行字符串 "Bing" 按 schema preprocess 转 ["Bing"]
|
|
114
|
-
*/
|
|
115
|
-
function parsePostFrontmatter(content: string): {
|
|
116
|
-
abbrlink?: string;
|
|
117
|
-
tags: string[];
|
|
118
|
-
categories: string[];
|
|
119
|
-
} {
|
|
120
|
-
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
121
|
-
if (!m) return { abbrlink: undefined, tags: [], categories: [] };
|
|
122
|
-
|
|
123
|
-
let data: Record<string, unknown>;
|
|
124
|
-
try {
|
|
125
|
-
data = (parseYaml(m[1]) as Record<string, unknown>) ?? {};
|
|
126
|
-
} catch {
|
|
127
|
-
return { abbrlink: undefined, tags: [], categories: [] };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 复刻 posts schema 的 preprocess:单行字符串转数组
|
|
131
|
-
const toList = (v: unknown): string[] => {
|
|
132
|
-
if (Array.isArray(v)) return v.map((x) => String(x));
|
|
133
|
-
if (typeof v === "string") return v.trim() ? [v.trim()] : [];
|
|
134
|
-
return [];
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const abbr = data.abbrlink;
|
|
138
|
-
return {
|
|
139
|
-
abbrlink: abbr === undefined || abbr === null ? undefined : String(abbr),
|
|
140
|
-
tags: toList(data.tags),
|
|
141
|
-
categories: toList(data.categories),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function scanContent(projectRoot: string): ContentFile[] {
|
|
146
|
-
const files: ContentFile[] = [];
|
|
147
|
-
const contentRoot = resolve(projectRoot, CONTENT_ROOT);
|
|
148
|
-
|
|
149
|
-
// Posts
|
|
150
|
-
const postsDir = join(contentRoot, "posts");
|
|
151
|
-
const allPostContents: {
|
|
152
|
-
abbrlink: string;
|
|
153
|
-
tags: string[];
|
|
154
|
-
categories: string[];
|
|
155
|
-
content: string;
|
|
156
|
-
}[] = [];
|
|
157
|
-
if (existsSync(postsDir)) {
|
|
158
|
-
for (const f of readdirSync(postsDir)) {
|
|
159
|
-
if (!f.endsWith(".md") && !f.endsWith(".mdx")) continue;
|
|
160
|
-
const content = readFileSync(join(postsDir, f), "utf-8");
|
|
161
|
-
const fm = parsePostFrontmatter(content);
|
|
162
|
-
|
|
163
|
-
// Per-post routes:与内容集合 glob(*.{md,mdx},含 _ 前缀文件,
|
|
164
|
-
// 见 schemas/collections.ts)保持一致——这些文件都会生成页面,
|
|
165
|
-
// 字体子集必须覆盖,否则页面 subset css 缺失 → 404。
|
|
166
|
-
const abbrlink = fm.abbrlink ?? basename(f, extname(f));
|
|
167
|
-
files.push({
|
|
168
|
-
route: `posts/${abbrlink}`,
|
|
169
|
-
filePath: join(postsDir, f),
|
|
170
|
-
abbrlink,
|
|
171
|
-
});
|
|
172
|
-
// Virtual routes (archives/tags/categories): include ALL posts including templates
|
|
173
|
-
// because content collections load them and they render on those pages
|
|
174
|
-
allPostContents.push({
|
|
175
|
-
abbrlink,
|
|
176
|
-
tags: fm.tags,
|
|
177
|
-
categories: fm.categories,
|
|
178
|
-
content,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Build virtual routes for archives, tags, categories (aggregated pages)
|
|
184
|
-
// These pages render content from ALL posts
|
|
185
|
-
if (allPostContents.length > 0) {
|
|
186
|
-
const allContent = allPostContents.map((p) => p.content).join("\n");
|
|
187
|
-
|
|
188
|
-
// Archives: all posts' content (titles, dates, etc.)
|
|
189
|
-
virtualRouteCache.set("archives", extractChars(allContent));
|
|
190
|
-
|
|
191
|
-
// Tags index: all tags + all posts' content
|
|
192
|
-
const allTags = [...new Set(allPostContents.flatMap((p) => p.tags))].join(" ");
|
|
193
|
-
virtualRouteCache.set("tags", extractChars(allContent + allTags));
|
|
194
|
-
|
|
195
|
-
// Categories index: all categories + all posts' content
|
|
196
|
-
const allCategories = [...new Set(allPostContents.flatMap((p) => p.categories))].join(" ");
|
|
197
|
-
virtualRouteCache.set("categories", extractChars(allContent + allCategories));
|
|
198
|
-
|
|
199
|
-
// Per-tag routes: tag name + posts with that tag
|
|
200
|
-
const tagPosts = new Map<string, string[]>();
|
|
201
|
-
for (const p of allPostContents) {
|
|
202
|
-
for (const tag of p.tags) {
|
|
203
|
-
const list = tagPosts.get(tag) ?? [];
|
|
204
|
-
list.push(p.content);
|
|
205
|
-
tagPosts.set(tag, list);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
for (const [tag, contents] of tagPosts) {
|
|
209
|
-
const tagContent = tag + " " + contents.join("\n");
|
|
210
|
-
virtualRouteCache.set(`tags-${tag}`, extractChars(tagContent));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Per-category routes: category name + posts in that category
|
|
214
|
-
const catPosts = new Map<string, string[]>();
|
|
215
|
-
for (const p of allPostContents) {
|
|
216
|
-
for (const cat of p.categories) {
|
|
217
|
-
const list = catPosts.get(cat) ?? [];
|
|
218
|
-
list.push(p.content);
|
|
219
|
-
catPosts.set(cat, list);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
for (const [cat, contents] of catPosts) {
|
|
223
|
-
const catContent = cat + " " + contents.join("\n");
|
|
224
|
-
virtualRouteCache.set(`categories-${cat}`, extractChars(catContent));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Build virtual routes for config-driven pages (home, links)
|
|
229
|
-
// These pages render text from config YAML files (nav titles, site title, etc.)
|
|
230
|
-
const configDir = join(contentRoot, "config");
|
|
231
|
-
if (existsSync(configDir)) {
|
|
232
|
-
const allConfigContent: string[] = [];
|
|
233
|
-
for (const f of readdirSync(configDir)) {
|
|
234
|
-
if (f.endsWith(".yml") || f.endsWith(".yaml")) {
|
|
235
|
-
allConfigContent.push(readFileSync(join(configDir, f), "utf-8"));
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (allConfigContent.length > 0) {
|
|
239
|
-
const configText = allConfigContent.join("\n");
|
|
240
|
-
// Home page: site title, author, navs, typetexts, social media, footer
|
|
241
|
-
virtualRouteCache.set("home", extractChars(configText));
|
|
242
|
-
// Links page: links config + navs + footer
|
|
243
|
-
virtualRouteCache.set("links", extractChars(configText));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// About
|
|
248
|
-
const aboutDir = join(contentRoot, "about");
|
|
249
|
-
if (existsSync(aboutDir)) {
|
|
250
|
-
for (const f of readdirSync(aboutDir)) {
|
|
251
|
-
if (!f.endsWith(".md") && !f.endsWith(".mdx")) continue;
|
|
252
|
-
if (f.startsWith("_")) continue;
|
|
253
|
-
files.push({
|
|
254
|
-
route: "about",
|
|
255
|
-
filePath: join(aboutDir, f),
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Words - aggregate all words files' characters into one
|
|
261
|
-
const wordsDir = join(contentRoot, "words");
|
|
262
|
-
if (existsSync(wordsDir)) {
|
|
263
|
-
const allWordsContent: string[] = [];
|
|
264
|
-
for (const f of readdirSync(wordsDir)) {
|
|
265
|
-
if (!f.endsWith(".md")) continue;
|
|
266
|
-
if (f.startsWith("_")) continue;
|
|
267
|
-
try {
|
|
268
|
-
allWordsContent.push(readFileSync(join(wordsDir, f), "utf-8"));
|
|
269
|
-
} catch {
|
|
270
|
-
/* skip */
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (allWordsContent.length > 0) {
|
|
274
|
-
files.push({
|
|
275
|
-
route: "words",
|
|
276
|
-
filePath: join(wordsDir, "words-combined.md"),
|
|
277
|
-
// We'll handle this virtual file specially in char extraction
|
|
278
|
-
});
|
|
279
|
-
// Store combined content in a map for later extraction
|
|
280
|
-
wordCharsCache.set("words", extractChars(allWordsContent.join("\n")));
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Add virtual routes (archives, tags, categories) as content files
|
|
285
|
-
for (const [route] of virtualRouteCache) {
|
|
286
|
-
files.push({
|
|
287
|
-
route,
|
|
288
|
-
filePath: resolve(projectRoot, CONTENT_ROOT, `_virtual/${route}.md`),
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return files;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Cache for virtual route char sets
|
|
296
|
-
const wordCharsCache = new Map<string, string>();
|
|
297
|
-
const virtualRouteCache = new Map<string, string>();
|
|
298
|
-
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
// Common subset: extract chars from UI source files
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
|
|
303
|
-
function collectCommonChars(projectRoot: string): string {
|
|
304
|
-
const chars = new Set(ALWAYS_INCLUDE_CHARS);
|
|
305
|
-
|
|
306
|
-
// Scan all source directories for UI text characters
|
|
307
|
-
const srcDir = resolve(projectRoot, "src");
|
|
308
|
-
|
|
309
|
-
function scanDir(dir: string) {
|
|
310
|
-
if (!existsSync(dir)) return;
|
|
311
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
312
|
-
const fullPath = join(dir, entry.name);
|
|
313
|
-
if (entry.isDirectory()) {
|
|
314
|
-
scanDir(fullPath);
|
|
315
|
-
} else if (
|
|
316
|
-
entry.name.endsWith(".astro") ||
|
|
317
|
-
entry.name.endsWith(".ts") ||
|
|
318
|
-
entry.name.endsWith(".json")
|
|
319
|
-
) {
|
|
320
|
-
try {
|
|
321
|
-
const content = readFileSync(fullPath, "utf-8");
|
|
322
|
-
// Only extract string literals and text content (skip code)
|
|
323
|
-
for (const ch of content) chars.add(ch);
|
|
324
|
-
} catch {
|
|
325
|
-
/* skip */
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
scanDir(srcDir);
|
|
332
|
-
|
|
333
|
-
// Also scan user config YAML files for nav titles, descriptions, etc.
|
|
334
|
-
const configDir = resolve(projectRoot, CONTENT_ROOT, "config");
|
|
335
|
-
if (existsSync(configDir)) {
|
|
336
|
-
for (const f of readdirSync(configDir)) {
|
|
337
|
-
if (!f.endsWith(".yml") && !f.endsWith(".yaml")) continue;
|
|
338
|
-
try {
|
|
339
|
-
const content = readFileSync(join(configDir, f), "utf-8");
|
|
340
|
-
for (const ch of content) chars.add(ch);
|
|
341
|
-
} catch {
|
|
342
|
-
/* skip */
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Also scan post frontmatter for tags, categories so those pages' chars are covered
|
|
348
|
-
const postsDir = resolve(projectRoot, CONTENT_ROOT, "posts");
|
|
349
|
-
if (existsSync(postsDir)) {
|
|
350
|
-
for (const f of readdirSync(postsDir)) {
|
|
351
|
-
if (!f.endsWith(".md") && !f.endsWith(".mdx")) continue;
|
|
352
|
-
if (f.startsWith("_")) continue;
|
|
353
|
-
try {
|
|
354
|
-
const content = readFileSync(join(postsDir, f), "utf-8");
|
|
355
|
-
for (const ch of content) chars.add(ch);
|
|
356
|
-
} catch {
|
|
357
|
-
/* skip */
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return [...chars].sort().join("");
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// ---------------------------------------------------------------------------
|
|
366
|
-
// Deduplication: merge files with identical char sets
|
|
367
|
-
// ---------------------------------------------------------------------------
|
|
368
|
-
|
|
369
|
-
function deduplicate(routes: RouteCharSet[]): Map<string, string[]> {
|
|
370
|
-
// hash → [route1, route2, ...]
|
|
371
|
-
const groups = new Map<string, string[]>();
|
|
372
|
-
for (const r of routes) {
|
|
373
|
-
const list = groups.get(r.hash) ?? [];
|
|
374
|
-
list.push(r.route);
|
|
375
|
-
groups.set(r.hash, list);
|
|
376
|
-
}
|
|
377
|
-
return groups;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// Main entry
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Run font subsetting during build.
|
|
386
|
-
* Called from integration hooks.
|
|
387
|
-
*
|
|
388
|
-
* @param projectRoot - Project root directory
|
|
389
|
-
* @param logger - Astro integration logger
|
|
390
|
-
* @param skipPerRoute - Skip per-route subsets (for dev mode, only generate common)
|
|
391
|
-
*/
|
|
392
|
-
export async function runFontSubsetting(
|
|
393
|
-
projectRoot: string,
|
|
394
|
-
logger: AstroIntegrationLogger,
|
|
395
|
-
skipPerRoute = false,
|
|
396
|
-
): Promise<void> {
|
|
397
|
-
logger.info("Font subsetting: scanning content...");
|
|
398
|
-
|
|
399
|
-
// 1. Locate font file (check project root first, then stalux package dir)
|
|
400
|
-
const fontPaths = [
|
|
401
|
-
resolve(projectRoot, FONT_INPUT),
|
|
402
|
-
resolve(projectRoot, "node_modules", "@xingwangzhe", "stalux", FONT_INPUT),
|
|
403
|
-
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", FONT_INPUT),
|
|
404
|
-
];
|
|
405
|
-
|
|
406
|
-
let fontPath: string | undefined;
|
|
407
|
-
for (const p of fontPaths) {
|
|
408
|
-
if (existsSync(p)) {
|
|
409
|
-
fontPath = p;
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (!fontPath) {
|
|
415
|
-
logger.warn(`Font not found at ${FONT_INPUT}, skipping subsetting`);
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
const fontBuffer = readFileSync(fontPath);
|
|
419
|
-
|
|
420
|
-
// Clean old generated font files to avoid stale cached references
|
|
421
|
-
const outDir = resolve(projectRoot, FONT_OUT_DIR);
|
|
422
|
-
if (existsSync(outDir)) {
|
|
423
|
-
for (const f of readdirSync(outDir)) {
|
|
424
|
-
if (f.startsWith("common-") || f.startsWith("subset-") || f === "common.css") {
|
|
425
|
-
try {
|
|
426
|
-
unlinkSync(join(outDir, f));
|
|
427
|
-
} catch {
|
|
428
|
-
/* skip */
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// 1.5 代码字体(Google Sans Code)— 确定性静态 @font-face,不经过 Astro 字体管线
|
|
435
|
-
// Astro 的 <Font> 组件会在 prerender 阶段启动一个随机端口的 HTTP server,
|
|
436
|
-
// 端口号会嵌进 font-file-url-resolver 虚拟模块的编译产物,破坏增量构建依赖图 hash。
|
|
437
|
-
// 这里直接复制 woff2 到 public/fonts/ 并生成静态 code.css,完全绕开该管线。
|
|
438
|
-
try {
|
|
439
|
-
const codeFontPaths = [
|
|
440
|
-
resolve(projectRoot, CODE_FONT_INPUT),
|
|
441
|
-
resolve(projectRoot, "node_modules", "@xingwangzhe", "stalux", CODE_FONT_INPUT),
|
|
442
|
-
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", CODE_FONT_INPUT),
|
|
443
|
-
];
|
|
444
|
-
const codeFontPath = codeFontPaths.find((p) => existsSync(p));
|
|
445
|
-
if (codeFontPath) {
|
|
446
|
-
const codeFontBuffer = readFileSync(codeFontPath);
|
|
447
|
-
const codeFontOut = join(outDir, "GoogleSansCode.woff2");
|
|
448
|
-
if (!existsSync(codeFontOut) || !readFileSync(codeFontOut).equals(codeFontBuffer)) {
|
|
449
|
-
writeFileSync(codeFontOut, codeFontBuffer);
|
|
450
|
-
logger.info(
|
|
451
|
-
` code font: ${codeFontBuffer.length / 1024} KB → GoogleSansCode.woff2`,
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
// 静态 CSS:@font-face + --font-code 变量(确定性内容,无 hash)
|
|
455
|
-
const codeCSS = `/* Auto-generated code font (deterministic, no Astro font pipeline) */
|
|
456
|
-
:root {
|
|
457
|
-
--font-code: "Google Sans Code", "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace;
|
|
458
|
-
}
|
|
459
|
-
@font-face {
|
|
460
|
-
font-family: "Google Sans Code";
|
|
461
|
-
src: url("/fonts/GoogleSansCode.woff2") format("woff2");
|
|
462
|
-
font-display: swap;
|
|
463
|
-
}
|
|
464
|
-
`;
|
|
465
|
-
writeFileSync(join(outDir, "code.css"), codeCSS);
|
|
466
|
-
} else {
|
|
467
|
-
logger.warn(
|
|
468
|
-
`Code font not found at ${CODE_FONT_INPUT}, --font-code falls back to system monospace`,
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
} catch (error) {
|
|
472
|
-
logger.warn(`Code font setup failed: ${String(error)}`);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// 2. Scan content files
|
|
476
|
-
const contentFiles = scanContent(projectRoot);
|
|
477
|
-
logger.info(`Font subsetting: found ${contentFiles.length} content files`);
|
|
478
|
-
|
|
479
|
-
// 3. Collect common chars (UI, nav, i18n)
|
|
480
|
-
const commonChars = collectCommonChars(projectRoot);
|
|
481
|
-
const commonHash = hashChars(commonChars);
|
|
482
|
-
|
|
483
|
-
// 4. Extract per-route char sets
|
|
484
|
-
const routeSets: RouteCharSet[] = [];
|
|
485
|
-
for (const file of contentFiles) {
|
|
486
|
-
let content: string;
|
|
487
|
-
if (file.route === "words" && wordCharsCache.has("words")) {
|
|
488
|
-
content = wordCharsCache.get("words")!;
|
|
489
|
-
} else if (virtualRouteCache.has(file.route)) {
|
|
490
|
-
content = virtualRouteCache.get(file.route)!;
|
|
491
|
-
} else {
|
|
492
|
-
content = readFileSync(file.filePath, "utf-8");
|
|
493
|
-
}
|
|
494
|
-
const chars = extractChars(content);
|
|
495
|
-
routeSets.push({
|
|
496
|
-
route: file.route,
|
|
497
|
-
chars,
|
|
498
|
-
hash: hashChars(chars),
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// 5. Deduplicate and generate subsets using subset-font (WASM)
|
|
503
|
-
mkdirSync(outDir, { recursive: true });
|
|
504
|
-
|
|
505
|
-
// Helper: subset using subset-font (async WASM)
|
|
506
|
-
async function doSubset(chars: string): Promise<Uint8Array> {
|
|
507
|
-
if (!chars) return new Uint8Array(0);
|
|
508
|
-
try {
|
|
509
|
-
const result = await subsetFont(fontBuffer, chars, {
|
|
510
|
-
targetFormat: "sfnt", // "sfnt" = TTF output
|
|
511
|
-
});
|
|
512
|
-
return result;
|
|
513
|
-
} catch {
|
|
514
|
-
return new Uint8Array(0);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Generate common subset
|
|
519
|
-
const commonFilename = `common-${commonHash}.ttf`;
|
|
520
|
-
const commonOutPath = join(outDir, commonFilename);
|
|
521
|
-
if (!existsSync(commonOutPath)) {
|
|
522
|
-
const data = await doSubset(commonChars);
|
|
523
|
-
if (data.length > 0) {
|
|
524
|
-
writeFileSync(commonOutPath, data);
|
|
525
|
-
logger.info(
|
|
526
|
-
` common subset: ${(data.length / 1024).toFixed(1)} KB → ${commonFilename}`,
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Generate common CSS (content-addressed filename: 字符集变化 → hash 变化 →
|
|
532
|
-
// 文件名变化,浏览器必然拉新 CSS,避免缓存旧 CSS 引用已删除的旧 TTF 导致 404)
|
|
533
|
-
// dev 模式(skipPerRoute)用固定名 common.css,由 dev 中间件场景复用;
|
|
534
|
-
// 生产构建用 common-<hash>.css,HTML 从 manifest 读取引用。
|
|
535
|
-
const commonCssName = skipPerRoute ? "common.css" : `common-${commonHash}.css`;
|
|
536
|
-
const commonCSS = `/* Auto-generated common font subset */
|
|
537
|
-
@font-face {
|
|
538
|
-
font-family: "LXGW WenKai Subset";
|
|
539
|
-
src: url("/fonts/${commonFilename}") format("truetype");
|
|
540
|
-
font-display: swap;
|
|
541
|
-
}
|
|
542
|
-
`;
|
|
543
|
-
writeFileSync(join(outDir, commonCssName), commonCSS);
|
|
544
|
-
|
|
545
|
-
const groups = deduplicate(routeSets);
|
|
546
|
-
const routeMap: Record<string, string> = {};
|
|
547
|
-
const postMap: Record<string, string> = {};
|
|
548
|
-
|
|
549
|
-
let subsetCount = 0;
|
|
550
|
-
|
|
551
|
-
if (!skipPerRoute) {
|
|
552
|
-
for (const [hash, routes] of groups) {
|
|
553
|
-
const filename = `subset-${hash}.ttf`;
|
|
554
|
-
const outPath = join(outDir, filename);
|
|
555
|
-
if (!existsSync(outPath)) {
|
|
556
|
-
const route = routeSets.find((r) => r.hash === hash);
|
|
557
|
-
const chars = route?.chars ?? "";
|
|
558
|
-
const uniqueChars = chars
|
|
559
|
-
.split("")
|
|
560
|
-
.filter((ch) => !commonChars.includes(ch))
|
|
561
|
-
.join("");
|
|
562
|
-
if (uniqueChars.length > 0) {
|
|
563
|
-
const data = await doSubset(uniqueChars);
|
|
564
|
-
if (data.length > 0) {
|
|
565
|
-
writeFileSync(outPath, data);
|
|
566
|
-
subsetCount++;
|
|
567
|
-
logger.info(
|
|
568
|
-
` subset ${subsetCount}: ${(data.length / 1024).toFixed(1)} KB → ${filename}`,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Generate per-route CSS (each route gets its own CSS file)
|
|
575
|
-
// CSS 文件名内容寻址:内容只引用一个 TTF,文件名带上该 TTF 的 hash,
|
|
576
|
-
// 内容变化 → 文件名变化 → 浏览器必然拉新,避免缓存旧 CSS 引用已删除 TTF。
|
|
577
|
-
// If no unique chars, reference the common TTF as fallback
|
|
578
|
-
const refTtf = existsSync(outPath) ? filename : commonFilename;
|
|
579
|
-
const refHash = existsSync(outPath) ? hash : commonHash;
|
|
580
|
-
for (const route of routes) {
|
|
581
|
-
const cssContent = `/* Auto-generated font subset for route: ${route} */
|
|
582
|
-
@font-face {
|
|
583
|
-
font-family: "LXGW WenKai Subset";
|
|
584
|
-
src: url("/fonts/${refTtf}") format("truetype");
|
|
585
|
-
font-display: swap;
|
|
586
|
-
}
|
|
587
|
-
`;
|
|
588
|
-
const cssName = route.replace("/", "-");
|
|
589
|
-
const cssFilename = `subset-${cssName}-${refHash}.css`;
|
|
590
|
-
writeFileSync(join(outDir, cssFilename), cssContent);
|
|
591
|
-
routeMap[route] = cssFilename;
|
|
592
|
-
|
|
593
|
-
if (route.startsWith("posts/")) {
|
|
594
|
-
const abbrlink = route.replace("posts/", "");
|
|
595
|
-
postMap[abbrlink] = cssFilename;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// 6. Generate manifest(dev 也生成:routes/posts 为空,供 Stalux.astro import)
|
|
602
|
-
const manifest: FontManifest = {
|
|
603
|
-
common: commonFilename,
|
|
604
|
-
commonCss: commonCssName,
|
|
605
|
-
routes: routeMap,
|
|
606
|
-
posts: postMap,
|
|
607
|
-
};
|
|
608
|
-
writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
609
|
-
|
|
610
|
-
// 副本写入包内 generated/:Stalux.astro import 它,让 manifest 进入 Astro
|
|
611
|
-
// 依赖图——manifest 变化时页面必然重建,增量构建 restored 的页面不会引用旧资源。
|
|
612
|
-
const genDir = resolve(dirname(fileURLToPath(import.meta.url)), "generated");
|
|
613
|
-
mkdirSync(genDir, { recursive: true });
|
|
614
|
-
writeFileSync(join(genDir, "fonts-manifest.json"), JSON.stringify(manifest));
|
|
615
|
-
|
|
616
|
-
logger.info(
|
|
617
|
-
`Font subsetting done: ${subsetCount} route subsets + common (${(fontBuffer.length / 1024 / 1024).toFixed(1)} MB → variable)`,
|
|
618
|
-
);
|
|
619
|
-
}
|