@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.
- package/README.md +118 -2
- package/package.json +70 -5
- package/src/components/AlbumCard.astro +73 -0
- package/src/components/Analytics.astro +22 -0
- package/src/components/BaseLayout.astro +188 -0
- package/src/components/Calendar.astro +379 -0
- package/src/components/CategoryBar.astro +74 -0
- package/src/components/Comment.astro +48 -0
- package/src/components/EncryptedContent.astro +42 -0
- package/src/components/Footer.astro +31 -0
- package/src/components/Header.astro +153 -0
- package/src/components/License.astro +29 -0
- package/src/components/Live2DWidget.astro +262 -0
- package/src/components/MusicPlayer.astro +940 -0
- package/src/components/OutdatedCard.astro +53 -0
- package/src/components/Pagination.astro +94 -0
- package/src/components/PhotoCard.astro +22 -0
- package/src/components/PostCard.astro +89 -0
- package/src/components/PostNav.astro +40 -0
- package/src/components/RecommendedPost.astro +166 -0
- package/src/components/SakuraEffect.astro +152 -0
- package/src/components/Sidebar.astro +334 -0
- package/src/components/Toc.astro +27 -0
- package/src/components/analytics/GoogleAnalytics.astro +22 -0
- package/src/components/analytics/La51Analytics.astro +52 -0
- package/src/components/analytics/MicrosoftClarity.astro +18 -0
- package/src/components/analytics/UmamiAnalytics.astro +86 -0
- package/src/components/comments/Artalk.astro +51 -0
- package/src/components/comments/Disqus.astro +43 -0
- package/src/components/comments/Giscus.astro +50 -0
- package/src/components/comments/Twikoo.astro +65 -0
- package/src/components/comments/Waline.astro +36 -0
- package/src/i18n/en.ts +164 -0
- package/src/i18n/index.ts +18 -0
- package/src/i18n/resolve.ts +49 -0
- package/src/i18n/zh-CN.ts +184 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +411 -0
- package/src/plugins/plantuml-encoder.ts +95 -0
- package/src/plugins/rehype-diagram.ts +218 -0
- package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
- package/src/plugins/rehype-email.ts +75 -0
- package/src/plugins/rehype-github-card.ts +114 -0
- package/src/plugins/rehype.ts +284 -0
- package/src/plugins/remark-diagram.ts +78 -0
- package/src/plugins/remark-directives.ts +100 -0
- package/src/plugins/remark-excerpt.ts +29 -0
- package/src/plugins/remark-image-grid.ts +97 -0
- package/src/plugins/remark-reading-time.ts +38 -0
- package/src/plugins/scripts/diagram-panzoom.js +388 -0
- package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
- package/src/routes/404.astro +14 -0
- package/src/routes/_hydrate.ts +34 -0
- package/src/routes/about.astro +25 -0
- package/src/routes/api/allPostMeta.json.ts +26 -0
- package/src/routes/archive.astro +81 -0
- package/src/routes/categories/[category].astro +37 -0
- package/src/routes/categories/index.astro +29 -0
- package/src/routes/friends.astro +83 -0
- package/src/routes/gallery/[album].astro +97 -0
- package/src/routes/gallery/index.astro +100 -0
- package/src/routes/guestbook.astro +37 -0
- package/src/routes/index.astro +47 -0
- package/src/routes/og/[...slug].ts +281 -0
- package/src/routes/page/[page].astro +53 -0
- package/src/routes/posts/[...slug].astro +350 -0
- package/src/routes/robots.txt.ts +18 -0
- package/src/routes/rss.xml.ts +37 -0
- package/src/routes/series/[name].astro +66 -0
- package/src/routes/series/index.astro +64 -0
- package/src/routes/sponsor.astro +84 -0
- package/src/routes/tags/[tag].astro +40 -0
- package/src/routes/tags/index.astro +29 -0
- package/src/schema.ts +1218 -0
- package/src/scripts/app.ts +1041 -0
- package/src/scripts/share-poster.ts +468 -0
- package/src/styles/global.css +4864 -0
- package/src/utils/crypto.ts +46 -0
- package/src/utils/gallery.ts +76 -0
- package/src/utils/lqip.ts +18 -0
- package/src/utils/related.ts +101 -0
- package/src/utils.ts +233 -0
- package/src/vite.ts +105 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
+
import Pagination from "virtual:theme/components/Pagination.astro";
|
|
5
|
+
import PostCard from "virtual:theme/components/PostCard.astro";
|
|
6
|
+
import Sidebar from "virtual:theme/components/Sidebar.astro";
|
|
7
|
+
import config from "virtual:theme/config";
|
|
8
|
+
import { t } from "../../i18n/index.js";
|
|
9
|
+
import { getSortedPosts } from "../../utils.js";
|
|
10
|
+
import { hydratePosts } from "../_hydrate.js";
|
|
11
|
+
|
|
12
|
+
export async function getStaticPaths() {
|
|
13
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
14
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
15
|
+
);
|
|
16
|
+
// 置顶留在分页流内(§4.39):与首页同一条 sorted 流切片,各页卡片数一致
|
|
17
|
+
const sorted = getSortedPosts(all);
|
|
18
|
+
const total = Math.max(
|
|
19
|
+
1,
|
|
20
|
+
Math.ceil(sorted.length / config.postList.pageSize),
|
|
21
|
+
);
|
|
22
|
+
// 第 1 页由 / 承担,这里生成 /page/2/ 起的分页
|
|
23
|
+
return Array.from({ length: total - 1 }, (_, i) => ({
|
|
24
|
+
params: { page: String(i + 2) },
|
|
25
|
+
props: { page: i + 2, total, posts: sorted },
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { page, total, posts } = Astro.props;
|
|
30
|
+
const pagePosts = await hydratePosts(
|
|
31
|
+
posts.slice(
|
|
32
|
+
(page - 1) * config.postList.pageSize,
|
|
33
|
+
page * config.postList.pageSize,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<BaseLayout title={t('pageN', { n: page })} page="home">
|
|
39
|
+
<div class="home-layout">
|
|
40
|
+
{/* 文章列表(§4.34):与首页同一容器约定,供 app.ts 恢复与切换网格/列表形态 */}
|
|
41
|
+
<div
|
|
42
|
+
class="post-list"
|
|
43
|
+
id="postList"
|
|
44
|
+
data-allow-switch={config.postList.allowSwitch ? "true" : undefined}
|
|
45
|
+
data-default-layout={config.postList.defaultLayout}
|
|
46
|
+
data-grid-col={config.postList.gridColumnWidth}
|
|
47
|
+
>
|
|
48
|
+
{pagePosts.map((post) => <PostCard post={post} featured={post.data.pinned} />)}
|
|
49
|
+
<Pagination page={page} total={total} basePath="/" />
|
|
50
|
+
</div>
|
|
51
|
+
<Sidebar posts={posts} />
|
|
52
|
+
</div>
|
|
53
|
+
</BaseLayout>
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render } from "astro:content";
|
|
3
|
+
// KaTeX 样式:文章页独占加载(构建期渲染无客户端 JS,仅此一份 CSS)
|
|
4
|
+
import "katex/dist/katex.min.css";
|
|
5
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
6
|
+
// 分类导航栏(§4.23):文章页主列顶部渲染(对齐首页 post-list 顶部位置,同列宽不全宽)
|
|
7
|
+
import CategoryBar from "virtual:theme/components/CategoryBar.astro";
|
|
8
|
+
import Comment from "virtual:theme/components/Comment.astro";
|
|
9
|
+
import EncryptedContent from "virtual:theme/components/EncryptedContent.astro";
|
|
10
|
+
import License from "virtual:theme/components/License.astro";
|
|
11
|
+
import OutdatedCard from "virtual:theme/components/OutdatedCard.astro";
|
|
12
|
+
import PostNav from "virtual:theme/components/PostNav.astro";
|
|
13
|
+
import RecommendedPost from "virtual:theme/components/RecommendedPost.astro";
|
|
14
|
+
import Sidebar from "virtual:theme/components/Sidebar.astro";
|
|
15
|
+
import Toc from "virtual:theme/components/Toc.astro";
|
|
16
|
+
import config from "virtual:theme/config";
|
|
17
|
+
import { t } from "../../i18n/index.js";
|
|
18
|
+
import { getLqipStyle } from "../../utils/lqip.js";
|
|
19
|
+
import { getRelatedPosts } from "../../utils/related.js";
|
|
20
|
+
import {
|
|
21
|
+
formatDate,
|
|
22
|
+
getSortedPosts,
|
|
23
|
+
resolveCover,
|
|
24
|
+
type SeriesEntry,
|
|
25
|
+
seriesEntries,
|
|
26
|
+
} from "../../utils.js";
|
|
27
|
+
|
|
28
|
+
export async function getStaticPaths() {
|
|
29
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
30
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
31
|
+
);
|
|
32
|
+
const sorted = getSortedPosts(all);
|
|
33
|
+
|
|
34
|
+
// 专题索引(§4.31→§4.75):seriesEntries 分块规范化(未分组块最前、
|
|
35
|
+
// 各分组按组内首篇顺序排列、全局序号连续),与专题详情页共用同一规则
|
|
36
|
+
const seriesGroups = new Map<string, typeof sorted>();
|
|
37
|
+
for (const post of sorted) {
|
|
38
|
+
const name = post.data.series;
|
|
39
|
+
if (!name) continue;
|
|
40
|
+
const list = seriesGroups.get(name);
|
|
41
|
+
if (list) list.push(post);
|
|
42
|
+
else seriesGroups.set(name, [post]);
|
|
43
|
+
}
|
|
44
|
+
const seriesSorted = new Map<
|
|
45
|
+
string,
|
|
46
|
+
SeriesEntry<(typeof sorted)[number]>[]
|
|
47
|
+
>();
|
|
48
|
+
for (const [name, list] of seriesGroups) {
|
|
49
|
+
seriesSorted.set(name, seriesEntries(list));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// sorted 内 idx+1 为更早一篇(prev),idx-1 为更新一篇(next)
|
|
53
|
+
return sorted.map((post, idx) => {
|
|
54
|
+
// 专题导航(§4.31):当前篇在专题内的位置 + 完整目录(含分块组头)
|
|
55
|
+
let seriesInfo: {
|
|
56
|
+
name: string;
|
|
57
|
+
total: number;
|
|
58
|
+
cur: number;
|
|
59
|
+
items: {
|
|
60
|
+
title: string;
|
|
61
|
+
id: string;
|
|
62
|
+
cur: boolean;
|
|
63
|
+
idx: number;
|
|
64
|
+
groupHead: string | null;
|
|
65
|
+
}[];
|
|
66
|
+
} | null = null;
|
|
67
|
+
const sName = post.data.series;
|
|
68
|
+
if (sName) {
|
|
69
|
+
const entries = seriesSorted.get(sName)!;
|
|
70
|
+
const sIdx = entries.findIndex((e) => e.post.id === post.id);
|
|
71
|
+
seriesInfo = {
|
|
72
|
+
name: sName,
|
|
73
|
+
total: entries.length,
|
|
74
|
+
cur: sIdx + 1,
|
|
75
|
+
items: entries.map((e) => ({
|
|
76
|
+
title: e.post.data.title,
|
|
77
|
+
id: e.post.id,
|
|
78
|
+
cur: e.index === sIdx,
|
|
79
|
+
idx: e.index + 1,
|
|
80
|
+
groupHead: e.groupHead,
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
params: { slug: post.id },
|
|
86
|
+
props: {
|
|
87
|
+
post,
|
|
88
|
+
prev: sorted[idx + 1] ?? null,
|
|
89
|
+
next: sorted[idx - 1] ?? null,
|
|
90
|
+
related: getRelatedPosts(sorted, post, config.relatedPosts.count),
|
|
91
|
+
// 文章页侧栏(P2)的聚合数据源
|
|
92
|
+
posts: sorted,
|
|
93
|
+
seriesInfo,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { post, prev, next, related, posts, seriesInfo } = Astro.props;
|
|
100
|
+
const d = post.data;
|
|
101
|
+
/* 封面解析(§4.33):image:"api" 时取确定性随机图;figure 与分享海报共用同一 URL */
|
|
102
|
+
const { cover: articleCover, retries: coverRetries } = resolveCover(
|
|
103
|
+
d.image,
|
|
104
|
+
d.firstImage,
|
|
105
|
+
config.postList.fallbackCover,
|
|
106
|
+
config.postList.randomCover.apis,
|
|
107
|
+
post.id,
|
|
108
|
+
);
|
|
109
|
+
const { Content, headings, remarkPluginFrontmatter } = await render(post);
|
|
110
|
+
const { words, minutes } = remarkPluginFrontmatter as {
|
|
111
|
+
words?: number;
|
|
112
|
+
minutes?: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const url = new URL(`/posts/${post.id}/`, Astro.site);
|
|
116
|
+
// 支持卡(P2):打赏入口 + 分享海报,二者均关闭则整卡不渲染;加密文章不渲染
|
|
117
|
+
const showShareBtn = config.sharePoster.enabled && !d.password;
|
|
118
|
+
const showSponsorBtn =
|
|
119
|
+
config.sponsor.showButtonInPost && config.pages.sponsor && !d.password;
|
|
120
|
+
// 文章页侧栏(P2):onPost 开启且非加密文章;侧栏内 toc widget 承担目录
|
|
121
|
+
const postSidebar = config.sidebar.onPost && !d.password;
|
|
122
|
+
|
|
123
|
+
// meta 区浏览量(§4.14,参考 orchid-blog):复用评论系统 pageview 能力——
|
|
124
|
+
// artalk/waline 由 SDK init 自动扫描填充对应选择器;twikoo 由 Twikoo.astro getCommentsCount 填充。
|
|
125
|
+
// 条件:评论启用 + provider 支持计数 + 文章允许评论且未加密(评论不渲染时无数据来源)
|
|
126
|
+
const c = config.comment;
|
|
127
|
+
const pvProvider =
|
|
128
|
+
c.enabled && !d.password && d.comment
|
|
129
|
+
? (c.provider === "artalk" && c.artalk.visitorCount && "artalk") ||
|
|
130
|
+
(c.provider === "waline" && c.waline.visitorCount && "waline") ||
|
|
131
|
+
(c.provider === "twikoo" && c.twikoo.envId && "twikoo") ||
|
|
132
|
+
null
|
|
133
|
+
: null;
|
|
134
|
+
const jsonLd = {
|
|
135
|
+
"@context": "https://schema.org",
|
|
136
|
+
"@type": "BlogPosting",
|
|
137
|
+
headline: d.title,
|
|
138
|
+
description: d.description,
|
|
139
|
+
datePublished: d.published.toISOString(),
|
|
140
|
+
dateModified: (d.updated ?? d.published).toISOString(),
|
|
141
|
+
wordCount: words,
|
|
142
|
+
keywords: d.tags.join(", "),
|
|
143
|
+
author: { "@type": "Person", name: d.author || config.author },
|
|
144
|
+
mainEntityOfPage: url.href,
|
|
145
|
+
};
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
<BaseLayout title={d.title} description={d.description} keywords={d.tags} page="post" ogImage={config.og.enabled ? `/og/${post.id}.png` : undefined}>
|
|
149
|
+
{/* 左右两栏布局(对齐参考模板):标题/封面/正文/许可/上下篇/支持卡/推荐/评论全部收进正文列,
|
|
150
|
+
侧栏(或 TOC)独占右列;≤767px 退化为单列堆叠 */}
|
|
151
|
+
<div class="post-layout">
|
|
152
|
+
{/* 阅读进度条(§4.12):仅文章页渲染,app.ts 滚动驱动 scaleX */}
|
|
153
|
+
<div class="read-progress" id="readProgress" aria-hidden="true"></div>
|
|
154
|
+
<div class="post-main">
|
|
155
|
+
{/* 分类导航栏(§4.23):主列顶部,与首页 post-list 顶部位置对等;enabled 关闭时组件自不渲染 */}
|
|
156
|
+
<CategoryBar />
|
|
157
|
+
<header class="post-header reveal">
|
|
158
|
+
<h1 class="post-title-lg" data-pagefind-body data-pagefind-weight="10" data-pagefind-meta="title">{d.title}</h1>
|
|
159
|
+
{/* meta 区图标化(§4.14,参考 orchid-blog PostMeta):flex-wrap 自动换行适配窄屏 */}
|
|
160
|
+
<div class="post-meta meta-icons">
|
|
161
|
+
<span class="meta-item">
|
|
162
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/></svg>
|
|
163
|
+
<time datetime={d.published.toISOString()}>{formatDate(d.published)}</time>
|
|
164
|
+
</span>
|
|
165
|
+
{d.updated && (
|
|
166
|
+
<span class="meta-item">
|
|
167
|
+
<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 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
|
|
168
|
+
<time datetime={d.updated.toISOString()}>{formatDate(d.updated)}</time>
|
|
169
|
+
</span>
|
|
170
|
+
)}
|
|
171
|
+
{/* 分类/标签内联(§4.22,对齐 orchid-blog PostMeta):跟随日期,不再分列标题上下 */}
|
|
172
|
+
{d.category && (
|
|
173
|
+
<span class="meta-item">
|
|
174
|
+
<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="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
|
|
175
|
+
<a href={`/categories/${encodeURIComponent(d.category)}/`}>{d.category}</a>
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
{d.tags.length > 0 && (
|
|
179
|
+
<span class="meta-item meta-tags-item">
|
|
180
|
+
<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="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></svg>
|
|
181
|
+
{d.tags.map((tag, i) => (
|
|
182
|
+
<>
|
|
183
|
+
{i > 0 && <span class="tag-sep" aria-hidden="true">/</span>}
|
|
184
|
+
<a href={`/tags/${encodeURIComponent(tag)}/`}>{tag}</a>
|
|
185
|
+
</>
|
|
186
|
+
))}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
{words ? (
|
|
190
|
+
<span class="meta-item">
|
|
191
|
+
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
|
|
192
|
+
{t('wordsCount', { n: words })}
|
|
193
|
+
</span>
|
|
194
|
+
) : null}
|
|
195
|
+
{minutes ? (
|
|
196
|
+
<span class="meta-item">
|
|
197
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
198
|
+
{t('minutesCount', { n: minutes })}
|
|
199
|
+
</span>
|
|
200
|
+
) : null}
|
|
201
|
+
{d.pinned && (
|
|
202
|
+
<span class="meta-item pin-flag">
|
|
203
|
+
<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="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
|
|
204
|
+
{t('pinned')}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
{d.password && (
|
|
208
|
+
<span class="meta-item enc-flag">
|
|
209
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
210
|
+
{t('postEncrypted')}
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
{pvProvider && (
|
|
214
|
+
<span class="meta-item meta-pv" aria-label={t('pageviews')}>
|
|
215
|
+
<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="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></svg>
|
|
216
|
+
{pvProvider === 'artalk' && <span class="artalk-pv-count" data-path={`/posts/${post.id}`}>1</span>}
|
|
217
|
+
{pvProvider === 'waline' && <span class="waline-pageview-count" data-path={`/posts/${post.id}`}>1</span>}
|
|
218
|
+
{pvProvider === 'twikoo' && <span id="twikoo_visitors" data-path={`/posts/${post.id}`}>1</span>}
|
|
219
|
+
</span>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</header>
|
|
223
|
+
{/* 灯箱组容器(§4.71):封面 + 专题导航 + 正文整体归组,封面成为组内第 0 张——
|
|
224
|
+
点击封面/正文任一图都可切换全组;纯归组标记无样式(.post-main 为块流,包装层零副作用)。
|
|
225
|
+
内部子元素不得再带组属性(嵌套容器双绑定,§4.70 契约),故 figure 去 data-lightbox、
|
|
226
|
+
article 去 data-lightbox-gallery。wrapper 不入 pagefind 索引(索引点在 article) */}
|
|
227
|
+
<div data-lightbox-gallery>
|
|
228
|
+
{
|
|
229
|
+
articleCover && (
|
|
230
|
+
<figure class="article-cover reveal">
|
|
231
|
+
<div class="qy-lqip" style={getLqipStyle(articleCover)} aria-hidden="true"></div>
|
|
232
|
+
<img
|
|
233
|
+
src={articleCover}
|
|
234
|
+
alt={d.title}
|
|
235
|
+
loading="eager"
|
|
236
|
+
data-lqip
|
|
237
|
+
data-api-retries={coverRetries ? JSON.stringify(coverRetries) : undefined}
|
|
238
|
+
/>
|
|
239
|
+
</figure>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
{/* 专题导航卡(§4.31):正文之前出专题目录,当前篇高亮不可点 */}
|
|
243
|
+
{
|
|
244
|
+
seriesInfo && (
|
|
245
|
+
<nav class="series-nav card reveal" aria-label={t('seriesCatalog')}>
|
|
246
|
+
<div class="series-nav-head">
|
|
247
|
+
<span class="series-nav-title">
|
|
248
|
+
<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="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>
|
|
249
|
+
{seriesInfo.name}
|
|
250
|
+
</span>
|
|
251
|
+
<span class="series-nav-progress">
|
|
252
|
+
<a href={`/series/${encodeURIComponent(seriesInfo.name)}/`}>{t('seriesCatalog')}</a>
|
|
253
|
+
<span class="series-nav-count">{t('seriesProgress', { cur: seriesInfo.cur, total: seriesInfo.total })}</span>
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
<ol class="series-nav-list">
|
|
257
|
+
{seriesInfo.items.map((item) => (
|
|
258
|
+
<>
|
|
259
|
+
{item.groupHead && <li class="series-nav-group" aria-hidden="true">{item.groupHead}</li>}
|
|
260
|
+
<li class:list={["series-nav-item", { cur: item.cur }]}>
|
|
261
|
+
{item.cur ? (
|
|
262
|
+
<span class="series-nav-row">
|
|
263
|
+
<span class="series-nav-idx">{item.idx}</span>
|
|
264
|
+
<span class="series-nav-label">{item.title}</span>
|
|
265
|
+
</span>
|
|
266
|
+
) : (
|
|
267
|
+
<a class="series-nav-row" href={`/posts/${item.id}/`}>
|
|
268
|
+
<span class="series-nav-idx">{item.idx}</span>
|
|
269
|
+
<span class="series-nav-label">{item.title}</span>
|
|
270
|
+
</a>
|
|
271
|
+
)}
|
|
272
|
+
</li>
|
|
273
|
+
</>
|
|
274
|
+
))}
|
|
275
|
+
</ol>
|
|
276
|
+
</nav>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
{/* data-pagefind-body(M3b-b6):限定 Pagefind 只索引标题与正文(h1 在 article 外,双 body 标记) */}
|
|
280
|
+
<article class="prose reveal" data-pagefind-body>
|
|
281
|
+
{d.password ? (
|
|
282
|
+
/* 加密文章(M3b-b7):构建期把正文渲染后整体加密,页面只出密文 + 锁 UI;
|
|
283
|
+
TOC 的 headings 来自构建期明文解析,会在锁旁泄漏标题结构,故一并隐藏 */
|
|
284
|
+
<EncryptedContent password={d.password} slug={post.id} hint={d.passwordHint}>
|
|
285
|
+
<Content />
|
|
286
|
+
</EncryptedContent>
|
|
287
|
+
) : (
|
|
288
|
+
<Content />
|
|
289
|
+
)}
|
|
290
|
+
</article>
|
|
291
|
+
</div>
|
|
292
|
+
{/* 过时提醒(§4.11):许可卡之前展示;加密文章不渲染 */}
|
|
293
|
+
{!d.password && <OutdatedCard updated={d.updated ?? d.published} published={d.published} />}
|
|
294
|
+
<License licenseName={d.licenseName} licenseUrl={d.licenseUrl} enabled={d.license} />
|
|
295
|
+
<PostNav prev={prev ?? undefined} next={next ?? undefined} />
|
|
296
|
+
{/* 支持卡(P2):打赏入口 + 分享海报(share-poster.ts 仍按 #qy-share-btn 与 data-* 绑定) */}
|
|
297
|
+
{
|
|
298
|
+
(showSponsorBtn || showShareBtn) && (
|
|
299
|
+
<div class="support-card reveal">
|
|
300
|
+
<div class="support-info">
|
|
301
|
+
<h3>{t('supportTitle')}</h3>
|
|
302
|
+
<p>{t('supportDesc')}</p>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="support-actions">
|
|
305
|
+
{showSponsorBtn && (
|
|
306
|
+
<a class="btn-sponsor" href="/sponsor/">
|
|
307
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
|
|
308
|
+
{t('sponsorMe')}
|
|
309
|
+
</a>
|
|
310
|
+
)}
|
|
311
|
+
{showShareBtn && (
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
id="qy-share-btn"
|
|
315
|
+
class="btn-poster"
|
|
316
|
+
data-title={d.title}
|
|
317
|
+
data-author={d.author || config.author || config.title}
|
|
318
|
+
data-desc={d.description}
|
|
319
|
+
data-date={d.published.toISOString()}
|
|
320
|
+
data-cover={articleCover ?? ''}
|
|
321
|
+
data-url={url.href}
|
|
322
|
+
data-site={config.title}
|
|
323
|
+
>
|
|
324
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
|
|
325
|
+
{t('sharePoster')}
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
{/* 相关/随机文章推荐(P1-12):相关栏构建期评分,随机栏客户端抽取;候选均已排除加密文章 */}
|
|
333
|
+
{
|
|
334
|
+
config.relatedPosts.enabled && (
|
|
335
|
+
<RecommendedPost related={related} currentId={post.id} randomCount={config.relatedPosts.randomCount} />
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
{/* 加密文章不渲染评论(评论内容依赖正文上下文,明文评论区会破坏加密语义) */}
|
|
339
|
+
<Comment path={`/posts/${post.id}`} enabled={d.comment && !d.password} title={d.title} />
|
|
340
|
+
</div>
|
|
341
|
+
{/* 侧栏开关(P2):onPost 时侧栏进右列,TOC 由侧栏 toc widget 承担;
|
|
342
|
+
关闭侧栏时 TOC 保持正文右侧原位 */}
|
|
343
|
+
{postSidebar ? (
|
|
344
|
+
<Sidebar posts={posts} variant="post" headings={headings} currentId={post.id} />
|
|
345
|
+
) : (
|
|
346
|
+
config.toc.enabled && !d.password && <Toc headings={headings} />
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
<script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
|
350
|
+
</BaseLayout>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = ({ site }) => {
|
|
4
|
+
// §4.63:sitemap 改由官方 @astrojs/sitemap 产出,入口为 sitemap-index.xml
|
|
5
|
+
const sitemap = site
|
|
6
|
+
? new URL("sitemap-index.xml", site).href
|
|
7
|
+
: "https://blog.example.com/sitemap-index.xml";
|
|
8
|
+
const body = [
|
|
9
|
+
"User-agent: *",
|
|
10
|
+
"Allow: /",
|
|
11
|
+
"",
|
|
12
|
+
`Sitemap: ${sitemap}`,
|
|
13
|
+
"",
|
|
14
|
+
].join("\n");
|
|
15
|
+
return new Response(body, {
|
|
16
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import config from "virtual:theme/config";
|
|
3
|
+
import rss from "@astrojs/rss";
|
|
4
|
+
import type { APIContext, APIRoute } from "astro";
|
|
5
|
+
import { getSortedPosts } from "../utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RSS(§4.63 官方 @astrojs/rss 端点):item description 使用 frontmatter
|
|
9
|
+
* description,不做全文输出(避免 RSS 端点再渲染全部 Markdown)。
|
|
10
|
+
* channel 头部、atom:link self、XML 转义由官方库负责;trailingSlash 与
|
|
11
|
+
* 主题 trailingSlash: "always" 口径一致(/posts/xxx/)。
|
|
12
|
+
*/
|
|
13
|
+
export const GET: APIRoute = async (context: APIContext) => {
|
|
14
|
+
const all = await getCollection("posts", ({ data }) => !data.draft);
|
|
15
|
+
const posts = getSortedPosts(all).slice(0, 20);
|
|
16
|
+
const site = context.site ?? new URL("https://blog.example.com");
|
|
17
|
+
|
|
18
|
+
return rss({
|
|
19
|
+
title: config.title,
|
|
20
|
+
description: config.description,
|
|
21
|
+
site,
|
|
22
|
+
items: posts.map((post) => ({
|
|
23
|
+
title: post.data.title,
|
|
24
|
+
description: post.data.description,
|
|
25
|
+
link: `/posts/${post.id}/`,
|
|
26
|
+
pubDate: post.data.published,
|
|
27
|
+
})),
|
|
28
|
+
// atom 命名空间 + feed 自引用(v4 不再自动生成,保持对原手写版的兼容)
|
|
29
|
+
xmlns: { atom: "http://www.w3.org/2005/Atom" },
|
|
30
|
+
// RSS <language> 通道级语言标记(跟随站点语言)
|
|
31
|
+
customData: [
|
|
32
|
+
`<language>${config.lang}</language>`,
|
|
33
|
+
`<atom:link href="${new URL("rss.xml", site).href}" rel="self" type="application/rss+xml" />`,
|
|
34
|
+
].join(""),
|
|
35
|
+
trailingSlash: true,
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { formatDate, type PostLike, seriesEntries } from "../../utils.js";
|
|
6
|
+
import { hydratePosts } from "../_hydrate.js";
|
|
7
|
+
|
|
8
|
+
// 专题详情页(§4.31→§4.75):seriesEntries 分块规范化——
|
|
9
|
+
// 未分组文章块最前,各分组按组内首篇顺序排列,全局序号连续
|
|
10
|
+
export async function getStaticPaths() {
|
|
11
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
12
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
13
|
+
);
|
|
14
|
+
const posts = await hydratePosts(all);
|
|
15
|
+
|
|
16
|
+
const groups = new Map<string, PostLike[]>();
|
|
17
|
+
for (const post of posts) {
|
|
18
|
+
const name = post.data.series;
|
|
19
|
+
if (!name) continue;
|
|
20
|
+
const list = groups.get(name);
|
|
21
|
+
if (list) list.push(post);
|
|
22
|
+
else groups.set(name, [post]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return [...groups.entries()].map(([name, list]) => ({
|
|
26
|
+
params: { name },
|
|
27
|
+
props: { name, entries: seriesEntries(list) },
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { name, entries } = Astro.props as {
|
|
32
|
+
name: string;
|
|
33
|
+
entries: ReturnType<typeof seriesEntries>;
|
|
34
|
+
};
|
|
35
|
+
const posts = entries.map((e) => e.post);
|
|
36
|
+
const latest = posts.reduce(
|
|
37
|
+
(max, p) => (p.data.published > max ? p.data.published : max),
|
|
38
|
+
posts[0]!.data.published,
|
|
39
|
+
);
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<BaseLayout title={name} page="series">
|
|
43
|
+
<header class="archive-head reveal">
|
|
44
|
+
<p class="page-sub series-crumb"><a href="/series/">{t('series')}</a></p>
|
|
45
|
+
<h1 class="page-title">{name}</h1>
|
|
46
|
+
<p class="page-sub">
|
|
47
|
+
{t('totalPosts', { n: posts.length })} · {t('seriesUpdated', { date: formatDate(latest) })}
|
|
48
|
+
</p>
|
|
49
|
+
</header>
|
|
50
|
+
<ol class="series-list">
|
|
51
|
+
{
|
|
52
|
+
entries.map((entry) => (
|
|
53
|
+
<>
|
|
54
|
+
{entry.groupHead && <li class="series-group-title" aria-hidden="true">{entry.groupHead}</li>}
|
|
55
|
+
<li class="series-item reveal">
|
|
56
|
+
<a class="series-link card" href={`/posts/${entry.post.id}/`}>
|
|
57
|
+
<span class="series-idx">{entry.index + 1}</span>
|
|
58
|
+
<span class="series-title">{entry.post.data.title}</span>
|
|
59
|
+
<span class="series-date">{formatDate(entry.post.data.published)}</span>
|
|
60
|
+
</a>
|
|
61
|
+
</li>
|
|
62
|
+
</>
|
|
63
|
+
))
|
|
64
|
+
}
|
|
65
|
+
</ol>
|
|
66
|
+
</BaseLayout>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { formatDate, type PostLike } from "../../utils.js";
|
|
6
|
+
import { hydratePosts } from "../_hydrate.js";
|
|
7
|
+
|
|
8
|
+
// 专题聚合页(§4.31):frontmatter series 非空即成组,按组内最新发布倒序
|
|
9
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
10
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
11
|
+
);
|
|
12
|
+
const posts = await hydratePosts(all);
|
|
13
|
+
|
|
14
|
+
interface SeriesGroup {
|
|
15
|
+
name: string;
|
|
16
|
+
list: PostLike[];
|
|
17
|
+
latest: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const groups = new Map<string, PostLike[]>();
|
|
21
|
+
for (const post of posts) {
|
|
22
|
+
const name = post.data.series;
|
|
23
|
+
if (!name) continue;
|
|
24
|
+
const list = groups.get(name);
|
|
25
|
+
if (list) list.push(post);
|
|
26
|
+
else groups.set(name, [post]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const seriesList: SeriesGroup[] = [...groups.entries()]
|
|
30
|
+
.map(([name, list]) => ({
|
|
31
|
+
name,
|
|
32
|
+
list,
|
|
33
|
+
latest: list.reduce(
|
|
34
|
+
(max, p) => (p.data.published > max ? p.data.published : max),
|
|
35
|
+
list[0]!.data.published,
|
|
36
|
+
),
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => b.latest.getTime() - a.latest.getTime());
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<BaseLayout title={t('series')} page="series">
|
|
42
|
+
<header class="archive-head reveal">
|
|
43
|
+
<h1 class="page-title">{t('series')}</h1>
|
|
44
|
+
<p class="page-sub">{t('seriesTotal', { n: seriesList.length })}</p>
|
|
45
|
+
</header>
|
|
46
|
+
{
|
|
47
|
+
seriesList.length === 0 ? (
|
|
48
|
+
<p class="series-empty card reveal">{t('seriesEmpty')}</p>
|
|
49
|
+
) : (
|
|
50
|
+
<div class="series-grid">
|
|
51
|
+
{seriesList.map((s) => (
|
|
52
|
+
<a class="series-card card reveal" href={`/series/${encodeURIComponent(s.name)}/`}>
|
|
53
|
+
<h2 class="series-name">{s.name}</h2>
|
|
54
|
+
<p class="series-meta">
|
|
55
|
+
<span>{t('seriesPostsCount', { n: s.list.length })}</span>
|
|
56
|
+
<span>·</span>
|
|
57
|
+
<span>{t('seriesUpdated', { date: formatDate(s.latest) })}</span>
|
|
58
|
+
</p>
|
|
59
|
+
</a>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
</BaseLayout>
|