@windcoder/astro-theme-orchid 0.4.3 → 0.4.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windcoder/astro-theme-orchid",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -30,6 +30,11 @@ const { cover: rawCover, retries } = resolveCover(
30
30
  下)构建期解析为产物 URL,public / 外链原样直通 */
31
31
  const resolvedCover = rawCover ? await resolveSiteImage(rawCover) : rawCover;
32
32
  const cover = config.postList.showCover ? resolvedCover : null;
33
+ /* §4.114:普通封面图失效的客户端兜底(api 文章 retries 末尾已含 fallbackCover,无需重复) */
34
+ const coverFallback =
35
+ !retries && config.postList.fallbackCover
36
+ ? await resolveSiteImage(config.postList.fallbackCover)
37
+ : "";
33
38
  /* §4.106:列表封面用缩略图(外链/未命中自动回退原图;点击进文章页看正文原图) */
34
39
  const thumb = cover ? getThumbInfo(cover) : null;
35
40
  const classes = [
@@ -59,6 +64,7 @@ const excerpt = d.excerpt || d.description;
59
64
  loading="lazy"
60
65
  data-lqip
61
66
  data-api-retries={retries ? JSON.stringify(retries) : undefined}
67
+ data-cover-fallback={coverFallback || undefined}
62
68
  />
63
69
  </a>
64
70
  )
