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,582 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import matter from "gray-matter";
5
+ import { marked } from "marked";
6
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
+
8
+ function resolveSiteRoot() {
9
+ if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
10
+ const yaml = path.join(process.cwd(), "site.config.yaml");
11
+ if (fs.existsSync(yaml)) return path.dirname(fs.realpathSync(yaml));
12
+ return process.cwd();
13
+ }
14
+
15
+ const root = resolveSiteRoot();
16
+ const contentRoot = path.join(root, "content");
17
+ const siteConfigPath = path.join(root, "site.config.yaml");
18
+ const columnsConfigPath = path.join(contentRoot, "columns.yaml");
19
+ const siteConfig = parseYaml(fs.readFileSync(siteConfigPath, "utf8"));
20
+ const generatedMediaRoot = path.join(root, "public", "media");
21
+ const gardenDir = path.join(root, ".garden");
22
+ const output = path.join(gardenDir, "generated-content.ts");
23
+ const siteConfigOutput = path.join(gardenDir, "site-config.ts");
24
+ const includeDrafts = process.env.CONTENT_INCLUDE_DRAFTS === "1";
25
+ const now = Date.now();
26
+ const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
27
+ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com")
28
+ .replace(/\/+$/u, "");
29
+ const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
30
+ ? `${siteUrl}${basePath}`
31
+ : siteUrl;
32
+ const siteName = process.env.SITE_NAME ?? siteConfig.name;
33
+ const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
34
+
35
+ const fail = (sourcePath, message) => {
36
+ throw new Error(`${sourcePath}: ${message}`);
37
+ };
38
+
39
+ const requireString = (data, key, sourcePath) => {
40
+ const value = data[key];
41
+ if (typeof value !== "string" || !value.trim()) {
42
+ fail(sourcePath, `front matter 缺少有效的 ${key}`);
43
+ }
44
+ return value.trim();
45
+ };
46
+
47
+ const optionalString = (data, key, sourcePath) => {
48
+ const value = data[key];
49
+ if (value === undefined || value === null || value === "") return undefined;
50
+ if (typeof value !== "string") fail(sourcePath, `${key} 必须是字符串`);
51
+ return value.trim();
52
+ };
53
+
54
+ const normalizeDate = (value, key, sourcePath) => {
55
+ const normalized = value instanceof Date ? value.toISOString() : value;
56
+ if (
57
+ typeof normalized !== "string" ||
58
+ !normalized ||
59
+ Number.isNaN(Date.parse(normalized))
60
+ ) {
61
+ fail(sourcePath, `${key} 必须是有效日期`);
62
+ }
63
+ return normalized;
64
+ };
65
+
66
+ const isExternalAsset = (value) =>
67
+ /^(?:[a-z]+:)?\/\//i.test(value) ||
68
+ value.startsWith("/") ||
69
+ value.startsWith("data:") ||
70
+ value.startsWith("#");
71
+
72
+ const assetCache = new Map();
73
+
74
+ function copyLocalAsset(value, markdownPath, sourcePath) {
75
+ if (!value || isExternalAsset(value)) return value;
76
+
77
+ const [assetPath, suffix = ""] = value.split(/(?=[?#])/u, 2);
78
+ const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
79
+ const absoluteSource = path.resolve(path.dirname(markdownPath), decodedPath);
80
+ const relativeSource = path.relative(contentRoot, absoluteSource);
81
+
82
+ if (
83
+ relativeSource.startsWith("..") ||
84
+ path.isAbsolute(relativeSource) ||
85
+ !fs.existsSync(absoluteSource) ||
86
+ !fs.statSync(absoluteSource).isFile()
87
+ ) {
88
+ fail(sourcePath, `找不到本地媒体文件 ${value}`);
89
+ }
90
+
91
+ if (!assetCache.has(absoluteSource)) {
92
+ const extension = path.extname(relativeSource);
93
+ const stem = relativeSource.slice(0, Math.max(0, relativeSource.length - extension.length));
94
+ const safeRelative = `${stem
95
+ .split(path.sep)
96
+ .map((segment) => segment.replace(/[^\w\u3400-\u9fff.-]+/gu, "-"))
97
+ .join(path.sep)}${extension.toLowerCase()}`;
98
+ const destination = path.join(generatedMediaRoot, safeRelative);
99
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
100
+ fs.copyFileSync(absoluteSource, destination);
101
+ assetCache.set(
102
+ absoluteSource,
103
+ `${basePath}/media/${safeRelative.split(path.sep).map(encodeURIComponent).join("/")}`,
104
+ );
105
+ }
106
+
107
+ return `${assetCache.get(absoluteSource)}${suffix}`;
108
+ }
109
+
110
+ function rewriteMarkdownAssets(content, markdownPath, sourcePath) {
111
+ const markdownImages = content.replace(
112
+ /(!\[[^\]]*\]\()<?([^)\s>]+)>?((?:\s+["'][^"']*["'])?\))/gu,
113
+ (_, prefix, src, suffix) =>
114
+ `${prefix}${copyLocalAsset(src, markdownPath, sourcePath)}${suffix}`,
115
+ );
116
+
117
+ return markdownImages.replace(
118
+ /(<(?:img|audio|video|source)\b[^>]*\bsrc=["'])([^"']+)(["'])/giu,
119
+ (_, prefix, src, suffix) =>
120
+ `${prefix}${copyLocalAsset(src, markdownPath, sourcePath)}${suffix}`,
121
+ );
122
+ }
123
+
124
+ function markdownStem(markdownPath) {
125
+ return path.basename(markdownPath, path.extname(markdownPath));
126
+ }
127
+
128
+ const widgetExtensions = new Set([".html", ".pdf"]);
129
+
130
+ function assertWidgetSidecar(src, markdownPath, sourcePath) {
131
+ const [assetPath] = src.split(/(?=[?#])/u, 2);
132
+ const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
133
+ if (isExternalAsset(decodedPath)) {
134
+ fail(sourcePath, "widget src 必须是同名目录里的本地 .html 或 .pdf,不能使用远程地址");
135
+ }
136
+
137
+ const absoluteSource = path.resolve(path.dirname(markdownPath), decodedPath);
138
+ const stem = markdownStem(markdownPath);
139
+ const sidecarRoot = path.join(path.dirname(markdownPath), stem);
140
+ const relativeToSidecar = path.relative(sidecarRoot, absoluteSource);
141
+ const extension = path.extname(absoluteSource).toLowerCase();
142
+ if (
143
+ relativeToSidecar.startsWith("..") ||
144
+ path.isAbsolute(relativeToSidecar) ||
145
+ !widgetExtensions.has(extension)
146
+ ) {
147
+ fail(
148
+ sourcePath,
149
+ `widget src 必须位于同名目录 ${stem}/ 内的 .html 或 .pdf,例如 ./${stem}/notes.pdf`,
150
+ );
151
+ }
152
+ return absoluteSource;
153
+ }
154
+
155
+ function inferWidgetKind(absoluteSource) {
156
+ const extension = path.extname(absoluteSource).toLowerCase();
157
+ if (extension === ".pdf") return "pdf";
158
+ const html = fs.readFileSync(absoluteSource, "utf8");
159
+ return /source:\s*["']garden-chart["']/u.test(html) ? "chart" : "html";
160
+ }
161
+
162
+ function resolveWidgetKind(requested, absoluteSource, sourcePath) {
163
+ const inferred = inferWidgetKind(absoluteSource);
164
+ if (requested == null || requested === "") return inferred;
165
+ if (typeof requested !== "string") fail(sourcePath, "widget kind 必须是 chart、html 或 pdf");
166
+ const kind = requested.trim();
167
+ if (!["chart", "html", "pdf"].includes(kind)) {
168
+ fail(sourcePath, "widget kind 必须是 chart、html 或 pdf");
169
+ }
170
+ const extension = path.extname(absoluteSource).toLowerCase();
171
+ if (kind === "pdf" && extension !== ".pdf") fail(sourcePath, "kind: pdf 只能用于 .pdf");
172
+ if ((kind === "chart" || kind === "html") && extension !== ".html") {
173
+ fail(sourcePath, `kind: ${kind} 只能用于 .html`);
174
+ }
175
+ return kind;
176
+ }
177
+
178
+ function rewriteWidgetFences(content, markdownPath, sourcePath) {
179
+ return content.replace(
180
+ /(^|\n)(`{3,}|~{3,})widget[ \t]*\r?\n([\s\S]*?)\r?\n\2[ \t]*(?=\r?\n|$)/gu,
181
+ (match, lead, fence, body) => {
182
+ let data;
183
+ try {
184
+ data = parseYaml(body);
185
+ } catch {
186
+ fail(sourcePath, "widget 围栏不是有效的 YAML");
187
+ }
188
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
189
+ fail(sourcePath, "widget 围栏必须是包含 src 与 caption 的 YAML 对象");
190
+ }
191
+
192
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
193
+ const src = typeof data.src === "string" ? data.src.trim() : "";
194
+ if (!caption) {
195
+ fail(sourcePath, "widget 缺少 caption;它必须是一句判断,供搜索、RSS 和无脚本读者阅读");
196
+ }
197
+ if (!src) {
198
+ fail(
199
+ sourcePath,
200
+ `widget 缺少 src;把文件放进同名目录,再写 src: ./${markdownStem(markdownPath)}/name.html 或 ./${markdownStem(markdownPath)}/name.pdf`,
201
+ );
202
+ }
203
+
204
+ const absoluteSource = assertWidgetSidecar(src, markdownPath, sourcePath);
205
+ const next = {
206
+ src: copyLocalAsset(src, markdownPath, sourcePath),
207
+ caption,
208
+ kind: resolveWidgetKind(data.kind, absoluteSource, sourcePath),
209
+ };
210
+ if (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) {
211
+ next.height = Math.round(data.height);
212
+ }
213
+ return `${lead}${fence}widget\n${stringifyYaml(next).trimEnd()}\n${fence}`;
214
+ },
215
+ );
216
+ }
217
+
218
+ function normalizeMedia(media, markdownPath, sourcePath) {
219
+ if (media === undefined || media === null) return undefined;
220
+ if (typeof media === "string") {
221
+ return copyLocalAsset(media, markdownPath, sourcePath);
222
+ }
223
+ if (!Array.isArray(media)) fail(sourcePath, "media 必须是字符串或附件数组");
224
+
225
+ return media.map((item, index) => {
226
+ if (!item || typeof item !== "object") {
227
+ fail(sourcePath, `media[${index}] 必须是对象`);
228
+ }
229
+ if (!["image", "audio", "video", "link"].includes(item.type)) {
230
+ fail(sourcePath, `media[${index}].type 不受支持`);
231
+ }
232
+ if (typeof item.src !== "string" || !item.src.trim()) {
233
+ fail(sourcePath, `media[${index}].src 不能为空`);
234
+ }
235
+ if (item.type === "link") {
236
+ try {
237
+ new URL(item.src);
238
+ } catch {
239
+ fail(sourcePath, `media[${index}].src 必须是完整链接`);
240
+ }
241
+ }
242
+ return {
243
+ ...item,
244
+ src:
245
+ item.type === "link"
246
+ ? item.src
247
+ : copyLocalAsset(item.src, markdownPath, sourcePath),
248
+ poster: item.poster
249
+ ? copyLocalAsset(item.poster, markdownPath, sourcePath)
250
+ : undefined,
251
+ };
252
+ });
253
+ }
254
+
255
+ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
256
+ const title = requireString(data, "title", sourcePath);
257
+ const slug = optionalString(data, "slug", sourcePath) ?? path.basename(markdownPath, ".md");
258
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
259
+ fail(sourcePath, "slug 只能包含小写字母、数字和连字符");
260
+ }
261
+
262
+ const date = normalizeDate(data.date, "date", sourcePath);
263
+ const updated = data.updated
264
+ ? normalizeDate(data.updated, "updated", sourcePath)
265
+ : undefined;
266
+ const publishAt = data.publishAt
267
+ ? normalizeDate(data.publishAt, "publishAt", sourcePath)
268
+ : undefined;
269
+ if (data.tags && !Array.isArray(data.tags)) fail(sourcePath, "tags 必须是数组");
270
+ if (data.pinned !== undefined && typeof data.pinned !== "boolean") {
271
+ fail(sourcePath, "pinned 必须是布尔值");
272
+ }
273
+ if (data.draft !== undefined && typeof data.draft !== "boolean") {
274
+ fail(sourcePath, "draft 必须是布尔值");
275
+ }
276
+
277
+ if (kind === "post") {
278
+ requireString(data, "description", sourcePath);
279
+ requireString(data, "topic", sourcePath);
280
+ }
281
+ if (kind === "thought" && data.mediaType) {
282
+ if (!["image", "audio", "video", "link", "text"].includes(data.mediaType)) {
283
+ fail(sourcePath, "mediaType 不受支持");
284
+ }
285
+ }
286
+
287
+ const publishTime = Date.parse(publishAt ?? date);
288
+ if (!includeDrafts && (data.draft === true || publishTime > now)) return null;
289
+
290
+ return {
291
+ ...data,
292
+ title,
293
+ slug,
294
+ path: kind === "post"
295
+ ? sourcePath.replace(/^posts\//u, "").replace(/\.md$/u, "")
296
+ : slug,
297
+ date,
298
+ updated,
299
+ publishAt,
300
+ kind,
301
+ draft: Boolean(data.draft),
302
+ sourcePath,
303
+ cover: data.cover
304
+ ? copyLocalAsset(data.cover, markdownPath, sourcePath)
305
+ : undefined,
306
+ columnCover: data.columnCover
307
+ ? copyLocalAsset(data.columnCover, markdownPath, sourcePath)
308
+ : undefined,
309
+ poster: data.poster
310
+ ? copyLocalAsset(data.poster, markdownPath, sourcePath)
311
+ : undefined,
312
+ media: normalizeMedia(data.media, markdownPath, sourcePath),
313
+ content: rewriteWidgetFences(
314
+ rewriteMarkdownAssets(content.trim(), markdownPath, sourcePath),
315
+ markdownPath,
316
+ sourcePath,
317
+ ),
318
+ };
319
+ }
320
+
321
+ function readMarkdownTree(directory, kind) {
322
+ if (!fs.existsSync(directory)) return [];
323
+
324
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
325
+ const fullPath = path.join(directory, entry.name);
326
+ if (entry.isDirectory()) return readMarkdownTree(fullPath, kind);
327
+ if (!entry.name.endsWith(".md")) return [];
328
+
329
+ const raw = fs.readFileSync(fullPath, "utf8");
330
+ const parsed = matter(raw);
331
+ const sourcePath = path.relative(contentRoot, fullPath);
332
+ const normalized = validateAndNormalize(
333
+ parsed.data,
334
+ parsed.content,
335
+ fullPath,
336
+ kind,
337
+ sourcePath,
338
+ );
339
+ return normalized ? [normalized] : [];
340
+ });
341
+ }
342
+
343
+ function assertUnique(items, keyFor, label) {
344
+ const seen = new Map();
345
+ for (const item of items) {
346
+ const key = keyFor(item);
347
+ const previous = seen.get(key);
348
+ if (previous) {
349
+ throw new Error(`${label} "${key}" 重复:${previous} 与 ${item.sourcePath}`);
350
+ }
351
+ seen.set(key, item.sourcePath);
352
+ }
353
+ }
354
+
355
+ function normalizePostReference(value, sourcePath) {
356
+ if (typeof value !== "string" || !value.trim()) {
357
+ fail(sourcePath, "posts 中的引用必须是非空字符串。");
358
+ }
359
+ const raw = value.trim().replaceAll("\\", "/");
360
+ const relativePath = raw
361
+ .replace(/^(?:content\/)?posts\//u, "")
362
+ .replace(/\.md$/u, "");
363
+ if (
364
+ relativePath.startsWith("/") ||
365
+ relativePath.split("/").some((segment) => segment === ".." || segment === ".")
366
+ ) {
367
+ fail(sourcePath, `博文引用 ${value} 不能越出 content/posts。`);
368
+ }
369
+ return { raw, relativePath };
370
+ }
371
+
372
+ function readColumnReferences(posts) {
373
+ if (!fs.existsSync(columnsConfigPath)) return [];
374
+ let config;
375
+ try {
376
+ config = parseYaml(fs.readFileSync(columnsConfigPath, "utf8"));
377
+ } catch {
378
+ fail("content/columns.yaml", "不是有效的 YAML。");
379
+ }
380
+ if (!Array.isArray(config.columns)) fail("content/columns.yaml", "columns 必须是数组。");
381
+ const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
382
+ const postsByPath = new Map(
383
+ posts.map((post) => [
384
+ post.sourcePath.replace(/^posts\//u, "").replace(/\.md$/u, ""),
385
+ post,
386
+ ]),
387
+ );
388
+ const seenColumns = new Set();
389
+ return config.columns.flatMap((column, index) => {
390
+ const sourcePath = `columns.yaml#columns[${index}]`;
391
+ if (!column || typeof column !== "object") fail(sourcePath, "专栏配置必须是对象。");
392
+ const value = column;
393
+ const slug = requireString(value, "slug", sourcePath);
394
+ const title = requireString(value, "title", sourcePath);
395
+ const description = requireString(value, "description", sourcePath);
396
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
397
+ fail(sourcePath, "slug 只能包含小写字母、数字和连字符。");
398
+ }
399
+ if (seenColumns.has(slug)) fail(sourcePath, `专栏 slug ${slug} 重复。`);
400
+ seenColumns.add(slug);
401
+ if (!Array.isArray(value.posts) || !value.posts.every((entry) => typeof entry === "string")) {
402
+ fail(sourcePath, "posts 必须是博文 slug 或相对路径数组。");
403
+ }
404
+ const referenced = new Set();
405
+ return value.posts.map((postReference, order) => {
406
+ const { raw, relativePath } = normalizePostReference(postReference, sourcePath);
407
+ const post = postsByPath.get(relativePath) ?? postsBySlug.get(raw);
408
+ if (!post) fail(sourcePath, `引用的博文 ${postReference} 不存在。`);
409
+ if (referenced.has(post.path)) {
410
+ fail(sourcePath, `博文 ${postReference} 在同一专栏中重复。`);
411
+ }
412
+ referenced.add(post.path);
413
+ return {
414
+ ...post,
415
+ kind: "column",
416
+ column: slug,
417
+ columnTitle: title,
418
+ columnDescription: description,
419
+ columnStatus: optionalString(value, "status", sourcePath) ?? "持续更新",
420
+ columnCover: optionalString(value, "cover", sourcePath),
421
+ columnCoverAlt: optionalString(value, "coverAlt", sourcePath),
422
+ order: order + 1,
423
+ };
424
+ });
425
+ });
426
+ }
427
+
428
+ fs.rmSync(generatedMediaRoot, { recursive: true, force: true });
429
+
430
+ const posts = readMarkdownTree(path.join(contentRoot, "posts"), "post").sort(
431
+ (a, b) => Date.parse(b.date) - Date.parse(a.date),
432
+ );
433
+ const columns = readColumnReferences(posts).sort(
434
+ (a, b) => (a.column === b.column ? a.order - b.order : a.column.localeCompare(b.column)),
435
+ );
436
+ const thoughts = readMarkdownTree(path.join(contentRoot, "thoughts"), "thought").sort(
437
+ (a, b) => Date.parse(b.date) - Date.parse(a.date),
438
+ );
439
+
440
+ assertUnique(posts, (item) => item.path, "博文路径");
441
+ assertUnique(columns, (item) => `${item.column}/${item.path}`, "专栏路径");
442
+ assertUnique(
443
+ columns,
444
+ (item) => `${item.column}/${item.order}`,
445
+ "专栏文章顺序",
446
+ );
447
+ assertUnique(thoughts, (item) => item.slug, "随想 slug");
448
+
449
+ const contentHash = crypto
450
+ .createHash("sha256")
451
+ .update(JSON.stringify({ posts, columns, thoughts }))
452
+ .digest("hex")
453
+ .slice(0, 12);
454
+
455
+ const source = `// Generated by garden sync. Edit files in content/ instead.
456
+ export type MediaItem = {
457
+ type: "image" | "audio" | "video" | "link";
458
+ src: string;
459
+ alt?: string;
460
+ poster?: string;
461
+ mime?: string;
462
+ title?: string;
463
+ description?: string;
464
+ };
465
+
466
+ export type ContentEntry = {
467
+ title: string;
468
+ slug: string;
469
+ path: string;
470
+ description?: string;
471
+ date: string;
472
+ updated?: string;
473
+ publishAt?: string;
474
+ draft?: boolean;
475
+ topic?: string;
476
+ tags?: string[];
477
+ pinned?: boolean;
478
+ readingTime?: string;
479
+ kind: "post" | "column" | "thought";
480
+ sourcePath: string;
481
+ content: string;
482
+ column?: string;
483
+ columnTitle?: string;
484
+ columnDescription?: string;
485
+ columnStatus?: string;
486
+ columnCover?: string;
487
+ columnCoverAlt?: string;
488
+ order?: number;
489
+ cover?: string;
490
+ coverAlt?: string;
491
+ mediaType?: "image" | "audio" | "video" | "link" | "text";
492
+ media?: string | MediaItem[];
493
+ mediaAlt?: string;
494
+ poster?: string;
495
+ linkTitle?: string;
496
+ linkDescription?: string;
497
+ };
498
+
499
+ export const contentHash = "${contentHash}";
500
+ export const posts = ${JSON.stringify(posts, null, 2)} as ContentEntry[];
501
+ export const columns = ${JSON.stringify(columns, null, 2)} as ContentEntry[];
502
+ export const thoughts = ${JSON.stringify(thoughts, null, 2)} as ContentEntry[];
503
+ `;
504
+
505
+ fs.mkdirSync(path.dirname(output), { recursive: true });
506
+ fs.writeFileSync(output, source);
507
+ fs.writeFileSync(
508
+ siteConfigOutput,
509
+ `// Generated by garden sync. Edit site.config.yaml instead.
510
+ export const siteConfig = ${JSON.stringify(siteConfig, null, 2)} as const;
511
+ export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.author.github}`)};
512
+ `,
513
+ );
514
+
515
+ const escapeXml = (value) =>
516
+ value
517
+ .replaceAll("&", "&amp;")
518
+ .replaceAll("<", "&lt;")
519
+ .replaceAll(">", "&gt;")
520
+ .replaceAll('"', "&quot;")
521
+ .replaceAll("'", "&apos;");
522
+ const cdata = (value) => value.replaceAll("]]>", "]]]]><![CDATA[>");
523
+ const publicRoot = path.join(root, "public");
524
+ const columnEntryPath = (entry) => entry.path.startsWith(`${entry.column}/`)
525
+ ? entry.path.slice(entry.column.length + 1)
526
+ : entry.path;
527
+ const feedEntries = [
528
+ ...posts.map((entry) => ({ ...entry, path: `/blog/${entry.path}/` })),
529
+ ...thoughts.map((entry) => ({ ...entry, path: `/thoughts/${entry.slug}/` })),
530
+ ].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
531
+
532
+ const feedLastBuildDate = (entries) => new Date(entries.reduce(
533
+ (latest, entry) => Math.max(latest, Date.parse(entry.updated ?? entry.date)),
534
+ 0,
535
+ )).toUTCString();
536
+
537
+ function writeRssFeed(fileName, entries) {
538
+ const rssItems = entries.map((entry) => `
539
+ <item>
540
+ <title>${escapeXml(entry.title)}</title>
541
+ <link>${escapeXml(`${publicBaseUrl}${entry.path}`)}</link>
542
+ <guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.path}`)}</guid>
543
+ <pubDate>${new Date(entry.date).toUTCString()}</pubDate>
544
+ <description>${escapeXml(entry.description ?? entry.content.replace(/\s+/gu, " ").slice(0, 180))}</description>
545
+ ${(entry.tags ?? []).map((tag) => `<category>${escapeXml(tag)}</category>`).join("")}
546
+ <content:encoded><![CDATA[${cdata(marked.parse(entry.content))}]]></content:encoded>
547
+ </item>`).join("");
548
+
549
+ fs.writeFileSync(path.join(publicRoot, fileName), `<?xml version="1.0" encoding="UTF-8" ?>
550
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
551
+ <channel>
552
+ <title>${escapeXml(siteName)}</title>
553
+ <link>${escapeXml(publicBaseUrl)}</link>
554
+ <atom:link href="${escapeXml(`${publicBaseUrl}/${fileName}`)}" rel="self" type="application/rss+xml" />
555
+ <description>${escapeXml(siteDescription)}</description>
556
+ <language>zh-CN</language>
557
+ <lastBuildDate>${feedLastBuildDate(entries)}</lastBuildDate>${rssItems}
558
+ </channel>
559
+ </rss>
560
+ `);
561
+ }
562
+
563
+ writeRssFeed("rss.xml", feedEntries);
564
+ writeRssFeed(
565
+ "posts.xml",
566
+ posts.map((entry) => ({ ...entry, path: `/blog/${entry.path}/` })),
567
+ );
568
+ const sitemapEntries = [
569
+ "/", "/blog/", "/thoughts/", "/columns/", "/about/",
570
+ ...posts.map((entry) => `/blog/${entry.path}/`),
571
+ ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
572
+ ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
573
+ ];
574
+ fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
575
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
576
+ <url><loc>${escapeXml(`${publicBaseUrl}${entry}`)}</loc></url>`).join("")}
577
+ </urlset>
578
+ `);
579
+ fs.writeFileSync(path.join(publicRoot, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${publicBaseUrl}/sitemap.xml\n`);
580
+ console.log(
581
+ `Synced ${posts.length} posts, ${columns.length} column entries, and ${thoughts.length} thoughts (${contentHash}).`,
582
+ );