hd-garden 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.
- package/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
- package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
- package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
- package/.env.example +56 -0
- package/.openai/hosting.json +5 -0
- package/LICENSE +21 -0
- package/Makefile +48 -0
- package/README.md +72 -0
- package/app/about/page.tsx +60 -0
- package/app/admin/page.tsx +117 -0
- package/app/api/admin/distribution/route.ts +120 -0
- package/app/api/admin/posts/pin/route.ts +32 -0
- package/app/api/admin/thoughts/[id]/route.ts +44 -0
- package/app/api/admin/thoughts/route.ts +120 -0
- package/app/api/admin/uploads/route.ts +20 -0
- package/app/api/auth/github/callback/route.ts +136 -0
- package/app/api/auth/github/route.ts +29 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/blog/[...path]/page.tsx +135 -0
- package/app/blog/page.tsx +139 -0
- package/app/chatgpt-auth.ts +86 -0
- package/app/columns/[column]/[...path]/page.tsx +160 -0
- package/app/columns/page.tsx +68 -0
- package/app/components/AdminDashboard.tsx +410 -0
- package/app/components/Comments.tsx +55 -0
- package/app/components/ContentEmptyState.tsx +51 -0
- package/app/components/ExternalEmbed.tsx +127 -0
- package/app/components/GardenAdminGuide.tsx +116 -0
- package/app/components/GardenContentGuide.tsx +241 -0
- package/app/components/GardenMediaGuide.tsx +81 -0
- package/app/components/GiscusComments.tsx +71 -0
- package/app/components/GithubLiveOverview.tsx +118 -0
- package/app/components/GithubProfile.tsx +326 -0
- package/app/components/MarkdownArticle.tsx +144 -0
- package/app/components/MermaidDiagram.tsx +117 -0
- package/app/components/PageIntro.tsx +32 -0
- package/app/components/SiteChrome.tsx +18 -0
- package/app/components/SiteFooter.tsx +29 -0
- package/app/components/SiteHeader.tsx +420 -0
- package/app/components/TableOfContents.tsx +433 -0
- package/app/components/ThoughtCard.tsx +242 -0
- package/app/components/Webmentions.tsx +194 -0
- package/app/components/WidgetChart.tsx +222 -0
- package/app/garden/page.tsx +284 -0
- package/app/garden-modules.d.ts +62 -0
- package/app/globals.css +4454 -0
- package/app/layout.tsx +102 -0
- package/app/lib/admin-auth.ts +134 -0
- package/app/lib/content.ts +112 -0
- package/app/lib/fallback-site-config.ts +56 -0
- package/app/lib/generated-content.ts +380 -0
- package/app/lib/github-content.ts +244 -0
- package/app/lib/markdown-plugins.ts +13 -0
- package/app/lib/oss.ts +100 -0
- package/app/lib/public-thoughts.ts +9 -0
- package/app/lib/repository-path.mjs +32 -0
- package/app/lib/site.ts +51 -0
- package/app/page.tsx +119 -0
- package/app/robots.ts +15 -0
- package/app/rss.xml/route.ts +84 -0
- package/app/site.config.ts +35 -0
- package/app/sitemap.ts +46 -0
- package/app/thoughts/[slug]/page.tsx +79 -0
- package/app/thoughts/page.tsx +33 -0
- package/app/yaml.d.ts +4 -0
- package/bin/garden.mjs +348 -0
- package/build/sites-vite-plugin.ts +39 -0
- package/eslint.config.mjs +20 -0
- package/next.config.ts +13 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.svg +4 -0
- package/public/images/garden/admin-studio.png +0 -0
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +582 -0
- package/scripts/fetch-embed.mjs +189 -0
- package/scripts/garden-paths.mjs +21 -0
- package/scripts/new-post.mjs +73 -0
- package/scripts/prepare-pages-output.mjs +182 -0
- package/scripts/send-webmentions.mjs +27 -0
- package/site.config.yaml +48 -0
- package/templates/site/Makefile +46 -0
- package/templates/site/README.md +13 -0
- package/templates/site/content/columns.yaml +1 -0
- package/templates/site/content/posts/.gitkeep +0 -0
- package/templates/site/content/thoughts/.gitkeep +0 -0
- package/templates/site/package.json +19 -0
- package/templates/site/site.config.yaml +46 -0
- package/tsconfig.json +34 -0
- package/vite.config.ts +130 -0
- package/worker/index.ts +53 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const adminCapabilities = [
|
|
2
|
+
{
|
|
3
|
+
number: "01",
|
|
4
|
+
title: "发布和管理随想",
|
|
5
|
+
description:
|
|
6
|
+
"新建随想、保存草稿、正式发布、撤回或删除。每次修改都会写回 content/thoughts/,不会另存一份数据库副本。",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
number: "02",
|
|
10
|
+
title: "上传多媒体",
|
|
11
|
+
description:
|
|
12
|
+
"图片、音频和视频可以直接上传到阿里云 OSS,随想中只保存公开地址,避免把大文件塞进 Git 历史。",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
number: "03",
|
|
16
|
+
title: "管理博文置顶",
|
|
17
|
+
description:
|
|
18
|
+
"后台可以切换博文的首页置顶状态,并查看博文所属专栏。正文仍然使用 Markdown 编辑,专栏顺序仍由 columns.yaml 管理。",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
number: "04",
|
|
22
|
+
title: "分发到其他平台",
|
|
23
|
+
description:
|
|
24
|
+
"可以直接发布到 X,或调用自建服务同步到 CSDN、知乎和掘金;没有配置接口时,也能复制标题、栏目和公开链接。",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const adminFlow = [
|
|
29
|
+
["GitHub 登录", "只允许白名单中的账号进入后台。"],
|
|
30
|
+
["执行内容操作", "发布随想、上传附件、置顶或分发博文。"],
|
|
31
|
+
["提交到内容仓库", "后台使用服务端令牌修改 Markdown。"],
|
|
32
|
+
["自动更新博客", "main 分支变更后由 Actions 重新发布。"],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function GardenAdminGuide() {
|
|
36
|
+
return (
|
|
37
|
+
<section className="garden-admin" id="admin" aria-labelledby="garden-admin-title">
|
|
38
|
+
<header className="garden-section-heading">
|
|
39
|
+
<div>
|
|
40
|
+
<p className="eyebrow">ADMIN / 管理后台</p>
|
|
41
|
+
<h2 id="garden-admin-title">管理后台能做什么</h2>
|
|
42
|
+
</div>
|
|
43
|
+
<p>
|
|
44
|
+
后台是一个可选的独立服务,处理需要登录和服务端密钥的工作。公开博客仍然是静态站点,内容仍然保存在 Git 仓库中。
|
|
45
|
+
</p>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<figure className="garden-admin-preview">
|
|
49
|
+
{/* vinext 的本地静态导出不提供 Next 图片优化端点,截图直接由 public 目录提供。 */}
|
|
50
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
51
|
+
<img
|
|
52
|
+
src="/images/garden/admin-studio.png"
|
|
53
|
+
alt="Garden 内容管理后台,包含随想发布、草稿管理与博文分发"
|
|
54
|
+
width={1440}
|
|
55
|
+
height={1000}
|
|
56
|
+
loading="lazy"
|
|
57
|
+
/>
|
|
58
|
+
<figcaption>
|
|
59
|
+
<strong>实际后台界面</strong>
|
|
60
|
+
<span>随想编辑、已发布与草稿管理,以及博文置顶和分发都集中在同一个工作区。</span>
|
|
61
|
+
</figcaption>
|
|
62
|
+
</figure>
|
|
63
|
+
|
|
64
|
+
<div className="garden-admin-capabilities">
|
|
65
|
+
{adminCapabilities.map((capability) => (
|
|
66
|
+
<article key={capability.number}>
|
|
67
|
+
<span>{capability.number}</span>
|
|
68
|
+
<h3>{capability.title}</h3>
|
|
69
|
+
<p>{capability.description}</p>
|
|
70
|
+
</article>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="garden-admin-boundary">
|
|
75
|
+
<strong>后台的边界</strong>
|
|
76
|
+
<p>
|
|
77
|
+
它不是一套完整 CMS。长文章继续在 Typora 或其他 Markdown 编辑器中编写,专栏继续在
|
|
78
|
+
<code> content/columns.yaml </code>
|
|
79
|
+
中编排;后台主要补上随想发布、媒体上传、博文置顶和内容分发。
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<ol className="garden-admin-flow" aria-label="管理后台发布流程">
|
|
84
|
+
{adminFlow.map(([title, description], index) => (
|
|
85
|
+
<li key={title}>
|
|
86
|
+
<span>{String(index + 1).padStart(2, "0")}</span>
|
|
87
|
+
<strong>{title}</strong>
|
|
88
|
+
<p>{description}</p>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ol>
|
|
92
|
+
|
|
93
|
+
<div className="garden-admin-enable">
|
|
94
|
+
<div>
|
|
95
|
+
<p className="eyebrow">ENABLE / 启用后台</p>
|
|
96
|
+
<h3>单独部署,再连接内容仓库</h3>
|
|
97
|
+
<ol>
|
|
98
|
+
<li>把同一份 Garden 部署到 Node、Docker 或 Cloudflare Workers。</li>
|
|
99
|
+
<li>配置 GitHub OAuth、允许登录的账号,以及仅能写入内容仓库的令牌。</li>
|
|
100
|
+
<li>需要上传附件或跨平台分发时,再配置 OSS 和各平台接口。</li>
|
|
101
|
+
</ol>
|
|
102
|
+
</div>
|
|
103
|
+
<pre aria-label="管理后台核心配置示例">
|
|
104
|
+
<code>{`ADMIN_URL=https://admin.example.com
|
|
105
|
+
CONTENT_REPOSITORY=owner/repository
|
|
106
|
+
CONTENT_BRANCH=main
|
|
107
|
+
ADMIN_GITHUB_LOGINS=your-github-login
|
|
108
|
+
|
|
109
|
+
# 令牌与密钥只保存在后台服务中
|
|
110
|
+
CONTENT_GITHUB_TOKEN=github_pat_xxx
|
|
111
|
+
GITHUB_OAUTH_CLIENT_ID=Ov23li_xxx`}</code>
|
|
112
|
+
</pre>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type SourcePanelProps = {
|
|
4
|
+
path: string;
|
|
5
|
+
source: string;
|
|
6
|
+
language: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function SourcePanel({ path, source, language }: SourcePanelProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="garden-source-panel">
|
|
12
|
+
<div className="garden-source-bar">
|
|
13
|
+
<span aria-hidden="true">
|
|
14
|
+
<i />
|
|
15
|
+
<i />
|
|
16
|
+
<i />
|
|
17
|
+
</span>
|
|
18
|
+
<code>{path}</code>
|
|
19
|
+
</div>
|
|
20
|
+
<pre>
|
|
21
|
+
<code className={`language-${language}`}>{source}</code>
|
|
22
|
+
</pre>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const postSource = `---
|
|
28
|
+
title: 我的第一篇文章
|
|
29
|
+
slug: first-post
|
|
30
|
+
date: 2026-08-03T09:00:00+08:00
|
|
31
|
+
description: 这篇文章记录我为什么重新开始写博客。
|
|
32
|
+
topic: 写作
|
|
33
|
+
tags: [Markdown, 博客]
|
|
34
|
+
pinned: false
|
|
35
|
+
cover: ./assets/cover.jpg
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
正文从这里开始。
|
|
39
|
+
|
|
40
|
+
## 一个小标题
|
|
41
|
+
|
|
42
|
+
可以继续插入图片、代码、公式和 Mermaid 图表。`;
|
|
43
|
+
|
|
44
|
+
const thoughtSource = `---
|
|
45
|
+
title: 雨后的散步
|
|
46
|
+
slug: walk-after-rain
|
|
47
|
+
date: 2026-08-03T18:30:00+08:00
|
|
48
|
+
tags: [生活]
|
|
49
|
+
media:
|
|
50
|
+
- type: image
|
|
51
|
+
src: ./assets/street.jpg
|
|
52
|
+
alt: 雨后的街道
|
|
53
|
+
- type: audio
|
|
54
|
+
src: ./assets/rain.mp3
|
|
55
|
+
title: 雨声
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
今天下班后多走了两公里。`;
|
|
59
|
+
|
|
60
|
+
const columnSource = `columns:
|
|
61
|
+
- slug: writing-system
|
|
62
|
+
title: 写作系统
|
|
63
|
+
description: 记录从选题到发布的完整写作过程。
|
|
64
|
+
status: 持续更新
|
|
65
|
+
cover: /images/columns/writing-system.webp
|
|
66
|
+
coverAlt: 桌面上的笔记本与钢笔
|
|
67
|
+
posts:
|
|
68
|
+
- writing/why-write
|
|
69
|
+
- writing/collect-material
|
|
70
|
+
- writing/revise-and-publish`;
|
|
71
|
+
|
|
72
|
+
type Guide = {
|
|
73
|
+
id: string;
|
|
74
|
+
number: string;
|
|
75
|
+
label: string;
|
|
76
|
+
title: string;
|
|
77
|
+
introduction: string;
|
|
78
|
+
steps: ReactNode[];
|
|
79
|
+
features: string[];
|
|
80
|
+
result: string;
|
|
81
|
+
path: string;
|
|
82
|
+
source: string;
|
|
83
|
+
language: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const guides: Guide[] = [
|
|
87
|
+
{
|
|
88
|
+
id: "write-post",
|
|
89
|
+
number: "01",
|
|
90
|
+
label: "博文",
|
|
91
|
+
title: "写一篇文章",
|
|
92
|
+
introduction:
|
|
93
|
+
"博文保存在 content/posts/ 中,可以按照主题建立任意层级的子目录。文件路径同时决定文章地址,正文仍然是普通 Markdown。",
|
|
94
|
+
steps: [
|
|
95
|
+
<>
|
|
96
|
+
在 <code>content/posts/</code> 中创建一个 <code>.md</code> 文件。
|
|
97
|
+
</>,
|
|
98
|
+
<>在 Front Matter 中填写标题、日期、摘要、主题、标签和封面。</>,
|
|
99
|
+
<>使用 Markdown 编写正文,图片和其他附件可以使用相对路径。</>,
|
|
100
|
+
],
|
|
101
|
+
features: [
|
|
102
|
+
"GFM 表格、任务列表与脚注",
|
|
103
|
+
"TypeScript、Go、Rust 等代码高亮",
|
|
104
|
+
"数学公式与 Mermaid 图表",
|
|
105
|
+
"图片、音视频与平台嵌入",
|
|
106
|
+
"草稿、定时发布与置顶",
|
|
107
|
+
"文章目录、上一篇与下一篇",
|
|
108
|
+
],
|
|
109
|
+
result:
|
|
110
|
+
"构建时会生成文章页面,并把标题、摘要和正文加入博文列表、主题归档、全文搜索与 RSS。",
|
|
111
|
+
path: "content/posts/writing/first-post.md",
|
|
112
|
+
source: postSource,
|
|
113
|
+
language: "markdown",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "write-thought",
|
|
117
|
+
number: "02",
|
|
118
|
+
label: "随想",
|
|
119
|
+
title: "发布一条随想",
|
|
120
|
+
introduction:
|
|
121
|
+
"随想适合记录几句话、一个链接或一组多媒体。它同样保存在 Git 仓库中,只需要更少的字段,也可以通过可选管理后台发布。",
|
|
122
|
+
steps: [
|
|
123
|
+
<>
|
|
124
|
+
直接创建 <code>content/thoughts/<slug>.md</code>,或者在管理后台填写内容。
|
|
125
|
+
</>,
|
|
126
|
+
<>填写标题、唯一 slug 和日期;需要暂存时加入草稿状态。</>,
|
|
127
|
+
<>
|
|
128
|
+
在 <code>media</code> 中按顺序添加图片、音频、视频或外部链接。
|
|
129
|
+
</>,
|
|
130
|
+
],
|
|
131
|
+
features: [
|
|
132
|
+
"纯文字短内容",
|
|
133
|
+
"多张图片与混合图集",
|
|
134
|
+
"音频和视频",
|
|
135
|
+
"带标题的外部链接",
|
|
136
|
+
"草稿与公开状态",
|
|
137
|
+
"独立永久链接",
|
|
138
|
+
],
|
|
139
|
+
result:
|
|
140
|
+
"发布后的内容会出现在随想时间线和首页最近随想中,每条随想都有可以单独分享的永久链接。",
|
|
141
|
+
path: "content/thoughts/walk-after-rain.md",
|
|
142
|
+
source: thoughtSource,
|
|
143
|
+
language: "markdown",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "make-column",
|
|
147
|
+
number: "03",
|
|
148
|
+
label: "专栏",
|
|
149
|
+
title: "创建一个专栏",
|
|
150
|
+
introduction:
|
|
151
|
+
"专栏不复制文章,而是在 content/columns.yaml 中引用已经存在的博文。posts 的排列顺序就是专栏的阅读顺序。",
|
|
152
|
+
steps: [
|
|
153
|
+
<>
|
|
154
|
+
在 <code>content/columns.yaml</code> 中添加专栏名称、介绍、状态和封面。
|
|
155
|
+
</>,
|
|
156
|
+
<>
|
|
157
|
+
在 <code>posts</code> 中填写博文相对 <code>content/posts/</code> 的路径。
|
|
158
|
+
</>,
|
|
159
|
+
<>按照希望读者阅读的顺序排列路径;同一篇文章可以加入多个专栏。</>,
|
|
160
|
+
],
|
|
161
|
+
features: [
|
|
162
|
+
"专栏标题、说明和状态",
|
|
163
|
+
"独立专栏封面",
|
|
164
|
+
"文章顺序可控",
|
|
165
|
+
"复用同一份 Markdown",
|
|
166
|
+
"一篇文章加入多个专栏",
|
|
167
|
+
"系列文章导航",
|
|
168
|
+
],
|
|
169
|
+
result:
|
|
170
|
+
"构建后会生成独立专栏页面。读者可以按照配置顺序浏览文章,文章页也会显示它所属的系列与前后篇目。",
|
|
171
|
+
path: "content/columns.yaml",
|
|
172
|
+
source: columnSource,
|
|
173
|
+
language: "yaml",
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
export function GardenContentGuide() {
|
|
178
|
+
return (
|
|
179
|
+
<section className="garden-guide" aria-labelledby="garden-guide-title">
|
|
180
|
+
<header className="garden-guide-heading">
|
|
181
|
+
<div>
|
|
182
|
+
<p className="eyebrow">CONTENT / 内容</p>
|
|
183
|
+
<h2 id="garden-guide-title">博文、随想和专栏</h2>
|
|
184
|
+
</div>
|
|
185
|
+
<p>
|
|
186
|
+
先选择内容类型,再编辑对应文件。三种内容都保存在源码仓库中,直接编辑文件即可完成全部公开内容的维护和发布。
|
|
187
|
+
</p>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
<nav className="garden-guide-index" aria-label="内容类型">
|
|
191
|
+
{guides.map((guide) => (
|
|
192
|
+
<a href={`#${guide.id}`} key={guide.id}>
|
|
193
|
+
<span>{guide.number}</span>
|
|
194
|
+
<strong>{guide.title}</strong>
|
|
195
|
+
<small>{guide.label}</small>
|
|
196
|
+
</a>
|
|
197
|
+
))}
|
|
198
|
+
</nav>
|
|
199
|
+
|
|
200
|
+
<div className="garden-guide-sections">
|
|
201
|
+
{guides.map((guide) => (
|
|
202
|
+
<article className="garden-content-guide" id={guide.id} key={guide.id}>
|
|
203
|
+
<div className="garden-content-copy">
|
|
204
|
+
<p className="eyebrow">
|
|
205
|
+
{guide.number} / {guide.label}
|
|
206
|
+
</p>
|
|
207
|
+
<h2>{guide.title}</h2>
|
|
208
|
+
<p className="garden-content-introduction">{guide.introduction}</p>
|
|
209
|
+
|
|
210
|
+
<ol className="garden-content-steps">
|
|
211
|
+
{guide.steps.map((step, index) => (
|
|
212
|
+
<li key={index}>
|
|
213
|
+
<strong>{index + 1}</strong>
|
|
214
|
+
<span>{step}</span>
|
|
215
|
+
</li>
|
|
216
|
+
))}
|
|
217
|
+
</ol>
|
|
218
|
+
|
|
219
|
+
<ul className="garden-content-features" aria-label={`${guide.title}支持的能力`}>
|
|
220
|
+
{guide.features.map((feature) => (
|
|
221
|
+
<li key={feature}>{feature}</li>
|
|
222
|
+
))}
|
|
223
|
+
</ul>
|
|
224
|
+
|
|
225
|
+
<aside className="garden-content-result">
|
|
226
|
+
<strong>发布后</strong>
|
|
227
|
+
<p>{guide.result}</p>
|
|
228
|
+
</aside>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<SourcePanel
|
|
232
|
+
path={guide.path}
|
|
233
|
+
source={guide.source}
|
|
234
|
+
language={guide.language}
|
|
235
|
+
/>
|
|
236
|
+
</article>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Globe,
|
|
3
|
+
VideoCamera,
|
|
4
|
+
WechatLogo,
|
|
5
|
+
XLogo,
|
|
6
|
+
} from "@phosphor-icons/react/ssr";
|
|
7
|
+
|
|
8
|
+
const embedTypes = [
|
|
9
|
+
{
|
|
10
|
+
title: "YouTube / Bilibili",
|
|
11
|
+
description: "识别普通分享链接,自动转换成响应式播放器,并保留原站入口。",
|
|
12
|
+
Icon: VideoCamera,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
title: "X / Twitter",
|
|
16
|
+
description: "以带平台标识的摘要卡片展示,避免依赖容易失效的第三方脚本。",
|
|
17
|
+
Icon: XLogo,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
title: "微信公众号",
|
|
21
|
+
description: "保留标题、摘要和原文链接;微信禁止稳定 iframe 时仍有完整降级体验。",
|
|
22
|
+
Icon: WechatLogo,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
title: "其他网站",
|
|
26
|
+
description: "手动提供标题、摘要与封面,生成不依赖远端抓取的网页预览卡片。",
|
|
27
|
+
Icon: Globe,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const embedSource = `\`\`\`embed
|
|
32
|
+
url: https://www.youtube.com/watch?v=VIDEO_ID
|
|
33
|
+
title: 这段视频讲了什么
|
|
34
|
+
description: 给读者一个值得点开的理由。
|
|
35
|
+
image: https://example.com/cover.jpg
|
|
36
|
+
\`\`\``;
|
|
37
|
+
|
|
38
|
+
export function GardenMediaGuide() {
|
|
39
|
+
return (
|
|
40
|
+
<section className="garden-media" id="media" aria-labelledby="garden-media-title">
|
|
41
|
+
<header>
|
|
42
|
+
<p className="eyebrow">EMBEDS / 高级嵌入</p>
|
|
43
|
+
<h2 id="garden-media-title">文章不只能放文字。</h2>
|
|
44
|
+
<p>
|
|
45
|
+
本地图片、音频和视频继续使用标准 Markdown / HTML;外部平台统一使用
|
|
46
|
+
<code> embed </code>代码块。可播放的平台直接播放,受平台限制的内容自动退化为摘要卡片。
|
|
47
|
+
</p>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<div className="garden-media-layout">
|
|
51
|
+
<ul className="garden-media-providers">
|
|
52
|
+
{embedTypes.map(({ title, description, Icon }, index) => (
|
|
53
|
+
<li key={title}>
|
|
54
|
+
<span>{String(index + 1).padStart(2, "0")}</span>
|
|
55
|
+
<Icon size={22} aria-hidden="true" />
|
|
56
|
+
<div>
|
|
57
|
+
<strong>{title}</strong>
|
|
58
|
+
<p>{description}</p>
|
|
59
|
+
</div>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
|
|
64
|
+
<div className="garden-media-source">
|
|
65
|
+
<div>
|
|
66
|
+
<span>content/posts/example.md</span>
|
|
67
|
+
<small>Markdown</small>
|
|
68
|
+
</div>
|
|
69
|
+
<pre><code>{embedSource}</code></pre>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<aside className="garden-media-boundary">
|
|
74
|
+
<strong>为什么不自动抓网页摘要?</strong>
|
|
75
|
+
<p>
|
|
76
|
+
构建阶段主动抓取任意 URL 会带来 SSRF、超时和内容漂移风险。Garden 选择把摘要写进 Markdown:发布结果可预测,原站失效时文章也仍然可读。写稿时可用 <code>make embed URL=...</code> 拉一次 Open Graph,检查后再贴进围栏。
|
|
77
|
+
</p>
|
|
78
|
+
</aside>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type GiscusConfig = {
|
|
6
|
+
repo: string;
|
|
7
|
+
repoId: string;
|
|
8
|
+
category: string;
|
|
9
|
+
categoryId: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function currentGiscusTheme() {
|
|
13
|
+
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function GiscusComments({
|
|
17
|
+
slug,
|
|
18
|
+
config,
|
|
19
|
+
}: {
|
|
20
|
+
slug: string;
|
|
21
|
+
config: GiscusConfig;
|
|
22
|
+
}) {
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!containerRef.current) return;
|
|
27
|
+
const container = containerRef.current;
|
|
28
|
+
container.replaceChildren();
|
|
29
|
+
|
|
30
|
+
const script = document.createElement("script");
|
|
31
|
+
script.src = "https://giscus.app/client.js";
|
|
32
|
+
script.async = true;
|
|
33
|
+
script.crossOrigin = "anonymous";
|
|
34
|
+
script.dataset.repo = config.repo;
|
|
35
|
+
script.dataset.repoId = config.repoId;
|
|
36
|
+
script.dataset.category = config.category;
|
|
37
|
+
script.dataset.categoryId = config.categoryId;
|
|
38
|
+
script.dataset.mapping = "pathname";
|
|
39
|
+
script.dataset.strict = "1";
|
|
40
|
+
script.dataset.reactionsEnabled = "1";
|
|
41
|
+
script.dataset.emitMetadata = "0";
|
|
42
|
+
script.dataset.inputPosition = "bottom";
|
|
43
|
+
script.dataset.theme = currentGiscusTheme();
|
|
44
|
+
script.dataset.lang = "zh-CN";
|
|
45
|
+
script.dataset.loading = "lazy";
|
|
46
|
+
container.append(script);
|
|
47
|
+
|
|
48
|
+
const observer = new MutationObserver(() => {
|
|
49
|
+
const frame = container.querySelector<HTMLIFrameElement>(".giscus-frame");
|
|
50
|
+
frame?.contentWindow?.postMessage(
|
|
51
|
+
{
|
|
52
|
+
giscus: {
|
|
53
|
+
setConfig: { theme: currentGiscusTheme() },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"https://giscus.app",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
observer.observe(document.documentElement, {
|
|
60
|
+
attributes: true,
|
|
61
|
+
attributeFilter: ["data-theme"],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
observer.disconnect();
|
|
66
|
+
container.replaceChildren();
|
|
67
|
+
};
|
|
68
|
+
}, [config, slug]);
|
|
69
|
+
|
|
70
|
+
return <div className="giscus-container" ref={containerRef} />;
|
|
71
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
GitCommit,
|
|
7
|
+
GithubLogo,
|
|
8
|
+
MapPin,
|
|
9
|
+
Users,
|
|
10
|
+
} from "@phosphor-icons/react";
|
|
11
|
+
|
|
12
|
+
export type GithubPublicProfile = {
|
|
13
|
+
avatar_url: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
login: string;
|
|
16
|
+
bio: string | null;
|
|
17
|
+
public_repos: number;
|
|
18
|
+
followers: number;
|
|
19
|
+
html_url: string;
|
|
20
|
+
location: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function visibleGithubText(value: string) {
|
|
24
|
+
return Array.from(value)
|
|
25
|
+
.filter((character) => (character.codePointAt(0) ?? 0) >= 32)
|
|
26
|
+
.join("")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function GithubLiveOverview({
|
|
31
|
+
username,
|
|
32
|
+
initialProfile,
|
|
33
|
+
initialLive,
|
|
34
|
+
statusMessage,
|
|
35
|
+
recentCommits,
|
|
36
|
+
}: {
|
|
37
|
+
username: string;
|
|
38
|
+
initialProfile: GithubPublicProfile;
|
|
39
|
+
initialLive: boolean;
|
|
40
|
+
statusMessage?: string;
|
|
41
|
+
recentCommits?: number;
|
|
42
|
+
}) {
|
|
43
|
+
const [profile, setProfile] = useState(initialProfile);
|
|
44
|
+
const [syncState, setSyncState] = useState<"syncing" | "synced" | "snapshot">("syncing");
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
|
|
49
|
+
fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, {
|
|
50
|
+
headers: {
|
|
51
|
+
Accept: "application/vnd.github+json",
|
|
52
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
53
|
+
},
|
|
54
|
+
cache: "no-store",
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
})
|
|
57
|
+
.then((response) => {
|
|
58
|
+
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
|
|
59
|
+
return response.json() as Promise<GithubPublicProfile>;
|
|
60
|
+
})
|
|
61
|
+
.then((nextProfile) => {
|
|
62
|
+
setProfile(nextProfile);
|
|
63
|
+
setSyncState("synced");
|
|
64
|
+
})
|
|
65
|
+
.catch((error: unknown) => {
|
|
66
|
+
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
67
|
+
setSyncState("snapshot");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return () => controller.abort();
|
|
71
|
+
}, [username]);
|
|
72
|
+
|
|
73
|
+
const displayName = visibleGithubText(profile.name ?? profile.login) || profile.login;
|
|
74
|
+
const profileBio = profile.bio?.trim();
|
|
75
|
+
const signature = statusMessage?.trim() || profileBio;
|
|
76
|
+
const syncLabel = syncState === "synced"
|
|
77
|
+
? "本次访问已同步"
|
|
78
|
+
: syncState === "snapshot"
|
|
79
|
+
? "暂用发布时快照"
|
|
80
|
+
: "正在同步当前资料";
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<header className="github-overview">
|
|
85
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
86
|
+
<img src={profile.avatar_url} alt={`${profile.login} 的 GitHub 头像`} />
|
|
87
|
+
<div className="github-identity">
|
|
88
|
+
<span className="eyebrow" aria-live="polite"><GithubLogo size={17} /> GitHub 公开资料 · {syncLabel}</span>
|
|
89
|
+
<h2>{displayName}</h2>
|
|
90
|
+
{signature && <p>{signature}</p>}
|
|
91
|
+
{profile.location && <span className="github-location"><MapPin size={14} />{profile.location}</span>}
|
|
92
|
+
</div>
|
|
93
|
+
<a href={profile.html_url} target="_blank" rel="noopener noreferrer">
|
|
94
|
+
查看 GitHub
|
|
95
|
+
<ArrowUpRight size={16} />
|
|
96
|
+
</a>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<dl className="github-metrics">
|
|
100
|
+
<div>
|
|
101
|
+
<GithubLogo size={21} aria-hidden="true" />
|
|
102
|
+
<dt>公开仓库</dt>
|
|
103
|
+
<dd>{syncState === "synced" || initialLive ? profile.public_repos : "—"}</dd>
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<Users size={21} aria-hidden="true" />
|
|
107
|
+
<dt>Followers</dt>
|
|
108
|
+
<dd>{syncState === "synced" || initialLive ? profile.followers : "—"}</dd>
|
|
109
|
+
</div>
|
|
110
|
+
<div>
|
|
111
|
+
<GitCommit size={21} aria-hidden="true" />
|
|
112
|
+
<dt>近 30 天 commits</dt>
|
|
113
|
+
<dd>{recentCommits ?? "—"}</dd>
|
|
114
|
+
</div>
|
|
115
|
+
</dl>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|