@windcoder/astro-theme-orchid 0.2.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 +4 -2
- package/package.json +6 -4
- 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 +13 -2
- 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/rss.xml.ts +41 -14
- package/src/routes/tags/[tag]/[...page].astro +51 -0
- package/src/schema.ts +44 -0
- package/src/styles/global.css +83 -0
- package/src/routes/categories/[category].astro +0 -37
- package/src/routes/tags/[tag].astro +0 -40
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
## 快速上手
|
|
14
14
|
|
|
15
|
+
> 新站点推荐直接从 [astro-theme-orchid-starter](https://github.com/iwinder/astro-theme-orchid-starter) 模板生成(Use this template),配置与内容骨架开箱即用;以下为在已有 Astro 站点上接入主题的步骤。
|
|
16
|
+
|
|
15
17
|
### 1. 安装
|
|
16
18
|
|
|
17
19
|
```sh
|
|
@@ -78,8 +80,8 @@ site/
|
|
|
78
80
|
|
|
79
81
|
完整配置说明与文章 frontmatter 全字段表统一放在 demo 站「主题使用指南」系列文章(源文件在本仓库 `site/src/content/posts/`),按主题分组:
|
|
80
82
|
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
+
- 基础:《站点基础配置》|《导航栏》|《首页横幅》|《侧边栏与小组件》|《个人资料卡》|《版权声明》|《文章列表与归档》
|
|
84
|
+
- 功能:《文章页功能》|《内容渲染》|《樱花特效》|《音乐播放器》|《Live2D 看板娘》|《字体》|《评论系统》|《公告》|《站点统计》|《广告位》|《友链》|《赞助》|《画廊》|《独立页面》
|
|
83
85
|
- 写作:《写作指南》(frontmatter 全字段与增强语法)|《MDX 指南》(MDX 启用与踩坑修复)
|
|
84
86
|
|
|
85
87
|
## 定制与覆盖
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windcoder/astro-theme-orchid",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"./styles/*": "./src/styles/*"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
|
-
"src"
|
|
34
|
+
"src",
|
|
35
|
+
"scripts",
|
|
36
|
+
"!scripts/*.test.mjs"
|
|
35
37
|
],
|
|
36
38
|
"peerDependencies": {
|
|
37
39
|
"@iconify-json/lucide": "^1.2.0",
|
|
@@ -61,11 +63,11 @@
|
|
|
61
63
|
"remark-math": "^6.0.0",
|
|
62
64
|
"satori": "^0.47.1",
|
|
63
65
|
"sharp": "^0.35.5",
|
|
66
|
+
"subset-font": "^2.5.0",
|
|
64
67
|
"tailwindcss": "^4.3.2"
|
|
65
68
|
},
|
|
66
69
|
"devDependencies": {
|
|
67
70
|
"@types/pako": "^2.0.3",
|
|
68
|
-
"@types/qrcode": "^1.5.6"
|
|
69
|
-
"subset-font": "^2.5.0"
|
|
71
|
+
"@types/qrcode": "^1.5.6"
|
|
70
72
|
}
|
|
71
73
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LQIP 生成脚本(M3b-b9,P1-14):扫描站点 public 下的图片,每张采样
|
|
4
|
+
* 2×2 像素取三色,产出 18 字符 compact hex(三段 rgb hex 拼接),
|
|
5
|
+
* 整体写入站点根 lqips.json(键 → 色,全量重建,增量收益有限故不做)。
|
|
6
|
+
*
|
|
7
|
+
* 运行:pnpm --filter site lqip(cwd 必须是 site 根)
|
|
8
|
+
* 消费链路:lqips.json → themeLqipsPlugin(vite.ts)→ virtual:theme/lqips
|
|
9
|
+
* → utils/lqip.ts getLqipStyle() → 构建期内联渐变(零客户端请求)。
|
|
10
|
+
*
|
|
11
|
+
* 键约定:`public:<posix 相对路径>`。主题图片一律 public 路径或外链
|
|
12
|
+
* (不用 astro:assets),故无需 src: 键;外链/无数据在消费端自然降级纯色。
|
|
13
|
+
* sharp 经本脚本文件位置解析到 theme 包依赖,与 cwd 无关。
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import sharp from "sharp";
|
|
19
|
+
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const publicDir = path.join(cwd, "public");
|
|
22
|
+
const outFile = path.join(cwd, "lqips.json");
|
|
23
|
+
|
|
24
|
+
const EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif"]);
|
|
25
|
+
const EXCLUDED_DIRS = new Set(["node_modules", ".astro", "dist", ".git"]);
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(publicDir)) {
|
|
28
|
+
console.error(`[lqip] 未找到 ${publicDir},请在 site 根目录运行本脚本`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 递归收集图片相对路径(posix 风格,排除构建/依赖目录) */
|
|
33
|
+
function collectImages(dir, base = "") {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
38
|
+
out.push(
|
|
39
|
+
...collectImages(
|
|
40
|
+
path.join(dir, entry.name),
|
|
41
|
+
base ? `${base}/${entry.name}` : entry.name,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
} else if (EXTS.has(path.extname(entry.name).toLowerCase())) {
|
|
45
|
+
out.push(base ? `${base}/${entry.name}` : entry.name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** sharp 2×2 fill 采样 → 取 [0][1][3] 三像素(左上/右上/右下)rgb hex 拼接 */
|
|
52
|
+
async function sampleColors(file) {
|
|
53
|
+
const { data, info } = await sharp(file)
|
|
54
|
+
.resize(2, 2, { fit: "fill" })
|
|
55
|
+
.removeAlpha()
|
|
56
|
+
.raw()
|
|
57
|
+
.toBuffer({ resolveWithObject: true });
|
|
58
|
+
const hex = (i) => {
|
|
59
|
+
const o = i * info.channels;
|
|
60
|
+
return [data[o], data[o + 1], data[o + 2]]
|
|
61
|
+
.map((v) => v.toString(16).padStart(2, "0"))
|
|
62
|
+
.join("");
|
|
63
|
+
};
|
|
64
|
+
return hex(0) + hex(1) + hex(3);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const files = collectImages(publicDir);
|
|
69
|
+
const entries = await Promise.all(
|
|
70
|
+
files.map(async (rel) => [
|
|
71
|
+
`public:${rel}`,
|
|
72
|
+
await sampleColors(path.join(publicDir, rel)),
|
|
73
|
+
]),
|
|
74
|
+
);
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
outFile,
|
|
77
|
+
JSON.stringify(Object.fromEntries(entries), null, 2) + "\n",
|
|
78
|
+
);
|
|
79
|
+
console.log(`[lqip] ${files.length} 张图片 → ${path.relative(cwd, outFile)}`);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error("[lqip] 生成失败:", err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
@@ -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,6 +41,7 @@ import {
|
|
|
41
41
|
rehypeExternalLinks,
|
|
42
42
|
rehypeFigure,
|
|
43
43
|
rehypeFirstImage,
|
|
44
|
+
rehypeNoReferrer,
|
|
44
45
|
} from "./plugins/rehype.js";
|
|
45
46
|
import {
|
|
46
47
|
rehypeDiagramPanZoom,
|
|
@@ -125,6 +126,8 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
125
126
|
// GitHub 仓库卡片(§4.10):github 标签 → 卡片(自带 target,须在外链处理前)
|
|
126
127
|
rehypeGithubCard,
|
|
127
128
|
rehypeExternalLinks,
|
|
129
|
+
// 图片防盗链(§4.87):命中域名外链图加 no-referrer(domains 空时插件空转)
|
|
130
|
+
[rehypeNoReferrer, { domains: config.noReferrer.domains }],
|
|
128
131
|
rehypeCallouts,
|
|
129
132
|
rehypeFigure,
|
|
130
133
|
rehypeFirstImage,
|
|
@@ -308,9 +311,13 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
308
311
|
["/page/[page]", "page/[page].astro"],
|
|
309
312
|
["/archive", "archive.astro"],
|
|
310
313
|
["/tags", "tags/index.astro"],
|
|
311
|
-
|
|
314
|
+
// §4.87 分类/标签分页:第一页 /tags/<tag>/(page 段省略),后续 /2/ 起
|
|
315
|
+
["/tags/[tag]/[...page]", "tags/[tag]/[...page].astro"],
|
|
312
316
|
["/categories", "categories/index.astro"],
|
|
313
|
-
[
|
|
317
|
+
[
|
|
318
|
+
"/categories/[category]/[...page]",
|
|
319
|
+
"categories/[category]/[...page].astro",
|
|
320
|
+
],
|
|
314
321
|
["/posts/[...slug]", "posts/[...slug].astro"],
|
|
315
322
|
// sitemap(§4.63):改由官方 @astrojs/sitemap integration 产出
|
|
316
323
|
// sitemap-index.xml,原手写 /sitemap.xml 路由移除
|
|
@@ -333,6 +340,10 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
333
340
|
routes.push(["/series", "series/index.astro"]);
|
|
334
341
|
routes.push(["/series/[name]", "series/[name].astro"]);
|
|
335
342
|
}
|
|
343
|
+
// 说说页(§4.87):moments collection 时间流;站点需先定义该集合(默认关)
|
|
344
|
+
if (config.pages.moments) {
|
|
345
|
+
routes.push(["/moments/[...page]", "moments/[...page].astro"]);
|
|
346
|
+
}
|
|
336
347
|
// 相关/随机文章推荐(P1-12):随机栏数据源
|
|
337
348
|
if (config.relatedPosts.enabled) {
|
|
338
349
|
routes.push(["/api/allPostMeta.json", "api/allPostMeta.json.ts"]);
|
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>
|
package/src/routes/rss.xml.ts
CHANGED
|
@@ -1,32 +1,59 @@
|
|
|
1
|
-
import { getCollection } from "astro:content";
|
|
1
|
+
import { getCollection, render } from "astro:content";
|
|
2
2
|
import config from "virtual:theme/config";
|
|
3
3
|
import rss from "@astrojs/rss";
|
|
4
4
|
import type { APIContext, APIRoute } from "astro";
|
|
5
|
+
import { experimental_AstroContainer as AstroContainer } from "astro/container";
|
|
5
6
|
import { getSortedPosts } from "../utils.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* RSS(§4.63 官方 @astrojs/rss 端点):
|
|
9
|
-
* description
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* RSS(§4.63 官方 @astrojs/rss 端点):
|
|
10
|
+
* - 默认 item description 使用 frontmatter description(摘要输出);
|
|
11
|
+
* - rss.fullText 开启后 item 追加 content:encoded 全文(§4.87)——经
|
|
12
|
+
* AstroContainer 逐篇容器渲染 Markdown(与文章页同一套 remark/rehype 插件产物),
|
|
13
|
+
* 构建时间随文章数增加,默认关;
|
|
14
|
+
* - 条数 rss.limit(默认 20);channel 头部、atom:link self、XML 转义由官方库负责;
|
|
15
|
+
* trailingSlash 与主题 trailingSlash: "always" 口径一致(/posts/xxx/)。
|
|
12
16
|
*/
|
|
13
17
|
export const GET: APIRoute = async (context: APIContext) => {
|
|
14
18
|
const all = await getCollection("posts", ({ data }) => !data.draft);
|
|
15
|
-
const posts = getSortedPosts(all).slice(0,
|
|
19
|
+
const posts = getSortedPosts(all).slice(0, config.rss.limit);
|
|
16
20
|
const site = context.site ?? new URL("https://blog.example.com");
|
|
17
21
|
|
|
22
|
+
const items = await Promise.all(
|
|
23
|
+
posts.map(async (post) => {
|
|
24
|
+
const item: {
|
|
25
|
+
title: string;
|
|
26
|
+
description: string;
|
|
27
|
+
link: string;
|
|
28
|
+
pubDate: Date;
|
|
29
|
+
content?: string;
|
|
30
|
+
} = {
|
|
31
|
+
title: post.data.title,
|
|
32
|
+
description: post.data.description,
|
|
33
|
+
link: `/posts/${post.id}/`,
|
|
34
|
+
pubDate: post.data.published,
|
|
35
|
+
};
|
|
36
|
+
if (config.rss.fullText && !post.data.password) {
|
|
37
|
+
// 加密文章不进 RSS 全文(内容构建期加密,明文进 RSS 会绕过密码)
|
|
38
|
+
const { Content } = await render(post);
|
|
39
|
+
const container = await AstroContainer.create();
|
|
40
|
+
item.content = await container.renderToString(Content);
|
|
41
|
+
}
|
|
42
|
+
return item;
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
18
46
|
return rss({
|
|
19
47
|
title: config.title,
|
|
20
48
|
description: config.description,
|
|
21
49
|
site,
|
|
22
|
-
items
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
xmlns: { atom: "http://www.w3.org/2005/Atom" },
|
|
50
|
+
items,
|
|
51
|
+
// atom 命名空间 + feed 自引用(v4 不再自动生成,保持对原手写版的兼容);
|
|
52
|
+
// content 命名空间供 fullText 的 content:encoded 使用
|
|
53
|
+
xmlns: {
|
|
54
|
+
atom: "http://www.w3.org/2005/Atom",
|
|
55
|
+
content: "http://purl.org/rss/1.0/modules/content/",
|
|
56
|
+
},
|
|
30
57
|
// RSS <language> 通道级语言标记(跟随站点语言)
|
|
31
58
|
customData: [
|
|
32
59
|
`<language>${config.lang}</language>`,
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { collectTags, formatDate, getSortedPosts } from "../../../utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 标签详情(§4.87 分页):每标签按 postList.pageSize 分页,第一页
|
|
11
|
+
* /tags/<tag>/(page 段省略),后续 /tags/<tag>/2/ 起。结构与分类详情页一致。
|
|
12
|
+
*/
|
|
13
|
+
export async function getStaticPaths({ paginate }: { paginate: any }) {
|
|
14
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
15
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
16
|
+
);
|
|
17
|
+
// 先排序再聚合,保证每个标签内文章按新→旧排列
|
|
18
|
+
const sorted = getSortedPosts(all);
|
|
19
|
+
return [...collectTags(sorted).entries()].flatMap(([tag, posts]) =>
|
|
20
|
+
paginate(posts, {
|
|
21
|
+
params: { tag },
|
|
22
|
+
props: { tag },
|
|
23
|
+
pageSize: config.postList.pageSize,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { page, tag } = Astro.props;
|
|
29
|
+
const posts = page.data;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<BaseLayout title={t('tagTitle', { tag })} page="tags">
|
|
33
|
+
<header class="archive-head reveal">
|
|
34
|
+
<h1 class="page-title">{tag}</h1>
|
|
35
|
+
<p class="page-sub">{t('totalPosts', { n: page.total })}</p>
|
|
36
|
+
</header>
|
|
37
|
+
<div class="tag-list">
|
|
38
|
+
{
|
|
39
|
+
posts.map((post: any) => (
|
|
40
|
+
<a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
|
|
41
|
+
<div class="rc-title">{post.data.title}</div>
|
|
42
|
+
<div class="rc-meta">
|
|
43
|
+
{formatDate(post.data.published)}
|
|
44
|
+
{post.data.category ? ` · ${post.data.category}` : ''}
|
|
45
|
+
</div>
|
|
46
|
+
</a>
|
|
47
|
+
))
|
|
48
|
+
}
|
|
49
|
+
</div>
|
|
50
|
+
<Pagination page={page.currentPage} total={page.lastPage} basePath={`/tags/${tag}/`} />
|
|
51
|
+
</BaseLayout>
|
package/src/schema.ts
CHANGED
|
@@ -342,6 +342,8 @@ export const themeConfigSchema = z.object({
|
|
|
342
342
|
gallery: z.boolean().default(true),
|
|
343
343
|
/** 专题(§4.31):frontmatter series 聚合页 /series/ 与 /series/[name]/ */
|
|
344
344
|
series: z.boolean().default(true),
|
|
345
|
+
/** 说说页(§4.87):/moments/ 短内容时间流;站点需定义 moments collection。默认关——老站点无该集合,开启前先在 content.config.ts 定义 */
|
|
346
|
+
moments: z.boolean().default(false),
|
|
345
347
|
})
|
|
346
348
|
.default({
|
|
347
349
|
about: true,
|
|
@@ -350,6 +352,7 @@ export const themeConfigSchema = z.object({
|
|
|
350
352
|
sponsor: true,
|
|
351
353
|
gallery: true,
|
|
352
354
|
series: true,
|
|
355
|
+
moments: false,
|
|
353
356
|
}),
|
|
354
357
|
/**
|
|
355
358
|
* 画廊/相册(M3b-b8)。相册数据纯配置驱动,照片两个来源:
|
|
@@ -915,6 +918,26 @@ export const themeConfigSchema = z.object({
|
|
|
915
918
|
method: z.enum(["base64", "rot13"]).default("base64"),
|
|
916
919
|
})
|
|
917
920
|
.default({ enabled: false, method: "base64" }),
|
|
921
|
+
/**
|
|
922
|
+
* 图片防盗链(§4.87):部分图床/CDN(如 B 站)校验 Referer,站内引用返回 403。
|
|
923
|
+
* 命中域名列表的外链图片构建期追加 referrerpolicy="no-referrer"。
|
|
924
|
+
* 裸域名匹配自身与全部子域;*.example.com 只匹配子域。留空不处理。
|
|
925
|
+
*/
|
|
926
|
+
noReferrer: z
|
|
927
|
+
.object({
|
|
928
|
+
domains: z.array(z.string()).default([]),
|
|
929
|
+
})
|
|
930
|
+
.default({ domains: [] }),
|
|
931
|
+
/**
|
|
932
|
+
* RSS 输出(§4.87):fullText 开启后 item 带 content:encoded 全文
|
|
933
|
+
* (构建期逐篇容器渲染,构建时间相应增加);limit 控制输出条数。
|
|
934
|
+
*/
|
|
935
|
+
rss: z
|
|
936
|
+
.object({
|
|
937
|
+
fullText: z.boolean().default(false),
|
|
938
|
+
limit: z.number().int().min(1).default(20),
|
|
939
|
+
})
|
|
940
|
+
.default({ fullText: false, limit: 20 }),
|
|
918
941
|
/**
|
|
919
942
|
* favicon 图标列表(§4.30):数组非空时替换默认 /favicon.svg;
|
|
920
943
|
* theme 指定 "light"/"dark" 时按系统深浅色经 media 切换(可配双图标)。
|
|
@@ -1216,3 +1239,24 @@ export function definePostSchema(extensions: z.ZodRawShape = {}) {
|
|
|
1216
1239
|
})
|
|
1217
1240
|
.extend(extensions);
|
|
1218
1241
|
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* 内容 schema 工厂:说说集合 frontmatter(§4.87 说说页)。
|
|
1245
|
+
* 站点 content.config.ts 定义 moments collection(loader 扫描
|
|
1246
|
+
* src/content/moments 目录下全部 .md 文件,schema 用本工厂)。
|
|
1247
|
+
* 正文即说说内容(Markdown,支持主题全部增强语法);主题开关 pages.moments
|
|
1248
|
+
* 打开后注入 /moments/(时间流,按 published 新→旧,每页 20 条)。
|
|
1249
|
+
*/
|
|
1250
|
+
export function defineMomentSchema(extensions: z.ZodRawShape = {}) {
|
|
1251
|
+
return z
|
|
1252
|
+
.object({
|
|
1253
|
+
published: z.coerce.date(),
|
|
1254
|
+
/** 心情 emoji(卡片右上角展示) */
|
|
1255
|
+
mood: z.string().optional(),
|
|
1256
|
+
tags: z.array(z.string()).default([]),
|
|
1257
|
+
/** 配图(public 路径或外链,网格展示 + 灯箱浏览) */
|
|
1258
|
+
images: z.array(z.string()).default([]),
|
|
1259
|
+
draft: z.boolean().default(false),
|
|
1260
|
+
})
|
|
1261
|
+
.extend(extensions);
|
|
1262
|
+
}
|
package/src/styles/global.css
CHANGED
|
@@ -3088,6 +3088,9 @@ html.dark .series-nav-item.cur .series-nav-row {
|
|
|
3088
3088
|
.gallery-banner {
|
|
3089
3089
|
position: relative;
|
|
3090
3090
|
overflow: hidden;
|
|
3091
|
+
/* 显式定宽阻断 aspect-ratio 反推:窄容器下 min-height 拉高后 3/1 会
|
|
3092
|
+
反解出 200×3=600px 的宽度穿透容器,导致页面横向滚动(§4.86) */
|
|
3093
|
+
width: 100%;
|
|
3091
3094
|
border-radius: var(--radius);
|
|
3092
3095
|
aspect-ratio: 3 / 1;
|
|
3093
3096
|
min-height: 200px;
|
|
@@ -3208,6 +3211,8 @@ gallery-filter {
|
|
|
3208
3211
|
.gallery-masonry {
|
|
3209
3212
|
column-count: 2;
|
|
3210
3213
|
column-gap: 12px;
|
|
3214
|
+
/* 与封面横幅/降级头部的间隔(§4.86,原来零间距贴合) */
|
|
3215
|
+
margin-top: 24px;
|
|
3211
3216
|
}
|
|
3212
3217
|
@media (min-width: 640px) {
|
|
3213
3218
|
.gallery-masonry {
|
|
@@ -3234,6 +3239,84 @@ gallery-filter {
|
|
|
3234
3239
|
transform: scale(1.025);
|
|
3235
3240
|
}
|
|
3236
3241
|
|
|
3242
|
+
/* 说说页(§4.87):时间流卡片 */
|
|
3243
|
+
.moments-list {
|
|
3244
|
+
display: flex;
|
|
3245
|
+
flex-direction: column;
|
|
3246
|
+
gap: 16px;
|
|
3247
|
+
}
|
|
3248
|
+
.moment {
|
|
3249
|
+
padding: 18px 22px;
|
|
3250
|
+
}
|
|
3251
|
+
.moment-meta {
|
|
3252
|
+
display: flex;
|
|
3253
|
+
align-items: center;
|
|
3254
|
+
justify-content: space-between;
|
|
3255
|
+
margin-bottom: 10px;
|
|
3256
|
+
font-size: 13px;
|
|
3257
|
+
color: var(--muted);
|
|
3258
|
+
}
|
|
3259
|
+
.moment-mood {
|
|
3260
|
+
font-size: 18px;
|
|
3261
|
+
line-height: 1;
|
|
3262
|
+
}
|
|
3263
|
+
.moment-content {
|
|
3264
|
+
font-size: 15px;
|
|
3265
|
+
line-height: 1.75;
|
|
3266
|
+
}
|
|
3267
|
+
.moment-content > :first-child {
|
|
3268
|
+
margin-top: 0;
|
|
3269
|
+
}
|
|
3270
|
+
.moment-content > :last-child {
|
|
3271
|
+
margin-bottom: 0;
|
|
3272
|
+
}
|
|
3273
|
+
.moment-images {
|
|
3274
|
+
display: grid;
|
|
3275
|
+
grid-template-columns: repeat(3, 1fr);
|
|
3276
|
+
gap: 8px;
|
|
3277
|
+
margin-top: 12px;
|
|
3278
|
+
}
|
|
3279
|
+
@media (max-width: 480px) {
|
|
3280
|
+
.moment-images {
|
|
3281
|
+
grid-template-columns: repeat(2, 1fr);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
.moment-img {
|
|
3285
|
+
overflow: hidden;
|
|
3286
|
+
border-radius: var(--radius-sm);
|
|
3287
|
+
background: var(--bg-soft);
|
|
3288
|
+
cursor: zoom-in;
|
|
3289
|
+
}
|
|
3290
|
+
.moment-img img {
|
|
3291
|
+
display: block;
|
|
3292
|
+
width: 100%;
|
|
3293
|
+
height: 100%;
|
|
3294
|
+
object-fit: cover;
|
|
3295
|
+
aspect-ratio: 1 / 1;
|
|
3296
|
+
transition: transform 0.35s ease;
|
|
3297
|
+
}
|
|
3298
|
+
.moment-img:hover img {
|
|
3299
|
+
transform: scale(1.04);
|
|
3300
|
+
}
|
|
3301
|
+
.moment-tags {
|
|
3302
|
+
display: flex;
|
|
3303
|
+
flex-wrap: wrap;
|
|
3304
|
+
gap: 8px;
|
|
3305
|
+
margin-top: 12px;
|
|
3306
|
+
}
|
|
3307
|
+
.moment-tag {
|
|
3308
|
+
padding: 1px 10px;
|
|
3309
|
+
border-radius: 999px;
|
|
3310
|
+
font-size: 12px;
|
|
3311
|
+
color: var(--ink-2);
|
|
3312
|
+
background: var(--bg-soft);
|
|
3313
|
+
transition: background-color 0.15s;
|
|
3314
|
+
}
|
|
3315
|
+
.moment-tag:hover {
|
|
3316
|
+
background: var(--accent-soft);
|
|
3317
|
+
color: var(--ink);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3237
3320
|
/* 灯箱(§4.69):样式由 photoswipe/photoswipe.css 提供(BaseLayout 引入) */
|
|
3238
3321
|
/* 灯箱自定义 UI(§4.70/§4.71):全屏/自动播放按钮与缩略图条(可开关、内容居中)。
|
|
3239
3322
|
自定义图标用 .qy-pswp-icn 独立类名,避开 .pswp__icn 的 fill 覆盖(图标走 stroke 描边) */
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from "astro:content";
|
|
3
|
-
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
-
import { t } from "../../i18n/index.js";
|
|
5
|
-
import { collectCategories, formatDate, getSortedPosts } from "../../utils.js";
|
|
6
|
-
|
|
7
|
-
// 先排序再聚合,保证每个分类内文章按新→旧排列
|
|
8
|
-
export async function getStaticPaths() {
|
|
9
|
-
const all = await getCollection("posts", ({ data }) =>
|
|
10
|
-
import.meta.env.PROD ? !data.draft : true,
|
|
11
|
-
);
|
|
12
|
-
const sorted = getSortedPosts(all);
|
|
13
|
-
return [...collectCategories(sorted).entries()].map(([category, posts]) => ({
|
|
14
|
-
params: { category },
|
|
15
|
-
props: { category, posts },
|
|
16
|
-
}));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const { category, posts } = Astro.props;
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
<BaseLayout title={t('categoryTitle', { category })} page="categories">
|
|
23
|
-
<header class="archive-head reveal">
|
|
24
|
-
<h1 class="page-title">{category}</h1>
|
|
25
|
-
<p class="page-sub">{t('totalPosts', { n: posts.length })}</p>
|
|
26
|
-
</header>
|
|
27
|
-
<div class="tag-list">
|
|
28
|
-
{
|
|
29
|
-
posts.map((post) => (
|
|
30
|
-
<a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
|
|
31
|
-
<div class="rc-title">{post.data.title}</div>
|
|
32
|
-
<div class="rc-meta">{formatDate(post.data.published)}</div>
|
|
33
|
-
</a>
|
|
34
|
-
))
|
|
35
|
-
}
|
|
36
|
-
</div>
|
|
37
|
-
</BaseLayout>
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from "astro:content";
|
|
3
|
-
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
-
import { t } from "../../i18n/index.js";
|
|
5
|
-
import { collectTags, formatDate, getSortedPosts } from "../../utils.js";
|
|
6
|
-
|
|
7
|
-
// 先排序再聚合,保证每个标签内文章按新→旧排列
|
|
8
|
-
export async function getStaticPaths() {
|
|
9
|
-
const all = await getCollection("posts", ({ data }) =>
|
|
10
|
-
import.meta.env.PROD ? !data.draft : true,
|
|
11
|
-
);
|
|
12
|
-
const sorted = getSortedPosts(all);
|
|
13
|
-
return [...collectTags(sorted).entries()].map(([tag, posts]) => ({
|
|
14
|
-
params: { tag },
|
|
15
|
-
props: { tag, posts },
|
|
16
|
-
}));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const { tag, posts } = Astro.props;
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
<BaseLayout title={t('tagTitle', { tag })} page="tags">
|
|
23
|
-
<header class="archive-head reveal">
|
|
24
|
-
<h1 class="page-title">{tag}</h1>
|
|
25
|
-
<p class="page-sub">{t('totalPosts', { n: posts.length })}</p>
|
|
26
|
-
</header>
|
|
27
|
-
<div class="tag-list">
|
|
28
|
-
{
|
|
29
|
-
posts.map((post) => (
|
|
30
|
-
<a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
|
|
31
|
-
<div class="rc-title">{post.data.title}</div>
|
|
32
|
-
<div class="rc-meta">
|
|
33
|
-
{formatDate(post.data.published)}
|
|
34
|
-
{post.data.category ? ` · ${post.data.category}` : ''}
|
|
35
|
-
</div>
|
|
36
|
-
</a>
|
|
37
|
-
))
|
|
38
|
-
}
|
|
39
|
-
</div>
|
|
40
|
-
</BaseLayout>
|