@@ -0,0 +1,75 @@
1
+ ---
2
+ /**
3
+ * 文章资源区:frontmatter resources(name/url/password)在正文后渲染资源卡。
4
+ * 按 url 域名识别品牌图标(gitee/github),其余地址用通用外链图标;
5
+ * password 有值时展示提取码。新地址类型无需改组件,直接复用 name/url 展示。
6
+ */
7
+ import { t } from "../i18n/index.js";
8
+
9
+ interface ResourceItem {
10
+ name: string;
11
+ url: string;
12
+ password?: string;
13
+ }
14
+
15
+ interface Props {
16
+ resources?: ResourceItem[];
17
+ }
18
+
19
+ const { resources } = Astro.props;
20
+ const items = (resources ?? []).filter((r) => r.url && r.url.trim() !== "");
21
+
22
+ type IconKind = "gitee" | "github" | "link";
23
+ function iconFor(url: string): IconKind {
24
+ let host = "";
25
+ try {
26
+ host = new URL(url).hostname.replace(/^www\./, "");
27
+ } catch {
28
+ return "link";
29
+ }
30
+ if (host === "gitee.com") return "gitee";
31
+ if (host === "github.com") return "github";
32
+ return "link";
33
+ }
34
+ ---
35
+
36
+ {
37
+ items.length > 0 && (
38
+ <section class="post-resources card reveal" aria-label={t("postResources")}>
39
+ <h2 class="post-resources-title">
40
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
41
+ {t("postResources")}
42
+ </h2>
43
+ <ul class="post-resources-list">
44
+ {items.map((r) => {
45
+ const kind = iconFor(r.url);
46
+ return (
47
+ <li class="post-resources-item">
48
+ <a href={r.url} target="_blank" rel="noopener">
49
+ <span class="post-resources-icon" aria-hidden="true">
50
+ {kind === "gitee" && (
51
+ <svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512zm259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.081 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.267-25.267z"/></svg>
52
+ )}
53
+ {kind === "github" && (
54
+ <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
55
+ )}
56
+ {kind === "link" && (
57
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
58
+ )}
59
+ </span>
60
+ <span class="post-resources-name">{r.name}</span>
61
+ {r.password && r.password.trim() !== "" && (
62
+ <span class="post-resources-password">
63
+ {t("resourcePassword")}
64
+ <code>{r.password}</code>
65
+ </span>
66
+ )}
67
+ <svg class="post-resources-ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
68
+ </a>
69
+ </li>
70
+ );
71
+ })}
72
+ </ul>
73
+ </section>
74
+ )
75
+ }
package/src/i18n/en.ts CHANGED
@@ -146,6 +146,9 @@ const en: Record<UiKey, string> = {
146
146
  "No series yet. Add a series field to a post's frontmatter to create one.",
147
147
  seriesProgress: "{cur} / {total}",
148
148
  seriesCatalog: "Series catalog",
149
+ /* Post resources: resource card rendered from frontmatter resources */
150
+ postResources: "Related resources",
151
+ resourcePassword: "Code",
149
152
  /* Music player (§4.36) */
150
153
  musicOpen: "Music player",
151
154
  musicClose: "Collapse player",
package/src/i18n/ja.ts CHANGED
@@ -150,6 +150,9 @@ const ja: Record<UiKey, string> = {
150
150
  "シリーズがまだありません。記事の frontmatter に series を記入すると作成できます。",
151
151
  seriesProgress: "{cur} / {total}",
152
152
  seriesCatalog: "シリーズ目次",
153
+ /* 記事リソース:frontmatter resources から描画されるリソースカード */
154
+ postResources: "関連リソース",
155
+ resourcePassword: "パスワード",
153
156
  /* 音楽プレイヤー */
154
157
  musicOpen: "音楽プレイヤー",
155
158
  musicClose: "プレイヤーを閉じる",
package/src/i18n/ko.ts CHANGED
@@ -149,6 +149,9 @@ const ko: Record<UiKey, string> = {
149
149
  "아직 시리즈가 없습니다. 글 frontmatter에 series를 입력하면 생성됩니다.",
150
150
  seriesProgress: "{cur} / {total}",
151
151
  seriesCatalog: "시리즈 목차",
152
+ /* 게시글 리소스: frontmatter resources 로 렌더링되는 리소스 카드 */
153
+ postResources: "관련 자료",
154
+ resourcePassword: "추출 코드",
152
155
  /* 음악 플레이어 */
153
156
  musicOpen: "음악 플레이어",
154
157
  musicClose: "플레이어 접기",
package/src/i18n/ru.ts CHANGED
@@ -150,6 +150,9 @@ const ru: Record<UiKey, string> = {
150
150
  "Серий пока нет. Заполните поле series в frontmatter записи, чтобы создать.",
151
151
  seriesProgress: "{cur} / {total}",
152
152
  seriesCatalog: "Содержание серии",
153
+ /* Ресурсы статьи: карточка из frontmatter resources */
154
+ postResources: "Материалы",
155
+ resourcePassword: "Код доступа",
153
156
  /* Музыкальный плеер */
154
157
  musicOpen: "Музыкальный плеер",
155
158
  musicClose: "Свернуть плеер",
package/src/i18n/zh-CN.ts CHANGED
@@ -166,6 +166,9 @@ const zhCN = {
166
166
  seriesEmpty: "还没有专题,在文章 frontmatter 中填写 series 字段即可创建。",
167
167
  seriesProgress: "{cur} / {total}",
168
168
  seriesCatalog: "专题目录",
169
+ /* 文章资源区:frontmatter resources 渲染的资源卡 */
170
+ postResources: "相关资源",
171
+ resourcePassword: "提取码",
169
172
  /* 音乐播放器(§4.36):data-labels 经组件 data-labels 属性带入客户端 */
170
173
  musicOpen: "音乐播放器",
171
174
  musicClose: "收起播放器",
package/src/i18n/zh-TW.ts CHANGED
@@ -145,6 +145,9 @@ const zhTW: Record<UiKey, string> = {
145
145
  seriesEmpty: "還沒有專題,在文章 frontmatter 中填寫 series 欄位即可建立。",
146
146
  seriesProgress: "{cur} / {total}",
147
147
  seriesCatalog: "專題目錄",
148
+ /* 文章資源區:frontmatter resources 渲染的資源卡 */
149
+ postResources: "相關資源",
150
+ resourcePassword: "提取碼",
148
151
  /* 音樂播放器 */
149
152
  musicOpen: "音樂播放器",
150
153
  musicClose: "收起播放器",
@@ -2,18 +2,19 @@
2
2
  import { getCollection } from "astro:content";
3
3
  import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
4
  import Pagination from "virtual:theme/components/Pagination.astro";
5
+ import PostCard from "virtual:theme/components/PostCard.astro";
6
+ import Sidebar from "virtual:theme/components/Sidebar.astro";
5
7
  import config from "virtual:theme/config";
6
8
  import { t } from "../../../i18n/index.js";
7
- import {
8
- collectCategories,
9
- formatDate,
10
- getSortedPosts,
11
- } from "../../../utils.js";
9
+ import { collectCategories, getSortedPosts } from "../../../utils.js";
10
+ import { hydratePosts } from "../../_hydrate.js";
12
11
 
13
12
  /**
14
13
  * 分类详情(§4.87 分页):每分类按 postList.pageSize 分页,第一页
15
- * /categories/<category>/(page 段省略),后续 /categories/<category>/2/ 起。
16
- * paginate() helper 生成 Page 对象(props.page),Page.data 即当前页文章。
14
+ * /categories/<category>/(page 段省略),后续 /categories/<category>/page/N/。
15
+ * 注意:分页段必须是 page/N/(§4.113)——Pagination 组件按此约定生成 href,
16
+ * paginate() 默认产出的 /N/ 会与组件链接错位导致 404,这里统一 remap。
17
+ * 布局与首页同构(§4.113):PostCard 列表 + Sidebar,替代原单列窄卡片。
17
18
  */
18
19
  export async function getStaticPaths({ paginate }: { paginate: any }) {
19
20
  const all = await getCollection("posts", ({ data }) =>
@@ -24,30 +25,40 @@ export async function getStaticPaths({ paginate }: { paginate: any }) {
24
25
  return [...collectCategories(sorted).entries()].flatMap(([category, posts]) =>
25
26
  paginate(posts, {
26
27
  params: { category },
27
- props: { category },
28
+ props: { category, sorted },
28
29
  pageSize: config.postList.pageSize,
29
- }),
30
+ }).map((entry: any, i: number) => ({
31
+ params: { category, page: i > 0 ? `page/${i + 1}` : undefined },
32
+ props: entry.props,
33
+ })),
30
34
  );
31
35
  }
32
36
 
33
- const { page, category } = Astro.props;
34
- const posts = page.data;
37
+ const { page, category, sorted } = Astro.props;
38
+ const pagePosts = await hydratePosts(page.data);
35
39
  ---
36
40
 
37
41
  <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
- }
42
+ <div class="home-layout">
43
+ {/* 文章列表:与首页同一容器约定,供 app.ts 恢复与切换网格/列表形态 */}
44
+ <div
45
+ class="post-list"
46
+ id="postList"
47
+ data-allow-switch={config.postList.allowSwitch ? "true" : undefined}
48
+ data-default-layout={config.postList.defaultLayout}
49
+ data-grid-col={config.postList.gridColumnWidth}
50
+ >
51
+ <header class="archive-head reveal">
52
+ <h1 class="page-title">{category}</h1>
53
+ <p class="page-sub">{t('totalPosts', { n: page.total })}</p>
54
+ </header>
55
+ {
56
+ pagePosts.map((post: any) => (
57
+ <PostCard post={post} featured={post.data.pinned} />
58
+ ))
59
+ }
60
+ <Pagination page={page.currentPage} total={page.lastPage} basePath={`/categories/${category}/`} />
61
+ </div>
62
+ <Sidebar posts={sorted} />
51
63
  </div>
52
- <Pagination page={page.currentPage} total={page.lastPage} basePath={`/categories/${category}/`} />
53
64
  </BaseLayout>
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { getEntry, render } from "astro:content";
3
3
  import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
+ import Comment from "virtual:theme/components/Comment.astro";
4
5
  import config from "virtual:theme/config";
5
6
  import { t } from "../i18n/index.js";
6
7
  import { resolveSiteImage } from "../utils/image.js";
@@ -88,4 +89,7 @@ const apply = config.friends.apply;
88
89
  </section>
89
90
  )
90
91
  }
92
+
93
+ <!-- §4.113:友链页评论区,支持直接留言申请友链(provider 未启用时自动 no-op) -->
94
+ <Comment path="/friends" />
91
95
  </BaseLayout>
@@ -8,7 +8,9 @@ import { formatDate } from "../../utils.js";
8
8
 
9
9
  /**
10
10
  * 说说页(§4.87):moments collection 时间流,按 published 新→旧,
11
- * 每页 20 条(第一页 /moments/,后续 /moments/2/)。
11
+ * 每页 20 条(第一页 /moments/,后续 /moments/page/N/)。
12
+ * 分页段必须是 page/N/(§4.113)——Pagination 组件按此约定生成 href,
13
+ * paginate() 默认产出的 /N/ 会与组件链接错位导致 404,这里统一 remap。
12
14
  * 正文走主题统一 markdown processor(全部增强语法可用);配图 images 网格
13
15
  * 展示并接入通用灯箱(data-lightbox-group 组浏览)。
14
16
  */
@@ -19,7 +21,10 @@ export async function getStaticPaths({ paginate }: { paginate: any }) {
19
21
  const sorted = [...all].sort(
20
22
  (a, b) => b.data.published.valueOf() - a.data.published.valueOf(),
21
23
  );
22
- return paginate(sorted, { pageSize: 20 });
24
+ return paginate(sorted, { pageSize: 20 }).map((entry: any, i: number) => ({
25
+ params: { page: i > 0 ? `page/${i + 1}` : undefined },
26
+ props: entry.props,
27
+ }));
23
28
  }
24
29
 
25
30
  const { page } = Astro.props;
@@ -10,6 +10,7 @@ import EncryptedContent from "virtual:theme/components/EncryptedContent.astro";
10
10
  import License from "virtual:theme/components/License.astro";
11
11
  import OutdatedCard from "virtual:theme/components/OutdatedCard.astro";
12
12
  import PostNav from "virtual:theme/components/PostNav.astro";
13
+ import PostResources from "virtual:theme/components/PostResources.astro";
13
14
  import RecommendedPost from "virtual:theme/components/RecommendedPost.astro";
14
15
  import Sidebar from "virtual:theme/components/Sidebar.astro";
15
16
  import Toc from "virtual:theme/components/Toc.astro";
@@ -111,6 +112,11 @@ const { cover: rawArticleCover, retries: coverRetries } = resolveCover(
111
112
  const articleCover = rawArticleCover
112
113
  ? await resolveSiteImage(rawArticleCover)
113
114
  : rawArticleCover;
115
+ /* §4.114:普通封面图失效的客户端兜底(api 文章 retries 末尾已含 fallbackCover,无需重复) */
116
+ const coverFallback =
117
+ !coverRetries && config.postList.fallbackCover
118
+ ? await resolveSiteImage(config.postList.fallbackCover)
119
+ : "";
114
120
  const { Content, headings, remarkPluginFrontmatter } = await render(post);
115
121
  const { words, minutes } = remarkPluginFrontmatter as {
116
122
  words?: number;
@@ -240,6 +246,7 @@ const jsonLd = {
240
246
  loading="eager"
241
247
  data-lqip
242
248
  data-api-retries={coverRetries ? JSON.stringify(coverRetries) : undefined}
249
+ data-cover-fallback={coverFallback || undefined}
243
250
  />
244
251
  </figure>
245
252
  )
@@ -297,6 +304,8 @@ const jsonLd = {
297
304
  {/* 过时提醒(§4.11):许可卡之前展示;加密文章不渲染 */}
298
305
  {!d.password && <OutdatedCard updated={d.updated ?? d.published} published={d.published} />}
299
306
  <License licenseName={d.licenseName} licenseUrl={d.licenseUrl} enabled={d.license} />
307
+ {/* 文章资源区:frontmatter resources 非空时渲染;加密文章随正文语义一并隐藏 */}
308
+ {!d.password && <PostResources resources={d.resources} />}
300
309
  <PostNav prev={prev ?? undefined} next={next ?? undefined} />
301
310
  {/* 支持卡(P2):打赏入口 + 分享海报(share-poster.ts 仍按 #qy-share-btn 与 data-* 绑定) */}
302
311
  {
@@ -2,13 +2,17 @@
2
2
  import { getCollection } from "astro:content";
3
3
  import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
4
  import Pagination from "virtual:theme/components/Pagination.astro";
5
+ import PostCard from "virtual:theme/components/PostCard.astro";
6
+ import Sidebar from "virtual:theme/components/Sidebar.astro";
5
7
  import config from "virtual:theme/config";
6
8
  import { t } from "../../../i18n/index.js";
7
- import { collectTags, formatDate, getSortedPosts } from "../../../utils.js";
9
+ import { collectTags, getSortedPosts } from "../../../utils.js";
10
+ import { hydratePosts } from "../../_hydrate.js";
8
11
 
9
12
  /**
10
13
  * 标签详情(§4.87 分页):每标签按 postList.pageSize 分页,第一页
11
- * /tags/<tag>/(page 段省略),后续 /tags/<tag>/2/ 起。结构与分类详情页一致。
14
+ * /tags/<tag>/(page 段省略),后续 /tags/<tag>/page/N/。结构与分类详情页一致。
15
+ * 分页段 page/N/ 与布局同构改造见分类详情页注释(§4.113)。
12
16
  */
13
17
  export async function getStaticPaths({ paginate }: { paginate: any }) {
14
18
  const all = await getCollection("posts", ({ data }) =>
@@ -19,33 +23,40 @@ export async function getStaticPaths({ paginate }: { paginate: any }) {
19
23
  return [...collectTags(sorted).entries()].flatMap(([tag, posts]) =>
20
24
  paginate(posts, {
21
25
  params: { tag },
22
- props: { tag },
26
+ props: { tag, sorted },
23
27
  pageSize: config.postList.pageSize,
24
- }),
28
+ }).map((entry: any, i: number) => ({
29
+ params: { tag, page: i > 0 ? `page/${i + 1}` : undefined },
30
+ props: entry.props,
31
+ })),
25
32
  );
26
33
  }
27
34
 
28
- const { page, tag } = Astro.props;
29
- const posts = page.data;
35
+ const { page, tag, sorted } = Astro.props;
36
+ const pagePosts = await hydratePosts(page.data);
30
37
  ---
31
38
 
32
39
  <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
- }
40
+ <div class="home-layout">
41
+ {/* 文章列表:与首页同一容器约定,供 app.ts 恢复与切换网格/列表形态 */}
42
+ <div
43
+ class="post-list"
44
+ id="postList"
45
+ data-allow-switch={config.postList.allowSwitch ? "true" : undefined}
46
+ data-default-layout={config.postList.defaultLayout}
47
+ data-grid-col={config.postList.gridColumnWidth}
48
+ >
49
+ <header class="archive-head reveal">
50
+ <h1 class="page-title">{tag}</h1>
51
+ <p class="page-sub">{t('totalPosts', { n: page.total })}</p>
52
+ </header>
53
+ {
54
+ pagePosts.map((post: any) => (
55
+ <PostCard post={post} featured={post.data.pinned} />
56
+ ))
57
+ }
58
+ <Pagination page={page.currentPage} total={page.lastPage} basePath={`/tags/${tag}/`} />
59
+ </div>
60
+ <Sidebar posts={sorted} />
49
61
  </div>
50
- <Pagination page={page.currentPage} total={page.lastPage} basePath={`/tags/${tag}/`} />
51
62
  </BaseLayout>
package/src/schema.ts CHANGED
@@ -368,9 +368,10 @@ export const themeConfigSchema = z.object({
368
368
  fallbackCover: z.string().optional(),
369
369
  /**
370
370
  * 随机封面图源(§4.33):apis 非空时,frontmatter 显式 image:"api" 的文章
371
- * 从该列表取封面。URL 支持 {slug} 占位符(替换为文章 id),
371
+ * 按 slug 确定性轮转选一条(§4.113,可传本地 public 路径列表做多源随机;
372
+ * 单条时恒为第一条)。URL 支持 {slug} 占位符(替换为文章 id),
372
373
  * 并自动追加 ?v=<hash>(Java hashCode(slug)),同 slug 每次构建稳定同图。
373
- * 客户端加载失败时按列表顺序依次重试,全部失败显示本地 fallbackCover。
374
+ * 客户端加载失败时按其余图源顺序依次重试,全部失败显示本地 fallbackCover。
374
375
  */
375
376
  randomCover: z
376
377
  .object({
@@ -1638,6 +1639,20 @@ export function definePostSchema(extensions: z.ZodRawShape = {}) {
1638
1639
  * 组名直接来自本字段文本,无 i18n 参与。
1639
1640
  */
1640
1641
  seriesGroup: z.string().optional(),
1642
+ /**
1643
+ * 文章相关资源:正文后渲染资源卡区。name 展示名、url 跳转地址、
1644
+ * password 提取码(可空)。按 url 域名识别品牌图标(gitee/github),
1645
+ * 其余地址类型直接复用通用外链图标展示,无需再改 schema。
1646
+ */
1647
+ resources: z
1648
+ .array(
1649
+ z.object({
1650
+ name: z.string(),
1651
+ url: z.string(),
1652
+ password: z.string().optional(),
1653
+ }),
1654
+ )
1655
+ .optional(),
1641
1656
  })
1642
1657
  .extend(extensions);
1643
1658
  }
@@ -1110,8 +1110,9 @@ doc.addEventListener("click", (e) => {
1110
1110
  }
1111
1111
  });
1112
1112
 
1113
- /* 随机封面加载失败重试(§4.33):img error 不冒泡,捕获阶段 document 级委托
1114
- (bundled 一次性执行,VT 换页后仍有效)。按 data-api-retries 顺序换源,
1113
+ /* 封面加载失败兜底(§4.33 随机重试 + §4.114 普通图兜底):img error 不冒泡,捕获阶段
1114
+ document 级委托(bundled 一次性执行,VT 换页后仍有效)。优先按 data-api-retries
1115
+ 顺序换源;无重试列表的普通封面图按 data-cover-fallback 换兜底图(仅试一次);
1115
1116
  全部失败加 cover-broken 藏 img,露出底层 LQIP 渐变占位。 */
1116
1117
  doc.addEventListener(
1117
1118
  "error",
@@ -1119,20 +1120,26 @@ doc.addEventListener(
1119
1120
  const img = e.target as HTMLImageElement | null;
1120
1121
  if (img?.tagName !== "IMG") return;
1121
1122
  const raw = img.dataset.apiRetries;
1122
- if (!raw) return;
1123
- let list: string[] = [];
1124
- try {
1125
- list = JSON.parse(raw);
1126
- } catch {
1127
- return;
1123
+ if (raw) {
1124
+ try {
1125
+ const list: string[] = JSON.parse(raw);
1126
+ const idx = Number(img.dataset.retryIdx ?? "1");
1127
+ if (idx >= 0 && idx < list.length) {
1128
+ img.dataset.retryIdx = String(idx + 1);
1129
+ img.src = list[idx];
1130
+ return;
1131
+ }
1132
+ } catch {
1133
+ /* 列表损坏则落到兜底分支 */
1134
+ }
1128
1135
  }
1129
- const idx = Number(img.dataset.retryIdx ?? "1");
1130
- if (idx >= 0 && idx < list.length) {
1131
- img.dataset.retryIdx = String(idx + 1);
1132
- img.src = list[idx];
1133
- } else {
1134
- img.classList.add("cover-broken");
1136
+ const fb = img.dataset.coverFallback;
1137
+ if (fb && !img.dataset.fallbackTried) {
1138
+ img.dataset.fallbackTried = "1";
1139
+ img.src = fb;
1140
+ return;
1135
1141
  }
1142
+ if (raw || fb) img.classList.add("cover-broken");
1136
1143
  },
1137
1144
  true,
1138
1145
  );
@@ -451,6 +451,8 @@ export async function openSharePoster(btn: HTMLElement) {
451
451
  img.src = poster;
452
452
  img.hidden = false;
453
453
  }
454
+ /* 生成完成:移除 loading(否则转圈常驻在海报上方) */
455
+ modal.querySelector<HTMLElement>(".poster-loading")?.remove();
454
456
  if (saveBtn) {
455
457
  saveBtn.disabled = false;
456
458
  saveBtn.addEventListener("click", () => {
@@ -3339,6 +3339,94 @@ html.dark .outdated-warning {
3339
3339
  border-radius: var(--radius-sm);
3340
3340
  background: var(--bg-soft);
3341
3341
  }
3342
+ /* 文章资源区:frontmatter resources 渲染的资源卡(License 之后、上下篇之前) */
3343
+ .post-resources {
3344
+ margin-top: 28px;
3345
+ padding: 16px 20px;
3346
+ }
3347
+ .post-resources-title {
3348
+ display: flex;
3349
+ align-items: center;
3350
+ gap: 8px;
3351
+ margin: 0 0 10px;
3352
+ font-size: 15.5px;
3353
+ font-weight: 700;
3354
+ color: var(--ink);
3355
+ }
3356
+ .post-resources-title svg {
3357
+ width: 16px;
3358
+ height: 16px;
3359
+ color: var(--accent);
3360
+ }
3361
+ .post-resources-list {
3362
+ list-style: none;
3363
+ margin: 0;
3364
+ padding: 0;
3365
+ display: grid;
3366
+ gap: 4px;
3367
+ }
3368
+ .post-resources-item a {
3369
+ display: flex;
3370
+ align-items: center;
3371
+ gap: 10px;
3372
+ padding: 7px 8px;
3373
+ border-radius: var(--radius-sm);
3374
+ font-size: 14px;
3375
+ color: var(--ink-2);
3376
+ transition: background 0.15s ease, color 0.15s ease;
3377
+ }
3378
+ .post-resources-item a:hover {
3379
+ background: var(--bg-soft);
3380
+ color: var(--accent-deep);
3381
+ }
3382
+ .post-resources-icon {
3383
+ flex: none;
3384
+ display: inline-flex;
3385
+ width: 18px;
3386
+ height: 18px;
3387
+ color: var(--muted);
3388
+ }
3389
+ .post-resources-item a:hover .post-resources-icon {
3390
+ color: var(--accent);
3391
+ }
3392
+ .post-resources-icon svg {
3393
+ width: 100%;
3394
+ height: 100%;
3395
+ }
3396
+ .post-resources-name {
3397
+ min-width: 0;
3398
+ overflow: hidden;
3399
+ text-overflow: ellipsis;
3400
+ white-space: nowrap;
3401
+ }
3402
+ .post-resources-password {
3403
+ flex: none;
3404
+ display: inline-flex;
3405
+ align-items: center;
3406
+ gap: 4px;
3407
+ margin-left: auto;
3408
+ font-size: 12.5px;
3409
+ color: var(--muted);
3410
+ }
3411
+ .post-resources-password code {
3412
+ padding: 1px 6px;
3413
+ border-radius: 4px;
3414
+ border: 1px solid var(--border);
3415
+ background: var(--bg-soft);
3416
+ font-size: 12px;
3417
+ color: var(--ink-2);
3418
+ }
3419
+ .post-resources-ext {
3420
+ flex: none;
3421
+ width: 14px;
3422
+ height: 14px;
3423
+ color: var(--muted);
3424
+ opacity: 0.6;
3425
+ }
3426
+ .post-resources-item a:hover .post-resources-ext {
3427
+ color: var(--accent);
3428
+ opacity: 1;
3429
+ }
3342
3430
  .post-nav {
3343
3431
  margin-top: 24px;
3344
3432
  display: grid;
@@ -5592,6 +5680,10 @@ a[data-encoded-email] {
5592
5680
  height: auto;
5593
5681
  border-radius: var(--radius-sm);
5594
5682
  }
5683
+ /* display:block 会覆盖 [hidden] 的 UA 样式,显式声明防生成前空框露出 */
5684
+ .poster-img[hidden] {
5685
+ display: none;
5686
+ }
5595
5687
  .poster-loading {
5596
5688
  display: flex;
5597
5689
  align-items: center;
package/src/utils.ts CHANGED
@@ -212,8 +212,9 @@ export function randomCoverUrl(template: string, slug: string): string {
212
212
 
213
213
  /**
214
214
  * 解析文章封面(§4.33):frontmatter image 为 "api" 且配置了图源时,
215
- * 返回 { cover, retries }——cover 为主图 URL,retries 为客户端
216
- * 加载失败时的依次重试列表(图源全量 + fallbackCover 兜底)。
215
+ * 返回 { cover, retries }——cover 按 slug 确定性轮转选一条(§4.113,
216
+ * 支持本地图片列表做多源随机;单图源退化为恒取第一条,行为不变),
217
+ * retries 为客户端加载失败时的依次重试列表(其余图源 + fallbackCover 兜底)。
217
218
  * 非 "api" 或未配置图源时原样返回,行为与之前一致。
218
219
  */
219
220
  export function resolveCover(
@@ -226,8 +227,9 @@ export function resolveCover(
226
227
  if (image !== "api" || apis.length === 0) {
227
228
  return { cover: image || firstImage || fallbackCover, retries: undefined };
228
229
  }
229
- const cover = randomCoverUrl(apis[0], slug);
230
- const retries = [...apis.map((u) => randomCoverUrl(u, slug))];
230
+ const urls = apis.map((u) => randomCoverUrl(u, slug));
231
+ const idx = seedHash(slug) % urls.length;
232
+ const retries = [...urls.slice(idx + 1), ...urls.slice(0, idx)];
231
233
  if (fallbackCover) retries.push(fallbackCover);
232
- return { cover, retries };
234
+ return { cover: urls[idx], retries };
233
235
  }