@windcoder/astro-theme-orchid 0.2.0 → 0.3.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.
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * Web Vitals 通用上报(§4.91):与统计厂商无关的 Core Web Vitals 采集。
4
+ * Google 官方 web-vitals 库经 Astro bundled script 打包(约 2KB gzip),
5
+ * LCP/CLS/INP/FCP/TTFB 五指标就绪即上报。
6
+ * 载荷以 text/plain 载 JSON 经 sendBeacon 发送(CORS 安全清单类型,免预检),
7
+ * sendBeacon 排队失败回退 fetch keepalive。endpoint 留空由 Analytics.astro 门控不注入。
8
+ * 配置经 DOM data 属性传入(bundled script 无 define:vars,SakuraEffect 同款惯例)。
9
+ */
10
+ interface Props {
11
+ /** 上报端点(POST) */
12
+ endpoint: string;
13
+ /** 站点标识 */
14
+ siteId?: string;
15
+ /** 采样率 0~1 */
16
+ sampleRate?: number;
17
+ /** 控制台打印载荷(调试) */
18
+ debug?: boolean;
19
+ }
20
+
21
+ const { endpoint, siteId = "", sampleRate = 1, debug = false } = Astro.props;
22
+ ---
23
+
24
+ <div
25
+ id="orchid-webvitals"
26
+ data-endpoint={endpoint}
27
+ data-site-id={siteId}
28
+ data-sample-rate={sampleRate}
29
+ data-debug={debug ? "true" : undefined}
30
+ hidden
31
+ ></div>
32
+
33
+ <script>
34
+ import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
35
+
36
+ const el = document.getElementById("orchid-webvitals");
37
+ const endpoint = el?.dataset.endpoint ?? "";
38
+ const siteId = el?.dataset.siteId ?? "";
39
+ const sampleRate = Number(el?.dataset.sampleRate ?? 1);
40
+ const debug = el?.dataset.debug === "true";
41
+
42
+ if (endpoint && Math.random() < sampleRate) {
43
+ const send = (metric: Metric) => {
44
+ const payload = JSON.stringify({
45
+ name: metric.name,
46
+ value: Math.round(metric.value),
47
+ rating: metric.rating,
48
+ delta: Math.round(metric.delta),
49
+ id: metric.id,
50
+ navType: metric.navigationType ?? "",
51
+ path: location.pathname,
52
+ site: siteId,
53
+ });
54
+ if (debug) console.log("[web-vitals]", payload);
55
+ const blob = new Blob([payload], { type: "text/plain;charset=UTF-8" });
56
+ if (!navigator.sendBeacon(endpoint, blob)) {
57
+ // 排队失败(页面卸载瞬间的兜底)
58
+ fetch(endpoint, {
59
+ method: "POST",
60
+ body: payload,
61
+ keepalive: true,
62
+ headers: { "content-type": "text/plain;charset=UTF-8" },
63
+ }).catch(() => {});
64
+ }
65
+ };
66
+ onCLS(send);
67
+ onFCP(send);
68
+ onINP(send);
69
+ onLCP(send);
70
+ onTTFB(send);
71
+ }
72
+ </script>
package/src/i18n/en.ts CHANGED
@@ -77,6 +77,12 @@ const en: Record<UiKey, string> = {
77
77
  avatarAlt: "Avatar of {name}",
78
78
  noFriends: "No friend links yet",
79
79
  applyFriend: "Apply for a link",
80
+ /* Bangumi watching page (§4.93) */
81
+ bangumiEmpty: "Nothing being watched yet",
82
+ bangumiFail: "Failed to fetch Bangumi data. Rebuild the site to retry.",
83
+ epProgress: "Ep. {n} / {total}",
84
+ epProgressOpen: "Ep. {n}",
85
+ myRate: "My rating {n}",
80
86
  guestbook: "Guestbook",
81
87
  guestbookSub: "Leave your trace here",
82
88
  commentComingSoon: "Comments are coming soon. Stay tuned.",
@@ -97,6 +103,9 @@ const en: Record<UiKey, string> = {
97
103
  allTags: "All",
98
104
  galleryNoAlbums: "No albums yet. Add one in theme config gallery.albums.",
99
105
  galleryNoPhotos: "No photos in this album",
106
+ /* 说说页(§4.87) */
107
+ momentsTitle: "Moments",
108
+ momentsEmpty: "No moments yet.",
100
109
  encryptedAlbum: "Encrypted album",
101
110
  searchAlbumNoResults: "No matching albums",
102
111
  postEncrypted: "Encrypted",
package/src/i18n/zh-CN.ts CHANGED
@@ -90,6 +90,12 @@ const zhCN = {
90
90
  avatarAlt: "{name} 的头像",
91
91
  noFriends: "还没有友链",
92
92
  applyFriend: "申请友链",
93
+ /* 番组追番页(§4.93) */
94
+ bangumiEmpty: "还没有在看中的番剧",
95
+ bangumiFail: "番组数据拉取失败,请重新构建站点后重试",
96
+ epProgress: "看到 {n} / {total} 集",
97
+ epProgressOpen: "看到 {n} 集",
98
+ myRate: "我的评分 {n}",
93
99
  guestbook: "留言板",
94
100
  guestbookSub: "欢迎留下你的足迹",
95
101
  commentComingSoon: "评论区即将开放,敬请期待。",
@@ -112,6 +118,9 @@ const zhCN = {
112
118
  allTags: "全部",
113
119
  galleryNoAlbums: "还没有相册,在主题配置 gallery.albums 中添加。",
114
120
  galleryNoPhotos: "相册里还没有照片",
121
+ /* 说说页(§4.87) */
122
+ momentsTitle: "说说",
123
+ momentsEmpty: "还没有说说。",
115
124
  encryptedAlbum: "加密相册",
116
125
  searchAlbumNoResults: "没有匹配的相册",
117
126
  /* 加密文章(M3b-b7):postEncrypted 用于列表/文章 meta 标记,其余构建期渲染进锁 UI */
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  rehypeExternalLinks,
42
42
  rehypeFigure,
43
43
  rehypeFirstImage,
44
+ rehypeNoReferrer,
44
45
  } from "./plugins/rehype.js";
45
46
  import {
46
47
  rehypeDiagramPanZoom,
@@ -125,6 +126,8 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
125
126
  // GitHub 仓库卡片(§4.10):github 标签 → 卡片(自带 target,须在外链处理前)
126
127
  rehypeGithubCard,
127
128
  rehypeExternalLinks,
129
+ // 图片防盗链(§4.87):命中域名外链图加 no-referrer(domains 空时插件空转)
130
+ [rehypeNoReferrer, { domains: config.noReferrer.domains }],
128
131
  rehypeCallouts,
129
132
  rehypeFigure,
130
133
  rehypeFirstImage,
@@ -308,9 +311,13 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
308
311
  ["/page/[page]", "page/[page].astro"],
309
312
  ["/archive", "archive.astro"],
310
313
  ["/tags", "tags/index.astro"],
311
- ["/tags/[tag]", "tags/[tag].astro"],
314
+ // §4.87 分类/标签分页:第一页 /tags/<tag>/(page 段省略),后续 /2/ 起
315
+ ["/tags/[tag]/[...page]", "tags/[tag]/[...page].astro"],
312
316
  ["/categories", "categories/index.astro"],
313
- ["/categories/[category]", "categories/[category].astro"],
317
+ [
318
+ "/categories/[category]/[...page]",
319
+ "categories/[category]/[...page].astro",
320
+ ],
314
321
  ["/posts/[...slug]", "posts/[...slug].astro"],
315
322
  // sitemap(§4.63):改由官方 @astrojs/sitemap integration 产出
316
323
  // sitemap-index.xml,原手写 /sitemap.xml 路由移除
@@ -333,6 +340,14 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
333
340
  routes.push(["/series", "series/index.astro"]);
334
341
  routes.push(["/series/[name]", "series/[name].astro"]);
335
342
  }
343
+ // 说说页(§4.87):moments collection 时间流;站点需先定义该集合(默认关)
344
+ if (config.pages.moments) {
345
+ routes.push(["/moments/[...page]", "moments/[...page].astro"]);
346
+ }
347
+ // 番组追番页(§4.93):构建期拉 bgm.tv「在看」收藏;enabled + userId 双条件才注入
348
+ if (config.bangumi.enabled && config.bangumi.userId.trim()) {
349
+ routes.push(["/bangumi", "bangumi.astro"]);
350
+ }
336
351
  // 相关/随机文章推荐(P1-12):随机栏数据源
337
352
  if (config.relatedPosts.enabled) {
338
353
  routes.push(["/api/allPostMeta.json", "api/allPostMeta.json.ts"]);
@@ -341,6 +356,12 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
341
356
  if (config.og.enabled) {
342
357
  routes.push(["/og/[...slug]", "og/[...slug].ts"]);
343
358
  }
359
+ // PWA(§4.95):manifest 与 Service Worker 为构建期生成的端点,enabled 才注入;
360
+ // head 里的 manifest 链接与 SW 注册脚本由 BaseLayout 按 pwa.enabled 输出
361
+ if (config.pwa.enabled) {
362
+ routes.push(["/manifest.webmanifest", "manifest.webmanifest.ts"]);
363
+ routes.push(["/sw.js", "sw.js.ts"]);
364
+ }
344
365
  for (const [pattern, file] of routes) {
345
366
  injectRoute({
346
367
  pattern,
@@ -35,12 +35,20 @@ function getClassList(props: any): string[] {
35
35
  /* ---------------------------------------------------------------- 外链 */
36
36
 
37
37
  /**
38
- * 外链新窗口打开:http(s) 绝对链接追加 target="_blank" rel="noopener noreferrer"
38
+ * 外链新窗口打开:http(s) 绝对链接追加 target="_blank" rel="noopener noreferrer";
39
+ * 外链 <img> 追加 data-external 标记(暗色外链图适配 §4.89 的 CSS 挂点,标记本身零副作用)
39
40
  */
40
41
  export function rehypeExternalLinks(): Transformer {
41
42
  return (tree) => {
42
43
  visit(tree, (node) => {
43
- if (node.type !== "element" || node.tagName !== "a") return;
44
+ if (node.type !== "element") return;
45
+ if (node.tagName === "img") {
46
+ if (/^https?:\/\//i.test(String(node.properties?.src ?? ""))) {
47
+ node.properties["data-external"] = "true";
48
+ }
49
+ return;
50
+ }
51
+ if (node.tagName !== "a") return;
44
52
  const href = String(node.properties?.href ?? "");
45
53
  if (!/^https?:\/\//i.test(href)) return;
46
54
  node.properties.target = "_blank";
@@ -53,6 +61,44 @@ export function rehypeExternalLinks(): Transformer {
53
61
  };
54
62
  }
55
63
 
64
+ /**
65
+ * 图片防盗链(§4.87):部分图床/CDN(如 B 站)校验 Referer 头,站内引用返回 403。
66
+ * 为匹配域名的外链 <img> 追加 referrerpolicy="no-referrer"(浏览器请求图片时不发
67
+ * Referer,绕过防盗链限制),仅影响外链图片与匹配域名,本地图片不受影响。
68
+ */
69
+ export interface NoReferrerOptions {
70
+ /** 域名列表:裸域名匹配自身与全部子域;*.example.com 只匹配子域 */
71
+ domains: string[];
72
+ }
73
+
74
+ export function rehypeNoReferrer(options: NoReferrerOptions): Transformer {
75
+ if (!options.domains.length) return () => {};
76
+ const matchers = options.domains.map((domain) => {
77
+ const d = domain.toLowerCase();
78
+ if (d.startsWith("*.")) {
79
+ const base = d.slice(2);
80
+ return (host: string) => host.endsWith(`.${base}`) && host !== base;
81
+ }
82
+ return (host: string) => host === d || host.endsWith(`.${d}`);
83
+ });
84
+ return (tree) => {
85
+ visit(tree, (node) => {
86
+ if (node.type !== "element" || node.tagName !== "img") return;
87
+ const src = String(node.properties?.src ?? "");
88
+ if (!/^https?:\/\//i.test(src)) return;
89
+ let host: string;
90
+ try {
91
+ host = new URL(src).hostname.toLowerCase();
92
+ } catch {
93
+ return;
94
+ }
95
+ if (matchers.some((match) => match(host))) {
96
+ node.properties.referrerPolicy = "no-referrer";
97
+ }
98
+ });
99
+ };
100
+ }
101
+
56
102
  /* -------------------------------------------------------------- callouts */
57
103
 
58
104
  const CALLOUT_META: Record<
@@ -0,0 +1,110 @@
1
+ ---
2
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
3
+ import config from "virtual:theme/config";
4
+ import { t } from "../i18n/index.js";
5
+
6
+ /* 番组追番页(§4.93):构建期从 bgm.tv v0 API 拉取用户「在看」动画收藏,
7
+ 渲染为静态页。外部依赖 fail-soft:请求失败不中断构建,页面照常生成并提示。 */
8
+
9
+ interface BgmSubject {
10
+ id: number;
11
+ name: string;
12
+ name_cn?: string;
13
+ score?: number;
14
+ eps?: number;
15
+ images?: { common?: string; medium?: string; large?: string };
16
+ }
17
+ interface BgmCollection {
18
+ updated_at?: string;
19
+ subject: BgmSubject;
20
+ ep_status?: number;
21
+ rate?: number;
22
+ }
23
+
24
+ const LIMIT = 50;
25
+ /* 50 * 20 = 1000 条安全上限:防止分页异常(total 失真)导致构建挂死 */
26
+ const MAX_PAGES = 20;
27
+ const UA =
28
+ "astro-theme-orchid (+https://github.com/iwinder/qy-astro-theme-orchid)";
29
+
30
+ let items: BgmCollection[] = [];
31
+ let failed = false;
32
+
33
+ try {
34
+ const headers = { "User-Agent": UA, Accept: "application/json" };
35
+ for (let offset = 0; offset < LIMIT * MAX_PAGES; offset += LIMIT) {
36
+ const url = `https://api.bgm.tv/v0/users/${config.bangumi.userId}/collections?subject_type=2&type=3&limit=${LIMIT}&offset=${offset}`;
37
+ const res = await fetch(url, {
38
+ headers,
39
+ signal: AbortSignal.timeout(10_000),
40
+ });
41
+ if (!res.ok) throw new Error(`bgm.tv responded ${res.status}`);
42
+ const page = (await res.json()) as { data?: BgmCollection[] };
43
+ const batch = page.data ?? [];
44
+ items = items.concat(batch);
45
+ if (batch.length < LIMIT) break;
46
+ }
47
+ /* 最近在看优先:按收藏更新时间倒序 */
48
+ items.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
49
+ } catch {
50
+ failed = true;
51
+ }
52
+
53
+ /* 进度文案:eps 有值时 clamp 到总集数并显示 x/y,否则只显示已看集数 */
54
+ function progress(item: BgmCollection): string {
55
+ const eps = item.subject.eps ?? 0;
56
+ const watched = item.ep_status ?? 0;
57
+ return eps > 0
58
+ ? t("epProgress", {
59
+ n: Math.min(watched, eps),
60
+ total: eps,
61
+ })
62
+ : t("epProgressOpen", { n: watched });
63
+ }
64
+ ---
65
+
66
+ <BaseLayout title={config.bangumi.title} description={config.bangumi.description} page="bangumi">
67
+ <header class="archive-head reveal">
68
+ <h1 class="page-title">{config.bangumi.title}</h1>
69
+ {
70
+ config.bangumi.description && (
71
+ <p class="page-sub">{config.bangumi.description}</p>
72
+ )
73
+ }
74
+ </header>
75
+
76
+ {
77
+ items.length > 0 ? (
78
+ <div class="bangumi-grid reveal">
79
+ {items.map((item) => {
80
+ const s = item.subject;
81
+ const cover = s.images?.common ?? s.images?.medium ?? s.images?.large;
82
+ return (
83
+ <a
84
+ class="bangumi-card card"
85
+ href={`https://bgm.tv/subject/${s.id}`}
86
+ target="_blank"
87
+ rel="noopener nofollow"
88
+ >
89
+ <div class="bangumi-cover">
90
+ {cover && <img src={cover} alt={s.name_cn || s.name} loading="lazy" decoding="async" />}
91
+ {typeof s.score === "number" && s.score > 0 && (
92
+ <span class="bangumi-score">{s.score.toFixed(1)}</span>
93
+ )}
94
+ </div>
95
+ <div class="bangumi-title">{s.name_cn || s.name}</div>
96
+ <div class="bangumi-meta">
97
+ <span class="bangumi-progress">{progress(item)}</span>
98
+ {typeof item.rate === "number" && item.rate > 0 && (
99
+ <span class="bangumi-rate">{t("myRate", { n: item.rate })}</span>
100
+ )}
101
+ </div>
102
+ </a>
103
+ );
104
+ })}
105
+ </div>
106
+ ) : (
107
+ <p class="tag-empty reveal">{failed ? t("bangumiFail") : t("bangumiEmpty")}</p>
108
+ )
109
+ }
110
+ </BaseLayout>
@@ -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 config from "virtual:theme/config";
6
+ import { t } from "../../../i18n/index.js";
7
+ import {
8
+ collectCategories,
9
+ formatDate,
10
+ getSortedPosts,
11
+ } from "../../../utils.js";
12
+
13
+ /**
14
+ * 分类详情(§4.87 分页):每分类按 postList.pageSize 分页,第一页
15
+ * /categories/<category>/(page 段省略),后续 /categories/<category>/2/ 起。
16
+ * paginate() helper 生成 Page 对象(props.page),Page.data 即当前页文章。
17
+ */
18
+ export async function getStaticPaths({ paginate }: { paginate: any }) {
19
+ const all = await getCollection("posts", ({ data }) =>
20
+ import.meta.env.PROD ? !data.draft : true,
21
+ );
22
+ // 先排序再聚合,保证每个分类内文章按新→旧排列
23
+ const sorted = getSortedPosts(all);
24
+ return [...collectCategories(sorted).entries()].flatMap(([category, posts]) =>
25
+ paginate(posts, {
26
+ params: { category },
27
+ props: { category },
28
+ pageSize: config.postList.pageSize,
29
+ }),
30
+ );
31
+ }
32
+
33
+ const { page, category } = Astro.props;
34
+ const posts = page.data;
35
+ ---
36
+
37
+ <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
+ }
51
+ </div>
52
+ <Pagination page={page.currentPage} total={page.lastPage} basePath={`/categories/${category}/`} />
53
+ </BaseLayout>
@@ -0,0 +1,33 @@
1
+ import config from "virtual:theme/config";
2
+ import type { APIRoute } from "astro";
3
+
4
+ /**
5
+ * PWA manifest(§4.95):构建期产出 /manifest.webmanifest。
6
+ * - scope/start_url 由 site 根路径派生(主题假定部署在站点根路径);
7
+ * - name/description 留空回落站点 title/description,short_name 留空回落 name;
8
+ * - icons 按 pwa.icons 全字段映射(可安装性至少需要 192x192 + 512x512 各一张);
9
+ * - enabled=false 时本端点根本不注入(index.ts 门控)。
10
+ */
11
+ export const GET: APIRoute = ({ site }) => {
12
+ const base = new URL("/", site ?? new URL("https://blog.example.com"))
13
+ .pathname;
14
+ const manifest = {
15
+ name: config.pwa.name || config.title,
16
+ short_name: config.pwa.shortName || config.pwa.name || config.title,
17
+ description: config.pwa.description || config.description,
18
+ start_url: base,
19
+ scope: base,
20
+ display: config.pwa.display,
21
+ background_color: config.pwa.backgroundColor,
22
+ theme_color: config.pwa.themeColor,
23
+ icons: config.pwa.icons.map((icon) => ({
24
+ src: icon.src,
25
+ sizes: icon.sizes,
26
+ type: icon.type,
27
+ purpose: icon.purpose,
28
+ })),
29
+ };
30
+ return new Response(JSON.stringify(manifest, null, 2), {
31
+ headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
32
+ });
33
+ };
@@ -0,0 +1,80 @@
1
+ ---
2
+ import { getCollection, render } 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 { formatDate } from "../../utils.js";
8
+
9
+ /**
10
+ * 说说页(§4.87):moments collection 时间流,按 published 新→旧,
11
+ * 每页 20 条(第一页 /moments/,后续 /moments/2/)。
12
+ * 正文走主题统一 markdown processor(全部增强语法可用);配图 images 网格
13
+ * 展示并接入通用灯箱(data-lightbox-group 组浏览)。
14
+ */
15
+ export async function getStaticPaths({ paginate }: { paginate: any }) {
16
+ const all = await getCollection("moments", ({ data }) =>
17
+ import.meta.env.PROD ? !data.draft : true,
18
+ );
19
+ const sorted = [...all].sort(
20
+ (a, b) => b.data.published.valueOf() - a.data.published.valueOf(),
21
+ );
22
+ return paginate(sorted, { pageSize: 20 });
23
+ }
24
+
25
+ const { page } = Astro.props;
26
+ const rendered = await Promise.all(
27
+ page.data.map(async (moment: any) => {
28
+ const { Content } = await render(moment);
29
+ return { moment, Content };
30
+ }),
31
+ );
32
+ ---
33
+
34
+ <BaseLayout title={t('momentsTitle')} page="moments">
35
+ <header class="archive-head reveal">
36
+ <h1 class="page-title">{t('momentsTitle')}</h1>
37
+ </header>
38
+
39
+ {
40
+ rendered.length === 0 ? (
41
+ <p class="gallery-empty reveal">{t('momentsEmpty')}</p>
42
+ ) : (
43
+ <div class="moments-list">
44
+ {rendered.map(({ moment, Content }) => (
45
+ <article class="moment card reveal">
46
+ <div class="moment-meta">
47
+ <time datetime={moment.data.published.toISOString()}>
48
+ {formatDate(moment.data.published)}
49
+ </time>
50
+ {moment.data.mood && <span class="moment-mood">{moment.data.mood}</span>}
51
+ </div>
52
+ <div class="moment-content">
53
+ <Content />
54
+ </div>
55
+ {moment.data.images.length > 0 && (
56
+ <div class="moment-images" data-lightbox-group>
57
+ {moment.data.images.map((src: string) => (
58
+ <figure class="moment-img">
59
+ <img src={src} alt={t('momentsTitle')} loading="lazy" decoding="async" />
60
+ </figure>
61
+ ))}
62
+ </div>
63
+ )}
64
+ {moment.data.tags.length > 0 && (
65
+ <div class="moment-tags">
66
+ {moment.data.tags.map((tg: string) => (
67
+ <a class="moment-tag" href={`/tags/${tg}/`}>
68
+ {tg}
69
+ </a>
70
+ ))}
71
+ </div>
72
+ )}
73
+ </article>
74
+ ))}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ <Pagination page={page.currentPage} total={page.lastPage} basePath="/moments/" />
80
+ </BaseLayout>
@@ -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 端点):item description 使用 frontmatter
9
- * description,不做全文输出(避免 RSS 端点再渲染全部 Markdown)。
10
- * channel 头部、atom:link self、XML 转义由官方库负责;trailingSlash 与
11
- * 主题 trailingSlash: "always" 口径一致(/posts/xxx/)。
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, 20);
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: 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" },
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>`,