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,420 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent,
11
+ } from "react";
12
+ import Fuse, { type FuseResultMatch } from "fuse.js";
13
+ import {
14
+ Cactus,
15
+ MagnifyingGlass,
16
+ Moon,
17
+ Sun,
18
+ X,
19
+ } from "@phosphor-icons/react";
20
+ import { searchRecords } from "../lib/content";
21
+ import { siteConfig } from "../site.config";
22
+
23
+ const nav = [
24
+ { href: "/", label: "首页" },
25
+ { href: "/blog", label: "博文" },
26
+ { href: "/thoughts", label: "随想" },
27
+ { href: "/columns", label: "专栏" },
28
+ { href: "/about", label: "关于" },
29
+ ];
30
+
31
+ type MatchRange = readonly [number, number];
32
+
33
+ function rangesFor(
34
+ matches: readonly FuseResultMatch[] | undefined,
35
+ key: string,
36
+ ): readonly MatchRange[] {
37
+ return matches?.find((match) => match.key === key)?.indices ?? [];
38
+ }
39
+
40
+ function HighlightedText({
41
+ text,
42
+ ranges,
43
+ }: {
44
+ text: string;
45
+ ranges: readonly MatchRange[];
46
+ }) {
47
+ if (!ranges.length) return text;
48
+
49
+ const merged = [...ranges]
50
+ .sort((left, right) => left[0] - right[0])
51
+ .reduce<MatchRange[]>((result, range) => {
52
+ const previous = result.at(-1);
53
+ if (previous && range[0] <= previous[1] + 1) {
54
+ result[result.length - 1] = [
55
+ previous[0],
56
+ Math.max(previous[1], range[1]),
57
+ ];
58
+ } else {
59
+ result.push(range);
60
+ }
61
+ return result;
62
+ }, []);
63
+
64
+ const parts: Array<{ text: string; matched: boolean }> = [];
65
+ let cursor = 0;
66
+ merged.forEach(([start, end]) => {
67
+ if (start > cursor) {
68
+ parts.push({ text: text.slice(cursor, start), matched: false });
69
+ }
70
+ parts.push({ text: text.slice(start, end + 1), matched: true });
71
+ cursor = end + 1;
72
+ });
73
+ if (cursor < text.length) {
74
+ parts.push({ text: text.slice(cursor), matched: false });
75
+ }
76
+
77
+ return (
78
+ <>
79
+ {parts.map((part, index) =>
80
+ part.matched ? (
81
+ <mark className="search-match" key={`${part.text}-${index}`}>
82
+ {part.text}
83
+ </mark>
84
+ ) : (
85
+ <span key={`${part.text}-${index}`}>{part.text}</span>
86
+ ),
87
+ )}
88
+ </>
89
+ );
90
+ }
91
+
92
+ function cropMatchedText(text: string, ranges: readonly MatchRange[]) {
93
+ if (!ranges.length) return { text, ranges };
94
+
95
+ const [focusStart, focusEnd] = ranges[0];
96
+ let start = Math.max(0, focusStart - 42);
97
+ let end = Math.min(text.length, Math.max(focusEnd + 52, start + 112));
98
+
99
+ if (start > 0) {
100
+ const nextSpace = text.indexOf(" ", start);
101
+ if (nextSpace !== -1 && nextSpace < focusStart) start = nextSpace + 1;
102
+ }
103
+ if (end < text.length) {
104
+ const previousSpace = text.lastIndexOf(" ", end);
105
+ if (previousSpace > focusEnd) end = previousSpace;
106
+ }
107
+
108
+ const prefix = start > 0 ? "…" : "";
109
+ const suffix = end < text.length ? "…" : "";
110
+ const visibleRanges = ranges
111
+ .filter(([rangeStart, rangeEnd]) => rangeEnd >= start && rangeStart < end)
112
+ .map(
113
+ ([rangeStart, rangeEnd]) =>
114
+ [
115
+ Math.max(rangeStart, start) - start + prefix.length,
116
+ Math.min(rangeEnd, end - 1) - start + prefix.length,
117
+ ] as MatchRange,
118
+ );
119
+
120
+ return {
121
+ text: `${prefix}${text.slice(start, end)}${suffix}`,
122
+ ranges: visibleRanges,
123
+ };
124
+ }
125
+
126
+ function searchPreview(
127
+ item: (typeof searchRecords)[number],
128
+ matches: readonly FuseResultMatch[] | undefined,
129
+ ): { text: string; ranges: readonly MatchRange[] } {
130
+ const descriptionRanges = rangesFor(matches, "description");
131
+ if (descriptionRanges.length) {
132
+ return cropMatchedText(item.description, descriptionRanges);
133
+ }
134
+
135
+ const contentRanges = rangesFor(matches, "content");
136
+ if (contentRanges.length) {
137
+ return cropMatchedText(item.content, contentRanges);
138
+ }
139
+
140
+ return { text: item.description, ranges: [] };
141
+ }
142
+
143
+ export function SiteHeader() {
144
+ const [searchOpen, setSearchOpen] = useState(false);
145
+ const [query, setQuery] = useState("");
146
+ const [dark, setDark] = useState(false);
147
+ const [activeResult, setActiveResult] = useState(-1);
148
+ const searchTriggerRef = useRef<HTMLButtonElement>(null);
149
+ const searchPanelRef = useRef<HTMLElement>(null);
150
+ const resultRefs = useRef<Array<HTMLAnchorElement | null>>([]);
151
+ const previousFocusRef = useRef<HTMLElement | null>(null);
152
+
153
+ const openSearch = useCallback(() => {
154
+ previousFocusRef.current =
155
+ document.activeElement instanceof HTMLElement
156
+ ? document.activeElement
157
+ : searchTriggerRef.current;
158
+ setSearchOpen(true);
159
+ }, []);
160
+
161
+ const closeSearch = useCallback(() => {
162
+ setSearchOpen(false);
163
+ setActiveResult(-1);
164
+ window.requestAnimationFrame(() => {
165
+ (previousFocusRef.current ?? searchTriggerRef.current)?.focus();
166
+ });
167
+ }, []);
168
+
169
+ useEffect(() => {
170
+ const stored = window.localStorage.getItem("theme");
171
+ const useDark =
172
+ stored === "dark" ||
173
+ (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
174
+ document.documentElement.dataset.theme = useDark ? "dark" : "light";
175
+ const frame = window.requestAnimationFrame(() => setDark(useDark));
176
+ return () => window.cancelAnimationFrame(frame);
177
+ }, []);
178
+
179
+ useEffect(() => {
180
+ const onKey = (event: KeyboardEvent) => {
181
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
182
+ event.preventDefault();
183
+ openSearch();
184
+ }
185
+ if (event.key === "Escape" && searchOpen) closeSearch();
186
+ };
187
+ window.addEventListener("keydown", onKey);
188
+ return () => window.removeEventListener("keydown", onKey);
189
+ }, [closeSearch, openSearch, searchOpen]);
190
+
191
+ const fuse = useMemo(
192
+ () =>
193
+ new Fuse(searchRecords, {
194
+ keys: [
195
+ { name: "title", weight: 0.5 },
196
+ { name: "description", weight: 0.3 },
197
+ { name: "content", weight: 0.15 },
198
+ { name: "topic", weight: 0.05 },
199
+ ],
200
+ threshold: 0.36,
201
+ ignoreLocation: true,
202
+ includeMatches: true,
203
+ }),
204
+ [],
205
+ );
206
+
207
+ const results = query.trim()
208
+ ? fuse.search(query).slice(0, 6)
209
+ : searchRecords.slice(0, 5).map((item) => ({ item, matches: [] }));
210
+
211
+ useEffect(() => {
212
+ if (!searchOpen) return;
213
+ const previousOverflow = document.body.style.overflow;
214
+ document.body.style.overflow = "hidden";
215
+ return () => {
216
+ document.body.style.overflow = previousOverflow;
217
+ };
218
+ }, [searchOpen]);
219
+
220
+ const moveResultFocus = (direction: 1 | -1) => {
221
+ if (!results.length) return;
222
+ const next =
223
+ activeResult < 0
224
+ ? direction === 1
225
+ ? 0
226
+ : results.length - 1
227
+ : (activeResult + direction + results.length) % results.length;
228
+ setActiveResult(next);
229
+ resultRefs.current[next]?.focus();
230
+ };
231
+
232
+ const handleSearchKeyDown = (
233
+ event: KeyboardEvent<HTMLElement>,
234
+ ) => {
235
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
236
+ event.preventDefault();
237
+ moveResultFocus(event.key === "ArrowDown" ? 1 : -1);
238
+ return;
239
+ }
240
+ if (
241
+ event.key === "Enter" &&
242
+ activeResult >= 0 &&
243
+ event.target instanceof HTMLInputElement
244
+ ) {
245
+ event.preventDefault();
246
+ resultRefs.current[activeResult]?.click();
247
+ return;
248
+ }
249
+ if (event.key !== "Tab" || !searchPanelRef.current) return;
250
+
251
+ const focusable = Array.from(
252
+ searchPanelRef.current.querySelectorAll<HTMLElement>(
253
+ 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
254
+ ),
255
+ );
256
+ if (!focusable.length) return;
257
+ const first = focusable[0];
258
+ const last = focusable.at(-1)!;
259
+ if (event.shiftKey && document.activeElement === first) {
260
+ event.preventDefault();
261
+ last.focus();
262
+ } else if (!event.shiftKey && document.activeElement === last) {
263
+ event.preventDefault();
264
+ first.focus();
265
+ }
266
+ };
267
+
268
+ const toggleTheme = () => {
269
+ const next = !dark;
270
+ setDark(next);
271
+ document.documentElement.dataset.theme = next ? "dark" : "light";
272
+ window.localStorage.setItem("theme", next ? "dark" : "light");
273
+ };
274
+
275
+ return (
276
+ <>
277
+ <header className="site-header">
278
+ <div className="header-inner">
279
+ <Link href="/" className="brand" aria-label={`${siteConfig.name} 首页`}>
280
+ <span className="brand-mark">
281
+ <Cactus size={34} weight="thin" aria-hidden="true" />
282
+ </span>
283
+ <span>
284
+ <strong>{siteConfig.name}</strong>
285
+ <small>{siteConfig.tagline}</small>
286
+ </span>
287
+ </Link>
288
+
289
+ <nav className="main-nav" aria-label="主导航">
290
+ {nav.map((item) => (
291
+ <Link key={item.href} href={item.href}>
292
+ {item.label}
293
+ </Link>
294
+ ))}
295
+ </nav>
296
+
297
+ <div className="header-actions">
298
+ <button
299
+ ref={searchTriggerRef}
300
+ className="icon-button search-trigger"
301
+ onClick={openSearch}
302
+ aria-label="搜索文章"
303
+ aria-expanded={searchOpen}
304
+ aria-controls="site-search-dialog"
305
+ >
306
+ <MagnifyingGlass size={22} weight="regular" />
307
+ <span className="key-hint">⌘K</span>
308
+ </button>
309
+ <button
310
+ className="icon-button"
311
+ onClick={toggleTheme}
312
+ aria-label={dark ? "切换到浅色模式" : "切换到深色模式"}
313
+ >
314
+ {dark ? <Sun size={23} /> : <Moon size={23} />}
315
+ </button>
316
+ </div>
317
+ </div>
318
+ </header>
319
+
320
+ {searchOpen && (
321
+ <div
322
+ className="search-backdrop"
323
+ role="presentation"
324
+ onMouseDown={(event) => {
325
+ if (event.currentTarget === event.target) closeSearch();
326
+ }}
327
+ >
328
+ <section
329
+ ref={searchPanelRef}
330
+ id="site-search-dialog"
331
+ className="search-panel"
332
+ role="dialog"
333
+ aria-modal="true"
334
+ aria-labelledby="site-search-title"
335
+ onKeyDown={handleSearchKeyDown}
336
+ >
337
+ <h2 className="sr-only" id="site-search-title">
338
+ 搜索站内内容
339
+ </h2>
340
+ <div className="search-input-wrap">
341
+ <MagnifyingGlass size={20} aria-hidden="true" />
342
+ <input
343
+ autoFocus
344
+ value={query}
345
+ onChange={(event) => {
346
+ setQuery(event.target.value);
347
+ setActiveResult(-1);
348
+ resultRefs.current = [];
349
+ }}
350
+ placeholder="搜索标题、主题或正文…"
351
+ aria-label="模糊搜索"
352
+ role="combobox"
353
+ aria-expanded="true"
354
+ aria-autocomplete="list"
355
+ aria-controls="site-search-results"
356
+ aria-activedescendant={
357
+ activeResult >= 0 ? `site-search-result-${activeResult}` : undefined
358
+ }
359
+ />
360
+ <button onClick={closeSearch} aria-label="关闭搜索">
361
+ <X size={19} />
362
+ </button>
363
+ </div>
364
+ <div
365
+ className="search-results"
366
+ id="site-search-results"
367
+ role="listbox"
368
+ aria-label="搜索结果"
369
+ >
370
+ <p className="search-label">
371
+ {query ? `${results.length} 个相关结果` : "最近内容"}
372
+ </p>
373
+ {results.map((result, index) => {
374
+ const preview = searchPreview(result.item, result.matches);
375
+ return (
376
+ <Link
377
+ key={`${result.item.path}-${result.item.title}`}
378
+ id={`site-search-result-${index}`}
379
+ ref={(node) => {
380
+ resultRefs.current[index] = node;
381
+ }}
382
+ href={result.item.path}
383
+ onClick={closeSearch}
384
+ onFocus={() => setActiveResult(index)}
385
+ role="option"
386
+ aria-selected={activeResult === index}
387
+ className={`search-result${activeResult === index ? " active" : ""}`}
388
+ >
389
+ <span>
390
+ <HighlightedText
391
+ text={result.item.topic}
392
+ ranges={rangesFor(result.matches, "topic")}
393
+ />
394
+ </span>
395
+ <strong>
396
+ <HighlightedText
397
+ text={result.item.title}
398
+ ranges={rangesFor(result.matches, "title")}
399
+ />
400
+ </strong>
401
+ <small>
402
+ <HighlightedText text={preview.text} ranges={preview.ranges} />
403
+ </small>
404
+ </Link>
405
+ );
406
+ })}
407
+ {results.length === 0 && (
408
+ <p className="empty-state">没有找到,换个更宽松的关键词试试。</p>
409
+ )}
410
+ </div>
411
+ <footer className="search-footer">
412
+ <span>支持模糊匹配</span>
413
+ <span>ESC 关闭</span>
414
+ </footer>
415
+ </section>
416
+ </div>
417
+ )}
418
+ </>
419
+ );
420
+ }