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,139 @@
1
+ import Link from "next/link";
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ PushPin,
6
+ } from "@phosphor-icons/react/ssr";
7
+ import { formatDate, postHref, posts, topics } from "../lib/content";
8
+ import { siteConfig } from "../site.config";
9
+ import { ContentEmptyState } from "../components/ContentEmptyState";
10
+
11
+ export const metadata = {
12
+ title: siteConfig.pages.blog.title,
13
+ description: siteConfig.pages.blog.subtitle,
14
+ };
15
+
16
+ const pageSize = 8;
17
+
18
+ const archiveHref = (topic?: string, page?: number) => {
19
+ const params = new URLSearchParams();
20
+ if (topic) params.set("topic", topic);
21
+ if (page && page > 1) params.set("page", String(page));
22
+ const query = params.toString();
23
+ return query ? `/blog?${query}` : "/blog";
24
+ };
25
+
26
+ export default async function BlogIndex({
27
+ searchParams,
28
+ }: {
29
+ searchParams: Promise<{ topic?: string; page?: string }>;
30
+ }) {
31
+ const requested = await searchParams;
32
+ const selectedTopic = topics.includes(requested.topic ?? "")
33
+ ? requested.topic
34
+ : undefined;
35
+ const filteredPosts = selectedTopic
36
+ ? posts.filter((post) => post.topic === selectedTopic)
37
+ : posts;
38
+ const pageCount = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
39
+ const parsedPage = Number.parseInt(requested.page ?? "1", 10);
40
+ const currentPage = Math.min(
41
+ pageCount,
42
+ Math.max(1, Number.isFinite(parsedPage) ? parsedPage : 1),
43
+ );
44
+ const visiblePosts = filteredPosts.slice(
45
+ (currentPage - 1) * pageSize,
46
+ currentPage * pageSize,
47
+ );
48
+
49
+ return (
50
+ <main className="page-shell index-page">
51
+ <header className="page-intro">
52
+ <p className="eyebrow">POSTS / 博文</p>
53
+ <h1>{siteConfig.pages.blog.title}</h1>
54
+ <p>{siteConfig.pages.blog.subtitle}</p>
55
+ </header>
56
+
57
+ <div className="topic-strip" aria-label="文章主题">
58
+ <Link
59
+ href="/blog"
60
+ className={!selectedTopic ? "active" : ""}
61
+ aria-current={!selectedTopic ? "page" : undefined}
62
+ >
63
+ 全部 · {posts.length}
64
+ </Link>
65
+ {topics.map((topic) => (
66
+ <Link
67
+ href={archiveHref(topic)}
68
+ className={selectedTopic === topic ? "active" : ""}
69
+ aria-current={selectedTopic === topic ? "page" : undefined}
70
+ key={topic}
71
+ >
72
+ {topic} · {posts.filter((post) => post.topic === topic).length}
73
+ </Link>
74
+ ))}
75
+ </div>
76
+
77
+ {visiblePosts.length > 0 ? <section className="archive-list" aria-label="全部博文">
78
+ {visiblePosts.map((post) => (
79
+ <Link
80
+ key={post.path}
81
+ href={postHref(post)}
82
+ className={`archive-row${post.cover ? " with-cover" : ""}`}
83
+ >
84
+ {post.cover && (
85
+ // eslint-disable-next-line @next/next/no-img-element
86
+ <img
87
+ className="archive-cover"
88
+ src={post.cover}
89
+ alt={post.coverAlt ?? `${post.title} 封面`}
90
+ loading="lazy"
91
+ />
92
+ )}
93
+ <div className="archive-copy">
94
+ <div className="archive-meta">
95
+ <time>{formatDate(post.date)}</time>
96
+ <span className="archive-topic">
97
+ {post.topic}
98
+ {post.pinned && (
99
+ <span className="pinned-badge">
100
+ <PushPin size={12} weight="fill" />
101
+ 置顶
102
+ </span>
103
+ )}
104
+ </span>
105
+ </div>
106
+ <h2>{post.title}</h2>
107
+ <p>{post.description}</p>
108
+ </div>
109
+ <ArrowRight size={19} />
110
+ </Link>
111
+ ))}
112
+ </section> : <ContentEmptyState kind="posts" />}
113
+
114
+ {pageCount > 1 && (
115
+ <nav className="archive-pagination" aria-label="博文分页">
116
+ {currentPage > 1 ? (
117
+ <Link href={archiveHref(selectedTopic, currentPage - 1)}>
118
+ <ArrowLeft size={16} />
119
+ 上一页
120
+ </Link>
121
+ ) : (
122
+ <span />
123
+ )}
124
+ <span>
125
+ {currentPage} / {pageCount}
126
+ </span>
127
+ {currentPage < pageCount ? (
128
+ <Link href={archiveHref(selectedTopic, currentPage + 1)}>
129
+ 下一页
130
+ <ArrowRight size={16} />
131
+ </Link>
132
+ ) : (
133
+ <span />
134
+ )}
135
+ </nav>
136
+ )}
137
+ </main>
138
+ );
139
+ }
@@ -0,0 +1,86 @@
1
+ import { headers } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+
4
+ export type ChatGPTUser = {
5
+ displayName: string;
6
+ email: string;
7
+ fullName: string | null;
8
+ };
9
+
10
+ const USER_EMAIL_HEADER = "oai-authenticated-user-email";
11
+ const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
12
+ const USER_FULL_NAME_ENCODING_HEADER =
13
+ "oai-authenticated-user-full-name-encoding";
14
+ const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
15
+ const SIGN_IN_PATH = "/signin-with-chatgpt";
16
+ const SIGN_OUT_PATH = "/signout-with-chatgpt";
17
+ const CALLBACK_PATH = "/callback";
18
+
19
+ export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
20
+ const requestHeaders = await headers();
21
+ const email = requestHeaders.get(USER_EMAIL_HEADER);
22
+ if (!email) return null;
23
+
24
+ const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
25
+ const fullName =
26
+ encodedFullName &&
27
+ requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
28
+ ? safeDecodeURIComponent(encodedFullName)
29
+ : null;
30
+
31
+ return {
32
+ displayName: fullName ?? email,
33
+ email,
34
+ fullName,
35
+ };
36
+ }
37
+
38
+ export async function requireChatGPTUser(
39
+ returnTo: string,
40
+ ): Promise<ChatGPTUser> {
41
+ const user = await getChatGPTUser();
42
+ if (user) return user;
43
+
44
+ redirect(chatGPTSignInPath(returnTo));
45
+ }
46
+
47
+ export function chatGPTSignInPath(returnTo: string): string {
48
+ const safeReturnTo = safeRelativeReturnPath(returnTo);
49
+ return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
50
+ }
51
+
52
+ export function chatGPTSignOutPath(returnTo = "/"): string {
53
+ const safeReturnTo = safeRelativeReturnPath(returnTo);
54
+ return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
55
+ }
56
+
57
+ function safeRelativeReturnPath(value: string): string {
58
+ if (!value.startsWith("/") || value.startsWith("//")) return "/";
59
+
60
+ let url: URL;
61
+ try {
62
+ url = new URL(value, "https://app.local");
63
+ } catch {
64
+ return "/";
65
+ }
66
+ if (url.origin !== "https://app.local") return "/";
67
+ if (isReservedAuthPath(url.pathname)) return "/";
68
+
69
+ return `${url.pathname}${url.search}${url.hash}`;
70
+ }
71
+
72
+ function isReservedAuthPath(pathname: string): boolean {
73
+ return (
74
+ pathname === SIGN_IN_PATH ||
75
+ pathname === SIGN_OUT_PATH ||
76
+ pathname === CALLBACK_PATH
77
+ );
78
+ }
79
+
80
+ function safeDecodeURIComponent(value: string): string | null {
81
+ try {
82
+ return decodeURIComponent(value);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
@@ -0,0 +1,160 @@
1
+ import Link from "next/link";
2
+ import type { Metadata } from "next";
3
+ import { notFound } from "next/navigation";
4
+ import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
5
+ import {
6
+ columns,
7
+ extractHeadings,
8
+ findColumnEntry,
9
+ formatDate,
10
+ columnHref,
11
+ columnEntryPath,
12
+ } from "../../../lib/content";
13
+ import { MarkdownArticle } from "../../../components/MarkdownArticle";
14
+ import { TableOfContents } from "../../../components/TableOfContents";
15
+ import { Comments } from "../../../components/Comments";
16
+ import { Webmentions } from "../../../components/Webmentions";
17
+ import {
18
+ absoluteUrl,
19
+ getSiteUrl,
20
+ webmentionApiEndpoint,
21
+ } from "../../../lib/site";
22
+ import { githubUrl, siteConfig } from "../../../site.config";
23
+
24
+ export function generateStaticParams() {
25
+ return columns.map((entry) => ({
26
+ column: entry.column,
27
+ path: columnEntryPath(entry).split("/"),
28
+ }));
29
+ }
30
+
31
+ export async function generateMetadata({
32
+ params,
33
+ }: {
34
+ params: Promise<{ column: string; path: string[] }>;
35
+ }) {
36
+ const { column, path } = await params;
37
+ const entry = findColumnEntry(column, path.join("/"));
38
+ if (!entry) return {};
39
+ const baseUrl = await getSiteUrl();
40
+ const pathname = columnHref(entry);
41
+ return {
42
+ title: entry.title.replace(/^\d+\s*·\s*/u, ""),
43
+ description: entry.description,
44
+ alternates: { canonical: pathname },
45
+ openGraph: {
46
+ type: "article",
47
+ url: absoluteUrl(pathname, baseUrl),
48
+ title: entry.title.replace(/^\d+\s*·\s*/u, ""),
49
+ description: entry.description,
50
+ publishedTime: new Date(entry.date).toISOString(),
51
+ modifiedTime: entry.updated
52
+ ? new Date(entry.updated).toISOString()
53
+ : undefined,
54
+ tags: entry.tags,
55
+ images: entry.cover ? [{ url: entry.cover, alt: entry.coverAlt }] : undefined,
56
+ },
57
+ twitter: {
58
+ card: "summary_large_image",
59
+ title: entry.title.replace(/^\d+\s*·\s*/u, ""),
60
+ description: entry.description,
61
+ images: entry.cover ? [entry.cover] : ["/og.png"],
62
+ },
63
+ } satisfies Metadata;
64
+ }
65
+
66
+ export default async function ColumnEntryPage({
67
+ params,
68
+ }: {
69
+ params: Promise<{ column: string; path: string[] }>;
70
+ }) {
71
+ const { column, path } = await params;
72
+ const entry = findColumnEntry(column, path.join("/"));
73
+ if (!entry) notFound();
74
+ const series = columns.filter((item) => item.column === column);
75
+ const currentIndex = series.findIndex((item) => item.path === entry.path);
76
+ const next = series[currentIndex + 1];
77
+ const headings = extractHeadings(entry.content);
78
+ const baseUrl = await getSiteUrl();
79
+ const pathname = columnHref(entry);
80
+ const canonicalUrl = absoluteUrl(pathname, baseUrl);
81
+
82
+ return (
83
+ <main className="column-reading-page">
84
+ <aside className="series-nav">
85
+ <Link href="/columns" className="back-link">
86
+ <ArrowLeft size={15} />
87
+ 所有专栏
88
+ </Link>
89
+ <p>主题专栏</p>
90
+ <h2>{entry.columnTitle}</h2>
91
+ <ol>
92
+ {series.map((item) => (
93
+ <li key={item.path} className={item.path === entry.path ? "active" : ""}>
94
+ <Link href={columnHref(item)}>{item.title}</Link>
95
+ </li>
96
+ ))}
97
+ </ol>
98
+ <small>{series.length} 篇 · 持续更新</small>
99
+ </aside>
100
+
101
+ <article className="article-column series-article h-entry">
102
+ <Link className="u-url sr-only" href={pathname}>
103
+ {entry.title} 永久链接
104
+ </Link>
105
+ <a
106
+ className="p-author h-card sr-only"
107
+ href={githubUrl}
108
+ rel="author"
109
+ >
110
+ {siteConfig.author.name}
111
+ </a>
112
+ {entry.cover && (
113
+ <figure className="article-hero article-cover">
114
+ {/* eslint-disable-next-line @next/next/no-img-element */}
115
+ <img
116
+ className="u-photo"
117
+ src={entry.cover}
118
+ alt={entry.coverAlt ?? `${entry.title} 封面`}
119
+ />
120
+ </figure>
121
+ )}
122
+ <header className="article-header">
123
+ <p className="eyebrow">
124
+ {entry.columnTitle} · 第 {currentIndex + 1} 篇
125
+ </p>
126
+ <h1 className="p-name">
127
+ {entry.title.replace(/^\d+\s*·\s*/u, "")}
128
+ </h1>
129
+ <p className="article-deck p-summary">{entry.description}</p>
130
+ <div className="article-meta">
131
+ <time className="dt-published" dateTime={entry.date}>
132
+ {formatDate(entry.date)}
133
+ </time>
134
+ <span>{entry.readingTime}</span>
135
+ </div>
136
+ </header>
137
+ <div className="e-content">
138
+ <MarkdownArticle content={entry.content} />
139
+ </div>
140
+ {next && (
141
+ <Link
142
+ href={columnHref(next)}
143
+ className="next-entry"
144
+ >
145
+ <span>下一篇</span>
146
+ <strong>{next.title}</strong>
147
+ <ArrowRight size={20} />
148
+ </Link>
149
+ )}
150
+ <Webmentions
151
+ target={canonicalUrl}
152
+ endpoint={webmentionApiEndpoint()}
153
+ />
154
+ <Comments slug={`${column}:${entry.path}`} />
155
+ </article>
156
+
157
+ <TableOfContents headings={headings} />
158
+ </main>
159
+ );
160
+ }
@@ -0,0 +1,68 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight } from "@phosphor-icons/react/ssr";
3
+ import { columnHref, columns } from "../lib/content";
4
+ import { PageIntro } from "../components/PageIntro";
5
+ import { ContentEmptyState } from "../components/ContentEmptyState";
6
+ import { siteConfig } from "../site.config";
7
+
8
+ export const metadata = {
9
+ title: siteConfig.pages.columns.title,
10
+ description: siteConfig.pages.columns.subtitle,
11
+ };
12
+
13
+ export default function ColumnsPage() {
14
+ const grouped = Array.from(
15
+ columns.reduce((result, entry) => {
16
+ const existing = result.get(entry.column ?? "");
17
+ if (existing) existing.push(entry);
18
+ else result.set(entry.column ?? "", [entry]);
19
+ return result;
20
+ }, new Map<string, typeof columns>()),
21
+ ).map(([slug, entries]) => ({
22
+ slug,
23
+ entries,
24
+ first: entries[0],
25
+ }));
26
+
27
+ return (
28
+ <main className="page-shell index-page">
29
+ <PageIntro
30
+ eyebrow="COLUMNS / 专栏"
31
+ title={siteConfig.pages.columns.title}
32
+ subtitle={siteConfig.pages.columns.subtitle}
33
+ />
34
+
35
+ {grouped.length > 0 ? <div className="column-feature-list">
36
+ {grouped.map(({ slug, entries, first }, index) => (
37
+ <Link
38
+ href={columnHref(first)}
39
+ className="column-feature"
40
+ key={slug}
41
+ >
42
+ <div className="column-feature-copy">
43
+ <span>
44
+ {first.columnStatus ?? "持续更新"} · {entries.length} 篇 ·{" "}
45
+ {first.topic ?? "主题写作"}
46
+ </span>
47
+ <h2>{first.columnTitle}</h2>
48
+ <p>
49
+ {first.columnDescription ??
50
+ first.description ??
51
+ "沿着一条清晰的阅读路径,把一个主题持续写深。"}
52
+ </p>
53
+ <strong>
54
+ 从第一篇开始
55
+ <ArrowRight size={17} />
56
+ </strong>
57
+ </div>
58
+ <div className="column-feature-index">
59
+ <span>主题专栏</span>
60
+ <strong>{String(index + 1).padStart(2, "0")}</strong>
61
+ <small>{slug.replaceAll("-", " ").toUpperCase()}</small>
62
+ </div>
63
+ </Link>
64
+ ))}
65
+ </div> : <ContentEmptyState kind="columns" />}
66
+ </main>
67
+ );
68
+ }