@windcoder/astro-theme-orchid 0.2.0 → 0.3.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.
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 文章迁移工具(§4.94):把 Hexo / Hugo 或通用 Markdown 博客文章迁移为符合
4
+ * 主题 definePostSchema 的 frontmatter(title/published 必需,其余尽力映射),
5
+ * 正文一字不动;默认 dry-run 只出报告,--write 才写盘。
6
+ *
7
+ * 用法(站点根目录):
8
+ * node node_modules/@windcoder/astro-theme-orchid/scripts/migrate-posts.mjs \
9
+ * --src <文章目录> [--platform hexo|hugo|generic] [--out migrated-posts] \
10
+ * [--write] [--flat]
11
+ *
12
+ * 规则:
13
+ * - 只迁移 frontmatter。主题无 <!-- more --> 摘要分隔符机制,列表摘要由
14
+ * remark-excerpt 取正文首段自动兜底,无需转换。
15
+ * - 标题三级回退:frontmatter → 正文首个 `# 标题` → 文件名;
16
+ * 发布时间缺失时用文件修改时间兜底。
17
+ * - 白名单之外的 frontmatter 字段不写入目标,仅在报告中列出,便于人工核对。
18
+ * - 正文中的相对路径图片在报告中提示:目录结构调整后需人工核对引用。
19
+ * - 输出文件名取自源文件名(Hugo 优先 slug 字段),冲突自动加 -2/-3 序号;
20
+ * 默认保留相对目录结构,--flat 拍平到输出根目录。
21
+ */
22
+ import {
23
+ mkdirSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { basename, dirname, join, relative, resolve } from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+ import { parse, stringify } from "yaml";
32
+
33
+ const pad2 = (n) => String(n).padStart(2, "0");
34
+
35
+ function parseArgs(argv) {
36
+ const args = {
37
+ src: "",
38
+ platform: "generic",
39
+ out: "migrated-posts",
40
+ write: false,
41
+ flat: false,
42
+ };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a === "--src") args.src = argv[++i];
46
+ else if (a === "--platform") args.platform = argv[++i] ?? "";
47
+ else if (a === "--out") args.out = argv[++i];
48
+ else if (a === "--write") args.write = true;
49
+ else if (a === "--flat") args.flat = true;
50
+ else if (a === "--help" || a === "-h") {
51
+ console.log(
52
+ "用法:node migrate-posts.mjs --src <文章目录> [--platform hexo|hugo|generic] [--out migrated-posts] [--write] [--flat]",
53
+ );
54
+ process.exit(0);
55
+ }
56
+ }
57
+ return args;
58
+ }
59
+
60
+ /** 解析 `---` 界定的 frontmatter;无界定符或 YAML 损坏时 broken/空对象兜底 */
61
+ export function parseFrontmatter(raw) {
62
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
63
+ if (!m) return { data: {}, body: raw, broken: false };
64
+ let data = {};
65
+ let broken = false;
66
+ try {
67
+ const parsed = parse(m[1]);
68
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
69
+ data = parsed;
70
+ } else if (parsed != null) {
71
+ broken = true;
72
+ }
73
+ } catch {
74
+ broken = true;
75
+ }
76
+ return { data, body: raw.slice(m[0].length), broken };
77
+ }
78
+
79
+ /** 日期值归一化为 YAML 友好字符串;非法返回 null。
80
+ * date-only 原样保留(避免时区漂移),hexo 斜杠格式转 ISO,
81
+ * 带显式时区偏移的 ISO 串直接取墙钟(同理,避免经 Date 本地化漂移),其余 new Date 兜底。 */
82
+ export function normalizeDate(value) {
83
+ if (value instanceof Date) {
84
+ return Number.isNaN(value.getTime()) ? null : formatDate(value);
85
+ }
86
+ if (typeof value !== "string") return null;
87
+ const s = value.trim();
88
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
89
+ const slash =
90
+ /^(\d{4})\/(\d{2})\/(\d{2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/.exec(s);
91
+ if (slash) {
92
+ const date = `${slash[1]}-${slash[2]}-${slash[3]}`;
93
+ return slash[4]
94
+ ? `${date}T${pad2(slash[4])}:${slash[5]}:${slash[6] ?? "00"}`
95
+ : date;
96
+ }
97
+ const offseted =
98
+ /^(\d{4}-\d{2}-\d{2})[Tt ](\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:?\d{2})$/.exec(
99
+ s,
100
+ );
101
+ if (offseted) return `${offseted[1]}T${offseted[2]}:${offseted[3] ?? "00"}`;
102
+ const d = new Date(s);
103
+ return Number.isNaN(d.getTime()) ? null : formatDate(d);
104
+ }
105
+
106
+ /** Date → 本地时分秒格式:零点只写日期,其余带 T 时间(无时区后缀,保墙钟) */
107
+ export function formatDate(d) {
108
+ const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
109
+ const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
110
+ return time === "00:00:00" ? date : `${date}T${time}`;
111
+ }
112
+
113
+ /** 任意嵌套结构拍平成非空字符串数组(hexo categories 可能是嵌套数组) */
114
+ function flattenStrings(value) {
115
+ if (value == null) return [];
116
+ if (Array.isArray(value)) return value.flatMap(flattenStrings);
117
+ const s = String(value).trim();
118
+ return s ? [s] : [];
119
+ }
120
+
121
+ /** 标题三级回退:frontmatter → 正文首个 `# 标题` → 文件名 */
122
+ export function resolveTitle(data, body, fallback) {
123
+ const t = data.title;
124
+ if (typeof t === "string" && t.trim()) {
125
+ return { title: t.trim(), from: "frontmatter" };
126
+ }
127
+ if (typeof t === "number") return { title: String(t), from: "frontmatter" };
128
+ const m = /^[ \t]*#[ \t]+(.+?)[ \t]*$/m.exec(body);
129
+ if (m) {
130
+ return { title: m[1].replace(/[#*`_]/g, "").trim(), from: "heading" };
131
+ }
132
+ return { title: fallback, from: "filename" };
133
+ }
134
+
135
+ /** 文件名净化:空白转 -,剔除文件系统不安全字符,空值兜底 "post"(保留中文) */
136
+ export function toSlug(name) {
137
+ const slug = name
138
+ .trim()
139
+ .replace(/\s+/g, "-")
140
+ .replace(/[/\\:*?"<>|\p{Cc}]/gu, "")
141
+ .replace(/^[.-]+|[.-]+$/g, "");
142
+ return slug || "post";
143
+ }
144
+
145
+ /** 正文里的相对路径图片(不含 http(s)://、//、/、data: 开头) */
146
+ export function findRelativeImages(body) {
147
+ const out = [];
148
+ const re = /!\[[^\]]*\]\(([^)\s]+)[^)]*\)|<img[^>]+src=["']([^"']+)["']/g;
149
+ for (const m of body.matchAll(re)) {
150
+ const url = m[1] ?? m[2];
151
+ if (
152
+ !url.startsWith("/") &&
153
+ !url.startsWith("data:") &&
154
+ !url.startsWith("#") &&
155
+ !/^(https?:)?\/\//.test(url)
156
+ ) {
157
+ out.push(url);
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * 按平台把源 frontmatter 映射为目标白名单字段(键序固定)。
165
+ * 白名单外与未消费的源键进 notes,不写入目标。
166
+ */
167
+ export function mapFrontmatter(data, platform) {
168
+ const notes = [];
169
+ const used = new Set();
170
+ /** 取第一个有值的键;候选键一律记为已消费(不进未映射报告) */
171
+ const take = (...keys) => {
172
+ let hit;
173
+ for (const k of keys) {
174
+ const v = data[k];
175
+ if (hit === undefined && v !== undefined && v !== null && v !== "") {
176
+ hit = v;
177
+ }
178
+ used.add(k);
179
+ }
180
+ return hit;
181
+ };
182
+
183
+ const fm = {};
184
+ const title = take("title");
185
+ if (title !== undefined) fm.title = String(title);
186
+
187
+ const description = take("description", "summary", "excerpt");
188
+ if (description !== undefined) fm.description = String(description);
189
+
190
+ const rawPublished =
191
+ platform === "generic"
192
+ ? take("published", "date", "created")
193
+ : take("date", "publishDate");
194
+ const published = normalizeDate(rawPublished);
195
+ if (published) fm.published = published;
196
+
197
+ const rawUpdated =
198
+ platform === "hugo"
199
+ ? take("lastmod", "updated")
200
+ : take("updated", "modified", "lastmod");
201
+ const updated = normalizeDate(rawUpdated);
202
+ if (updated) fm.updated = updated;
203
+
204
+ if (platform === "hexo") {
205
+ const p = take("published");
206
+ if (p === false || p === "false") fm.draft = true;
207
+ } else {
208
+ const d = take("draft");
209
+ if (d === true || d === "true") fm.draft = true;
210
+ }
211
+
212
+ let image;
213
+ if (platform === "hexo") {
214
+ image = take("cover", "image") ?? flattenStrings(take("photos"))[0];
215
+ } else if (platform === "hugo") {
216
+ image = take("image", "cover") ?? flattenStrings(take("images"))[0];
217
+ } else {
218
+ image = take("cover", "image", "banner", "thumbnail");
219
+ }
220
+ if (image !== undefined && image !== "") fm.image = String(image);
221
+
222
+ const tags = flattenStrings(take("tags"));
223
+ if (tags.length) fm.tags = tags;
224
+
225
+ const categories = flattenStrings(take("category", "categories"));
226
+ if (categories.length) fm.category = categories[0];
227
+
228
+ // hugo slug 供输出文件名用(见 migrateFile),标记已消费避免误报
229
+ if (platform === "hugo") take("slug");
230
+
231
+ const unmapped = Object.keys(data).filter((k) => !used.has(k));
232
+ if (unmapped.length) {
233
+ notes.push(`未映射字段:${unmapped.join("、")}(未写入目标)`);
234
+ }
235
+ return { fm, notes };
236
+ }
237
+
238
+ /** 递归收集 src 下 .md/.markdown,返回 posix 分隔符的相对路径(排序稳定) */
239
+ export function scanMarkdownFiles(srcDir) {
240
+ const out = [];
241
+ function walk(dir) {
242
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
243
+ const abs = join(dir, ent.name);
244
+ if (ent.isDirectory()) walk(abs);
245
+ else if (ent.isFile() && /\.(md|markdown)$/i.test(ent.name)) {
246
+ out.push(relative(srcDir, abs).replace(/\\/g, "/"));
247
+ }
248
+ }
249
+ }
250
+ walk(srcDir);
251
+ return out.sort();
252
+ }
253
+
254
+ /** 迁移单篇:映射 frontmatter + 兜底补齐 + 生成输出路径与文件内容 */
255
+ export function migrateFile({ rel, raw, platform, mtimeMs, usedSlugs, flat }) {
256
+ const notes = [];
257
+ const { data, body, broken } = parseFrontmatter(raw);
258
+ if (broken) notes.push("frontmatter 解析失败,已按无元数据处理");
259
+
260
+ const { fm, notes: mapNotes } = mapFrontmatter(data, platform);
261
+ notes.push(...mapNotes);
262
+
263
+ const base = basename(rel).replace(/\.(md|markdown)$/i, "");
264
+ const title = resolveTitle(data, body, base);
265
+ fm.title = title.title;
266
+ if (title.from === "heading") notes.push("标题缺失,已取正文首个一级标题");
267
+ else if (title.from === "filename") notes.push("标题缺失,已使用文件名");
268
+
269
+ if (!fm.published) {
270
+ fm.published = formatDate(new Date(mtimeMs));
271
+ notes.push("发布时间缺失,已用文件修改时间兜底");
272
+ }
273
+
274
+ for (const img of new Set(findRelativeImages(body))) {
275
+ notes.push(`正文含相对路径图片:${img}(目录调整后需人工核对)`);
276
+ }
277
+
278
+ const slugSource =
279
+ platform === "hugo" && typeof data.slug === "string" && data.slug.trim()
280
+ ? data.slug.trim()
281
+ : base;
282
+ const slug = toSlug(slugSource);
283
+ const dir =
284
+ flat || dirname(rel) === "." ? "" : `${dirname(rel).replace(/\\/g, "/")}/`;
285
+ let relOut = `${dir}${slug}.md`;
286
+ for (let n = 2; usedSlugs.has(relOut); n++) relOut = `${dir}${slug}-${n}.md`;
287
+ usedSlugs.add(relOut);
288
+
289
+ const content = `---\n${stringify(fm)}---\n\n${body.replace(/^\r?\n+/, "")}`;
290
+ return { relOut, fm, content, notes };
291
+ }
292
+
293
+ /** 迁移整目录:write 为 false 时只生成报告不落盘 */
294
+ export function runMigration({
295
+ src,
296
+ out,
297
+ platform,
298
+ write = false,
299
+ flat = false,
300
+ }) {
301
+ const usedSlugs = new Set();
302
+ const results = [];
303
+ for (const rel of scanMarkdownFiles(src)) {
304
+ const abs = join(src, rel);
305
+ const { mtimeMs } = statSync(abs);
306
+ const r = migrateFile({
307
+ rel,
308
+ raw: readFileSync(abs, "utf8"),
309
+ platform,
310
+ mtimeMs,
311
+ usedSlugs,
312
+ flat,
313
+ });
314
+ if (write) {
315
+ const absOut = resolve(out, r.relOut);
316
+ mkdirSync(dirname(absOut), {
317
+ recursive: true,
318
+ });
319
+ writeFileSync(absOut, r.content);
320
+ }
321
+ results.push({ source: rel, output: r.relOut, notes: r.notes });
322
+ }
323
+ return { platform, total: results.length, write, results };
324
+ }
325
+
326
+ async function main() {
327
+ const args = parseArgs(process.argv.slice(2));
328
+ if (!args.src) {
329
+ console.error(
330
+ "[migrate-posts] 缺少 --src <文章目录>。加 --help 查看用法。",
331
+ );
332
+ process.exit(1);
333
+ }
334
+ if (!["hexo", "hugo", "generic"].includes(args.platform)) {
335
+ console.error(
336
+ `[migrate-posts] 未知 --platform ${args.platform},可选 hexo | hugo | generic。`,
337
+ );
338
+ process.exit(1);
339
+ }
340
+ const src = resolve(args.src);
341
+ try {
342
+ if (!scanMarkdownFiles(src).length) {
343
+ console.log(
344
+ `[migrate-posts] ${src} 下没有 .md / .markdown 文件,无事可做。`,
345
+ );
346
+ process.exit(0);
347
+ }
348
+ } catch {
349
+ console.error(`[migrate-posts] 无法读取目录 ${src},请确认路径。`);
350
+ process.exit(1);
351
+ }
352
+
353
+ console.log(
354
+ `[migrate-posts] 平台 ${args.platform},输出 ${resolve(args.out)}${args.write ? "" : "(dry-run 预览,不会写盘)"}\n`,
355
+ );
356
+
357
+ const report = runMigration({ ...args, src });
358
+ for (const f of report.results) {
359
+ console.log(` ${f.source} → ${f.output}`);
360
+ for (const n of f.notes) console.log(` ⚠ ${n}`);
361
+ }
362
+
363
+ const noted = report.results.filter((f) => f.notes.length).length;
364
+ console.log(
365
+ `\n[migrate-posts] 完成:${report.total} 篇,${noted} 篇有提示${
366
+ args.write
367
+ ? `,已写入 ${resolve(args.out)}`
368
+ : "。确认无误后加 --write 落盘"
369
+ }`,
370
+ );
371
+ }
372
+
373
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
374
+ main();
375
+ }
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 字体子集化脚本(M4-3,移植自 orchid-blog/scripts/subset-fonts.ts)
4
+ *
5
+ * astro build 之后运行:扫描 dist/ 全部 HTML 收集实际用到的字符,
6
+ * 为 subset-fonts.json 清单中声明的本地字体生成 woff2 子集,
7
+ * 重写 dist 内 CSS/HTML 对原字体的 url()/href 引用,并删除 dist 中的原字体。
8
+ * 相比原版的改动:原版靠 __SUBSET_FONT_{id}__ 占位符替换,但源码中并无
9
+ * 占位符生成方(半成品机制);本版直接重写原字体 URL,配合站点自写的
10
+ * @font-face 即可端到端生效,且不再依赖 Astro Font API 与站点 src/config。
11
+ *
12
+ * 运行:pnpm --filter site build 内链(cwd 必须是 site 根)
13
+ * 清单:site 根 subset-fonts.json(可选):
14
+ * { "fonts": [{ "src": "/assets/fonts/Foo.ttf", "id": "foo", "extraChars": "…" }] }
15
+ * - src 必填:dist 绝对路径(/ 开头),也兼容 ./public/ 前缀写法
16
+ * - id 选填:默认取文件名;extraChars 选填:补充动态内容字符
17
+ * 无清单或清单为空时跳过(exit 0),CI 无需感知。
18
+ * dev 模式不受影响:dev 直接走 public 原字体,仅构建产物被子集化。
19
+ */
20
+ import crypto from "node:crypto";
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import subsetFont from "subset-font";
24
+
25
+ const DIST_DIR = "dist";
26
+ const MANIFEST_FILE = "subset-fonts.json";
27
+
28
+ /** 与 generate-lqips.mjs 一致:目录递归替代 glob 依赖 */
29
+ async function walkFiles(dir, exts, out = []) {
30
+ let entries;
31
+ try {
32
+ entries = await fs.readdir(dir, { withFileTypes: true });
33
+ } catch {
34
+ return out;
35
+ }
36
+ for (const entry of entries) {
37
+ const full = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ await walkFiles(full, exts, out);
40
+ } else if (exts.has(path.extname(entry.name).toLowerCase())) {
41
+ out.push(full);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * 将清单 src 归一为 dist 绝对 URL(/ 开头)。
49
+ * 兼容 ./public/x、public/x、/public/x、/x 四种写法;无法归一返回 null。
50
+ */
51
+ function toPublicPath(rawSrc) {
52
+ if (rawSrc.startsWith("/")) {
53
+ const m = rawSrc.match(/^\/public\/(.+)$/);
54
+ return m ? `/${m[1]}` : rawSrc;
55
+ }
56
+ const m = rawSrc.match(/^\.?\/?public\/(.+)$/);
57
+ return m ? `/${m[1]}` : null;
58
+ }
59
+
60
+ /**
61
+ * 从 HTML 提取可见文本:剔除 script/style,剥标签,
62
+ * 解常见实体,另收 alt/title/aria-label/placeholder 属性值(移植原版)
63
+ */
64
+ function extractTextFromHtml(html) {
65
+ let text = html.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ");
66
+ text = text.replace(/<[^>]+>/g, " ");
67
+ text = text
68
+ .replace(/&amp;/g, "&")
69
+ .replace(/&lt;/g, "<")
70
+ .replace(/&gt;/g, ">")
71
+ .replace(/&quot;/g, '"')
72
+ .replace(/&#39;/g, "'")
73
+ .replace(/&nbsp;/g, " ");
74
+ const attrs = html.matchAll(
75
+ /(?:alt|title|aria-label|placeholder)=["']([^"']+)["']/gi,
76
+ );
77
+ for (const m of attrs) text += m[1];
78
+ return text;
79
+ }
80
+
81
+ async function collectChars(distDir) {
82
+ const files = await walkFiles(distDir, new Set([".html"]));
83
+ const chars = new Set();
84
+ for (const file of files) {
85
+ const html = await fs.readFile(file, "utf-8");
86
+ for (const c of extractTextFromHtml(html)) chars.add(c);
87
+ }
88
+ return [...chars].join("");
89
+ }
90
+
91
+ function contentHash(buffer) {
92
+ return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 16);
93
+ }
94
+
95
+ /** 生成 url(...) 与 href/src 属性两种引用形态的重写规则 */
96
+ function rewriteReferences(content, originalUrl, subsetUrl) {
97
+ const escaped = originalUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
+ const urlRe = new RegExp(`url\\(\\s*(['"]?)${escaped}\\1\\s*\\)`, "g");
99
+ const attrRe = new RegExp(`((?:href|src)=['"])${escaped}(['"])`, "g");
100
+ return content
101
+ .replace(urlRe, `url($1${subsetUrl}$1)`)
102
+ .replace(attrRe, `$1${subsetUrl}$2`);
103
+ }
104
+
105
+ async function main() {
106
+ const cwd = process.cwd();
107
+ const distDir = path.join(cwd, DIST_DIR);
108
+
109
+ // 1. 读清单;无清单/空清单 → no-op 跳过
110
+ let manifest;
111
+ try {
112
+ manifest = JSON.parse(
113
+ await fs.readFile(path.join(cwd, MANIFEST_FILE), "utf-8"),
114
+ );
115
+ } catch {
116
+ console.log("[subset-fonts] 无 subset-fonts.json 清单,跳过(exit 0)");
117
+ return;
118
+ }
119
+ const entries = (manifest.fonts ?? [])
120
+ .map((e) => {
121
+ const publicUrl = toPublicPath(e.src ?? "");
122
+ if (!publicUrl) {
123
+ console.warn(
124
+ `[subset-fonts] ⚠ 无法归一 src 路径,跳过:${e.src}(需 /assets/... 或 public/ 前缀)`,
125
+ );
126
+ return null;
127
+ }
128
+ const id = (e.id || path.basename(publicUrl, path.extname(publicUrl)))
129
+ .toLowerCase()
130
+ .replace(/\s+/g, "-");
131
+ return { id, url: publicUrl, extraChars: e.extraChars ?? "" };
132
+ })
133
+ .filter(Boolean);
134
+
135
+ if (entries.length === 0) {
136
+ console.log("[subset-fonts] 清单为空,跳过(exit 0)");
137
+ return;
138
+ }
139
+
140
+ // 2. 收集 dist 页面实际字符
141
+ console.log("[subset-fonts] 扫描 dist/ HTML 收集字符…");
142
+ const pageChars = await collectChars(distDir);
143
+ if (pageChars.length === 0) {
144
+ console.warn("[subset-fonts] ⚠ dist/ 无 HTML 或无字符,跳过");
145
+ return;
146
+ }
147
+ console.log(`[subset-fonts] 收集到 ${pageChars.length} 个去重字符`);
148
+
149
+ // 3. 逐字体生成子集
150
+ const outDir = path.join(distDir, "assets", "fonts");
151
+ await fs.mkdir(outDir, { recursive: true });
152
+ const results = [];
153
+ for (const entry of entries) {
154
+ const originalPath = path.join(distDir, entry.url.slice(1));
155
+ let fontBuffer;
156
+ try {
157
+ fontBuffer = await fs.readFile(originalPath);
158
+ } catch {
159
+ console.error(
160
+ `[subset-fonts] ❌ dist 中未找到字体 ${entry.url}(确认已在 public/ 放置并参与构建)`,
161
+ );
162
+ continue;
163
+ }
164
+
165
+ const charSet = new Set([...pageChars, ...entry.extraChars]);
166
+ const chars = [...charSet].join("");
167
+ try {
168
+ const subsetBuffer = await subsetFont(fontBuffer, chars, {
169
+ targetFormat: "woff2",
170
+ preserveNameTable: true,
171
+ });
172
+ const hash = contentHash(subsetBuffer);
173
+ const outName = `${entry.id}.${hash}.woff2`;
174
+ await fs.writeFile(path.join(outDir, outName), subsetBuffer);
175
+
176
+ const kb = (n) => (n / 1024).toFixed(1);
177
+ const saved = (
178
+ ((fontBuffer.length - subsetBuffer.length) / fontBuffer.length) *
179
+ 100
180
+ ).toFixed(1);
181
+ console.log(
182
+ `[subset-fonts] ✔ ${entry.id} → ${outName}(${kb(subsetBuffer.length)} KB,原 ${kb(fontBuffer.length)} KB,省 ${saved}%)`,
183
+ );
184
+ results.push({ ...entry, outName });
185
+ } catch (err) {
186
+ console.error(`[subset-fonts] ❌ ${entry.id} 子集化失败:`, err);
187
+ }
188
+ }
189
+ if (results.length === 0) {
190
+ console.warn("[subset-fonts] ⚠ 未产出任何子集");
191
+ return;
192
+ }
193
+
194
+ // 4. 重写 dist 内 CSS/HTML 引用(url() 与 href/src 属性)
195
+ console.log("[subset-fonts] 重写 dist 内字体引用…");
196
+ const targets = await walkFiles(distDir, new Set([".css", ".html"]));
197
+ for (const file of targets) {
198
+ let content = await fs.readFile(file, "utf-8");
199
+ const before = content;
200
+ for (const r of results) {
201
+ content = rewriteReferences(content, r.url, `/assets/fonts/${r.outName}`);
202
+ }
203
+ if (content !== before) {
204
+ await fs.writeFile(file, content);
205
+ console.log(`[subset-fonts] ✔ 已更新 ${path.relative(cwd, file)}`);
206
+ }
207
+ }
208
+
209
+ // 5. 删除 dist 中的原字体(public 源文件保留,dev 仍走原字体)
210
+ for (const r of results) {
211
+ const originalPath = path.join(distDir, r.url.slice(1));
212
+ try {
213
+ await fs.unlink(originalPath);
214
+ console.log(
215
+ `[subset-fonts] ✔ 已删除 ${path.relative(cwd, originalPath)}`,
216
+ );
217
+ } catch {
218
+ // 原文件可能已不在 dist,忽略
219
+ }
220
+ }
221
+ console.log("[subset-fonts] ✨ 字体子集化完成");
222
+ }
223
+
224
+ main().catch((err) => {
225
+ console.error("[subset-fonts] ❌ 执行失败:", err);
226
+ process.exit(1);
227
+ });
@@ -8,15 +8,18 @@ import GoogleAnalytics from "./analytics/GoogleAnalytics.astro";
8
8
  import La51Analytics from "./analytics/La51Analytics.astro";
9
9
  import MicrosoftClarity from "./analytics/MicrosoftClarity.astro";
10
10
  import UmamiAnalytics from "./analytics/UmamiAnalytics.astro";
11
+ import WebVitals from "./analytics/WebVitals.astro";
11
12
 
12
13
  const a = config.analytics;
13
14
  const showGA = a.googleAnalyticsId.trim() !== "";
14
15
  const showClarity = a.microsoftClarityId.trim() !== "";
15
16
  const showUmami = a.umami.websiteId.trim() !== "";
16
17
  const show51la = a.la51.id.trim() !== "";
18
+ const showWebVitals = a.webVitals.endpoint.trim() !== "";
17
19
  ---
18
20
 
19
21
  {showGA && <GoogleAnalytics analyticsId={a.googleAnalyticsId} />}
20
22
  {showClarity && <MicrosoftClarity clarityId={a.microsoftClarityId} />}
21
23
  {showUmami && <UmamiAnalytics {...a.umami} />}
22
24
  {show51la && <La51Analytics analyticsId={a.la51.id} sdkUrl={a.la51.sdkUrl} ck={a.la51.ck} autoTrack={a.la51.autoTrack} hashMode={a.la51.hashMode} screenRecord={a.la51.screenRecord} />}
25
+ {showWebVitals && <WebVitals {...a.webVitals} />}
@@ -50,6 +50,17 @@ const showBanner =
50
50
  /* hero 文案(§4.42):quote 留空不渲染;有 banner 时叠加其上,否则渲染为列表上方独立区块 */
51
51
  const heroEnabled = config.banner.enabled && Boolean(config.banner.quote);
52
52
  const heroOnBanner = heroEnabled && showBanner && page === "home";
53
+ /* 全站背景壁纸(§4.92):与 banner 相互独立;数组时构建期随机选一张(每页各自取,零 JS) */
54
+ const bgSrc = Array.isArray(config.background.src)
55
+ ? config.background.src.length
56
+ ? config.background.src[
57
+ Math.floor(Math.random() * config.background.src.length)
58
+ ]
59
+ : ""
60
+ : config.background.src || "";
61
+ const showWallpaper = config.background.enabled && Boolean(bgSrc);
62
+ /* 模糊边缘会泛出透明底:按模糊量轻微放大补偿(1px blur ≈ 1% 放大),服务端算好 scale */
63
+ const bgScale = (1 + config.background.blur / 100).toFixed(3);
53
64
  /* 字体角色映射(§4.66):配置了 role 的字体生成 html:root 规则,把主题三角色
54
65
  变量重定向为 var(字体变量), 原角色回退栈(取自 global.css :root 默认值,
55
66
  字体未覆盖的字形如中文自然回落)。html:root Specificity (0,1,1) 高于
@@ -70,7 +81,7 @@ const fontRoleCss = config.fonts
70
81
  ---
71
82
 
72
83
  <!doctype html>
73
- <html lang={config.lang} style={`--accent-h:${h};--accent-s:${s}%;--accent-l:${l}%;--page-width:${config.pageWidth}px`} data-accent-h={h} data-color-mode={config.colorMode}>
84
+ <html lang={config.lang} style={`--accent-h:${h};--accent-s:${s}%;--accent-l:${l}%;--page-width:${config.pageWidth}px${config.darkMode.adaptExternalImages ? ";--img-adapt-b:.85" : ""}`} data-accent-h={h} data-color-mode={config.colorMode}>
74
85
  <head>
75
86
  <meta charset="utf-8" />
76
87
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -97,6 +108,8 @@ const fontRoleCss = config.fonts
97
108
  <link rel="alternate" type="application/rss+xml" title={config.title} href={Astro.site ? new URL("rss.xml", Astro.site).href : "/rss.xml"} />
98
109
  <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f4ef" />
99
110
  <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#131516" />
111
+ {/* PWA(§4.95):manifest 清单链接,端点由 pwa.enabled 门控注入 */}
112
+ {config.pwa.enabled && <link rel="manifest" href="/manifest.webmanifest" />}
100
113
  {/* OG/Twitter meta(b9.4):ogImage 仅文章页传入;绝对链接依赖 site 配置 */}
101
114
  <meta property="og:type" content={ogImage ? 'article' : 'website'} />
102
115
  <meta property="og:title" content={pageTitle} />
@@ -122,10 +135,36 @@ const fontRoleCss = config.fonts
122
135
  <script is:inline>
123
136
  (function(){var m=localStorage.getItem('qy-mode');var d=document.documentElement.dataset.colorMode||'system';if(m==='dark'||(!m&&d==='dark')||(!m&&d==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');var h=localStorage.getItem('qy-hue');if(h)document.documentElement.style.setProperty('--accent-h',h);})();
124
137
  </script>
138
+ {/* PWA(§4.95):SW 注册。load 后注册不抢占首屏;is:inline + define:vars 在
139
+ ClientRouter VT swap 时不重复执行,全站仅注册一次;注册失败静默(离线兜底
140
+ 属增强能力,不影响正常浏览) */}
141
+ {config.pwa.enabled && (
142
+ <script is:inline define:vars={{ swUrl: "/sw.js" }}>
143
+ if ("serviceWorker" in navigator) {
144
+ window.addEventListener("load", function () {
145
+ navigator.serviceWorker.register(swUrl).catch(function () {});
146
+ });
147
+ }
148
+ </script>
149
+ )}
125
150
  </head>
126
151
  <!-- 客户端动态文案载体(i18n,M3b-b5):app.ts 读取,VT swap 后随新 body 重建 -->
127
152
  <script type="application/json" id="qy-ui-labels" set:html={JSON.stringify({ close: t('close'), prevImage: t('prevImage'), nextImage: t('nextImage'), copied: t('copied'), fullscreen: t('fullscreen'), slideshow: t('slideshow'), thumbs: t('thumbs'), sharePoster: t('sharePoster'), generatingPoster: t('generatingPoster'), copyLink: t('copyLink'), savePoster: t('savePoster'), scanToRead: t('scanToRead'), author: t('author'), searchLoading: t('searchLoading'), searchNoResults: t('searchNoResults'), searchUnavailable: t('searchUnavailable'), searchTypeSomething: t('searchTypeSomething') })} />
128
153
  <body data-page={page}>
154
+ <!-- 全站背景壁纸(§4.92):fixed 于视口、z-index:-1,叠在 body 传播的画布底色之上、
155
+ 内容之下——壁纸加载中仍有画布底色兜底;enabled=false / src 未配置不渲染 -->
156
+ {
157
+ showWallpaper && (
158
+ <div
159
+ class="site-wallpaper"
160
+ aria-hidden="true"
161
+ style={`--wp-dim:${config.background.dimOpacity};--wp-dim-dark:${config.background.darkDimOpacity};--wp-blur:${config.background.blur}px;--wp-scale:${bgScale}`}
162
+ >
163
+ <img src={bgSrc} alt="" loading="eager" decoding="async" />
164
+ <div class="site-wallpaper-dim" />
165
+ </div>
166
+ )
167
+ }
129
168
  <a class="skip-link" href="#main">{t('skipToContent')}</a>
130
169
  <Header />
131
170
  <!-- 首页 banner(§4.42,hero+壁纸合并单配置):Header 之后下沉横幅 + 黑蒙层;enabled=false / src 未配置 / homeOnly 非首页不渲染 -->