@windcoder/astro-theme-orchid 0.0.0-stage → 0.2.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.
Files changed (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 构建期内容加密(M3b-b7):AES-256-GCM + PBKDF2(node:crypto)。
3
+ * 与客户端 app.ts 的 Web Crypto 解密参数严格对齐:
4
+ * PBKDF2-SHA256 100000 轮派生 256 位密钥;输出 base64(salt[16] + iv[12] + authTag[16] + ciphertext)。
5
+ * salt/iv 由 password + slug 经 HMAC-SHA256 确定性派生——同一篇文与同一密码
6
+ * 每次构建产出相同密文,使 sessionStorage 缓存的密码跨刷新/跨构建稳定有效。
7
+ * 密文经 data-* 属性输出,文本内容不含明文;注:这是「防君子」级保护,
8
+ * 密钥材料来自用户输入,安全性依赖密码强度。
9
+ */
10
+ import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto";
11
+
12
+ const PBKDF2_ITERATIONS = 100_000;
13
+ const SALT_LENGTH = 16;
14
+ const IV_LENGTH = 12;
15
+ const KEY_LENGTH = 32;
16
+
17
+ /** 由 key + 上下文字符串确定性派生 length 字节(HMAC-SHA256 截断) */
18
+ function deriveBytes(key: string, context: string, length: number): Buffer {
19
+ return createHmac("sha256", key).update(context).digest().subarray(0, length);
20
+ }
21
+
22
+ /** 加密 HTML 字符串,输出 base64(salt + iv + authTag + ciphertext) */
23
+ export function encryptContent(
24
+ html: string,
25
+ password: string,
26
+ slug: string,
27
+ ): string {
28
+ const salt = deriveBytes(password, `salt:${slug}`, SALT_LENGTH);
29
+ const iv = deriveBytes(password, `iv:${slug}`, IV_LENGTH);
30
+ const key = pbkdf2Sync(
31
+ password,
32
+ salt,
33
+ PBKDF2_ITERATIONS,
34
+ KEY_LENGTH,
35
+ "sha256",
36
+ );
37
+
38
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
39
+ const encrypted = Buffer.concat([
40
+ cipher.update(html, "utf8"),
41
+ cipher.final(),
42
+ ]);
43
+ const authTag = cipher.getAuthTag();
44
+
45
+ return Buffer.concat([salt, iv, authTag, encrypted]).toString("base64");
46
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * 画廊相册扫描(M3b-b8):构建期在站点根目录扫描 public/gallery/<id>/。
6
+ * process.cwd() 在 dev/build 时均为站点项目根,与 Astro 解析 public/ 一致。
7
+ */
8
+
9
+ const IMAGE_RE = /\.(jpe?g|png|webp|avif|gif)$/i;
10
+ const COVER_RE = /^cover\./i;
11
+
12
+ export interface GalleryAlbumMeta {
13
+ id: string;
14
+ name: string;
15
+ description?: string;
16
+ date?: string;
17
+ location?: string;
18
+ tags?: string[];
19
+ cover?: string;
20
+ password?: string;
21
+ passwordHint?: string;
22
+ }
23
+
24
+ /**
25
+ * 扫描相册照片:本地文件(cover.* 提到首位)+ urls.txt 远程 URL(追加在本地之后)。
26
+ * 目录不存在返回空数组(空相册合法,渲染空态)。
27
+ */
28
+ export function scanAlbumPhotos(albumId: string): string[] {
29
+ const dir = path.join(process.cwd(), "public", "gallery", albumId);
30
+ if (!fs.existsSync(dir)) return [];
31
+
32
+ const files = fs
33
+ .readdirSync(dir)
34
+ .filter((f) => IMAGE_RE.test(f))
35
+ .sort();
36
+ const coverIdx = files.findIndex((f) => COVER_RE.test(f));
37
+ if (coverIdx > 0) {
38
+ const [coverFile] = files.splice(coverIdx, 1);
39
+ files.unshift(coverFile);
40
+ }
41
+ const localPhotos = files.map((f) => `/gallery/${albumId}/${f}`);
42
+
43
+ // urls.txt:每行一个远程图片 URL,# 开头为注释
44
+ const urlsFile = path.join(dir, "urls.txt");
45
+ let remotePhotos: string[] = [];
46
+ if (fs.existsSync(urlsFile)) {
47
+ remotePhotos = fs
48
+ .readFileSync(urlsFile, "utf-8")
49
+ .split("\n")
50
+ .map((line) => line.trim())
51
+ .filter((line) => line && !line.startsWith("#"));
52
+ }
53
+ return [...localPhotos, ...remotePhotos];
54
+ }
55
+
56
+ /** 相册封面:显式 cover → cover.* 文件 → 第一张照片 → 空(空态兜底) */
57
+ export function getAlbumCover(
58
+ album: GalleryAlbumMeta,
59
+ photos: string[],
60
+ ): string {
61
+ if (album.cover) return album.cover;
62
+ const coverFile = photos.find((p) => COVER_RE.test(p));
63
+ return coverFile || photos[0] || "";
64
+ }
65
+
66
+ /** 列表页数据组装:扫描 + 封面 + 计数一次完成 */
67
+ export function buildAlbumIndex(albums: GalleryAlbumMeta[]) {
68
+ return albums.map((album) => {
69
+ const photos = scanAlbumPhotos(album.id);
70
+ return {
71
+ ...album,
72
+ cover: getAlbumCover(album, photos),
73
+ photoCount: photos.length,
74
+ };
75
+ });
76
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * LQIP 占位渐变(M3b-b9,P1-14):构建期从 virtual:theme/lqips 查表,
3
+ * 返回内联渐变样式。查不到(外链 / data URL / 未生成 lqips.json)时
4
+ * 返回纯色兜底,功能自然降级,无需配置开关。
5
+ */
6
+ import lqips from "virtual:theme/lqips";
7
+
8
+ /** 图片 public 路径(/assets/x.jpg)→ 18 字符 compact hex 三段渐变背景 */
9
+ export function getLqipStyle(src: string): string {
10
+ const hex = lqips[`public:${src.replace(/^\//, "")}`];
11
+ if (hex && /^[0-9a-f]{18}$/i.test(hex)) {
12
+ const c1 = `#${hex.slice(0, 6)}`;
13
+ const c2 = `#${hex.slice(6, 12)}`;
14
+ const c3 = `#${hex.slice(12, 18)}`;
15
+ return `background:linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`;
16
+ }
17
+ return "background:var(--muted)";
18
+ }
@@ -0,0 +1,101 @@
1
+ import type { PostLike } from "../utils.js";
2
+
3
+ /**
4
+ * 标题分词:Intl.Segmenter 对中文按词切分,英文/数字按词切分;
5
+ * 过滤标点与空白,英文统一小写。每次调用新建 segmenter 成本可接受(构建期)。
6
+ */
7
+ function tokenizeTitle(title: string): Set<string> {
8
+ const tokens = new Set<string>();
9
+ const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
10
+ for (const { segment, isWordLike } of segmenter.segment(title)) {
11
+ if (!isWordLike) continue;
12
+ tokens.add(segment.toLowerCase());
13
+ }
14
+ return tokens;
15
+ }
16
+
17
+ /** Jaccard 相似度:交集 / 并集(空集对空集记 0) */
18
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
19
+ if (a.size === 0 && b.size === 0) return 0;
20
+ let intersection = 0;
21
+ for (const item of a) {
22
+ if (b.has(item)) intersection++;
23
+ }
24
+ const union = a.size + b.size - intersection;
25
+ return union === 0 ? 0 : intersection / union;
26
+ }
27
+
28
+ /**
29
+ * 相关文章推荐(P1-12,移植自 orchid-blog getRelatedPosts)。
30
+ * 评分:totalScore = tagMatchScore(0-100) + titleSimilarityScore(0-100)
31
+ * + timeFreshnessScore(0-30) + categoryBonus(0|10)
32
+ * - 标签/标题均为 Jaccard 相似度 × 100
33
+ * - 时间新鲜度按 6 个月半衰期指数衰减
34
+ * - 同分类加 10 分
35
+ * 候选排除:自身与加密文章(加密内容不可预览,标题亦不进推荐位)。
36
+ * 排序:先取有标签匹配者(按总分降序),不足 maxCount 时
37
+ * 用无标签匹配者按「新鲜度 + 同分类」降序补充。
38
+ */
39
+ export function getRelatedPosts<T extends PostLike>(
40
+ all: T[],
41
+ current: T,
42
+ maxCount = 5,
43
+ ): T[] {
44
+ if (maxCount <= 0) return [];
45
+ const candidates = all.filter((p) => p.id !== current.id && !p.data.password);
46
+
47
+ const currentTags = new Set(current.data.tags || []);
48
+ const currentTokens = tokenizeTitle(current.data.title);
49
+ const currentCategory = current.data.category || "";
50
+ const now = Date.now();
51
+
52
+ const scored = candidates.map((post) => {
53
+ const tagMatchScore =
54
+ jaccardSimilarity(currentTags, new Set(post.data.tags || [])) * 100;
55
+ const titleSimilarityScore =
56
+ jaccardSimilarity(currentTokens, tokenizeTitle(post.data.title)) * 100;
57
+ const daysSincePublished =
58
+ (now - post.data.published.getTime()) / (1000 * 60 * 60 * 24);
59
+ const timeFreshnessScore =
60
+ 30 * Math.exp((-Math.LN2 * daysSincePublished) / 180);
61
+ const postCategory = post.data.category || "";
62
+ const categoryBonus =
63
+ currentCategory && postCategory && currentCategory === postCategory
64
+ ? 10
65
+ : 0;
66
+ return {
67
+ post,
68
+ totalScore:
69
+ tagMatchScore +
70
+ titleSimilarityScore +
71
+ timeFreshnessScore +
72
+ categoryBonus,
73
+ tagMatchScore,
74
+ timeFreshnessScore,
75
+ categoryBonus,
76
+ };
77
+ });
78
+
79
+ scored.sort((a, b) => b.totalScore - a.totalScore);
80
+
81
+ const result: T[] = [];
82
+ for (const s of scored) {
83
+ if (result.length >= maxCount) break;
84
+ if (s.tagMatchScore > 0) result.push(s.post);
85
+ }
86
+ if (result.length < maxCount) {
87
+ const rest = scored
88
+ .filter((s) => s.tagMatchScore === 0)
89
+ .sort(
90
+ (a, b) =>
91
+ b.timeFreshnessScore +
92
+ b.categoryBonus -
93
+ (a.timeFreshnessScore + a.categoryBonus),
94
+ );
95
+ for (const s of rest) {
96
+ if (result.length >= maxCount) break;
97
+ result.push(s.post);
98
+ }
99
+ }
100
+ return result;
101
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,233 @@
1
+ import config from "virtual:theme/config";
2
+
3
+ /**
4
+ * 格式化为 YYYY-MM-DD(§4.30:配置 timezone 时经 Intl timeZone 指定,与构建机时区解耦;
5
+ * 未配置跟随本地时区,避免 toISOString 的 UTC 偏移)
6
+ */
7
+ export function formatDate(date: Date): string {
8
+ return new Intl.DateTimeFormat("en-CA", {
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ timeZone: config.timezone || undefined,
13
+ }).format(date);
14
+ }
15
+
16
+ /** 文章最小结构约束:兼容站点内 CollectionEntry<'posts'> 与主题内部中间结果 */
17
+ export interface PostLike {
18
+ id: string;
19
+ body?: string;
20
+ data: {
21
+ title: string;
22
+ description: string;
23
+ published: Date;
24
+ updated?: Date;
25
+ draft: boolean;
26
+ pinned: boolean;
27
+ image?: string;
28
+ tags: string[];
29
+ category?: string;
30
+ /** 加密文章:非空即视为加密(列表锁标 / 正文条件加密 / 评论 TOC 规避) */
31
+ password?: string;
32
+ /** 加密提示(仅展示,不参与解密) */
33
+ passwordHint?: string;
34
+ /** remark 插件注入:摘要 / 首图 / 字数 / 阅读时长 */
35
+ excerpt?: string;
36
+ firstImage?: string;
37
+ words?: number;
38
+ minutes?: number;
39
+ cardStyle?: "cover" | "compact" | "text";
40
+ /** 专题名(§4.31):同专题文章聚合到 /series/<name>/ */
41
+ series?: string;
42
+ /** 专题内顺序(小在前);未配置则按发布时间升序 */
43
+ seriesOrder?: number;
44
+ /** 专题内分组(§4.75):专题页/专题目录按组分块展示 */
45
+ seriesGroup?: string;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * hex(#rrggbb)转 HSL,用于构建期把 accentColor 注入 --accent-h/s/l。
51
+ * 例:#4a9d8f → { h: 170, s: 36, l: 45 }
52
+ */
53
+ export function hexToHsl(hex: string): { h: number; s: number; l: number } {
54
+ const m = hex.replace("#", "");
55
+ const r = parseInt(m.slice(0, 2), 16) / 255;
56
+ const g = parseInt(m.slice(2, 4), 16) / 255;
57
+ const b = parseInt(m.slice(4, 6), 16) / 255;
58
+ const max = Math.max(r, g, b);
59
+ const min = Math.min(r, g, b);
60
+ const l = (max + min) / 2;
61
+ let h = 0;
62
+ let s = 0;
63
+ if (max !== min) {
64
+ const d = max - min;
65
+ s = d / (1 - Math.abs(2 * l - 1));
66
+ switch (max) {
67
+ case r:
68
+ h = 60 * (((g - b) / d) % 6);
69
+ break;
70
+ case g:
71
+ h = 60 * ((b - r) / d + 2);
72
+ break;
73
+ default:
74
+ h = 60 * ((r - g) / d + 4);
75
+ }
76
+ }
77
+ return {
78
+ h: Math.round((h + 360) % 360),
79
+ s: Math.round(s * 100),
80
+ l: Math.round(l * 100),
81
+ };
82
+ }
83
+
84
+ /** 过滤草稿并排序:置顶优先,其余按发布时间倒序 */
85
+ export function getSortedPosts<T extends PostLike>(posts: T[]): T[] {
86
+ return posts
87
+ .filter((p) => !p.data.draft)
88
+ .sort((a, b) => {
89
+ if (a.data.pinned !== b.data.pinned) return a.data.pinned ? -1 : 1;
90
+ return b.data.published.getTime() - a.data.published.getTime();
91
+ });
92
+ }
93
+
94
+ /** 专题内排序比较器:seriesOrder 升序(未配置排最后),同序按发布时间升序 */
95
+ function compareSeriesOrder<T extends PostLike>(a: T, b: T): number {
96
+ const oa = a.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
97
+ const ob = b.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
98
+ if (oa !== ob) return oa - ob;
99
+ return a.data.published.getTime() - b.data.published.getTime();
100
+ }
101
+
102
+ /** 专题渲染序列条目:groupHead 非空 = 该条目是新分块首条,值为组名(未分组块无头) */
103
+ export interface SeriesEntry<T> {
104
+ post: T;
105
+ /** 全局序号(0 起,贯穿分块连续编号) */
106
+ index: number;
107
+ groupHead: string | null;
108
+ }
109
+
110
+ /**
111
+ * 专题分块序列(§4.75):按 seriesOrder 排序后分块规范化——
112
+ * 未分组文章为最前块,各分组(seriesGroup 非空)按组内首篇顺序依次排列。
113
+ * 同组文章 seriesOrder 不连续时自动归拢,不依赖作者保证组内连续。
114
+ * 序号贯穿所有分块连续递增,供专题页与文章页专题目录共用。
115
+ */
116
+ export function seriesEntries<T extends PostLike>(
117
+ posts: T[],
118
+ ): SeriesEntry<T>[] {
119
+ const sorted = [...posts].sort(compareSeriesOrder);
120
+ const entries: SeriesEntry<T>[] = [];
121
+ const groups = new Map<string, T[]>();
122
+ for (const post of sorted) {
123
+ const group = post.data.seriesGroup;
124
+ if (!group) {
125
+ entries.push({ post, index: entries.length, groupHead: null });
126
+ continue;
127
+ }
128
+ const list = groups.get(group);
129
+ if (list) list.push(post);
130
+ else groups.set(group, [post]);
131
+ }
132
+ for (const [group, list] of groups) {
133
+ for (const post of list) {
134
+ entries.push({
135
+ post,
136
+ index: entries.length,
137
+ groupHead: post === list[0] ? group : null,
138
+ });
139
+ }
140
+ }
141
+ return entries;
142
+ }
143
+
144
+ /** 按标签聚合:Map<标签名, 文章[]>,标签按文章数降序 */
145
+ export function collectTags<T extends PostLike>(posts: T[]): Map<string, T[]> {
146
+ const map = new Map<string, T[]>();
147
+ for (const post of posts) {
148
+ for (const tag of post.data.tags) {
149
+ const list = map.get(tag);
150
+ if (list) list.push(post);
151
+ else map.set(tag, [post]);
152
+ }
153
+ }
154
+ return new Map([...map.entries()].sort((a, b) => b[1].length - a[1].length));
155
+ }
156
+
157
+ /** 按分类聚合:Map<分类名, 文章[]>,分类按文章数降序(无分类文章不参与) */
158
+ export function collectCategories<T extends PostLike>(
159
+ posts: T[],
160
+ ): Map<string, T[]> {
161
+ const map = new Map<string, T[]>();
162
+ for (const post of posts) {
163
+ const cat = post.data.category;
164
+ if (!cat) continue;
165
+ const list = map.get(cat);
166
+ if (list) list.push(post);
167
+ else map.set(cat, [post]);
168
+ }
169
+ return new Map([...map.entries()].sort((a, b) => b[1].length - a[1].length));
170
+ }
171
+
172
+ /** 计算整站已发布文章总字数(站点信息卡「共 N 字」用) */
173
+ export function countTotalWords(posts: PostLike[]): number {
174
+ return posts.reduce((sum, p) => sum + (p.data.words ?? 0), 0);
175
+ }
176
+
177
+ /** 建站至今天数(since: YYYY-MM-DD) */
178
+ export function daysSince(since: string): number {
179
+ const start = new Date(`${since}T00:00:00`);
180
+ if (Number.isNaN(start.getTime())) return 0;
181
+ return Math.max(0, Math.floor((Date.now() - start.getTime()) / 86400000));
182
+ }
183
+
184
+ /* ---------- 随机封面(§4.33) ---------- */
185
+
186
+ /**
187
+ * Java String.hashCode 同款散列:同 slug 跨构建/跨设备稳定,
188
+ * 作为随机封面的确定性 seed(对齐 orchid-blog getSeedHash)。
189
+ */
190
+ export function seedHash(str: string): number {
191
+ let h = 0;
192
+ for (let i = 0; i < str.length; i++)
193
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
194
+ return Math.abs(h);
195
+ }
196
+
197
+ /** 把 seed 拼进 URL(已有 query 用 &,否则 ?),同 URL 每次构建请求同图 */
198
+ export function appendSeedParam(url: string, hash: number): string {
199
+ return `${url}${url.includes("?") ? "&" : "?"}v=${hash}`;
200
+ }
201
+
202
+ /**
203
+ * 随机封面单条 URL:{slug} 占位符替换为文章 id,再追加 ?v=<hash>。
204
+ * 同 slug 的/apis[i] 输出恒定 → 列表页与文章页、多次构建之间图片一致。
205
+ */
206
+ export function randomCoverUrl(template: string, slug: string): string {
207
+ const withSlug = template.includes("{slug}")
208
+ ? template.replaceAll("{slug}", slug)
209
+ : template;
210
+ return appendSeedParam(withSlug, seedHash(slug));
211
+ }
212
+
213
+ /**
214
+ * 解析文章封面(§4.33):frontmatter image 为 "api" 且配置了图源时,
215
+ * 返回 { cover, retries }——cover 为主图 URL,retries 为客户端
216
+ * 加载失败时的依次重试列表(图源全量 + fallbackCover 兜底)。
217
+ * 非 "api" 或未配置图源时原样返回,行为与之前一致。
218
+ */
219
+ export function resolveCover(
220
+ image: string | undefined,
221
+ firstImage: string | undefined,
222
+ fallbackCover: string | undefined,
223
+ apis: string[],
224
+ slug: string,
225
+ ): { cover: string | undefined; retries: string[] | undefined } {
226
+ if (image !== "api" || apis.length === 0) {
227
+ return { cover: image || firstImage || fallbackCover, retries: undefined };
228
+ }
229
+ const cover = randomCoverUrl(apis[0], slug);
230
+ const retries = [...apis.map((u) => randomCoverUrl(u, slug))];
231
+ if (fallbackCover) retries.push(fallbackCover);
232
+ return { cover, retries };
233
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ResolvedThemeConfig } from "./schema.js";
5
+
6
+ /**
7
+ * 主题内部统一通过 `virtual:theme/...` 引用可覆盖组件:
8
+ * import Header from 'virtual:theme/components/Header.astro'
9
+ * 解析优先级:site/src/overrides/(路径镜像虚拟 id,去掉前缀)
10
+ * virtual:theme/components/X.astro → site/src/overrides/components/X.astro
11
+ * 下存在同名文件时优先采用;override 文件内部再 import 同一虚拟路径时
12
+ * 强制走默认实现,从而支持「包装默认实现」式定制而不产生自引用死循环。
13
+ */
14
+ const VIRTUAL_PREFIX = "virtual:theme/";
15
+
16
+ export function overridesPlugin() {
17
+ // vite.ts 位于包内 src/ 下,默认实现根即 src/
18
+ const themeSrcDir = path.dirname(fileURLToPath(import.meta.url));
19
+ let overridesDir: string | undefined;
20
+
21
+ return {
22
+ name: "@windcoder/astro-theme-orchid:overrides",
23
+ enforce: "pre" as const,
24
+ // root 在 configResolved 阶段确定(兼容 URL / string 两种形态)
25
+ configResolved(resolvedConfig: { root: URL | string }) {
26
+ const rootDir =
27
+ typeof resolvedConfig.root === "string"
28
+ ? resolvedConfig.root
29
+ : fileURLToPath(resolvedConfig.root);
30
+ overridesDir = path.join(rootDir, "src", "overrides");
31
+ },
32
+ resolveId(id: string, importer?: string) {
33
+ if (!id.startsWith(VIRTUAL_PREFIX)) return null;
34
+ const rel = id.slice(VIRTUAL_PREFIX.length);
35
+ if (overridesDir) {
36
+ const relImporter = importer
37
+ ? path.relative(overridesDir, importer)
38
+ : "";
39
+ const importerInOverrides =
40
+ !!importer &&
41
+ relImporter !== "" &&
42
+ !relImporter.startsWith("..") &&
43
+ !path.isAbsolute(relImporter);
44
+ if (!importerInOverrides) {
45
+ const overrideFile = path.join(overridesDir, rel);
46
+ if (fs.existsSync(overrideFile)) return overrideFile;
47
+ }
48
+ }
49
+ return path.join(themeSrcDir, rel);
50
+ },
51
+ };
52
+ }
53
+
54
+ /** 将站点配置暴露为虚拟模块,主题组件通过 `import config from 'virtual:theme/config'` 消费 */
55
+ export function themeConfigPlugin(config: ResolvedThemeConfig) {
56
+ const virtualId = "virtual:theme/config";
57
+ const resolvedId = "\0" + virtualId;
58
+ return {
59
+ name: "@windcoder/astro-theme-orchid:config",
60
+ enforce: "pre" as const,
61
+ resolveId(id: string) {
62
+ return id === virtualId ? resolvedId : null;
63
+ },
64
+ load(id: string) {
65
+ if (id === resolvedId) return `export default ${JSON.stringify(config)};`;
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * LQIP 虚拟模块(M3b-b9,P1-14):读取站点根 lqips.json
72
+ * (generate-lqips.mjs 产物,键 `public:<路径>` → 18 字符 compact hex),
73
+ * 以 `virtual:theme/lqips` 暴露给构建期内联占位渐变(utils/lqip.ts)。
74
+ * 文件缺失(未跑生成脚本)时导出空对象,消费端自然降级纯色,不阻塞构建。
75
+ * 注意:本插件必须注册在 overridesPlugin 之前——后者的 resolveId 会兜住
76
+ * 所有 virtual:theme/* 并返回一个不存在的文件路径。
77
+ */
78
+ export function themeLqipsPlugin() {
79
+ const virtualId = "virtual:theme/lqips";
80
+ const resolvedId = "\0" + virtualId;
81
+ let lqips: Record<string, string> = {};
82
+ return {
83
+ name: "@windcoder/astro-theme-orchid:lqips",
84
+ enforce: "pre" as const,
85
+ configResolved(resolvedConfig: { root: URL | string }) {
86
+ const rootDir =
87
+ typeof resolvedConfig.root === "string"
88
+ ? resolvedConfig.root
89
+ : fileURLToPath(resolvedConfig.root);
90
+ try {
91
+ lqips = JSON.parse(
92
+ fs.readFileSync(path.join(rootDir, "lqips.json"), "utf8"),
93
+ );
94
+ } catch {
95
+ lqips = {};
96
+ }
97
+ },
98
+ resolveId(id: string) {
99
+ return id === virtualId ? resolvedId : null;
100
+ },
101
+ load(id: string) {
102
+ if (id === resolvedId) return `export default ${JSON.stringify(lqips)};`;
103
+ },
104
+ };
105
+ }