@windcoder/astro-theme-orchid 0.1.0 → 0.2.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 +16 -207
- package/package.json +72 -70
- package/scripts/generate-lqips.mjs +83 -0
- package/scripts/subset-fonts.mjs +227 -0
- package/src/i18n/en.ts +3 -0
- package/src/i18n/zh-CN.ts +3 -0
- package/src/index.ts +18 -2
- package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
- package/src/plugins/rehype.ts +38 -0
- package/src/routes/categories/[category]/[...page].astro +53 -0
- package/src/routes/moments/[...page].astro +80 -0
- package/src/routes/posts/[...slug].astro +48 -35
- package/src/routes/rss.xml.ts +41 -14
- package/src/routes/series/[name].astro +23 -20
- package/src/routes/tags/[tag]/[...page].astro +51 -0
- package/src/schema.ts +50 -0
- package/src/styles/global.css +117 -0
- package/src/utils.ts +52 -0
- package/src/routes/categories/[category].astro +0 -37
- package/src/routes/tags/[tag].astro +0 -40
|
@@ -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(/&/g, "&")
|
|
69
|
+
.replace(/</g, "<")
|
|
70
|
+
.replace(/>/g, ">")
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, "'")
|
|
73
|
+
.replace(/ /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
|
+
});
|
package/src/i18n/en.ts
CHANGED
|
@@ -97,6 +97,9 @@ const en: Record<UiKey, string> = {
|
|
|
97
97
|
allTags: "All",
|
|
98
98
|
galleryNoAlbums: "No albums yet. Add one in theme config gallery.albums.",
|
|
99
99
|
galleryNoPhotos: "No photos in this album",
|
|
100
|
+
/* 说说页(§4.87) */
|
|
101
|
+
momentsTitle: "Moments",
|
|
102
|
+
momentsEmpty: "No moments yet.",
|
|
100
103
|
encryptedAlbum: "Encrypted album",
|
|
101
104
|
searchAlbumNoResults: "No matching albums",
|
|
102
105
|
postEncrypted: "Encrypted",
|
package/src/i18n/zh-CN.ts
CHANGED
|
@@ -112,6 +112,9 @@ const zhCN = {
|
|
|
112
112
|
allTags: "全部",
|
|
113
113
|
galleryNoAlbums: "还没有相册,在主题配置 gallery.albums 中添加。",
|
|
114
114
|
galleryNoPhotos: "相册里还没有照片",
|
|
115
|
+
/* 说说页(§4.87) */
|
|
116
|
+
momentsTitle: "说说",
|
|
117
|
+
momentsEmpty: "还没有说说。",
|
|
115
118
|
encryptedAlbum: "加密相册",
|
|
116
119
|
searchAlbumNoResults: "没有匹配的相册",
|
|
117
120
|
/* 加密文章(M3b-b7):postEncrypted 用于列表/文章 meta 标记,其余构建期渲染进锁 UI */
|
package/src/index.ts
CHANGED
|
@@ -41,12 +41,14 @@ import {
|
|
|
41
41
|
rehypeExternalLinks,
|
|
42
42
|
rehypeFigure,
|
|
43
43
|
rehypeFirstImage,
|
|
44
|
+
rehypeNoReferrer,
|
|
44
45
|
} from "./plugins/rehype.js";
|
|
45
46
|
import {
|
|
46
47
|
rehypeDiagramPanZoom,
|
|
47
48
|
rehypeMermaid,
|
|
48
49
|
rehypePlantuml,
|
|
49
50
|
} from "./plugins/rehype-diagram.js";
|
|
51
|
+
import { rehypeEcVtRerun } from "./plugins/rehype-ec-vt-rerun.js";
|
|
50
52
|
import { rehypeEmailProtection } from "./plugins/rehype-email.js";
|
|
51
53
|
import { rehypeGithubCard } from "./plugins/rehype-github-card.js";
|
|
52
54
|
import { remarkMermaid, remarkPlantuml } from "./plugins/remark-diagram.js";
|
|
@@ -124,6 +126,8 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
124
126
|
// GitHub 仓库卡片(§4.10):github 标签 → 卡片(自带 target,须在外链处理前)
|
|
125
127
|
rehypeGithubCard,
|
|
126
128
|
rehypeExternalLinks,
|
|
129
|
+
// 图片防盗链(§4.87):命中域名外链图加 no-referrer(domains 空时插件空转)
|
|
130
|
+
[rehypeNoReferrer, { domains: config.noReferrer.domains }],
|
|
127
131
|
rehypeCallouts,
|
|
128
132
|
rehypeFigure,
|
|
129
133
|
rehypeFirstImage,
|
|
@@ -242,6 +246,10 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
242
246
|
frames: { showCopyToClipboardButton: true },
|
|
243
247
|
},
|
|
244
248
|
]);
|
|
249
|
+
// §4.74:必须在 EC 之后追加——社区折叠插件(expressive-code-collapsible)
|
|
250
|
+
// 注入的内联脚本无 View Transitions 适配,ClientRouter 换页后按钮绑定
|
|
251
|
+
// 失效;此插件给该脚本打 data-astro-rerun 标记,swap 时由 Astro 重执行
|
|
252
|
+
rehypePlugins.push(rehypeEcVtRerun);
|
|
245
253
|
updateConfig({
|
|
246
254
|
// 主题路由全部按尾斜杠组织(如 /posts/xxx/),统一强制,dev 与 prod 行为一致
|
|
247
255
|
trailingSlash: "always",
|
|
@@ -303,9 +311,13 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
303
311
|
["/page/[page]", "page/[page].astro"],
|
|
304
312
|
["/archive", "archive.astro"],
|
|
305
313
|
["/tags", "tags/index.astro"],
|
|
306
|
-
|
|
314
|
+
// §4.87 分类/标签分页:第一页 /tags/<tag>/(page 段省略),后续 /2/ 起
|
|
315
|
+
["/tags/[tag]/[...page]", "tags/[tag]/[...page].astro"],
|
|
307
316
|
["/categories", "categories/index.astro"],
|
|
308
|
-
[
|
|
317
|
+
[
|
|
318
|
+
"/categories/[category]/[...page]",
|
|
319
|
+
"categories/[category]/[...page].astro",
|
|
320
|
+
],
|
|
309
321
|
["/posts/[...slug]", "posts/[...slug].astro"],
|
|
310
322
|
// sitemap(§4.63):改由官方 @astrojs/sitemap integration 产出
|
|
311
323
|
// sitemap-index.xml,原手写 /sitemap.xml 路由移除
|
|
@@ -328,6 +340,10 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
328
340
|
routes.push(["/series", "series/index.astro"]);
|
|
329
341
|
routes.push(["/series/[name]", "series/[name].astro"]);
|
|
330
342
|
}
|
|
343
|
+
// 说说页(§4.87):moments collection 时间流;站点需先定义该集合(默认关)
|
|
344
|
+
if (config.pages.moments) {
|
|
345
|
+
routes.push(["/moments/[...page]", "moments/[...page].astro"]);
|
|
346
|
+
}
|
|
331
347
|
// 相关/随机文章推荐(P1-12):随机栏数据源
|
|
332
348
|
if (config.relatedPosts.enabled) {
|
|
333
349
|
routes.push(["/api/allPostMeta.json", "api/allPostMeta.json.ts"]);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §4.74:Astro ClientRouter(View Transitions)下 EC 社区折叠插件的换页失效修复。
|
|
3
|
+
* §4.76:二次根因修正——data-astro-rerun 确实会重执行脚本,但被脚本自身的
|
|
4
|
+
* window 级幂等守卫拦截(早退),新页按钮依然无监听;首版修复曾试图把守卫
|
|
5
|
+
* 标志位改名规避,实测仍有同会话跨页残留(写作指南页无折叠块但注入脚本并
|
|
6
|
+
* 置标志,再 VT 进快速开始时读标志早退),最终改为整段删除守卫与赋值。
|
|
7
|
+
*
|
|
8
|
+
* 现象:经系列导航等 VT 换页进入新文章后,折叠代码块的展开按钮点击无效。
|
|
9
|
+
*
|
|
10
|
+
* 根因(两层):
|
|
11
|
+
* 1. expressive-code-collapsible@0.1.0 的交互脚本只在 DOMContentLoaded
|
|
12
|
+
* 绑定一次,且 MutationObserver 闭包引用换页前的旧 body——ClientRouter
|
|
13
|
+
* swap 整体替换 body 后:内联脚本不重新执行、observer 观察的旧 body 已
|
|
14
|
+
* 脱离文档,新页面的 .ec-collapse__toggle 无 click 监听。
|
|
15
|
+
* (EC core 官方脚本已用 astro:page-load 自适配 VT,copy 按钮等不受影响。)
|
|
16
|
+
* → 修复:标记 data-astro-rerun 让 ClientRouter swap 时重执行该脚本。
|
|
17
|
+
* 2. §4.76 两轮实测发现重执行仍无效:脚本首两行是 window 级早退守卫
|
|
18
|
+
* (if (window.ecCollapsibleInit) return; window.ecCollapsibleInit = true;),
|
|
19
|
+
* ClientRouter 换页只换 body 不换 window——标志位在同一浏览器会话内跨页
|
|
20
|
+
* 存活:任何注入过该脚本的页面(含无折叠代码块的文章,脚本照注入置位)
|
|
21
|
+
* 之后 VT 进入有折叠块的文章时,重执行被早退拦截,绑定整体短路。
|
|
22
|
+
* → 修复:脚本文本中整段删除该守卫与标志赋值,每次执行都完整初始化。
|
|
23
|
+
* 安全性:按钮级 dataset.init 守卫防重复绑定;MutationObserver 每次重
|
|
24
|
+
* 建并观察当前 body,旧 observer 附着已脱离文档的旧 body,随其 GC;
|
|
25
|
+
* data-astro-rerun 每次 swap 恰执行一次、full load 一次,无同 DOM 重入;
|
|
26
|
+
* 离开含折叠块的页面后脚本不存在、不执行,无额外开销。
|
|
27
|
+
*
|
|
28
|
+
* 仅含折叠代码块的页面才有该脚本;若上游插件改守卫名或改结构,删除正则
|
|
29
|
+
* 不命中即退化为 §4.74 行为(仅重执行,不删守卫),不会更糟。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** 内联脚本特征:社区折叠插件按钮选择器,官方 EC 脚本不含此串 */
|
|
33
|
+
const COLLAPSIBLE_SCRIPT_MARKER = "ec-collapse__toggle";
|
|
34
|
+
|
|
35
|
+
/** window 级早退守卫:if (window.ecCollapsibleInit) return; (含前导空白) */
|
|
36
|
+
const GUARD_RE = /\s*if\s*\(\s*window\.ecCollapsibleInit\s*\)\s*return\s*;?/;
|
|
37
|
+
/** 守卫标志赋值:window.ecCollapsibleInit = true; (含前导空白) */
|
|
38
|
+
const FLAG_SET_RE = /\s*window\.ecCollapsibleInit\s*=\s*true\s*;?/;
|
|
39
|
+
|
|
40
|
+
interface HastNode {
|
|
41
|
+
type: string;
|
|
42
|
+
tagName?: string;
|
|
43
|
+
properties?: Record<string, unknown>;
|
|
44
|
+
children?: HastNode[];
|
|
45
|
+
value?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scriptText(node: HastNode): string {
|
|
49
|
+
return (node.children ?? [])
|
|
50
|
+
.map((child) => (typeof child.value === "string" ? child.value : ""))
|
|
51
|
+
.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(node: HastNode): void {
|
|
55
|
+
for (const child of node.children ?? []) {
|
|
56
|
+
if (child.type !== "element") continue;
|
|
57
|
+
if (
|
|
58
|
+
child.tagName === "script" &&
|
|
59
|
+
!child.properties?.src &&
|
|
60
|
+
scriptText(child).includes(COLLAPSIBLE_SCRIPT_MARKER)
|
|
61
|
+
) {
|
|
62
|
+
child.properties["data-astro-rerun"] = true;
|
|
63
|
+
// 删除 window 级早退守卫(§4.76 根因 2):守卫标志在 ClientRouter
|
|
64
|
+
// 换页间随 window 存活,任何先访问过含该脚本页面的会话都会让后续
|
|
65
|
+
// 重执行早退短路;删除后每次执行都完整绑定新页按钮
|
|
66
|
+
for (const text of child.children ?? []) {
|
|
67
|
+
if (typeof text.value === "string") {
|
|
68
|
+
text.value = text.value
|
|
69
|
+
.replace(GUARD_RE, "")
|
|
70
|
+
.replace(FLAG_SET_RE, "");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
walk(child);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function rehypeEcVtRerun() {
|
|
79
|
+
return (tree: HastNode) => walk(tree);
|
|
80
|
+
}
|
package/src/plugins/rehype.ts
CHANGED
|
@@ -53,6 +53,44 @@ export function rehypeExternalLinks(): Transformer {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 图片防盗链(§4.87):部分图床/CDN(如 B 站)校验 Referer 头,站内引用返回 403。
|
|
58
|
+
* 为匹配域名的外链 <img> 追加 referrerpolicy="no-referrer"(浏览器请求图片时不发
|
|
59
|
+
* Referer,绕过防盗链限制),仅影响外链图片与匹配域名,本地图片不受影响。
|
|
60
|
+
*/
|
|
61
|
+
export interface NoReferrerOptions {
|
|
62
|
+
/** 域名列表:裸域名匹配自身与全部子域;*.example.com 只匹配子域 */
|
|
63
|
+
domains: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rehypeNoReferrer(options: NoReferrerOptions): Transformer {
|
|
67
|
+
if (!options.domains.length) return () => {};
|
|
68
|
+
const matchers = options.domains.map((domain) => {
|
|
69
|
+
const d = domain.toLowerCase();
|
|
70
|
+
if (d.startsWith("*.")) {
|
|
71
|
+
const base = d.slice(2);
|
|
72
|
+
return (host: string) => host.endsWith(`.${base}`) && host !== base;
|
|
73
|
+
}
|
|
74
|
+
return (host: string) => host === d || host.endsWith(`.${d}`);
|
|
75
|
+
});
|
|
76
|
+
return (tree) => {
|
|
77
|
+
visit(tree, (node) => {
|
|
78
|
+
if (node.type !== "element" || node.tagName !== "img") return;
|
|
79
|
+
const src = String(node.properties?.src ?? "");
|
|
80
|
+
if (!/^https?:\/\//i.test(src)) return;
|
|
81
|
+
let host: string;
|
|
82
|
+
try {
|
|
83
|
+
host = new URL(src).hostname.toLowerCase();
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (matchers.some((match) => match(host))) {
|
|
88
|
+
node.properties.referrerPolicy = "no-referrer";
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
/* -------------------------------------------------------------- callouts */
|
|
57
95
|
|
|
58
96
|
const CALLOUT_META: Record<
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
+
import Pagination from "virtual:theme/components/Pagination.astro";
|
|
5
|
+
import config from "virtual:theme/config";
|
|
6
|
+
import { t } from "../../../i18n/index.js";
|
|
7
|
+
import {
|
|
8
|
+
collectCategories,
|
|
9
|
+
formatDate,
|
|
10
|
+
getSortedPosts,
|
|
11
|
+
} from "../../../utils.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 分类详情(§4.87 分页):每分类按 postList.pageSize 分页,第一页
|
|
15
|
+
* /categories/<category>/(page 段省略),后续 /categories/<category>/2/ 起。
|
|
16
|
+
* paginate() helper 生成 Page 对象(props.page),Page.data 即当前页文章。
|
|
17
|
+
*/
|
|
18
|
+
export async function getStaticPaths({ paginate }: { paginate: any }) {
|
|
19
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
20
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
21
|
+
);
|
|
22
|
+
// 先排序再聚合,保证每个分类内文章按新→旧排列
|
|
23
|
+
const sorted = getSortedPosts(all);
|
|
24
|
+
return [...collectCategories(sorted).entries()].flatMap(([category, posts]) =>
|
|
25
|
+
paginate(posts, {
|
|
26
|
+
params: { category },
|
|
27
|
+
props: { category },
|
|
28
|
+
pageSize: config.postList.pageSize,
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { page, category } = Astro.props;
|
|
34
|
+
const posts = page.data;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<BaseLayout title={t('categoryTitle', { category })} page="categories">
|
|
38
|
+
<header class="archive-head reveal">
|
|
39
|
+
<h1 class="page-title">{category}</h1>
|
|
40
|
+
<p class="page-sub">{t('totalPosts', { n: page.total })}</p>
|
|
41
|
+
</header>
|
|
42
|
+
<div class="tag-list">
|
|
43
|
+
{
|
|
44
|
+
posts.map((post: any) => (
|
|
45
|
+
<a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
|
|
46
|
+
<div class="rc-title">{post.data.title}</div>
|
|
47
|
+
<div class="rc-meta">{formatDate(post.data.published)}</div>
|
|
48
|
+
</a>
|
|
49
|
+
))
|
|
50
|
+
}
|
|
51
|
+
</div>
|
|
52
|
+
<Pagination page={page.currentPage} total={page.lastPage} basePath={`/categories/${category}/`} />
|
|
53
|
+
</BaseLayout>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render } from "astro:content";
|
|
3
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
+
import Pagination from "virtual:theme/components/Pagination.astro";
|
|
5
|
+
import config from "virtual:theme/config";
|
|
6
|
+
import { t } from "../../i18n/index.js";
|
|
7
|
+
import { formatDate } from "../../utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 说说页(§4.87):moments collection 时间流,按 published 新→旧,
|
|
11
|
+
* 每页 20 条(第一页 /moments/,后续 /moments/2/)。
|
|
12
|
+
* 正文走主题统一 markdown processor(全部增强语法可用);配图 images 网格
|
|
13
|
+
* 展示并接入通用灯箱(data-lightbox-group 组浏览)。
|
|
14
|
+
*/
|
|
15
|
+
export async function getStaticPaths({ paginate }: { paginate: any }) {
|
|
16
|
+
const all = await getCollection("moments", ({ data }) =>
|
|
17
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
18
|
+
);
|
|
19
|
+
const sorted = [...all].sort(
|
|
20
|
+
(a, b) => b.data.published.valueOf() - a.data.published.valueOf(),
|
|
21
|
+
);
|
|
22
|
+
return paginate(sorted, { pageSize: 20 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { page } = Astro.props;
|
|
26
|
+
const rendered = await Promise.all(
|
|
27
|
+
page.data.map(async (moment: any) => {
|
|
28
|
+
const { Content } = await render(moment);
|
|
29
|
+
return { moment, Content };
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<BaseLayout title={t('momentsTitle')} page="moments">
|
|
35
|
+
<header class="archive-head reveal">
|
|
36
|
+
<h1 class="page-title">{t('momentsTitle')}</h1>
|
|
37
|
+
</header>
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
rendered.length === 0 ? (
|
|
41
|
+
<p class="gallery-empty reveal">{t('momentsEmpty')}</p>
|
|
42
|
+
) : (
|
|
43
|
+
<div class="moments-list">
|
|
44
|
+
{rendered.map(({ moment, Content }) => (
|
|
45
|
+
<article class="moment card reveal">
|
|
46
|
+
<div class="moment-meta">
|
|
47
|
+
<time datetime={moment.data.published.toISOString()}>
|
|
48
|
+
{formatDate(moment.data.published)}
|
|
49
|
+
</time>
|
|
50
|
+
{moment.data.mood && <span class="moment-mood">{moment.data.mood}</span>}
|
|
51
|
+
</div>
|
|
52
|
+
<div class="moment-content">
|
|
53
|
+
<Content />
|
|
54
|
+
</div>
|
|
55
|
+
{moment.data.images.length > 0 && (
|
|
56
|
+
<div class="moment-images" data-lightbox-group>
|
|
57
|
+
{moment.data.images.map((src: string) => (
|
|
58
|
+
<figure class="moment-img">
|
|
59
|
+
<img src={src} alt={t('momentsTitle')} loading="lazy" decoding="async" />
|
|
60
|
+
</figure>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
{moment.data.tags.length > 0 && (
|
|
65
|
+
<div class="moment-tags">
|
|
66
|
+
{moment.data.tags.map((tg: string) => (
|
|
67
|
+
<a class="moment-tag" href={`/tags/${tg}/`}>
|
|
68
|
+
{tg}
|
|
69
|
+
</a>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</article>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
<Pagination page={page.currentPage} total={page.lastPage} basePath="/moments/" />
|
|
80
|
+
</BaseLayout>
|