@windcoder/astro-theme-orchid 0.3.0 → 0.4.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 +2 -0
- package/package.json +1 -1
- package/scripts/generate-lqips.mjs +239 -20
- package/src/components/AlbumCard.astro +4 -1
- package/src/components/BaseLayout.astro +209 -64
- package/src/components/DisplaySettings.astro +383 -0
- package/src/components/Header.astro +56 -2
- package/src/components/MusicPlayer.astro +45 -5
- package/src/components/MusicWidget.astro +265 -0
- package/src/components/PhotoCard.astro +13 -1
- package/src/components/PostCard.astro +9 -2
- package/src/components/SakuraEffect.astro +126 -28
- package/src/components/Sidebar.astro +28 -7
- package/src/i18n/en.ts +36 -0
- package/src/i18n/ja.ts +213 -0
- package/src/i18n/ko.ts +212 -0
- package/src/i18n/resolve.ts +10 -1
- package/src/i18n/ru.ts +213 -0
- package/src/i18n/zh-CN.ts +37 -0
- package/src/i18n/zh-TW.ts +208 -0
- package/src/index.ts +24 -0
- package/src/plugins/rehype.ts +66 -2
- package/src/routes/friends.astro +10 -2
- package/src/routes/page/[page].astro +3 -0
- package/src/routes/posts/[...slug].astro +7 -2
- package/src/routes/search.astro +24 -0
- package/src/routes/sponsor.astro +17 -4
- package/src/schema.ts +364 -71
- package/src/scripts/app.ts +817 -37
- package/src/styles/global.css +865 -36
- package/src/utils/gallery.ts +3 -1
- package/src/utils/image.ts +90 -0
- package/src/utils/lqip.ts +21 -12
- package/src/utils/thumb.ts +31 -0
- package/src/vite.ts +95 -8
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
清新简约的 Astro 博客主题,以 **Astro Integration** 形式提供:站点只做三件事——声明域名、接入主题、传入配置;页面、样式引擎、路由全部由主题注入。支持版本化升级与组件覆盖(`src/overrides/` 同名替换,升级永不覆盖站点定制)。
|
|
4
4
|
|
|
5
|
+
> 部分功能设计参考了 [Firefly](https://github.com/CuteLeaf/Firefly) 主题,在此致谢。
|
|
6
|
+
|
|
5
7
|
## 特性一览
|
|
6
8
|
|
|
7
9
|
- **页面**:首页(featured/cover/compact/text 四种卡片混排)、文章页(TOC/相关推荐/分享海报/打赏入口)、归档(按年折叠)、分类、标签、专题、画廊(含加密相册)、友链、追番、留言板、打赏、关于
|
package/package.json
CHANGED
|
@@ -1,25 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* LQIP
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* LQIP + 缩略图生成脚本(M3b-b9,P1-14;§4.106 扩展双产物;§4.107 外链 LQIP):
|
|
4
|
+
* 产出两份构建期元数据:
|
|
5
|
+
* 1. lqips.json:每张采样 2×2 像素取三色 → 18 字符 compact hex(三段 rgb hex 拼接)。
|
|
6
|
+
* 两类键:`public:<posix 相对路径>`(站点 public 图片)+ `url:<完整外链 URL>`
|
|
7
|
+
* (文章封面/正文首图/相册 urls.txt 等外链图,fetch 下载后同样采样——
|
|
8
|
+
* 缓存即产物自身:已有 url: 键不重复抓取,`--refresh` 强制重抓)。
|
|
9
|
+
* 2. thumbs.json:每张 public 图同步生成同目录 `<名>.thumb.webp`(480 宽 q75,
|
|
10
|
+
* 不放大小图),并记录原图宽高(灯箱打开原图时需正确原始尺寸)。
|
|
11
|
+
* 外链图不做缩略图(版权/CORS/构建网络依赖,消费端回退原图)。
|
|
6
12
|
*
|
|
7
13
|
* 运行:pnpm --filter site lqip(cwd 必须是 site 根)
|
|
8
|
-
* 消费链路:
|
|
9
|
-
* →
|
|
14
|
+
* 消费链路:
|
|
15
|
+
* lqips.json → themeLqipsPlugin(vite.ts)→ virtual:theme/lqips
|
|
16
|
+
* → utils/lqip.ts getLqipStyle() / rehypeImageEnhance() → 构建期内联渐变;
|
|
17
|
+
* thumbs.json → themeLqipsPlugin(vite.ts)→ virtual:theme/thumbs
|
|
18
|
+
* → utils/thumb.ts getThumbInfo() → 列表封面构建期换缩略图 URL。
|
|
10
19
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
20
|
+
* 外键一致性约定:url: 键的 URL 必须与渲染期逐字符一致——
|
|
21
|
+
* - frontmatter image / 正文首图 / urls.txt 行:原样提取(与 gallery.ts 的
|
|
22
|
+
* trim + `#` 注释过滤规则相同);
|
|
23
|
+
* - image:"api":复刻 utils.ts 的 randomCoverUrl(seedHash + ?v= 追加),
|
|
24
|
+
* apis 模板从 site/src/config/*.ts 正则提取首个 apis 数组。
|
|
25
|
+
* 相册「显式外链 cover」(相册元数据字段)暂不收集:demo 站无此用法,
|
|
26
|
+
* 且元数据格式多样,出现需求时再扩展。sharp 经本脚本位置解析 theme 依赖。
|
|
14
27
|
*/
|
|
15
28
|
import fs from "node:fs";
|
|
16
29
|
import path from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
30
|
import sharp from "sharp";
|
|
19
31
|
|
|
20
32
|
const cwd = process.cwd();
|
|
21
33
|
const publicDir = path.join(cwd, "public");
|
|
22
|
-
const
|
|
34
|
+
const srcDir = path.join(cwd, "src");
|
|
35
|
+
const lqipFile = path.join(cwd, "lqips.json");
|
|
36
|
+
const thumbsFile = path.join(cwd, "thumbs.json");
|
|
37
|
+
|
|
38
|
+
const THUMB_WIDTH = 480;
|
|
39
|
+
const THUMB_QUALITY = 75;
|
|
40
|
+
const THUMB_SUFFIX = ".thumb.webp";
|
|
41
|
+
const FETCH_CONCURRENCY = 5;
|
|
42
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
43
|
+
const REFRESH = process.argv.includes("--refresh");
|
|
23
44
|
|
|
24
45
|
const EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif"]);
|
|
25
46
|
const EXCLUDED_DIRS = new Set(["node_modules", ".astro", "dist", ".git"]);
|
|
@@ -29,7 +50,7 @@ if (!fs.existsSync(publicDir)) {
|
|
|
29
50
|
process.exit(1);
|
|
30
51
|
}
|
|
31
52
|
|
|
32
|
-
/** 递归收集图片相对路径(posix
|
|
53
|
+
/** 递归收集图片相对路径(posix 风格,排除构建/依赖目录与缩略图产物) */
|
|
33
54
|
function collectImages(dir, base = "") {
|
|
34
55
|
const out = [];
|
|
35
56
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -41,7 +62,10 @@ function collectImages(dir, base = "") {
|
|
|
41
62
|
base ? `${base}/${entry.name}` : entry.name,
|
|
42
63
|
),
|
|
43
64
|
);
|
|
44
|
-
} else if (
|
|
65
|
+
} else if (
|
|
66
|
+
EXTS.has(path.extname(entry.name).toLowerCase()) &&
|
|
67
|
+
!entry.name.endsWith(THUMB_SUFFIX)
|
|
68
|
+
) {
|
|
45
69
|
out.push(base ? `${base}/${entry.name}` : entry.name);
|
|
46
70
|
}
|
|
47
71
|
}
|
|
@@ -49,8 +73,8 @@ function collectImages(dir, base = "") {
|
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
/** sharp 2×2 fill 采样 → 取 [0][1][3] 三像素(左上/右上/右下)rgb hex 拼接 */
|
|
52
|
-
async function sampleColors(
|
|
53
|
-
const { data, info } = await sharp(
|
|
76
|
+
async function sampleColors(input) {
|
|
77
|
+
const { data, info } = await sharp(input)
|
|
54
78
|
.resize(2, 2, { fit: "fill" })
|
|
55
79
|
.removeAlpha()
|
|
56
80
|
.raw()
|
|
@@ -64,19 +88,214 @@ async function sampleColors(file) {
|
|
|
64
88
|
return hex(0) + hex(1) + hex(3);
|
|
65
89
|
}
|
|
66
90
|
|
|
91
|
+
/** 生成 480 宽 webp 缩略图(不放大小图),返回 { thumb 相对路径, 原图宽高 } */
|
|
92
|
+
async function makeThumb(rel) {
|
|
93
|
+
const file = path.join(publicDir, rel);
|
|
94
|
+
const parsed = path.parse(rel);
|
|
95
|
+
const thumbRel = path.posix.format({
|
|
96
|
+
dir: parsed.dir,
|
|
97
|
+
name: `${parsed.name}.thumb`,
|
|
98
|
+
ext: ".webp",
|
|
99
|
+
});
|
|
100
|
+
const meta = await sharp(file).metadata();
|
|
101
|
+
await sharp(file)
|
|
102
|
+
.resize({ width: THUMB_WIDTH, withoutEnlargement: true })
|
|
103
|
+
.webp({ quality: THUMB_QUALITY })
|
|
104
|
+
.toFile(path.join(publicDir, thumbRel));
|
|
105
|
+
return {
|
|
106
|
+
thumb: thumbRel,
|
|
107
|
+
width: meta.width ?? 0,
|
|
108
|
+
height: meta.height ?? 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------- 外链 URL 收集(§4.107) ----------
|
|
113
|
+
|
|
114
|
+
/** 复刻 utils.ts seedHash(Java String.hashCode 同款),保证与渲染期键一致 */
|
|
115
|
+
function seedHash(str) {
|
|
116
|
+
let h = 0;
|
|
117
|
+
for (let i = 0; i < str.length; i++)
|
|
118
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
119
|
+
return Math.abs(h);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 复刻 utils.ts randomCoverUrl:{slug} 替换 + ?v=<hash>(有 query 用 &) */
|
|
123
|
+
function randomCoverUrl(template, slug) {
|
|
124
|
+
const withSlug = template.includes("{slug}")
|
|
125
|
+
? template.replaceAll("{slug}", slug)
|
|
126
|
+
: template;
|
|
127
|
+
return `${withSlug}${withSlug.includes("?") ? "&" : "?"}v=${seedHash(slug)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 从 site/src/config/*.ts 正则提取首个 randomCover.apis 模板(提取不到返回 null) */
|
|
131
|
+
function getApiTemplate() {
|
|
132
|
+
const configDir = path.join(srcDir, "config");
|
|
133
|
+
if (!fs.existsSync(configDir)) return null;
|
|
134
|
+
for (const name of fs.readdirSync(configDir).sort()) {
|
|
135
|
+
if (!/\.(m|c)?(ts|js)$/.test(name)) continue;
|
|
136
|
+
const text = fs.readFileSync(path.join(configDir, name), "utf8");
|
|
137
|
+
const apis = text.match(/apis:\s*\[([^\]]*)\]/);
|
|
138
|
+
if (apis) {
|
|
139
|
+
const url = apis[1].match(/["']([^"']+)["']/);
|
|
140
|
+
if (url) return url[1];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 正文首个外链图 URL:跳过 ```/~~~ 围栏代码块,md 图片语法或 <img> 均可 */
|
|
147
|
+
function extractFirstImage(body) {
|
|
148
|
+
let inFence = false;
|
|
149
|
+
for (const line of body.split("\n")) {
|
|
150
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
151
|
+
inFence = !inFence;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (inFence) continue;
|
|
155
|
+
let m = line.match(/!\[[^\]]*\]\(\s*(https?:\/\/[^)\s]+)/);
|
|
156
|
+
if (m) return m[1];
|
|
157
|
+
m = line.match(/<img[^>]*\ssrc=["'](https?:\/\/[^"']+)["']/i);
|
|
158
|
+
if (m) return m[1];
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 收集外链图片 URL:文章 frontmatter image(含 api 确定性解析)+ 正文首图 + 相册 urls.txt */
|
|
164
|
+
function collectRemoteUrls() {
|
|
165
|
+
const urls = new Set();
|
|
166
|
+
const apiTemplate = getApiTemplate();
|
|
167
|
+
const postsDir = path.join(srcDir, "content", "posts");
|
|
168
|
+
if (fs.existsSync(postsDir)) {
|
|
169
|
+
const walkMd = (dir) => {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
172
|
+
if (entry.isDirectory())
|
|
173
|
+
out.push(...walkMd(path.join(dir, entry.name)));
|
|
174
|
+
else if (/\.(md|mdx)$/i.test(entry.name))
|
|
175
|
+
out.push(path.join(dir, entry.name));
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
};
|
|
179
|
+
for (const file of walkMd(postsDir)) {
|
|
180
|
+
const text = fs.readFileSync(file, "utf8");
|
|
181
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
182
|
+
let image;
|
|
183
|
+
if (fm) {
|
|
184
|
+
const im = fm[1].match(/^image:\s*["']?([^"'\n]+?)["']?\s*$/m);
|
|
185
|
+
if (im) image = im[1].trim();
|
|
186
|
+
}
|
|
187
|
+
if (image && image !== "api" && /^https?:\/\//.test(image)) {
|
|
188
|
+
urls.add(image);
|
|
189
|
+
} else if (image === "api" && apiTemplate) {
|
|
190
|
+
const slug = path.basename(file).replace(/\.(md|mdx)$/i, "");
|
|
191
|
+
urls.add(randomCoverUrl(apiTemplate, slug));
|
|
192
|
+
}
|
|
193
|
+
// 正文首图(无封面文章它是列表封面;有封面文章供正文增强查表)
|
|
194
|
+
const body = fm ? text.slice(fm[0].length) : text;
|
|
195
|
+
const first = extractFirstImage(body);
|
|
196
|
+
if (first) urls.add(first);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const galleryDir = path.join(publicDir, "gallery");
|
|
200
|
+
if (fs.existsSync(galleryDir)) {
|
|
201
|
+
for (const entry of fs.readdirSync(galleryDir, { withFileTypes: true })) {
|
|
202
|
+
if (!entry.isDirectory()) continue;
|
|
203
|
+
const urlsFile = path.join(galleryDir, entry.name, "urls.txt");
|
|
204
|
+
if (!fs.existsSync(urlsFile)) continue;
|
|
205
|
+
for (const line of fs.readFileSync(urlsFile, "utf-8").split("\n")) {
|
|
206
|
+
const u = line.trim();
|
|
207
|
+
if (u && !u.startsWith("#") && /^https?:\/\//.test(u)) urls.add(u);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return [...urls];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** fetch 外链图 → sharp 采样(超时/非 2xx 抛错由调用方跳过) */
|
|
215
|
+
async function fetchSample(url) {
|
|
216
|
+
const res = await fetch(url, {
|
|
217
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
218
|
+
headers: { "user-agent": "orchid-theme-lqip/1.0" },
|
|
219
|
+
});
|
|
220
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
221
|
+
return sampleColors(Buffer.from(await res.arrayBuffer()));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 固定并发的小任务池(保持顺序写 out) */
|
|
225
|
+
async function mapPool(items, limit, fn) {
|
|
226
|
+
const out = new Array(items.length);
|
|
227
|
+
let i = 0;
|
|
228
|
+
async function worker() {
|
|
229
|
+
while (i < items.length) {
|
|
230
|
+
const idx = i++;
|
|
231
|
+
try {
|
|
232
|
+
out[idx] = await fn(items[idx]);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(
|
|
235
|
+
`[lqip] 外链抓取失败,跳过: ${items[idx]} — ${err.message}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
await Promise.all(
|
|
241
|
+
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
|
242
|
+
);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
67
246
|
try {
|
|
247
|
+
// public 图片:LQIP + 缩略图(照旧重算,public 内容变了要跟上)
|
|
68
248
|
const files = collectImages(publicDir);
|
|
69
|
-
const
|
|
70
|
-
|
|
249
|
+
const lqipEntries = [];
|
|
250
|
+
const thumbEntries = [];
|
|
251
|
+
for (const rel of files) {
|
|
252
|
+
lqipEntries.push([
|
|
71
253
|
`public:${rel}`,
|
|
72
254
|
await sampleColors(path.join(publicDir, rel)),
|
|
73
|
-
])
|
|
255
|
+
]);
|
|
256
|
+
thumbEntries.push([`public:${rel}`, await makeThumb(rel)]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 外链:lqips.json 已有 url: 键即缓存,只抓缺失项
|
|
260
|
+
let cached = {};
|
|
261
|
+
if (!REFRESH) {
|
|
262
|
+
try {
|
|
263
|
+
cached = Object.fromEntries(
|
|
264
|
+
Object.entries(JSON.parse(fs.readFileSync(lqipFile, "utf8"))).filter(
|
|
265
|
+
([k, v]) => k.startsWith("url:") && /^[0-9a-f]{18}$/i.test(v),
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
cached = {};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const remoteUrls = collectRemoteUrls();
|
|
273
|
+
const pending = remoteUrls.filter((u) => !(`url:${u}` in cached));
|
|
274
|
+
console.log(
|
|
275
|
+
`[lqip] 外链 ${remoteUrls.length} 条(缓存命中 ${remoteUrls.length - pending.length},待抓 ${pending.length})`,
|
|
74
276
|
);
|
|
277
|
+
const fetched = await mapPool(pending, FETCH_CONCURRENCY, async (url) => [
|
|
278
|
+
url,
|
|
279
|
+
await fetchSample(url),
|
|
280
|
+
]);
|
|
281
|
+
// 缓存命中的 url: 键原样合并(否则第二次运行会把它从产物中丢掉)
|
|
282
|
+
for (const [k, v] of Object.entries(cached)) lqipEntries.push([k, v]);
|
|
283
|
+
for (const pair of fetched) {
|
|
284
|
+
if (pair) lqipEntries.push([`url:${pair[0]}`, pair[1]]);
|
|
285
|
+
}
|
|
286
|
+
|
|
75
287
|
fs.writeFileSync(
|
|
76
|
-
|
|
77
|
-
|
|
288
|
+
lqipFile,
|
|
289
|
+
// tab 缩进与 Biome format 一致:生成物入库即合规,重跑不会反复触发 CI format 报错
|
|
290
|
+
JSON.stringify(Object.fromEntries(lqipEntries), null, "\t") + "\n",
|
|
291
|
+
);
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
thumbsFile,
|
|
294
|
+
JSON.stringify(Object.fromEntries(thumbEntries), null, "\t") + "\n",
|
|
295
|
+
);
|
|
296
|
+
console.log(
|
|
297
|
+
`[lqip] public ${files.length} 张 + 外链 ${remoteUrls.length - pending.length + fetched.filter(Boolean).length} 条 → ${path.relative(cwd, lqipFile)} + ${path.relative(cwd, thumbsFile)}`,
|
|
78
298
|
);
|
|
79
|
-
console.log(`[lqip] ${files.length} 张图片 → ${path.relative(cwd, outFile)}`);
|
|
80
299
|
} catch (err) {
|
|
81
300
|
console.error("[lqip] 生成失败:", err);
|
|
82
301
|
process.exit(1);
|
|
@@ -5,6 +5,7 @@ import { t } from "../i18n/index.js";
|
|
|
5
5
|
* data-tags(标签数组)与 data-search(名称/描述/地点/标签全文小写)供 gallery-filter 筛选。
|
|
6
6
|
*/
|
|
7
7
|
import { getLqipStyle } from "../utils/lqip.js";
|
|
8
|
+
import { getThumbInfo } from "../utils/thumb.js";
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
id: string;
|
|
@@ -33,6 +34,8 @@ const {
|
|
|
33
34
|
const searchText = [name, description, location, ...tags]
|
|
34
35
|
.join(" ")
|
|
35
36
|
.toLowerCase();
|
|
37
|
+
/* §4.106:相册封面用缩略图(点击进详情页看原图,卡片本身无灯箱) */
|
|
38
|
+
const thumb = getThumbInfo(cover);
|
|
36
39
|
---
|
|
37
40
|
|
|
38
41
|
<a class="album-card card" href={`/gallery/${id}/`} data-tags={tags.join(',')} data-search={searchText} aria-label={encrypted ? `${name}(${t('encryptedAlbum')})` : name}>
|
|
@@ -41,7 +44,7 @@ const searchText = [name, description, location, ...tags]
|
|
|
41
44
|
<>
|
|
42
45
|
{/* LQIP 占位渐变(b9.5):外链封面查不到数据时降级纯色 */}
|
|
43
46
|
<div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
|
|
44
|
-
<img src={
|
|
47
|
+
<img src={thumb.src} alt={name} loading="lazy" decoding="async" data-lqip />
|
|
45
48
|
</>
|
|
46
49
|
) : (
|
|
47
50
|
<span class="album-cover-fallback">
|