@windcoder/astro-theme-orchid 0.2.1 → 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.
- package/README.md +3 -3
- package/package.json +4 -2
- package/scripts/check-friends.mjs +227 -0
- package/scripts/migrate-posts.mjs +375 -0
- package/src/components/Analytics.astro +3 -0
- package/src/components/BaseLayout.astro +40 -1
- package/src/components/analytics/WebVitals.astro +72 -0
- package/src/i18n/en.ts +6 -0
- package/src/i18n/zh-CN.ts +6 -0
- package/src/index.ts +10 -0
- package/src/plugins/rehype.ts +10 -2
- package/src/routes/bangumi.astro +110 -0
- package/src/routes/manifest.webmanifest.ts +33 -0
- package/src/routes/sw.js.ts +127 -0
- package/src/schema.ts +110 -1
- package/src/styles/global.css +118 -0
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 特性一览
|
|
6
6
|
|
|
7
|
-
- **页面**:首页(featured/cover/compact/text 四种卡片混排)、文章页(TOC
|
|
7
|
+
- **页面**:首页(featured/cover/compact/text 四种卡片混排)、文章页(TOC/相关推荐/分享海报/打赏入口)、归档(按年折叠)、分类、标签、专题、画廊(含加密相册)、友链、追番、留言板、打赏、关于
|
|
8
8
|
- **写作增强**:LaTeX(KaTeX + mhchem)、Mermaid 双主题、PlantUML、Callouts、GitHub 卡片、图语法(figure/九宫格)、Expressive Code 代码块(双主题/行号/折叠/复制/语言徽标)、邮箱防爬
|
|
9
9
|
- **互动系统**:评论五套(Artalk / Twikoo / Waline / Giscus / Disqus)、文章加密、过时提醒、公告、广告位
|
|
10
10
|
- **个性化**:深浅模式、hue 动态调色、樱花飘落、背景壁纸、音乐播放器(跨页不断播)、Live2D 看板娘、i18n(zh-CN / en)
|
|
11
|
-
- **工程化**:配置启动即校验(Zod)、OG 分享图、LQIP 占位、字体子集化、站点统计四件套(GA / Clarity / Umami / 51.la)、RSS / sitemap
|
|
11
|
+
- **工程化**:配置启动即校验(Zod)、OG 分享图、LQIP 占位、字体子集化、站点统计四件套(GA / Clarity / Umami / 51.la)、RSS / sitemap、PWA 可安装(manifest + 离线兜底 Service Worker)
|
|
12
12
|
|
|
13
13
|
## 快速上手
|
|
14
14
|
|
|
@@ -81,7 +81,7 @@ site/
|
|
|
81
81
|
完整配置说明与文章 frontmatter 全字段表统一放在 demo 站「主题使用指南」系列文章(源文件在本仓库 `site/src/content/posts/`),按主题分组:
|
|
82
82
|
|
|
83
83
|
- 基础:《站点基础配置》|《导航栏》|《首页横幅》|《侧边栏与小组件》|《个人资料卡》|《版权声明》|《文章列表与归档》
|
|
84
|
-
- 功能:《文章页功能》|《内容渲染》|《樱花特效》|《音乐播放器》|《Live2D
|
|
84
|
+
- 功能:《文章页功能》|《内容渲染》|《樱花特效》|《音乐播放器》|《Live2D 看板娘》|《字体》|《评论系统》|《公告》|《站点统计》|《广告位》|《友链》|《追番》|《PWA》|《赞助》|《画廊》|《独立页面》
|
|
85
85
|
- 写作:《写作指南》(frontmatter 全字段与增强语法)|《MDX 指南》(MDX 启用与踩坑修复)
|
|
86
86
|
|
|
87
87
|
## 定制与覆盖
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windcoder/astro-theme-orchid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -64,7 +64,9 @@
|
|
|
64
64
|
"satori": "^0.47.1",
|
|
65
65
|
"sharp": "^0.35.5",
|
|
66
66
|
"subset-font": "^2.5.0",
|
|
67
|
-
"tailwindcss": "^4.3.2"
|
|
67
|
+
"tailwindcss": "^4.3.2",
|
|
68
|
+
"web-vitals": "^6.2.3",
|
|
69
|
+
"yaml": "^2.9.1"
|
|
68
70
|
},
|
|
69
71
|
"devDependencies": {
|
|
70
72
|
"@types/pako": "^2.0.3",
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 友链存活检测(§4.90):读取构建产物 dist/friends/index.html,抽取友链卡片
|
|
4
|
+
* (a.friend-card + .friend-name),并发探测各站点可达性,输出检测报告。
|
|
5
|
+
*
|
|
6
|
+
* 设计为发布后/定时(CI cron)手动运行,不挂进 build——构建保持离线可复现。
|
|
7
|
+
*
|
|
8
|
+
* 用法(站点根目录):
|
|
9
|
+
* node node_modules/@windcoder/astro-theme-orchid/scripts/check-friends.mjs \
|
|
10
|
+
* [--dist dist] [--timeout 8000] [--retries 1] [--concurrency 6] \
|
|
11
|
+
* [--json report.json] [--fail-on-dead]
|
|
12
|
+
*
|
|
13
|
+
* 判级:ok = 2xx/3xx;warn = 403/405/406/429/999(常见反爬拦截,未必失联);
|
|
14
|
+
* dead = 网络层失败(解析/超时/拒绝)与其余 4xx/5xx。网络层失败与 5xx 重试。
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
const UA =
|
|
21
|
+
"Mozilla/5.0 (compatible; OrchidFriendChecker/+https://github.com/iwinder/qy-astro-theme-orchid)";
|
|
22
|
+
/** 这些状态码通常是 WAF/反爬拦截而非失联,只警告不定罪 */
|
|
23
|
+
const WARN_CODES = new Set([403, 405, 406, 429, 999]);
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = {
|
|
27
|
+
dist: "dist",
|
|
28
|
+
timeout: 8000,
|
|
29
|
+
retries: 1,
|
|
30
|
+
concurrency: 6,
|
|
31
|
+
json: "",
|
|
32
|
+
failOnDead: false,
|
|
33
|
+
};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
if (a === "--dist") args.dist = argv[++i];
|
|
37
|
+
else if (a === "--timeout") args.timeout = Number(argv[++i]);
|
|
38
|
+
else if (a === "--retries") args.retries = Number(argv[++i]);
|
|
39
|
+
else if (a === "--concurrency") args.concurrency = Number(argv[++i]);
|
|
40
|
+
else if (a === "--json") args.json = argv[++i];
|
|
41
|
+
else if (a === "--fail-on-dead") args.failOnDead = true;
|
|
42
|
+
else if (a === "--help" || a === "-h") {
|
|
43
|
+
console.log(
|
|
44
|
+
"用法:node check-friends.mjs [--dist dist] [--timeout 8000] [--retries 1] [--concurrency 6] [--json report.json] [--fail-on-dead]",
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeEntities(s) {
|
|
53
|
+
return s
|
|
54
|
+
.replace(/&/g, "&")
|
|
55
|
+
.replace(/</g, "<")
|
|
56
|
+
.replace(/>/g, ">")
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, "'");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 从友链页 HTML 抽取 [{ title, url }](主题自有标记,结构稳定) */
|
|
62
|
+
export function extractFriends(html) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const re =
|
|
65
|
+
/<a class="friend-card card" href="([^"]+)"[\s\S]*?<div class="friend-name">([\s\S]*?)<\/div>/g;
|
|
66
|
+
let m;
|
|
67
|
+
while (true) {
|
|
68
|
+
m = re.exec(html);
|
|
69
|
+
if (!m) break;
|
|
70
|
+
out.push({
|
|
71
|
+
url: decodeEntities(m[1]),
|
|
72
|
+
title: decodeEntities(m[2].replace(/<[^>]+>/g, "")).trim(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** HTTP 状态码 → ok/warn/dead 判级(0 = 网络层失败) */
|
|
79
|
+
export function classify(code) {
|
|
80
|
+
if (code === 0) return "dead";
|
|
81
|
+
if (code >= 200 && code < 400) return "ok";
|
|
82
|
+
if (WARN_CODES.has(code)) return "warn";
|
|
83
|
+
return "dead";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 单次探测:GET + 超时 + 立即丢弃响应体(只看状态码) */
|
|
87
|
+
async function probeOnce(url, timeout) {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
redirect: "follow",
|
|
92
|
+
signal: AbortSignal.timeout(timeout),
|
|
93
|
+
headers: { "user-agent": UA, accept: "text/html,*/*;q=0.8" },
|
|
94
|
+
});
|
|
95
|
+
res.body?.cancel().catch(() => {});
|
|
96
|
+
return { code: res.status, ms: Date.now() - started, msg: "" };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const cause = err?.cause?.code ?? err?.code ?? "";
|
|
99
|
+
const msg =
|
|
100
|
+
err?.name === "TimeoutError" || err?.name === "AbortError"
|
|
101
|
+
? "超时"
|
|
102
|
+
: cause === "ENOTFOUND" || cause === "EAI_AGAIN"
|
|
103
|
+
? "域名无法解析"
|
|
104
|
+
: cause === "ECONNREFUSED"
|
|
105
|
+
? "连接被拒绝"
|
|
106
|
+
: cause === "ECONNRESET"
|
|
107
|
+
? "连接被重置"
|
|
108
|
+
: cause === "CERT_HAS_EXPIRED" ||
|
|
109
|
+
cause === "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
|
|
110
|
+
? "证书异常"
|
|
111
|
+
: String(cause || err?.message || err);
|
|
112
|
+
return { code: 0, ms: Date.now() - started, msg };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 带重试探测:网络层失败与 5xx 重试(4xx 定局重试无意义) */
|
|
117
|
+
export async function probe(url, { timeout, retries }) {
|
|
118
|
+
let last = await probeOnce(url, timeout);
|
|
119
|
+
for (let i = 0; i < retries && (last.code === 0 || last.code >= 500); i++) {
|
|
120
|
+
last = await probeOnce(url, timeout);
|
|
121
|
+
}
|
|
122
|
+
return { ...last, state: classify(last.code) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** CJK 感知的终端定宽 */
|
|
126
|
+
function pad(s, width) {
|
|
127
|
+
let visual = 0;
|
|
128
|
+
for (const ch of s) visual += ch.codePointAt(0) > 0xff ? 2 : 1;
|
|
129
|
+
return visual >= width ? s : s + " ".repeat(width - visual);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const args = parseArgs(process.argv.slice(2));
|
|
134
|
+
const pagePath = resolve(args.dist, "friends", "index.html");
|
|
135
|
+
let html;
|
|
136
|
+
try {
|
|
137
|
+
html = readFileSync(pagePath, "utf8");
|
|
138
|
+
} catch {
|
|
139
|
+
console.error(
|
|
140
|
+
`[check-friends] 找不到 ${pagePath}\n 友链页未开启(pages.friends: false)或尚未构建;先 pnpm build 再检测。`,
|
|
141
|
+
);
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const friends = extractFriends(html);
|
|
146
|
+
if (!friends.length) {
|
|
147
|
+
console.log("[check-friends] 友链页无友链卡片,无事可做。");
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
console.log(
|
|
151
|
+
`[check-friends] 共 ${friends.length} 条友链,并发 ${args.concurrency},超时 ${args.timeout}ms,重试 ${args.retries} 次\n`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const results = new Array(friends.length);
|
|
155
|
+
let cursor = 0;
|
|
156
|
+
async function worker() {
|
|
157
|
+
while (cursor < friends.length) {
|
|
158
|
+
const idx = cursor++;
|
|
159
|
+
const { url, title } = friends[idx];
|
|
160
|
+
process.stdout.write(` 探测 ${title} …\r`);
|
|
161
|
+
const r = await probe(url, args);
|
|
162
|
+
results[idx] = { ...r, title, url };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await Promise.all(
|
|
166
|
+
Array.from({ length: Math.min(args.concurrency, friends.length) }, worker),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const ICON = { ok: "✓", warn: "⚠", dead: "✗" };
|
|
170
|
+
console.log(
|
|
171
|
+
"\n┌────┬──────┬────────┬────────┬──────────────────────┬──────────────────────────────────────┐",
|
|
172
|
+
);
|
|
173
|
+
console.log(
|
|
174
|
+
`│ ${pad("状态", 2)} │ ${pad("站点", 18)} │ ${pad("HTTP", 6)} │ ${pad("耗时", 6)} │ ${pad("说明", 20)} │ 链接`,
|
|
175
|
+
);
|
|
176
|
+
console.log(
|
|
177
|
+
"├────┼──────┼────────┼────────┼──────────────────────┼──────────────────────────────────────┤",
|
|
178
|
+
);
|
|
179
|
+
for (const r of results) {
|
|
180
|
+
const note = r.state === "warn" ? "疑似反爬拦截,请人工复核" : r.msg;
|
|
181
|
+
console.log(
|
|
182
|
+
`│ ${ICON[r.state]} │ ${pad(r.title, 18)} │ ${pad(String(r.code || "-"), 6)} │ ${pad(`${r.ms}ms`, 6)} │ ${pad(note, 20)} │ ${r.url}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
console.log(
|
|
186
|
+
"└────┴──────┴────────┴────────┴──────────────────────┴──────────────────────────────────────┘",
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const ok = results.filter((r) => r.state === "ok").length;
|
|
190
|
+
const warn = results.filter((r) => r.state === "warn").length;
|
|
191
|
+
const dead = results.filter((r) => r.state === "dead").length;
|
|
192
|
+
console.log(`\n[check-friends] 完成:ok ${ok} / warn ${warn} / dead ${dead}`);
|
|
193
|
+
if (warn)
|
|
194
|
+
console.log(
|
|
195
|
+
" ⚠ warn 为常见反爬状态码(403/405/406/429/999),浏览器开一下再决定是否下架",
|
|
196
|
+
);
|
|
197
|
+
if (dead) {
|
|
198
|
+
for (const r of results.filter((x) => x.state === "dead"))
|
|
199
|
+
console.log(` ✗ ${r.title} <${r.url}> ${r.msg || `HTTP ${r.code}`}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (args.json) {
|
|
203
|
+
const report = {
|
|
204
|
+
checkedAt: new Date().toISOString(),
|
|
205
|
+
total: friends.length,
|
|
206
|
+
ok,
|
|
207
|
+
warn,
|
|
208
|
+
dead,
|
|
209
|
+
results: results.map(({ title, url, state, code, ms, msg }) => ({
|
|
210
|
+
title,
|
|
211
|
+
url,
|
|
212
|
+
state,
|
|
213
|
+
code,
|
|
214
|
+
ms,
|
|
215
|
+
msg,
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
writeFileSync(resolve(args.json), `${JSON.stringify(report, null, 2)}\n`);
|
|
219
|
+
console.log(` 报告已写入 ${resolve(args.json)}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (args.failOnDead && dead > 0) process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
226
|
+
main();
|
|
227
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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} />}
|