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,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowUpRight,
|
|
3
|
+
PushPin,
|
|
4
|
+
Star,
|
|
5
|
+
} from "@phosphor-icons/react/ssr";
|
|
6
|
+
import { githubUsername } from "../lib/site";
|
|
7
|
+
import { siteConfig } from "../site.config";
|
|
8
|
+
import { GithubLiveOverview, type GithubPublicProfile } from "./GithubLiveOverview";
|
|
9
|
+
|
|
10
|
+
type GithubRepository = {
|
|
11
|
+
nameWithOwner: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
url: string;
|
|
14
|
+
stargazerCount: number;
|
|
15
|
+
primaryLanguage: { name: string; color: string | null } | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ContributionDay = {
|
|
19
|
+
date: string;
|
|
20
|
+
contributionCount: number;
|
|
21
|
+
contributionLevel: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type GithubEvent = {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
created_at: string;
|
|
28
|
+
repo: { name: string };
|
|
29
|
+
payload: {
|
|
30
|
+
action?: string;
|
|
31
|
+
ref?: string | null;
|
|
32
|
+
ref_type?: string | null;
|
|
33
|
+
pull_request?: { html_url?: string; title?: string };
|
|
34
|
+
issue?: { html_url?: string; title?: string };
|
|
35
|
+
release?: { html_url?: string; name?: string; tag_name?: string };
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type GithubActivity = {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
repository: string;
|
|
43
|
+
date: string;
|
|
44
|
+
url: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type GithubGraphqlResponse = {
|
|
48
|
+
data?: {
|
|
49
|
+
user?: {
|
|
50
|
+
pinnedItems: { nodes: Array<GithubRepository | null> };
|
|
51
|
+
status: { message: string; emoji: string } | null;
|
|
52
|
+
year: {
|
|
53
|
+
contributionCalendar: {
|
|
54
|
+
totalContributions: number;
|
|
55
|
+
weeks: Array<{ contributionDays: ContributionDay[] }>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
recent: { totalCommitContributions: number };
|
|
59
|
+
} | null;
|
|
60
|
+
};
|
|
61
|
+
errors?: Array<{ message: string }>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const fallback: GithubPublicProfile = {
|
|
65
|
+
avatar_url: `https://github.com/${githubUsername}.png`,
|
|
66
|
+
name: siteConfig.author.name,
|
|
67
|
+
login: githubUsername,
|
|
68
|
+
bio: siteConfig.author.githubBio,
|
|
69
|
+
public_repos: 0,
|
|
70
|
+
followers: 0,
|
|
71
|
+
html_url: `https://github.com/${githubUsername}`,
|
|
72
|
+
location: null,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const apiHeaders = (token?: string) => ({
|
|
76
|
+
Accept: "application/vnd.github+json",
|
|
77
|
+
"User-Agent": "Garden-Blog",
|
|
78
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
79
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function loadGithubUser(token?: string) {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(`https://api.github.com/users/${githubUsername}`, {
|
|
85
|
+
headers: apiHeaders(token),
|
|
86
|
+
next: { revalidate: 3600 },
|
|
87
|
+
});
|
|
88
|
+
if (!response.ok) return { profile: fallback, live: false };
|
|
89
|
+
return { profile: (await response.json()) as GithubPublicProfile, live: true };
|
|
90
|
+
} catch {
|
|
91
|
+
return { profile: fallback, live: false };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function activityFromEvent(event: GithubEvent): GithubActivity | null {
|
|
96
|
+
const repository = event.repo.name;
|
|
97
|
+
const repositoryUrl = `https://github.com/${repository}`;
|
|
98
|
+
|
|
99
|
+
if (event.type === "PushEvent") {
|
|
100
|
+
return { id: event.id, label: "推送了代码", repository, date: event.created_at, url: `${repositoryUrl}/commits` };
|
|
101
|
+
}
|
|
102
|
+
if (event.type === "PullRequestEvent") {
|
|
103
|
+
const action = event.payload.action === "closed" ? "完成了 Pull Request" : "提交了 Pull Request";
|
|
104
|
+
return { id: event.id, label: action, repository, date: event.created_at, url: event.payload.pull_request?.html_url ?? repositoryUrl };
|
|
105
|
+
}
|
|
106
|
+
if (event.type === "IssuesEvent") {
|
|
107
|
+
const action = event.payload.action === "closed" ? "关闭了 Issue" : "参与了 Issue";
|
|
108
|
+
return { id: event.id, label: action, repository, date: event.created_at, url: event.payload.issue?.html_url ?? repositoryUrl };
|
|
109
|
+
}
|
|
110
|
+
if (event.type === "CreateEvent") {
|
|
111
|
+
const target = event.payload.ref_type === "repository" ? "创建了仓库" : `创建了${event.payload.ref_type === "tag" ? "标签" : "分支"}`;
|
|
112
|
+
return { id: event.id, label: target, repository, date: event.created_at, url: repositoryUrl };
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "ReleaseEvent") {
|
|
115
|
+
return { id: event.id, label: "发布了新版本", repository, date: event.created_at, url: event.payload.release?.html_url ?? `${repositoryUrl}/releases` };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadGithubActivity(token?: string) {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(
|
|
123
|
+
`https://api.github.com/users/${githubUsername}/events/public?per_page=100`,
|
|
124
|
+
{ headers: apiHeaders(token), next: { revalidate: 1800 } },
|
|
125
|
+
);
|
|
126
|
+
if (!response.ok) return [];
|
|
127
|
+
return ((await response.json()) as GithubEvent[])
|
|
128
|
+
.map(activityFromEvent)
|
|
129
|
+
.filter((activity): activity is GithubActivity => Boolean(activity))
|
|
130
|
+
.slice(0, 6);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadConfiguredRepositories(token?: string) {
|
|
137
|
+
const names = siteConfig.author.githubPinned ?? [];
|
|
138
|
+
const repositories = await Promise.all(names.slice(0, 6).map(async (name) => {
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(`https://api.github.com/repos/${name}`, {
|
|
141
|
+
headers: apiHeaders(token),
|
|
142
|
+
next: { revalidate: 3600 },
|
|
143
|
+
});
|
|
144
|
+
if (!response.ok) return null;
|
|
145
|
+
const repository = await response.json() as {
|
|
146
|
+
full_name: string;
|
|
147
|
+
description: string | null;
|
|
148
|
+
html_url: string;
|
|
149
|
+
stargazers_count: number;
|
|
150
|
+
language: string | null;
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
nameWithOwner: repository.full_name,
|
|
154
|
+
description: repository.description,
|
|
155
|
+
url: repository.html_url,
|
|
156
|
+
stargazerCount: repository.stargazers_count,
|
|
157
|
+
primaryLanguage: repository.language ? { name: repository.language, color: null } : null,
|
|
158
|
+
} satisfies GithubRepository;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
return repositories.filter((repository): repository is GithubRepository => Boolean(repository));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function loadGithubGraph(token?: string) {
|
|
167
|
+
if (!token) return null;
|
|
168
|
+
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const recentFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
171
|
+
const yearFrom = new Date(now.getTime() - 364 * 24 * 60 * 60 * 1000);
|
|
172
|
+
const query = `query GardenGithubProfile($login: String!, $recentFrom: DateTime!, $yearFrom: DateTime!, $to: DateTime!) {
|
|
173
|
+
user(login: $login) {
|
|
174
|
+
status { message emoji }
|
|
175
|
+
pinnedItems(first: 6, types: [REPOSITORY]) {
|
|
176
|
+
nodes {
|
|
177
|
+
... on Repository {
|
|
178
|
+
nameWithOwner
|
|
179
|
+
description
|
|
180
|
+
url
|
|
181
|
+
stargazerCount
|
|
182
|
+
primaryLanguage { name color }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
year: contributionsCollection(from: $yearFrom, to: $to) {
|
|
187
|
+
contributionCalendar {
|
|
188
|
+
totalContributions
|
|
189
|
+
weeks { contributionDays { date contributionCount contributionLevel } }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
recent: contributionsCollection(from: $recentFrom, to: $to) {
|
|
193
|
+
totalCommitContributions
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}`;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { ...apiHeaders(token), "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
query,
|
|
204
|
+
variables: {
|
|
205
|
+
login: githubUsername,
|
|
206
|
+
recentFrom: recentFrom.toISOString(),
|
|
207
|
+
yearFrom: yearFrom.toISOString(),
|
|
208
|
+
to: now.toISOString(),
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
next: { revalidate: 3600 },
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok) return null;
|
|
214
|
+
const payload = await response.json() as GithubGraphqlResponse;
|
|
215
|
+
if (payload.errors?.length || !payload.data?.user) return null;
|
|
216
|
+
return payload.data.user;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formatActivityDate(value: string) {
|
|
223
|
+
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(new Date(value));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function contributionLevelClass(level: string) {
|
|
227
|
+
return level.toLowerCase().replaceAll("_", "-");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function GithubProfile() {
|
|
231
|
+
const token = process.env.GITHUB_TOKEN;
|
|
232
|
+
const [{ profile, live }, graph, activity] = await Promise.all([
|
|
233
|
+
loadGithubUser(token),
|
|
234
|
+
loadGithubGraph(token),
|
|
235
|
+
loadGithubActivity(token),
|
|
236
|
+
]);
|
|
237
|
+
const pinned = graph
|
|
238
|
+
? graph.pinnedItems.nodes.filter((repository): repository is GithubRepository => Boolean(repository))
|
|
239
|
+
: await loadConfiguredRepositories(token);
|
|
240
|
+
const calendarDays = graph?.year.contributionCalendar.weeks.flatMap((week) => week.contributionDays) ?? [];
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<section className="github-profile" aria-label="GitHub 资料">
|
|
244
|
+
<GithubLiveOverview
|
|
245
|
+
username={githubUsername}
|
|
246
|
+
initialProfile={profile}
|
|
247
|
+
initialLive={live}
|
|
248
|
+
statusMessage={graph?.status?.message}
|
|
249
|
+
recentCommits={graph?.recent.totalCommitContributions}
|
|
250
|
+
/>
|
|
251
|
+
|
|
252
|
+
<section className="github-pinned" aria-labelledby="github-pinned-title">
|
|
253
|
+
<header>
|
|
254
|
+
<div>
|
|
255
|
+
<p className="eyebrow">PINNED / 置顶仓库</p>
|
|
256
|
+
<h3 id="github-pinned-title">正在持续构建的项目</h3>
|
|
257
|
+
</div>
|
|
258
|
+
<PushPin size={20} aria-hidden="true" />
|
|
259
|
+
</header>
|
|
260
|
+
{pinned.length > 0 ? (
|
|
261
|
+
<div className="github-repository-grid">
|
|
262
|
+
{pinned.map((repository) => (
|
|
263
|
+
<a href={repository.url} target="_blank" rel="noopener noreferrer" key={repository.nameWithOwner}>
|
|
264
|
+
<strong>{repository.nameWithOwner.split("/").at(-1)}</strong>
|
|
265
|
+
<p>{repository.description ?? "查看这个公开仓库的代码与最近更新。"}</p>
|
|
266
|
+
<span>
|
|
267
|
+
{repository.primaryLanguage && <><i style={{ background: repository.primaryLanguage.color ?? undefined }} />{repository.primaryLanguage.name}</>}
|
|
268
|
+
<small><Star size={13} weight="fill" />{repository.stargazerCount}</small>
|
|
269
|
+
</span>
|
|
270
|
+
</a>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
) : (
|
|
274
|
+
<p className="github-data-note">配置 GitHub Token 或 author.githubPinned 后,这里会展示置顶仓库。</p>
|
|
275
|
+
)}
|
|
276
|
+
</section>
|
|
277
|
+
|
|
278
|
+
<section className="github-contributions" aria-labelledby="github-contributions-title">
|
|
279
|
+
<header>
|
|
280
|
+
<div>
|
|
281
|
+
<p className="eyebrow">CONTRIBUTIONS / 贡献</p>
|
|
282
|
+
<h3 id="github-contributions-title">过去一年的公开贡献</h3>
|
|
283
|
+
</div>
|
|
284
|
+
<strong>{graph ? `${graph.year.contributionCalendar.totalContributions} 次贡献` : "需要 GitHub Token"}</strong>
|
|
285
|
+
</header>
|
|
286
|
+
{calendarDays.length > 0 ? (
|
|
287
|
+
<div className="github-calendar" role="img" aria-label={`过去一年 ${graph?.year.contributionCalendar.totalContributions ?? 0} 次公开贡献`}>
|
|
288
|
+
{calendarDays.map((day) => (
|
|
289
|
+
<span
|
|
290
|
+
className={`github-contribution-day level-${contributionLevelClass(day.contributionLevel)}`}
|
|
291
|
+
key={day.date}
|
|
292
|
+
title={`${day.date} · ${day.contributionCount} 次贡献`}
|
|
293
|
+
aria-label={`${day.date},${day.contributionCount} 次贡献`}
|
|
294
|
+
/>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
<p className="github-data-note">正式发布时会使用工作流的只读令牌生成贡献热点图。</p>
|
|
299
|
+
)}
|
|
300
|
+
</section>
|
|
301
|
+
|
|
302
|
+
<section className="github-activity" aria-labelledby="github-activity-title">
|
|
303
|
+
<header>
|
|
304
|
+
<p className="eyebrow">CONTRIBUTION ACTIVITY / 最近活动</p>
|
|
305
|
+
<h3 id="github-activity-title">最近在 GitHub 上做了什么</h3>
|
|
306
|
+
</header>
|
|
307
|
+
{activity.length > 0 ? (
|
|
308
|
+
<ol>
|
|
309
|
+
{activity.map((item) => (
|
|
310
|
+
<li key={item.id}>
|
|
311
|
+
<time dateTime={item.date}>{formatActivityDate(item.date)}</time>
|
|
312
|
+
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
|
313
|
+
<span>{item.label}</span>
|
|
314
|
+
<strong>{item.repository}</strong>
|
|
315
|
+
<ArrowUpRight size={15} />
|
|
316
|
+
</a>
|
|
317
|
+
</li>
|
|
318
|
+
))}
|
|
319
|
+
</ol>
|
|
320
|
+
) : (
|
|
321
|
+
<p className="github-data-note">GitHub 暂时没有返回最近的公开贡献活动。</p>
|
|
322
|
+
)}
|
|
323
|
+
</section>
|
|
324
|
+
</section>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { markdownPlugins } from "../lib/markdown-plugins";
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
} from "react";
|
|
7
|
+
import ReactMarkdown from "react-markdown";
|
|
8
|
+
import rehypeRaw from "rehype-raw";
|
|
9
|
+
import rehypeKatex from "rehype-katex";
|
|
10
|
+
import rehypeSlug from "rehype-slug";
|
|
11
|
+
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
12
|
+
import rehypeHighlight from "rehype-highlight";
|
|
13
|
+
import { MermaidDiagram } from "./MermaidDiagram";
|
|
14
|
+
import { ExternalEmbed } from "./ExternalEmbed";
|
|
15
|
+
import { WidgetChart } from "./WidgetChart";
|
|
16
|
+
|
|
17
|
+
function CodeBlock({
|
|
18
|
+
className,
|
|
19
|
+
children,
|
|
20
|
+
...props
|
|
21
|
+
}: HTMLAttributes<HTMLElement> & { node?: unknown }) {
|
|
22
|
+
if (className?.includes("language-mermaid")) {
|
|
23
|
+
return <MermaidDiagram source={String(children).trim()} />;
|
|
24
|
+
}
|
|
25
|
+
if (className?.includes("language-embed")) {
|
|
26
|
+
return <ExternalEmbed source={String(children).trim()} />;
|
|
27
|
+
}
|
|
28
|
+
if (className?.includes("language-widget")) {
|
|
29
|
+
return <WidgetChart source={String(children).trim()} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const codeProps = { ...props };
|
|
33
|
+
delete codeProps.node;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<code className={className} {...codeProps}>
|
|
37
|
+
{children}
|
|
38
|
+
</code>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const calloutKinds: Record<string, { tone: "info" | "success" | "warning" | "danger" }> = {
|
|
43
|
+
NOTE: { tone: "info" }, INFO: { tone: "info" },
|
|
44
|
+
TIP: { tone: "success" }, SUCCESS: { tone: "success" },
|
|
45
|
+
IMPORTANT: { tone: "warning" }, WARNING: { tone: "warning" },
|
|
46
|
+
CAUTION: { tone: "danger" }, DANGER: { tone: "danger" }, FAILURE: { tone: "danger" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const automaticCalloutLabels = new Set([
|
|
50
|
+
"提示", "说明", "建议", "成功", "重要",
|
|
51
|
+
"告警", "警告", "警示", "危险", "失败",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export function normalizeCallouts(markdown: string) {
|
|
55
|
+
let fence: string | null = null;
|
|
56
|
+
let pendingLegacyLabel = false;
|
|
57
|
+
return markdown.split("\n").flatMap((line) => {
|
|
58
|
+
const marker = line.match(/^\s*(`{3,}|~{3,})/)?.[1];
|
|
59
|
+
if (marker) { if (!fence) fence = marker[0]; else if (marker[0] === fence) fence = null; return [line]; }
|
|
60
|
+
if (fence) return [line];
|
|
61
|
+
const legacyMarker = line.match(/garden-callout-marker--(info|success|warning|danger)/u)?.[1];
|
|
62
|
+
if (legacyMarker) {
|
|
63
|
+
pendingLegacyLabel = true;
|
|
64
|
+
return [line];
|
|
65
|
+
}
|
|
66
|
+
if (pendingLegacyLabel && /^>\s*$/u.test(line)) return [line];
|
|
67
|
+
if (pendingLegacyLabel) {
|
|
68
|
+
pendingLegacyLabel = false;
|
|
69
|
+
const label = line.match(/^>\s*\*\*(.+?)\*\*\s*$/u)?.[1].trim();
|
|
70
|
+
if (label && automaticCalloutLabels.has(label)) return [];
|
|
71
|
+
}
|
|
72
|
+
const match = line.match(/^>\s*\[!([a-z]+)\]\s*(.*)$/iu);
|
|
73
|
+
const callout = match ? calloutKinds[match[1].toUpperCase()] : undefined;
|
|
74
|
+
if (!callout) return [line];
|
|
75
|
+
const title = match?.[2].trim();
|
|
76
|
+
const lines = [`> <span class="garden-callout-marker garden-callout-marker--${callout.tone}" aria-hidden="true"></span>`, ">"];
|
|
77
|
+
if (title && !automaticCalloutLabels.has(title)) lines.push(`> **${title}**`, ">");
|
|
78
|
+
return lines;
|
|
79
|
+
}).join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function MarkdownArticle({ content }: { content: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="markdown-body">
|
|
85
|
+
<ReactMarkdown
|
|
86
|
+
remarkPlugins={markdownPlugins}
|
|
87
|
+
rehypePlugins={[
|
|
88
|
+
rehypeRaw,
|
|
89
|
+
rehypeKatex,
|
|
90
|
+
rehypeSlug,
|
|
91
|
+
[rehypeAutolinkHeadings, { behavior: "wrap" }],
|
|
92
|
+
rehypeHighlight,
|
|
93
|
+
]}
|
|
94
|
+
components={{
|
|
95
|
+
a: ({ href, children, ...props }) => {
|
|
96
|
+
const external = href?.startsWith("http");
|
|
97
|
+
return (
|
|
98
|
+
<a
|
|
99
|
+
href={href}
|
|
100
|
+
{...props}
|
|
101
|
+
{...(external
|
|
102
|
+
? { target: "_blank", rel: "noopener noreferrer" }
|
|
103
|
+
: {})}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</a>
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
img: ({ alt, ...props }) => (
|
|
110
|
+
// Native images keep Typora-authored relative paths intact.
|
|
111
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
112
|
+
<img loading="lazy" alt={alt ?? ""} {...props} />
|
|
113
|
+
),
|
|
114
|
+
code: CodeBlock,
|
|
115
|
+
pre: ({ children, ...props }) => {
|
|
116
|
+
const child = Children.only(children);
|
|
117
|
+
const className = isValidElement<{ className?: string }>(child)
|
|
118
|
+
? child.props.className
|
|
119
|
+
: undefined;
|
|
120
|
+
const language = className?.match(/language-([\w-]+)/)?.[1];
|
|
121
|
+
const preProps = { ...props };
|
|
122
|
+
delete preProps.node;
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
className?.includes("language-mermaid") ||
|
|
126
|
+
className?.includes("language-embed") ||
|
|
127
|
+
className?.includes("language-widget")
|
|
128
|
+
) {
|
|
129
|
+
return <>{children}</>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<pre {...preProps} data-language={language?.toUpperCase()}>
|
|
134
|
+
{children}
|
|
135
|
+
</pre>
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{normalizeCallouts(content)}
|
|
141
|
+
</ReactMarkdown>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useState } from "react";
|
|
4
|
+
|
|
5
|
+
let mermaidRenderQueue: Promise<unknown> = Promise.resolve();
|
|
6
|
+
|
|
7
|
+
function renderMermaid(source: string, id: string) {
|
|
8
|
+
const task = mermaidRenderQueue.then(async () => {
|
|
9
|
+
const mermaid = (await import("mermaid")).default;
|
|
10
|
+
mermaid.initialize({
|
|
11
|
+
startOnLoad: false,
|
|
12
|
+
theme: "neutral",
|
|
13
|
+
fontFamily: "var(--font-mono)",
|
|
14
|
+
securityLevel: "strict",
|
|
15
|
+
suppressErrorRendering: true,
|
|
16
|
+
});
|
|
17
|
+
return mermaid.render(id, source);
|
|
18
|
+
});
|
|
19
|
+
mermaidRenderQueue = task.then(
|
|
20
|
+
() => undefined,
|
|
21
|
+
() => undefined,
|
|
22
|
+
);
|
|
23
|
+
return task;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MermaidDiagram({ source }: { source: string }) {
|
|
27
|
+
const reactId = useId();
|
|
28
|
+
const [view, setView] = useState<"render" | "code">("render");
|
|
29
|
+
const [svg, setSvg] = useState("");
|
|
30
|
+
const [error, setError] = useState(false);
|
|
31
|
+
const panelId = `mermaid-panel-${reactId.replaceAll(":", "")}`;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let active = true;
|
|
35
|
+
const draw = async () => {
|
|
36
|
+
setError(false);
|
|
37
|
+
try {
|
|
38
|
+
const id = `mermaid-${reactId.replaceAll(":", "")}`;
|
|
39
|
+
const result = await renderMermaid(source, id);
|
|
40
|
+
if (active) setSvg(result.svg);
|
|
41
|
+
} catch {
|
|
42
|
+
if (active) setError(true);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
void draw();
|
|
46
|
+
return () => {
|
|
47
|
+
active = false;
|
|
48
|
+
};
|
|
49
|
+
}, [reactId, source]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<figure
|
|
53
|
+
className="mermaid-block language-mermaid"
|
|
54
|
+
data-mermaid-theme="neutral"
|
|
55
|
+
>
|
|
56
|
+
<figcaption className="mermaid-toolbar">
|
|
57
|
+
<span>Mermaid</span>
|
|
58
|
+
<span
|
|
59
|
+
className="mermaid-view-switcher"
|
|
60
|
+
role="tablist"
|
|
61
|
+
aria-label="切换 Mermaid 显示方式"
|
|
62
|
+
>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
role="tab"
|
|
66
|
+
aria-selected={view === "render"}
|
|
67
|
+
aria-controls={panelId}
|
|
68
|
+
tabIndex={view === "render" ? 0 : -1}
|
|
69
|
+
onClick={() => setView("render")}
|
|
70
|
+
>
|
|
71
|
+
图表
|
|
72
|
+
</button>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
role="tab"
|
|
76
|
+
aria-selected={view === "code"}
|
|
77
|
+
aria-controls={panelId}
|
|
78
|
+
tabIndex={view === "code" ? 0 : -1}
|
|
79
|
+
onClick={() => setView("code")}
|
|
80
|
+
>
|
|
81
|
+
Code
|
|
82
|
+
</button>
|
|
83
|
+
</span>
|
|
84
|
+
</figcaption>
|
|
85
|
+
{view === "render" ? (
|
|
86
|
+
<div
|
|
87
|
+
className="mermaid-canvas"
|
|
88
|
+
id={panelId}
|
|
89
|
+
role="tabpanel"
|
|
90
|
+
aria-live="polite"
|
|
91
|
+
>
|
|
92
|
+
{svg ? (
|
|
93
|
+
<div
|
|
94
|
+
className="mermaid-svg"
|
|
95
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
96
|
+
/>
|
|
97
|
+
) : null}
|
|
98
|
+
{!svg && !error ? (
|
|
99
|
+
<span className="mermaid-loading">正在绘制图表…</span>
|
|
100
|
+
) : null}
|
|
101
|
+
{error ? (
|
|
102
|
+
<span className="mermaid-error">图表渲染失败,请检查语法。</span>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
<pre
|
|
107
|
+
className="mermaid-code"
|
|
108
|
+
id={panelId}
|
|
109
|
+
role="tabpanel"
|
|
110
|
+
data-language="MERMAID"
|
|
111
|
+
>
|
|
112
|
+
<code>{source}</code>
|
|
113
|
+
</pre>
|
|
114
|
+
)}
|
|
115
|
+
</figure>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type PageIntroProps = {
|
|
4
|
+
eyebrow: string;
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Shared heading treatment for every top-level public page. */
|
|
11
|
+
export function PageIntro({
|
|
12
|
+
eyebrow,
|
|
13
|
+
title,
|
|
14
|
+
subtitle,
|
|
15
|
+
children,
|
|
16
|
+
}: PageIntroProps) {
|
|
17
|
+
return (
|
|
18
|
+
<header className="page-intro">
|
|
19
|
+
<p className="eyebrow">{eyebrow}</p>
|
|
20
|
+
<h1>
|
|
21
|
+
{title.split("\n").map((line, index) => (
|
|
22
|
+
<span key={`${line}-${index}`}>
|
|
23
|
+
{index > 0 && <br />}
|
|
24
|
+
{line}
|
|
25
|
+
</span>
|
|
26
|
+
))}
|
|
27
|
+
</h1>
|
|
28
|
+
{subtitle && <p>{subtitle}</p>}
|
|
29
|
+
{children && <div className="page-intro-actions">{children}</div>}
|
|
30
|
+
</header>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { SiteFooter } from "./SiteFooter";
|
|
5
|
+
import { SiteHeader } from "./SiteHeader";
|
|
6
|
+
|
|
7
|
+
export function SiteChrome({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const pathname = usePathname();
|
|
9
|
+
if (pathname?.startsWith("/admin")) return <>{children}</>;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<SiteHeader />
|
|
14
|
+
<div id="main-content">{children}</div>
|
|
15
|
+
<SiteFooter />
|
|
16
|
+
</>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { GithubLogo, Rss } from "@phosphor-icons/react/ssr";
|
|
2
|
+
import { githubUrl, siteConfig } from "../site.config";
|
|
3
|
+
|
|
4
|
+
export function SiteFooter() {
|
|
5
|
+
return (
|
|
6
|
+
<footer className="site-footer">
|
|
7
|
+
<div className="footer-inner">
|
|
8
|
+
<p>
|
|
9
|
+
© {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
|
|
10
|
+
<span className="footer-framework"> · 基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
|
|
11
|
+
</p>
|
|
12
|
+
<div>
|
|
13
|
+
<a
|
|
14
|
+
href={githubUrl}
|
|
15
|
+
aria-label="GitHub"
|
|
16
|
+
rel="me"
|
|
17
|
+
>
|
|
18
|
+
<GithubLogo size={19} />
|
|
19
|
+
GitHub
|
|
20
|
+
</a>
|
|
21
|
+
<a href="/rss.xml" aria-label="RSS 订阅">
|
|
22
|
+
<Rss size={18} />
|
|
23
|
+
RSS
|
|
24
|
+
</a>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</footer>
|
|
28
|
+
);
|
|
29
|
+
}
|