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.
Files changed (91) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
  2. package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
  3. package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
  4. package/.env.example +56 -0
  5. package/.openai/hosting.json +5 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +48 -0
  8. package/README.md +72 -0
  9. package/app/about/page.tsx +60 -0
  10. package/app/admin/page.tsx +117 -0
  11. package/app/api/admin/distribution/route.ts +120 -0
  12. package/app/api/admin/posts/pin/route.ts +32 -0
  13. package/app/api/admin/thoughts/[id]/route.ts +44 -0
  14. package/app/api/admin/thoughts/route.ts +120 -0
  15. package/app/api/admin/uploads/route.ts +20 -0
  16. package/app/api/auth/github/callback/route.ts +136 -0
  17. package/app/api/auth/github/route.ts +29 -0
  18. package/app/api/auth/logout/route.ts +13 -0
  19. package/app/blog/[...path]/page.tsx +135 -0
  20. package/app/blog/page.tsx +139 -0
  21. package/app/chatgpt-auth.ts +86 -0
  22. package/app/columns/[column]/[...path]/page.tsx +160 -0
  23. package/app/columns/page.tsx +68 -0
  24. package/app/components/AdminDashboard.tsx +410 -0
  25. package/app/components/Comments.tsx +55 -0
  26. package/app/components/ContentEmptyState.tsx +51 -0
  27. package/app/components/ExternalEmbed.tsx +127 -0
  28. package/app/components/GardenAdminGuide.tsx +116 -0
  29. package/app/components/GardenContentGuide.tsx +241 -0
  30. package/app/components/GardenMediaGuide.tsx +81 -0
  31. package/app/components/GiscusComments.tsx +71 -0
  32. package/app/components/GithubLiveOverview.tsx +118 -0
  33. package/app/components/GithubProfile.tsx +326 -0
  34. package/app/components/MarkdownArticle.tsx +144 -0
  35. package/app/components/MermaidDiagram.tsx +117 -0
  36. package/app/components/PageIntro.tsx +32 -0
  37. package/app/components/SiteChrome.tsx +18 -0
  38. package/app/components/SiteFooter.tsx +29 -0
  39. package/app/components/SiteHeader.tsx +420 -0
  40. package/app/components/TableOfContents.tsx +433 -0
  41. package/app/components/ThoughtCard.tsx +242 -0
  42. package/app/components/Webmentions.tsx +194 -0
  43. package/app/components/WidgetChart.tsx +222 -0
  44. package/app/garden/page.tsx +284 -0
  45. package/app/garden-modules.d.ts +62 -0
  46. package/app/globals.css +4454 -0
  47. package/app/layout.tsx +102 -0
  48. package/app/lib/admin-auth.ts +134 -0
  49. package/app/lib/content.ts +112 -0
  50. package/app/lib/fallback-site-config.ts +56 -0
  51. package/app/lib/generated-content.ts +380 -0
  52. package/app/lib/github-content.ts +244 -0
  53. package/app/lib/markdown-plugins.ts +13 -0
  54. package/app/lib/oss.ts +100 -0
  55. package/app/lib/public-thoughts.ts +9 -0
  56. package/app/lib/repository-path.mjs +32 -0
  57. package/app/lib/site.ts +51 -0
  58. package/app/page.tsx +119 -0
  59. package/app/robots.ts +15 -0
  60. package/app/rss.xml/route.ts +84 -0
  61. package/app/site.config.ts +35 -0
  62. package/app/sitemap.ts +46 -0
  63. package/app/thoughts/[slug]/page.tsx +79 -0
  64. package/app/thoughts/page.tsx +33 -0
  65. package/app/yaml.d.ts +4 -0
  66. package/bin/garden.mjs +348 -0
  67. package/build/sites-vite-plugin.ts +39 -0
  68. package/eslint.config.mjs +20 -0
  69. package/next.config.ts +13 -0
  70. package/package.json +112 -0
  71. package/postcss.config.mjs +7 -0
  72. package/public/favicon.svg +4 -0
  73. package/public/images/garden/admin-studio.png +0 -0
  74. package/public/og.png +0 -0
  75. package/scripts/build-content.mjs +582 -0
  76. package/scripts/fetch-embed.mjs +189 -0
  77. package/scripts/garden-paths.mjs +21 -0
  78. package/scripts/new-post.mjs +73 -0
  79. package/scripts/prepare-pages-output.mjs +182 -0
  80. package/scripts/send-webmentions.mjs +27 -0
  81. package/site.config.yaml +48 -0
  82. package/templates/site/Makefile +46 -0
  83. package/templates/site/README.md +13 -0
  84. package/templates/site/content/columns.yaml +1 -0
  85. package/templates/site/content/posts/.gitkeep +0 -0
  86. package/templates/site/content/thoughts/.gitkeep +0 -0
  87. package/templates/site/package.json +19 -0
  88. package/templates/site/site.config.yaml +46 -0
  89. package/tsconfig.json +34 -0
  90. package/vite.config.ts +130 -0
  91. 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
+ }