@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 +1 -1
- package/src/components/PostCard.astro +6 -0
- package/src/components/PostResources.astro +75 -0
- package/src/i18n/en.ts +3 -0
- package/src/i18n/ja.ts +3 -0
- package/src/i18n/ko.ts +3 -0
- package/src/i18n/ru.ts +3 -0
- package/src/i18n/zh-CN.ts +3 -0
- package/src/i18n/zh-TW.ts +3 -0
- package/src/routes/categories/[category]/[...page].astro +36 -25
- package/src/routes/friends.astro +4 -0
- package/src/routes/moments/[...page].astro +7 -2
- package/src/routes/posts/[...slug].astro +9 -0
- package/src/routes/tags/[tag]/[...page].astro +34 -23
- package/src/schema.ts +17 -2
- package/src/scripts/app.ts +21 -14
- package/src/scripts/share-poster.ts +2 -0
- package/src/styles/global.css +92 -0
- package/src/utils.ts +7 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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>/
|
|
16
|
-
*
|
|
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
|
|
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
|
-
<
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
</
|
|
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>
|
package/src/routes/friends.astro
CHANGED
|
@@ -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/
|
|
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,
|
|
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>/
|
|
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
|
|
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
|
-
<
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
371
|
+
* 按 slug 确定性轮转选一条(§4.113,可传本地 public 路径列表做多源随机;
|
|
372
|
+
* 单条时恒为第一条)。URL 支持 {slug} 占位符(替换为文章 id),
|
|
372
373
|
* 并自动追加 ?v=<hash>(Java hashCode(slug)),同 slug 每次构建稳定同图。
|
|
373
|
-
*
|
|
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
|
}
|
package/src/scripts/app.ts
CHANGED
|
@@ -1110,8 +1110,9 @@ doc.addEventListener("click", (e) => {
|
|
|
1110
1110
|
}
|
|
1111
1111
|
});
|
|
1112
1112
|
|
|
1113
|
-
/*
|
|
1114
|
-
|
|
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 (
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
1130
|
-
if (
|
|
1131
|
-
img.dataset.
|
|
1132
|
-
img.src =
|
|
1133
|
-
|
|
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", () => {
|
package/src/styles/global.css
CHANGED
|
@@ -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
|
|
216
|
-
*
|
|
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
|
|
230
|
-
const
|
|
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
|
}
|