@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.
Files changed (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
@@ -0,0 +1,29 @@
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, getSortedPosts } from "../../utils.js";
6
+
7
+ // 分类云:复用 tag-cloud 样式,分类内文章数 > 1 用大字号
8
+ const all = await getCollection("posts", ({ data }) =>
9
+ import.meta.env.PROD ? !data.draft : true,
10
+ );
11
+ const cats = [...collectCategories(getSortedPosts(all)).entries()];
12
+ ---
13
+
14
+ <BaseLayout title={t('categories')} page="categories">
15
+ <header class="archive-head reveal">
16
+ <h1 class="page-title">{t('categories')}</h1>
17
+ <p class="page-sub">{t('totalCategories', { n: cats.length })}</p>
18
+ </header>
19
+ <div class="tag-cloud card reveal">
20
+ {
21
+ cats.map(([cat, list]) => (
22
+ <a class={list.length > 1 ? 'tag-s3' : 'tag-s1'} href={`/categories/${encodeURIComponent(cat)}/`}>
23
+ {cat}
24
+ <span class="tag-count">{list.length}</span>
25
+ </a>
26
+ ))
27
+ }
28
+ </div>
29
+ </BaseLayout>
@@ -0,0 +1,83 @@
1
+ ---
2
+ import { getEntry, render } from "astro:content";
3
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
+ import config from "virtual:theme/config";
5
+ import { t } from "../i18n/index.js";
6
+
7
+ // 数据源 = themeConfig.friends.list(决策 8:与侧栏友链 widget 同源)
8
+ const friends = config.friends.list
9
+ .filter((f) => f.enabled)
10
+ .sort((a, b) =>
11
+ config.friends.sort === "random"
12
+ ? Math.random() - 0.5
13
+ : b.weight - a.weight,
14
+ );
15
+
16
+ // 可选 spec 正文(src/content/spec/friends.md),渲染在卡片列表上方
17
+ const entry = await getEntry("spec", "friends").catch(() => null);
18
+ const { Content } = entry ? await render(entry) : { Content: null };
19
+
20
+ const apply = config.friends.apply;
21
+ ---
22
+
23
+ <BaseLayout title={config.friends.title} page="friends">
24
+ <header class="archive-head reveal">
25
+ <h1 class="page-title">{config.friends.title}</h1>
26
+ {config.friends.description && <p class="page-sub">{config.friends.description}</p>}
27
+ </header>
28
+
29
+ {
30
+ Content && (
31
+ <article class="card page-card prose reveal" data-lightbox-gallery>
32
+ <Content />
33
+ </article>
34
+ )
35
+ }
36
+
37
+ {
38
+ friends.length > 0 ? (
39
+ <div class="friend-grid reveal">
40
+ {friends.map((f) => (
41
+ <a class="friend-card card" href={f.site} target="_blank" rel="noopener">
42
+ <div class="friend-avatar">
43
+ {f.image ? (
44
+ <img src={f.image} alt={t('avatarAlt', { name: f.title })} loading="lazy" />
45
+ ) : (
46
+ <span class="avatar-fallback">{f.title.slice(0, 1)}</span>
47
+ )}
48
+ </div>
49
+ <div class="friend-info">
50
+ <div class="friend-name">{f.title}</div>
51
+ {f.desc && <div class="friend-desc">{f.desc}</div>}
52
+ {f.tags.length > 0 && (
53
+ <div class="friend-tags">
54
+ {f.tags.map((ft) => (
55
+ <span class="chip">{ft}</span>
56
+ ))}
57
+ </div>
58
+ )}
59
+ </div>
60
+ </a>
61
+ ))}
62
+ </div>
63
+ ) : (
64
+ <p class="tag-empty reveal">{t('noFriends')}</p>
65
+ )
66
+ }
67
+
68
+ {
69
+ apply.title && (
70
+ <section class="friend-apply card reveal">
71
+ <div>
72
+ <div class="fa-title">{apply.title}</div>
73
+ {apply.description && <p class="fa-desc">{apply.description}</p>}
74
+ </div>
75
+ {apply.link && (
76
+ <a class="btn" href={apply.link}>
77
+ {apply.label || t('applyFriend')}
78
+ </a>
79
+ )}
80
+ </section>
81
+ )
82
+ }
83
+ </BaseLayout>
@@ -0,0 +1,97 @@
1
+ ---
2
+ /**
3
+ * 相册详情页(M3b-b8):封面横幅(无封面降级卡式头部)+ 瀑布流照片墙。
4
+ * 加密相册复用 b7 EncryptedContent(slug = album.id),解密注入后
5
+ * 灯箱组委托(app.ts doc 级)依然生效。
6
+ */
7
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
8
+ import EncryptedContent from "virtual:theme/components/EncryptedContent.astro";
9
+ import PhotoCard from "virtual:theme/components/PhotoCard.astro";
10
+ import config from "virtual:theme/config";
11
+ import { t } from "../../i18n/index.js";
12
+ import { getAlbumCover, scanAlbumPhotos } from "../../utils/gallery.js";
13
+ import { getLqipStyle } from "../../utils/lqip.js";
14
+
15
+ export function getStaticPaths() {
16
+ return config.gallery.albums.map((album) => ({
17
+ params: { album: album.id },
18
+ props: { album },
19
+ }));
20
+ }
21
+
22
+ const { album } = Astro.props;
23
+ const photos = scanAlbumPhotos(album.id);
24
+ const cover = getAlbumCover(album, photos);
25
+ const tags = album.tags || [];
26
+ ---
27
+
28
+ <BaseLayout title={album.name} page="gallery">
29
+ {
30
+ cover ? (
31
+ <header class="gallery-banner reveal">
32
+ {/* LQIP 占位渐变(b9.5,回补 b8 画廊图片):z-index 低于 banner-inner/back/遮罩 */}
33
+ <div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
34
+ <img src={cover} alt={album.name} data-lqip />
35
+ <a class="gallery-back" href="/gallery/">
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
37
+ {t('backToAlbums')}
38
+ </a>
39
+ <div class="gallery-banner-inner">
40
+ <h1 class="gallery-title">{album.name}</h1>
41
+ {album.description && <p class="gallery-desc">{album.description}</p>}
42
+ <div class="gallery-meta">
43
+ {album.date && (
44
+ <span>
45
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
46
+ {album.date}
47
+ </span>
48
+ )}
49
+ {album.location && (
50
+ <span>
51
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="3" /></svg>
52
+ {album.location}
53
+ </span>
54
+ )}
55
+ <span>{t('galleryPhotos', { n: photos.length })}</span>
56
+ {tags.length > 0 && (
57
+ <span class="gallery-meta-tags">
58
+ {tags.map((tag) => <span>{tag}</span>)}
59
+ </span>
60
+ )}
61
+ </div>
62
+ </div>
63
+ </header>
64
+ ) : (
65
+ <header class="gallery-head-plain card reveal">
66
+ <h1 class="page-title">{album.name}</h1>
67
+ {album.description && <p class="page-sub">{album.description}</p>}
68
+ <div class="gallery-meta">
69
+ {album.date && <span>{album.date}</span>}
70
+ {album.location && <span>{album.location}</span>}
71
+ <span>{t('galleryPhotos', { n: photos.length })}</span>
72
+ {tags.length > 0 && (
73
+ <span class="gallery-meta-tags">
74
+ {tags.map((tag) => <span>{tag}</span>)}
75
+ </span>
76
+ )}
77
+ </div>
78
+ </header>
79
+ )
80
+ }
81
+
82
+ {
83
+ photos.length === 0 ? (
84
+ <p class="gallery-empty reveal">{t('galleryNoPhotos')}</p>
85
+ ) : album.password ? (
86
+ <EncryptedContent password={album.password} slug={album.id} hint={album.passwordHint}>
87
+ <div class="gallery-masonry" data-lightbox-group style={`--col-width: ${config.gallery.columnWidth}px`}>
88
+ {photos.map((src, i) => <PhotoCard src={src} alt={`${album.name} · ${i + 1}`} />)}
89
+ </div>
90
+ </EncryptedContent>
91
+ ) : (
92
+ <div class="gallery-masonry" data-lightbox-group style={`--col-width: ${config.gallery.columnWidth}px`}>
93
+ {photos.map((src, i) => <PhotoCard src={src} alt={`${album.name} · ${i + 1}`} />)}
94
+ </div>
95
+ )
96
+ }
97
+ </BaseLayout>
@@ -0,0 +1,100 @@
1
+ ---
2
+ import AlbumCard from "virtual:theme/components/AlbumCard.astro";
3
+ /**
4
+ * 画廊列表页(M3b-b8):相册网格 + 标签 chips 筛选 + 关键词搜索。
5
+ * 筛选用 <gallery-filter> custom element(is:inline + 防重复 define),
6
+ * VT 换页后新 DOM 自动 re-connect,与原版验证过的模式一致。
7
+ */
8
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
9
+ import config from "virtual:theme/config";
10
+ import { t } from "../../i18n/index.js";
11
+ import { buildAlbumIndex } from "../../utils/gallery.js";
12
+
13
+ const albums = buildAlbumIndex(config.gallery.albums);
14
+ const allTags = [...new Set(albums.flatMap((a) => a.tags || []))].sort();
15
+ ---
16
+
17
+ <BaseLayout title={config.gallery.title} page="gallery">
18
+ <header class="archive-head reveal">
19
+ <h1 class="page-title">{config.gallery.title}</h1>
20
+ {config.gallery.description && <p class="page-sub">{config.gallery.description}</p>}
21
+ </header>
22
+
23
+ {
24
+ albums.length > 0 ? (
25
+ <gallery-filter class="reveal">
26
+ <div class="gallery-search">
27
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
28
+ <input type="search" data-gallery-search placeholder={t('searchAlbums')} aria-label={t('searchAlbums')} />
29
+ </div>
30
+ {allTags.length > 0 && (
31
+ <div class="chips">
32
+ <button type="button" class="chip is-on" data-tag-filter="">
33
+ {t('allTags')}
34
+ </button>
35
+ {allTags.map((tag) => (
36
+ <button type="button" class="chip" data-tag-filter={tag}>
37
+ {tag}
38
+ </button>
39
+ ))}
40
+ </div>
41
+ )}
42
+ <div class="gallery-grid">
43
+ {albums.map((album) => (
44
+ <AlbumCard
45
+ id={album.id}
46
+ name={album.name}
47
+ cover={album.cover}
48
+ description={album.description}
49
+ date={album.date}
50
+ location={album.location}
51
+ tags={album.tags}
52
+ photoCount={album.photoCount}
53
+ encrypted={!!album.password}
54
+ />
55
+ ))}
56
+ </div>
57
+ <p class="gallery-empty" data-gallery-empty hidden>{t('searchAlbumNoResults')}</p>
58
+ </gallery-filter>
59
+ ) : (
60
+ <p class="gallery-empty reveal">{t('galleryNoAlbums')}</p>
61
+ )
62
+ }
63
+ </BaseLayout>
64
+
65
+ <script is:inline>
66
+ if (!customElements.get('gallery-filter')) {
67
+ customElements.define(
68
+ 'gallery-filter',
69
+ class extends HTMLElement {
70
+ connectedCallback() {
71
+ const search = this.querySelector('[data-gallery-search]');
72
+ const chips = Array.from(this.querySelectorAll('[data-tag-filter]'));
73
+ const cards = Array.from(this.querySelectorAll('.album-card'));
74
+ const empty = this.querySelector('[data-gallery-empty]');
75
+ let tag = '';
76
+ const apply = () => {
77
+ const q = (search && search.value ? search.value : '').trim().toLowerCase();
78
+ let visible = 0;
79
+ for (const card of cards) {
80
+ const okTag = !tag || (card.dataset.tags || '').split(',').includes(tag);
81
+ const okQ = !q || (card.dataset.search || '').includes(q);
82
+ const show = okTag && okQ;
83
+ card.classList.toggle('hidden', !show);
84
+ if (show) visible++;
85
+ }
86
+ if (empty) empty.hidden = visible > 0;
87
+ };
88
+ this.addEventListener('click', (e) => {
89
+ const btn = e.target.closest('[data-tag-filter]');
90
+ if (!btn) return;
91
+ tag = btn.dataset.tagFilter || '';
92
+ chips.forEach((c) => c.classList.toggle('is-on', c === btn));
93
+ apply();
94
+ });
95
+ if (search) search.addEventListener('input', apply);
96
+ }
97
+ },
98
+ );
99
+ }
100
+ </script>
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { getEntry, render } from "astro:content";
3
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
+ import Comment from "virtual:theme/components/Comment.astro";
5
+ import config from "virtual:theme/config";
6
+ import { t } from "../i18n/index.js";
7
+
8
+ // 正文来自站点 spec 集合(src/content/spec/guestbook.md)
9
+ const entry = await getEntry("spec", "guestbook").catch(() => null);
10
+ const { Content } = entry ? await render(entry) : { Content: null };
11
+ const commentOn = config.comment.enabled && config.comment.provider !== "none";
12
+ ---
13
+
14
+ <BaseLayout title={t('guestbook')} page="guestbook">
15
+ <header class="archive-head reveal">
16
+ <h1 class="page-title">{t('guestbook')}</h1>
17
+ <p class="page-sub">{t('guestbookSub')}</p>
18
+ </header>
19
+
20
+ {
21
+ Content && (
22
+ <article class="card page-card prose reveal" data-lightbox-gallery>
23
+ <Content />
24
+ </article>
25
+ )
26
+ }
27
+
28
+ {
29
+ commentOn ? (
30
+ <Comment path="/guestbook" />
31
+ ) : (
32
+ <div class="card page-card comment-placeholder reveal">
33
+ <p>{t('commentComingSoon')}</p>
34
+ </div>
35
+ )
36
+ }
37
+ </BaseLayout>
@@ -0,0 +1,47 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "virtual:theme/components/BaseLayout.astro";
4
+ import CategoryBar from "virtual:theme/components/CategoryBar.astro";
5
+ import Pagination from "virtual:theme/components/Pagination.astro";
6
+ import PostCard from "virtual:theme/components/PostCard.astro";
7
+ import Sidebar from "virtual:theme/components/Sidebar.astro";
8
+ import config from "virtual:theme/config";
9
+ import { t } from "../i18n/index.js";
10
+ import { getSortedPosts } from "../utils.js";
11
+ import { hydratePosts } from "./_hydrate.js";
12
+
13
+ // 首页 = 分页第 1 页。置顶文章留在分页流内(§4.39,对齐 orchid-blog paginate 全量流口径):
14
+ // 排序置顶在前,占第 1 页 pageSize 名额,各页卡片数一致,翻页不会出现「少一块」。
15
+ const all = await getCollection("posts", ({ data }) =>
16
+ import.meta.env.PROD ? !data.draft : true,
17
+ );
18
+ const sorted = getSortedPosts(all);
19
+ const total = Math.max(1, Math.ceil(sorted.length / config.postList.pageSize));
20
+
21
+ const pagePosts = await hydratePosts(sorted.slice(0, config.postList.pageSize));
22
+ ---
23
+
24
+ <BaseLayout page="home">
25
+ <div class="home-layout">
26
+ {/* 文章列表(§4.34):id/data 属性供 app.ts 恢复与切换网格/列表形态 */}
27
+ <div
28
+ class="post-list"
29
+ id="postList"
30
+ data-allow-switch={config.postList.allowSwitch ? "true" : undefined}
31
+ data-default-layout={config.postList.defaultLayout}
32
+ data-grid-col={config.postList.gridColumnWidth}
33
+ >
34
+ {/* 分类导航栏(§4.15→§4.21):仅首页、与置顶/近期文章同列宽;「更多」固定跳分类页 */}
35
+ <CategoryBar />
36
+ {
37
+ pagePosts.length > 0 ? (
38
+ pagePosts.map((post) => <PostCard post={post} featured={post.data.pinned} />)
39
+ ) : (
40
+ <p class="empty-note card">{t('emptyPosts')}</p>
41
+ )
42
+ }
43
+ <Pagination page={1} total={total} basePath="/" />
44
+ </div>
45
+ <Sidebar posts={sorted} />
46
+ </div>
47
+ </BaseLayout>
@@ -0,0 +1,281 @@
1
+ import type { CollectionEntry } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
+ import config from "virtual:theme/config";
4
+ import type { APIRoute } from "astro";
5
+ import satori from "satori";
6
+ import { formatDate, getSortedPosts, hexToHsl } from "../../utils.js";
7
+
8
+ /**
9
+ * OG 分享图(P1-11,默认关):为每篇文章构建期生成 1200x630 分享图(/og/<slug>.png)。
10
+ * satori(对象树→SVG)+ sharp(SVG→PNG)。相对原版 orchid-blog 的主题化调整:
11
+ * 1. 不读站点磁盘头像/favicon(主题中立),页头以 accent 圆角方块作站点标识;
12
+ * 2. 修复原版缺陷:Google Fonts 拉取失败时原版 fonts 为空数组,satori 会抛
13
+ * "No font provided" 令整个构建崩溃,此处降级为 sharp 纯形状占位图 + warn,构建不中断。
14
+ */
15
+
16
+ interface FontOption {
17
+ data: Buffer | ArrayBuffer;
18
+ name: string;
19
+ weight: 400 | 700;
20
+ style: "normal";
21
+ }
22
+
23
+ export async function getStaticPaths() {
24
+ // 与相关文章口径一致:草稿(getSortedPosts 内已滤)、加密文章不生成分享图
25
+ const sorted = getSortedPosts(await getCollection("posts"));
26
+ return sorted
27
+ .filter((post) => !post.data.password)
28
+ .map((post) => ({ params: { slug: `${post.id}.png` }, props: { post } }));
29
+ }
30
+
31
+ let fontCache: { regular: Buffer | null; bold: null | Buffer } | null = null;
32
+
33
+ /** Google Fonts CSS2 API 抽 Noto Sans SC woff2(400/700 两档),模块级缓存;失败缓存 null 供降级 */
34
+ async function fetchNotoSansSCFonts() {
35
+ if (fontCache) return fontCache;
36
+ try {
37
+ const cssResp = await fetch(
38
+ "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap",
39
+ );
40
+ if (!cssResp.ok) throw new Error(`Google Fonts CSS 响应 ${cssResp.status}`);
41
+ const cssText = await cssResp.text();
42
+ const getUrlForWeight = (weight: number) => {
43
+ const block = cssText.match(
44
+ new RegExp(`@font-face\\s*{[^}]*font-weight:\\s*${weight}[^}]*}`),
45
+ );
46
+ return block?.[0]?.match(/url\((https:[^)]+)\)/)?.[1] ?? null;
47
+ };
48
+ const regularUrl = getUrlForWeight(400);
49
+ const boldUrl = getUrlForWeight(700);
50
+ if (!regularUrl || !boldUrl)
51
+ throw new Error("Google Fonts CSS 中未找到 woff2 地址");
52
+ const [rResp, bResp] = await Promise.all([
53
+ fetch(regularUrl),
54
+ fetch(boldUrl),
55
+ ]);
56
+ if (!rResp.ok || !bResp.ok) throw new Error("字体文件下载失败");
57
+ fontCache = {
58
+ regular: Buffer.from(await rResp.arrayBuffer()),
59
+ bold: Buffer.from(await bResp.arrayBuffer()),
60
+ };
61
+ } catch (err) {
62
+ console.warn("[og] Google Fonts 拉取失败,OG 图降级为占位图:", err);
63
+ fontCache = { regular: null, bold: null };
64
+ }
65
+ return fontCache;
66
+ }
67
+
68
+ /** 无字体时的占位图:纯形状 SVG(深底 + accent 竖条),不依赖任何字体 */
69
+ async function placeholderPng(accent: string) {
70
+ const sharp = (await import("sharp")).default;
71
+ const { h } = hexToHsl(accent);
72
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"><rect width="1200" height="630" fill="hsl(${h}, 15%, 12%)"/><rect x="60" y="281" width="10" height="68" rx="6" fill="${accent}"/></svg>`;
73
+ return sharp(Buffer.from(svg)).png().toBuffer();
74
+ }
75
+
76
+ export const GET: APIRoute = async ({ props }) => {
77
+ const { post } = props as { post: CollectionEntry<"posts"> };
78
+ const { h } = hexToHsl(config.accentColor);
79
+ const primary = `hsl(${h}, 90%, 65%)`;
80
+ const textColor = "hsl(0, 0%, 95%)";
81
+ const subtleTextColor = `hsl(${h}, 10%, 75%)`;
82
+ const backgroundColor = `hsl(${h}, 15%, 12%)`;
83
+
84
+ const { regular, bold } = await fetchNotoSansSCFonts();
85
+ const fonts: FontOption[] = [];
86
+ if (regular)
87
+ fonts.push({
88
+ name: "Noto Sans SC",
89
+ data: regular,
90
+ weight: 400,
91
+ style: "normal",
92
+ });
93
+ if (bold)
94
+ fonts.push({
95
+ name: "Noto Sans SC",
96
+ data: bold,
97
+ weight: 700,
98
+ style: "normal",
99
+ });
100
+ if (fonts.length === 0) {
101
+ // 降级:占位图保证构建不中断(原版此处 satori 抛 "No font provided" 崩构建)
102
+ const png = await placeholderPng(config.accentColor);
103
+ return new Response(new Uint8Array(png), {
104
+ headers: {
105
+ "Content-Type": "image/png",
106
+ "Cache-Control": "public, max-age=31536000, immutable",
107
+ },
108
+ });
109
+ }
110
+
111
+ const description = post.data.description;
112
+ const author = config.author || config.title;
113
+
114
+ const template = {
115
+ type: "div",
116
+ props: {
117
+ style: {
118
+ height: "100%",
119
+ width: "100%",
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ backgroundColor,
123
+ fontFamily: '"Noto Sans SC", sans-serif',
124
+ padding: "60px",
125
+ },
126
+ children: [
127
+ // 页头:accent 圆角方块 + 站点名(原版为 favicon 图,主题化后不依赖站点资源)
128
+ {
129
+ type: "div",
130
+ props: {
131
+ style: {
132
+ width: "100%",
133
+ display: "flex",
134
+ alignItems: "center",
135
+ gap: "20px",
136
+ },
137
+ children: [
138
+ {
139
+ type: "div",
140
+ props: {
141
+ style: {
142
+ width: "48px",
143
+ height: "48px",
144
+ borderRadius: "10px",
145
+ backgroundColor: primary,
146
+ },
147
+ },
148
+ },
149
+ {
150
+ type: "div",
151
+ props: {
152
+ style: {
153
+ fontSize: "36px",
154
+ fontWeight: 600,
155
+ color: subtleTextColor,
156
+ },
157
+ children: config.title,
158
+ },
159
+ },
160
+ ],
161
+ },
162
+ },
163
+ // 中部:accent 竖条 + 标题(3 行截断)+ 描述(2 行截断)
164
+ {
165
+ type: "div",
166
+ props: {
167
+ style: {
168
+ display: "flex",
169
+ flexDirection: "column",
170
+ justifyContent: "center",
171
+ flexGrow: 1,
172
+ gap: "20px",
173
+ },
174
+ children: [
175
+ {
176
+ type: "div",
177
+ props: {
178
+ style: { display: "flex", alignItems: "flex-start" },
179
+ children: [
180
+ {
181
+ type: "div",
182
+ props: {
183
+ style: {
184
+ width: "10px",
185
+ height: "68px",
186
+ backgroundColor: primary,
187
+ borderRadius: "6px",
188
+ marginTop: "14px",
189
+ },
190
+ },
191
+ },
192
+ {
193
+ type: "div",
194
+ props: {
195
+ style: {
196
+ fontSize: "72px",
197
+ fontWeight: 700,
198
+ lineHeight: 1.2,
199
+ color: textColor,
200
+ marginLeft: "25px",
201
+ display: "-webkit-box",
202
+ overflow: "hidden",
203
+ textOverflow: "ellipsis",
204
+ lineClamp: 3,
205
+ WebkitLineClamp: 3,
206
+ WebkitBoxOrient: "vertical",
207
+ },
208
+ children: post.data.title,
209
+ },
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ description && {
215
+ type: "div",
216
+ props: {
217
+ style: {
218
+ fontSize: "32px",
219
+ lineHeight: 1.5,
220
+ color: subtleTextColor,
221
+ paddingLeft: "35px",
222
+ display: "-webkit-box",
223
+ overflow: "hidden",
224
+ textOverflow: "ellipsis",
225
+ lineClamp: 2,
226
+ WebkitLineClamp: 2,
227
+ WebkitBoxOrient: "vertical",
228
+ },
229
+ children: description,
230
+ },
231
+ },
232
+ ].filter(Boolean),
233
+ },
234
+ },
235
+ // 页脚:作者 + 日期(原版含头像,主题化后不依赖站点资源)
236
+ {
237
+ type: "div",
238
+ props: {
239
+ style: {
240
+ display: "flex",
241
+ justifyContent: "space-between",
242
+ alignItems: "center",
243
+ width: "100%",
244
+ },
245
+ children: [
246
+ {
247
+ type: "div",
248
+ props: {
249
+ style: {
250
+ fontSize: "28px",
251
+ fontWeight: 600,
252
+ color: textColor,
253
+ },
254
+ children: author,
255
+ },
256
+ },
257
+ {
258
+ type: "div",
259
+ props: {
260
+ style: { fontSize: "28px", color: subtleTextColor },
261
+ children: formatDate(post.data.published),
262
+ },
263
+ },
264
+ ],
265
+ },
266
+ },
267
+ ],
268
+ },
269
+ };
270
+
271
+ const svg = await satori(template, { width: 1200, height: 630, fonts });
272
+ const sharp = (await import("sharp")).default;
273
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
274
+
275
+ return new Response(new Uint8Array(png), {
276
+ headers: {
277
+ "Content-Type": "image/png",
278
+ "Cache-Control": "public, max-age=31536000, immutable",
279
+ },
280
+ });
281
+ };