@windcoder/astro-theme-orchid 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -207
- package/package.json +72 -70
- package/scripts/generate-lqips.mjs +83 -0
- package/scripts/subset-fonts.mjs +227 -0
- package/src/i18n/en.ts +3 -0
- package/src/i18n/zh-CN.ts +3 -0
- package/src/index.ts +18 -2
- package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
- package/src/plugins/rehype.ts +38 -0
- package/src/routes/categories/[category]/[...page].astro +53 -0
- package/src/routes/moments/[...page].astro +80 -0
- package/src/routes/posts/[...slug].astro +48 -35
- package/src/routes/rss.xml.ts +41 -14
- package/src/routes/series/[name].astro +23 -20
- package/src/routes/tags/[tag]/[...page].astro +51 -0
- package/src/schema.ts +50 -0
- package/src/styles/global.css +117 -0
- package/src/utils.ts +52 -0
- package/src/routes/categories/[category].astro +0 -37
- package/src/routes/tags/[tag].astro +0 -40
|
@@ -17,7 +17,13 @@ import config from "virtual:theme/config";
|
|
|
17
17
|
import { t } from "../../i18n/index.js";
|
|
18
18
|
import { getLqipStyle } from "../../utils/lqip.js";
|
|
19
19
|
import { getRelatedPosts } from "../../utils/related.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
formatDate,
|
|
22
|
+
getSortedPosts,
|
|
23
|
+
resolveCover,
|
|
24
|
+
type SeriesEntry,
|
|
25
|
+
seriesEntries,
|
|
26
|
+
} from "../../utils.js";
|
|
21
27
|
|
|
22
28
|
export async function getStaticPaths() {
|
|
23
29
|
const all = await getCollection("posts", ({ data }) =>
|
|
@@ -25,7 +31,8 @@ export async function getStaticPaths() {
|
|
|
25
31
|
);
|
|
26
32
|
const sorted = getSortedPosts(all);
|
|
27
33
|
|
|
28
|
-
// 专题索引(§4.31):
|
|
34
|
+
// 专题索引(§4.31→§4.75):seriesEntries 分块规范化(未分组块最前、
|
|
35
|
+
// 各分组按组内首篇顺序排列、全局序号连续),与专题详情页共用同一规则
|
|
29
36
|
const seriesGroups = new Map<string, typeof sorted>();
|
|
30
37
|
for (const post of sorted) {
|
|
31
38
|
const name = post.data.series;
|
|
@@ -34,40 +41,43 @@ export async function getStaticPaths() {
|
|
|
34
41
|
if (list) list.push(post);
|
|
35
42
|
else seriesGroups.set(name, [post]);
|
|
36
43
|
}
|
|
37
|
-
const seriesSorted = new Map<
|
|
44
|
+
const seriesSorted = new Map<
|
|
45
|
+
string,
|
|
46
|
+
SeriesEntry<(typeof sorted)[number]>[]
|
|
47
|
+
>();
|
|
38
48
|
for (const [name, list] of seriesGroups) {
|
|
39
|
-
seriesSorted.set(
|
|
40
|
-
name,
|
|
41
|
-
[...list].sort((a, b) => {
|
|
42
|
-
const oa = a.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
43
|
-
const ob = b.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
44
|
-
if (oa !== ob) return oa - ob;
|
|
45
|
-
return a.data.published.getTime() - b.data.published.getTime();
|
|
46
|
-
}),
|
|
47
|
-
);
|
|
49
|
+
seriesSorted.set(name, seriesEntries(list));
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
// sorted 内 idx+1 为更早一篇(prev),idx-1 为更新一篇(next)
|
|
51
53
|
return sorted.map((post, idx) => {
|
|
52
|
-
// 专题导航(§4.31):当前篇在专题内的位置 +
|
|
54
|
+
// 专题导航(§4.31):当前篇在专题内的位置 + 完整目录(含分块组头)
|
|
53
55
|
let seriesInfo: {
|
|
54
56
|
name: string;
|
|
55
57
|
total: number;
|
|
56
58
|
cur: number;
|
|
57
|
-
items: {
|
|
59
|
+
items: {
|
|
60
|
+
title: string;
|
|
61
|
+
id: string;
|
|
62
|
+
cur: boolean;
|
|
63
|
+
idx: number;
|
|
64
|
+
groupHead: string | null;
|
|
65
|
+
}[];
|
|
58
66
|
} | null = null;
|
|
59
67
|
const sName = post.data.series;
|
|
60
68
|
if (sName) {
|
|
61
|
-
const
|
|
62
|
-
const sIdx =
|
|
69
|
+
const entries = seriesSorted.get(sName)!;
|
|
70
|
+
const sIdx = entries.findIndex((e) => e.post.id === post.id);
|
|
63
71
|
seriesInfo = {
|
|
64
72
|
name: sName,
|
|
65
|
-
total:
|
|
73
|
+
total: entries.length,
|
|
66
74
|
cur: sIdx + 1,
|
|
67
|
-
items:
|
|
68
|
-
title:
|
|
69
|
-
id:
|
|
70
|
-
cur:
|
|
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,
|
|
71
81
|
})),
|
|
72
82
|
};
|
|
73
83
|
}
|
|
@@ -244,20 +254,23 @@ const jsonLd = {
|
|
|
244
254
|
</span>
|
|
245
255
|
</div>
|
|
246
256
|
<ol class="series-nav-list">
|
|
247
|
-
{seriesInfo.items.map((item
|
|
248
|
-
|
|
249
|
-
{item.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<span class="series-nav-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
</>
|
|
261
274
|
))}
|
|
262
275
|
</ol>
|
|
263
276
|
</nav>
|
package/src/routes/rss.xml.ts
CHANGED
|
@@ -1,32 +1,59 @@
|
|
|
1
|
-
import { getCollection } from "astro:content";
|
|
1
|
+
import { getCollection, render } from "astro:content";
|
|
2
2
|
import config from "virtual:theme/config";
|
|
3
3
|
import rss from "@astrojs/rss";
|
|
4
4
|
import type { APIContext, APIRoute } from "astro";
|
|
5
|
+
import { experimental_AstroContainer as AstroContainer } from "astro/container";
|
|
5
6
|
import { getSortedPosts } from "../utils.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* RSS(§4.63 官方 @astrojs/rss 端点):
|
|
9
|
-
* description
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* RSS(§4.63 官方 @astrojs/rss 端点):
|
|
10
|
+
* - 默认 item description 使用 frontmatter description(摘要输出);
|
|
11
|
+
* - rss.fullText 开启后 item 追加 content:encoded 全文(§4.87)——经
|
|
12
|
+
* AstroContainer 逐篇容器渲染 Markdown(与文章页同一套 remark/rehype 插件产物),
|
|
13
|
+
* 构建时间随文章数增加,默认关;
|
|
14
|
+
* - 条数 rss.limit(默认 20);channel 头部、atom:link self、XML 转义由官方库负责;
|
|
15
|
+
* trailingSlash 与主题 trailingSlash: "always" 口径一致(/posts/xxx/)。
|
|
12
16
|
*/
|
|
13
17
|
export const GET: APIRoute = async (context: APIContext) => {
|
|
14
18
|
const all = await getCollection("posts", ({ data }) => !data.draft);
|
|
15
|
-
const posts = getSortedPosts(all).slice(0,
|
|
19
|
+
const posts = getSortedPosts(all).slice(0, config.rss.limit);
|
|
16
20
|
const site = context.site ?? new URL("https://blog.example.com");
|
|
17
21
|
|
|
22
|
+
const items = await Promise.all(
|
|
23
|
+
posts.map(async (post) => {
|
|
24
|
+
const item: {
|
|
25
|
+
title: string;
|
|
26
|
+
description: string;
|
|
27
|
+
link: string;
|
|
28
|
+
pubDate: Date;
|
|
29
|
+
content?: string;
|
|
30
|
+
} = {
|
|
31
|
+
title: post.data.title,
|
|
32
|
+
description: post.data.description,
|
|
33
|
+
link: `/posts/${post.id}/`,
|
|
34
|
+
pubDate: post.data.published,
|
|
35
|
+
};
|
|
36
|
+
if (config.rss.fullText && !post.data.password) {
|
|
37
|
+
// 加密文章不进 RSS 全文(内容构建期加密,明文进 RSS 会绕过密码)
|
|
38
|
+
const { Content } = await render(post);
|
|
39
|
+
const container = await AstroContainer.create();
|
|
40
|
+
item.content = await container.renderToString(Content);
|
|
41
|
+
}
|
|
42
|
+
return item;
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
18
46
|
return rss({
|
|
19
47
|
title: config.title,
|
|
20
48
|
description: config.description,
|
|
21
49
|
site,
|
|
22
|
-
items
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
xmlns: { atom: "http://www.w3.org/2005/Atom" },
|
|
50
|
+
items,
|
|
51
|
+
// atom 命名空间 + feed 自引用(v4 不再自动生成,保持对原手写版的兼容);
|
|
52
|
+
// content 命名空间供 fullText 的 content:encoded 使用
|
|
53
|
+
xmlns: {
|
|
54
|
+
atom: "http://www.w3.org/2005/Atom",
|
|
55
|
+
content: "http://purl.org/rss/1.0/modules/content/",
|
|
56
|
+
},
|
|
30
57
|
// RSS <language> 通道级语言标记(跟随站点语言)
|
|
31
58
|
customData: [
|
|
32
59
|
`<language>${config.lang}</language>`,
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { getCollection } from "astro:content";
|
|
3
3
|
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
|
-
import { formatDate, type PostLike } from "../../utils.js";
|
|
5
|
+
import { formatDate, type PostLike, seriesEntries } from "../../utils.js";
|
|
6
6
|
import { hydratePosts } from "../_hydrate.js";
|
|
7
7
|
|
|
8
|
-
// 专题详情页(§4.31
|
|
8
|
+
// 专题详情页(§4.31→§4.75):seriesEntries 分块规范化——
|
|
9
|
+
// 未分组文章块最前,各分组按组内首篇顺序排列,全局序号连续
|
|
9
10
|
export async function getStaticPaths() {
|
|
10
11
|
const all = await getCollection("posts", ({ data }) =>
|
|
11
12
|
import.meta.env.PROD ? !data.draft : true,
|
|
@@ -21,18 +22,17 @@ export async function getStaticPaths() {
|
|
|
21
22
|
else groups.set(name, [post]);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
return [...groups.entries()].map(([name, list]) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (oa !== ob) return oa - ob;
|
|
29
|
-
return a.data.published.getTime() - b.data.published.getTime();
|
|
30
|
-
});
|
|
31
|
-
return { params: { name }, props: { name, posts: sorted } };
|
|
32
|
-
});
|
|
25
|
+
return [...groups.entries()].map(([name, list]) => ({
|
|
26
|
+
params: { name },
|
|
27
|
+
props: { name, entries: seriesEntries(list) },
|
|
28
|
+
}));
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
const { name,
|
|
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
36
|
const latest = posts.reduce(
|
|
37
37
|
(max, p) => (p.data.published > max ? p.data.published : max),
|
|
38
38
|
posts[0]!.data.published,
|
|
@@ -49,14 +49,17 @@ const latest = posts.reduce(
|
|
|
49
49
|
</header>
|
|
50
50
|
<ol class="series-list">
|
|
51
51
|
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
</>
|
|
60
63
|
))
|
|
61
64
|
}
|
|
62
65
|
</ol>
|
|
@@ -0,0 +1,51 @@
|
|
|
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 config from "virtual:theme/config";
|
|
6
|
+
import { t } from "../../../i18n/index.js";
|
|
7
|
+
import { collectTags, formatDate, getSortedPosts } from "../../../utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 标签详情(§4.87 分页):每标签按 postList.pageSize 分页,第一页
|
|
11
|
+
* /tags/<tag>/(page 段省略),后续 /tags/<tag>/2/ 起。结构与分类详情页一致。
|
|
12
|
+
*/
|
|
13
|
+
export async function getStaticPaths({ paginate }: { paginate: any }) {
|
|
14
|
+
const all = await getCollection("posts", ({ data }) =>
|
|
15
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
16
|
+
);
|
|
17
|
+
// 先排序再聚合,保证每个标签内文章按新→旧排列
|
|
18
|
+
const sorted = getSortedPosts(all);
|
|
19
|
+
return [...collectTags(sorted).entries()].flatMap(([tag, posts]) =>
|
|
20
|
+
paginate(posts, {
|
|
21
|
+
params: { tag },
|
|
22
|
+
props: { tag },
|
|
23
|
+
pageSize: config.postList.pageSize,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { page, tag } = Astro.props;
|
|
29
|
+
const posts = page.data;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<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
|
+
}
|
|
49
|
+
</div>
|
|
50
|
+
<Pagination page={page.currentPage} total={page.lastPage} basePath={`/tags/${tag}/`} />
|
|
51
|
+
</BaseLayout>
|
package/src/schema.ts
CHANGED
|
@@ -342,6 +342,8 @@ export const themeConfigSchema = z.object({
|
|
|
342
342
|
gallery: z.boolean().default(true),
|
|
343
343
|
/** 专题(§4.31):frontmatter series 聚合页 /series/ 与 /series/[name]/ */
|
|
344
344
|
series: z.boolean().default(true),
|
|
345
|
+
/** 说说页(§4.87):/moments/ 短内容时间流;站点需定义 moments collection。默认关——老站点无该集合,开启前先在 content.config.ts 定义 */
|
|
346
|
+
moments: z.boolean().default(false),
|
|
345
347
|
})
|
|
346
348
|
.default({
|
|
347
349
|
about: true,
|
|
@@ -350,6 +352,7 @@ export const themeConfigSchema = z.object({
|
|
|
350
352
|
sponsor: true,
|
|
351
353
|
gallery: true,
|
|
352
354
|
series: true,
|
|
355
|
+
moments: false,
|
|
353
356
|
}),
|
|
354
357
|
/**
|
|
355
358
|
* 画廊/相册(M3b-b8)。相册数据纯配置驱动,照片两个来源:
|
|
@@ -915,6 +918,26 @@ export const themeConfigSchema = z.object({
|
|
|
915
918
|
method: z.enum(["base64", "rot13"]).default("base64"),
|
|
916
919
|
})
|
|
917
920
|
.default({ enabled: false, method: "base64" }),
|
|
921
|
+
/**
|
|
922
|
+
* 图片防盗链(§4.87):部分图床/CDN(如 B 站)校验 Referer,站内引用返回 403。
|
|
923
|
+
* 命中域名列表的外链图片构建期追加 referrerpolicy="no-referrer"。
|
|
924
|
+
* 裸域名匹配自身与全部子域;*.example.com 只匹配子域。留空不处理。
|
|
925
|
+
*/
|
|
926
|
+
noReferrer: z
|
|
927
|
+
.object({
|
|
928
|
+
domains: z.array(z.string()).default([]),
|
|
929
|
+
})
|
|
930
|
+
.default({ domains: [] }),
|
|
931
|
+
/**
|
|
932
|
+
* RSS 输出(§4.87):fullText 开启后 item 带 content:encoded 全文
|
|
933
|
+
* (构建期逐篇容器渲染,构建时间相应增加);limit 控制输出条数。
|
|
934
|
+
*/
|
|
935
|
+
rss: z
|
|
936
|
+
.object({
|
|
937
|
+
fullText: z.boolean().default(false),
|
|
938
|
+
limit: z.number().int().min(1).default(20),
|
|
939
|
+
})
|
|
940
|
+
.default({ fullText: false, limit: 20 }),
|
|
918
941
|
/**
|
|
919
942
|
* favicon 图标列表(§4.30):数组非空时替换默认 /favicon.svg;
|
|
920
943
|
* theme 指定 "light"/"dark" 时按系统深浅色经 media 切换(可配双图标)。
|
|
@@ -1207,6 +1230,33 @@ export function definePostSchema(extensions: z.ZodRawShape = {}) {
|
|
|
1207
1230
|
series: z.string().optional(),
|
|
1208
1231
|
/** 专题内顺序(小在前);未配置则按发布时间升序 */
|
|
1209
1232
|
seriesOrder: z.number().optional(),
|
|
1233
|
+
/**
|
|
1234
|
+
* 专题内分组(§4.75):专题页与文章页专题目录按组分块展示
|
|
1235
|
+
* (未分组文章块在最前,各分组按组内首篇顺序依次排列,全局序号连续)。
|
|
1236
|
+
* 组名直接来自本字段文本,无 i18n 参与。
|
|
1237
|
+
*/
|
|
1238
|
+
seriesGroup: z.string().optional(),
|
|
1239
|
+
})
|
|
1240
|
+
.extend(extensions);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* 内容 schema 工厂:说说集合 frontmatter(§4.87 说说页)。
|
|
1245
|
+
* 站点 content.config.ts 定义 moments collection(loader 扫描
|
|
1246
|
+
* src/content/moments 目录下全部 .md 文件,schema 用本工厂)。
|
|
1247
|
+
* 正文即说说内容(Markdown,支持主题全部增强语法);主题开关 pages.moments
|
|
1248
|
+
* 打开后注入 /moments/(时间流,按 published 新→旧,每页 20 条)。
|
|
1249
|
+
*/
|
|
1250
|
+
export function defineMomentSchema(extensions: z.ZodRawShape = {}) {
|
|
1251
|
+
return z
|
|
1252
|
+
.object({
|
|
1253
|
+
published: z.coerce.date(),
|
|
1254
|
+
/** 心情 emoji(卡片右上角展示) */
|
|
1255
|
+
mood: z.string().optional(),
|
|
1256
|
+
tags: z.array(z.string()).default([]),
|
|
1257
|
+
/** 配图(public 路径或外链,网格展示 + 灯箱浏览) */
|
|
1258
|
+
images: z.array(z.string()).default([]),
|
|
1259
|
+
draft: z.boolean().default(false),
|
|
1210
1260
|
})
|
|
1211
1261
|
.extend(extensions);
|
|
1212
1262
|
}
|
package/src/styles/global.css
CHANGED
|
@@ -2705,6 +2705,28 @@ button.archive-year .year-caret {
|
|
|
2705
2705
|
display: grid;
|
|
2706
2706
|
gap: 10px;
|
|
2707
2707
|
}
|
|
2708
|
+
/* §4.75 专题分组:详情页组头(组内首条之前的小标题) */
|
|
2709
|
+
.series-group-title {
|
|
2710
|
+
margin-top: 16px;
|
|
2711
|
+
padding-left: 2px;
|
|
2712
|
+
font-size: 13px;
|
|
2713
|
+
font-weight: 700;
|
|
2714
|
+
letter-spacing: 0.04em;
|
|
2715
|
+
color: var(--muted);
|
|
2716
|
+
display: flex;
|
|
2717
|
+
align-items: center;
|
|
2718
|
+
gap: 8px;
|
|
2719
|
+
}
|
|
2720
|
+
.series-group-title::before {
|
|
2721
|
+
content: "";
|
|
2722
|
+
width: 14px;
|
|
2723
|
+
height: 3px;
|
|
2724
|
+
border-radius: 2px;
|
|
2725
|
+
background: var(--accent);
|
|
2726
|
+
}
|
|
2727
|
+
.series-group-title:first-child {
|
|
2728
|
+
margin-top: 0;
|
|
2729
|
+
}
|
|
2708
2730
|
.series-link {
|
|
2709
2731
|
display: flex;
|
|
2710
2732
|
align-items: center;
|
|
@@ -2794,6 +2816,18 @@ button.archive-year .year-caret {
|
|
|
2794
2816
|
display: grid;
|
|
2795
2817
|
gap: 2px;
|
|
2796
2818
|
}
|
|
2819
|
+
/* §4.75 专题分组:文章页专题目录组头 */
|
|
2820
|
+
.series-nav-group {
|
|
2821
|
+
margin-top: 8px;
|
|
2822
|
+
padding-left: 2px;
|
|
2823
|
+
font-size: 12px;
|
|
2824
|
+
font-weight: 700;
|
|
2825
|
+
letter-spacing: 0.04em;
|
|
2826
|
+
color: var(--muted);
|
|
2827
|
+
}
|
|
2828
|
+
.series-nav-group:first-child {
|
|
2829
|
+
margin-top: 0;
|
|
2830
|
+
}
|
|
2797
2831
|
.series-nav-item {
|
|
2798
2832
|
position: relative;
|
|
2799
2833
|
padding-left: 14px;
|
|
@@ -3054,6 +3088,9 @@ html.dark .series-nav-item.cur .series-nav-row {
|
|
|
3054
3088
|
.gallery-banner {
|
|
3055
3089
|
position: relative;
|
|
3056
3090
|
overflow: hidden;
|
|
3091
|
+
/* 显式定宽阻断 aspect-ratio 反推:窄容器下 min-height 拉高后 3/1 会
|
|
3092
|
+
反解出 200×3=600px 的宽度穿透容器,导致页面横向滚动(§4.86) */
|
|
3093
|
+
width: 100%;
|
|
3057
3094
|
border-radius: var(--radius);
|
|
3058
3095
|
aspect-ratio: 3 / 1;
|
|
3059
3096
|
min-height: 200px;
|
|
@@ -3174,6 +3211,8 @@ gallery-filter {
|
|
|
3174
3211
|
.gallery-masonry {
|
|
3175
3212
|
column-count: 2;
|
|
3176
3213
|
column-gap: 12px;
|
|
3214
|
+
/* 与封面横幅/降级头部的间隔(§4.86,原来零间距贴合) */
|
|
3215
|
+
margin-top: 24px;
|
|
3177
3216
|
}
|
|
3178
3217
|
@media (min-width: 640px) {
|
|
3179
3218
|
.gallery-masonry {
|
|
@@ -3200,6 +3239,84 @@ gallery-filter {
|
|
|
3200
3239
|
transform: scale(1.025);
|
|
3201
3240
|
}
|
|
3202
3241
|
|
|
3242
|
+
/* 说说页(§4.87):时间流卡片 */
|
|
3243
|
+
.moments-list {
|
|
3244
|
+
display: flex;
|
|
3245
|
+
flex-direction: column;
|
|
3246
|
+
gap: 16px;
|
|
3247
|
+
}
|
|
3248
|
+
.moment {
|
|
3249
|
+
padding: 18px 22px;
|
|
3250
|
+
}
|
|
3251
|
+
.moment-meta {
|
|
3252
|
+
display: flex;
|
|
3253
|
+
align-items: center;
|
|
3254
|
+
justify-content: space-between;
|
|
3255
|
+
margin-bottom: 10px;
|
|
3256
|
+
font-size: 13px;
|
|
3257
|
+
color: var(--muted);
|
|
3258
|
+
}
|
|
3259
|
+
.moment-mood {
|
|
3260
|
+
font-size: 18px;
|
|
3261
|
+
line-height: 1;
|
|
3262
|
+
}
|
|
3263
|
+
.moment-content {
|
|
3264
|
+
font-size: 15px;
|
|
3265
|
+
line-height: 1.75;
|
|
3266
|
+
}
|
|
3267
|
+
.moment-content > :first-child {
|
|
3268
|
+
margin-top: 0;
|
|
3269
|
+
}
|
|
3270
|
+
.moment-content > :last-child {
|
|
3271
|
+
margin-bottom: 0;
|
|
3272
|
+
}
|
|
3273
|
+
.moment-images {
|
|
3274
|
+
display: grid;
|
|
3275
|
+
grid-template-columns: repeat(3, 1fr);
|
|
3276
|
+
gap: 8px;
|
|
3277
|
+
margin-top: 12px;
|
|
3278
|
+
}
|
|
3279
|
+
@media (max-width: 480px) {
|
|
3280
|
+
.moment-images {
|
|
3281
|
+
grid-template-columns: repeat(2, 1fr);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
.moment-img {
|
|
3285
|
+
overflow: hidden;
|
|
3286
|
+
border-radius: var(--radius-sm);
|
|
3287
|
+
background: var(--bg-soft);
|
|
3288
|
+
cursor: zoom-in;
|
|
3289
|
+
}
|
|
3290
|
+
.moment-img img {
|
|
3291
|
+
display: block;
|
|
3292
|
+
width: 100%;
|
|
3293
|
+
height: 100%;
|
|
3294
|
+
object-fit: cover;
|
|
3295
|
+
aspect-ratio: 1 / 1;
|
|
3296
|
+
transition: transform 0.35s ease;
|
|
3297
|
+
}
|
|
3298
|
+
.moment-img:hover img {
|
|
3299
|
+
transform: scale(1.04);
|
|
3300
|
+
}
|
|
3301
|
+
.moment-tags {
|
|
3302
|
+
display: flex;
|
|
3303
|
+
flex-wrap: wrap;
|
|
3304
|
+
gap: 8px;
|
|
3305
|
+
margin-top: 12px;
|
|
3306
|
+
}
|
|
3307
|
+
.moment-tag {
|
|
3308
|
+
padding: 1px 10px;
|
|
3309
|
+
border-radius: 999px;
|
|
3310
|
+
font-size: 12px;
|
|
3311
|
+
color: var(--ink-2);
|
|
3312
|
+
background: var(--bg-soft);
|
|
3313
|
+
transition: background-color 0.15s;
|
|
3314
|
+
}
|
|
3315
|
+
.moment-tag:hover {
|
|
3316
|
+
background: var(--accent-soft);
|
|
3317
|
+
color: var(--ink);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3203
3320
|
/* 灯箱(§4.69):样式由 photoswipe/photoswipe.css 提供(BaseLayout 引入) */
|
|
3204
3321
|
/* 灯箱自定义 UI(§4.70/§4.71):全屏/自动播放按钮与缩略图条(可开关、内容居中)。
|
|
3205
3322
|
自定义图标用 .qy-pswp-icn 独立类名,避开 .pswp__icn 的 fill 覆盖(图标走 stroke 描边) */
|
package/src/utils.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface PostLike {
|
|
|
41
41
|
series?: string;
|
|
42
42
|
/** 专题内顺序(小在前);未配置则按发布时间升序 */
|
|
43
43
|
seriesOrder?: number;
|
|
44
|
+
/** 专题内分组(§4.75):专题页/专题目录按组分块展示 */
|
|
45
|
+
seriesGroup?: string;
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -89,6 +91,56 @@ export function getSortedPosts<T extends PostLike>(posts: T[]): T[] {
|
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
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
|
+
|
|
92
144
|
/** 按标签聚合:Map<标签名, 文章[]>,标签按文章数降序 */
|
|
93
145
|
export function collectTags<T extends PostLike>(posts: T[]): Map<string, T[]> {
|
|
94
146
|
const map = new Map<string, T[]>();
|
|
@@ -1,37 +0,0 @@
|
|
|
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 { collectCategories, formatDate, getSortedPosts } from "../../utils.js";
|
|
6
|
-
|
|
7
|
-
// 先排序再聚合,保证每个分类内文章按新→旧排列
|
|
8
|
-
export async function getStaticPaths() {
|
|
9
|
-
const all = await getCollection("posts", ({ data }) =>
|
|
10
|
-
import.meta.env.PROD ? !data.draft : true,
|
|
11
|
-
);
|
|
12
|
-
const sorted = getSortedPosts(all);
|
|
13
|
-
return [...collectCategories(sorted).entries()].map(([category, posts]) => ({
|
|
14
|
-
params: { category },
|
|
15
|
-
props: { category, posts },
|
|
16
|
-
}));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const { category, posts } = Astro.props;
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
<BaseLayout title={t('categoryTitle', { category })} page="categories">
|
|
23
|
-
<header class="archive-head reveal">
|
|
24
|
-
<h1 class="page-title">{category}</h1>
|
|
25
|
-
<p class="page-sub">{t('totalPosts', { n: posts.length })}</p>
|
|
26
|
-
</header>
|
|
27
|
-
<div class="tag-list">
|
|
28
|
-
{
|
|
29
|
-
posts.map((post) => (
|
|
30
|
-
<a class="related-card card tag-item reveal" href={`/posts/${post.id}/`}>
|
|
31
|
-
<div class="rc-title">{post.data.title}</div>
|
|
32
|
-
<div class="rc-meta">{formatDate(post.data.published)}</div>
|
|
33
|
-
</a>
|
|
34
|
-
))
|
|
35
|
-
}
|
|
36
|
-
</div>
|
|
37
|
-
</BaseLayout>
|