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,189 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+
4
+ const timeoutMs = 8000;
5
+ const maxBytes = 1_000_000;
6
+ const maxRedirects = 3;
7
+
8
+ const option = (name) => (process.env[`GARDEN_EMBED_${name}`] ?? "").trim();
9
+
10
+ function fail(message) {
11
+ throw new Error(message);
12
+ }
13
+
14
+ function decodeEntities(value) {
15
+ return value
16
+ .replaceAll("&", "&")
17
+ .replaceAll(""", '"')
18
+ .replaceAll("'", "'")
19
+ .replaceAll("'", "'")
20
+ .replaceAll("&lt;", "<")
21
+ .replaceAll("&gt;", ">")
22
+ .replace(/&#(\d+);/gu, (_, code) => String.fromCodePoint(Number(code)))
23
+ .replace(/&#x([0-9a-f]+);/giu, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
24
+ .replaceAll("&nbsp;", " ")
25
+ .trim();
26
+ }
27
+
28
+ function isBlockedHostname(hostname) {
29
+ const host = hostname.replace(/\.$/u, "").toLowerCase();
30
+ return (
31
+ host === "localhost" ||
32
+ host.endsWith(".localhost") ||
33
+ host.endsWith(".local") ||
34
+ host === "0.0.0.0" ||
35
+ host === "::" ||
36
+ host === "[::1]"
37
+ );
38
+ }
39
+
40
+ function isBlockedAddress(address) {
41
+ const ip = address.toLowerCase().replace(/^\[|\]$/gu, "");
42
+ if (ip === "127.0.0.1" || ip === "::1" || ip === "::" || ip === "0.0.0.0") return true;
43
+ if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd")) return true;
44
+ if (ip.startsWith("::ffff:")) return isBlockedAddress(ip.slice(7));
45
+
46
+ const parts = ip.split(".").map(Number);
47
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return false;
48
+ if (parts[0] === 0 || parts[0] === 10 || parts[0] === 127) return true;
49
+ if (parts[0] === 169 && parts[1] === 254) return true;
50
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
51
+ if (parts[0] === 192 && parts[1] === 168) return true;
52
+ return false;
53
+ }
54
+
55
+ async function assertPublicHttpUrl(raw) {
56
+ let url;
57
+ try {
58
+ url = new URL(raw);
59
+ } catch {
60
+ fail("URL 无效。");
61
+ }
62
+ if (!["http:", "https:"].includes(url.protocol)) fail("只接受 http 或 https。");
63
+ if (url.username || url.password) fail("URL 不能带用户名或密码。");
64
+ if (isBlockedHostname(url.hostname) || isIP(url.hostname) && isBlockedAddress(url.hostname)) {
65
+ fail("不能抓取本机、内网或链路本地地址。");
66
+ }
67
+
68
+ const records = await lookup(url.hostname, { all: true, verbatim: true });
69
+ if (records.some((record) => isBlockedAddress(record.address))) {
70
+ fail("不能抓取解析到内网或本机的地址。");
71
+ }
72
+ return url;
73
+ }
74
+
75
+ function metaContent(html, keys) {
76
+ for (const key of keys) {
77
+ const escaped = key.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
78
+ const patterns = [
79
+ new RegExp(
80
+ `<meta\\b[^>]*?(?:property|name)=["']${escaped}["'][^>]*?\\bcontent=["']([^"']*)["'][^>]*>`,
81
+ "iu",
82
+ ),
83
+ new RegExp(
84
+ `<meta\\b[^>]*?\\bcontent=["']([^"']*)["'][^>]*?(?:property|name)=["']${escaped}["'][^>]*>`,
85
+ "iu",
86
+ ),
87
+ ];
88
+ for (const pattern of patterns) {
89
+ const match = html.match(pattern)?.[1];
90
+ if (match) return decodeEntities(match);
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function pageTitle(html) {
97
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/iu)?.[1];
98
+ return match ? decodeEntities(match.replaceAll(/\s+/gu, " ")) : undefined;
99
+ }
100
+
101
+ function yamlScalar(value) {
102
+ return JSON.stringify(value);
103
+ }
104
+
105
+ async function readLimited(response) {
106
+ const type = response.headers.get("content-type") ?? "";
107
+ if (!/text\/html|application\/xhtml\+xml/iu.test(type) && type !== "") {
108
+ fail(`响应不是 HTML(${type})。`);
109
+ }
110
+
111
+ const reader = response.body?.getReader();
112
+ if (!reader) fail("响应没有正文。");
113
+
114
+ const chunks = [];
115
+ let size = 0;
116
+ while (true) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ size += value.byteLength;
120
+ if (size > maxBytes) fail("页面过大,已停止抓取。");
121
+ chunks.push(value);
122
+ }
123
+ return new TextDecoder("utf-8").decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
124
+ }
125
+
126
+ async function fetchPublic(url, redirectsLeft) {
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
129
+ try {
130
+ const response = await fetch(url, {
131
+ redirect: "manual",
132
+ signal: controller.signal,
133
+ headers: {
134
+ accept: "text/html,application/xhtml+xml",
135
+ "user-agent": "GardenEmbedSnapshot/1.0",
136
+ },
137
+ });
138
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
139
+ if (redirectsLeft <= 0) fail("重定向次数过多。");
140
+ const location = response.headers.get("location");
141
+ if (!location) fail("重定向缺少 Location。");
142
+ const next = await assertPublicHttpUrl(new URL(location, url).toString());
143
+ return fetchPublic(next, redirectsLeft - 1);
144
+ }
145
+ if (!response.ok) fail(`抓取失败:HTTP ${response.status}。`);
146
+ return readLimited(response);
147
+ } catch (error) {
148
+ if (error?.name === "AbortError") fail("抓取超时。");
149
+ throw error;
150
+ } finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
154
+
155
+ async function main() {
156
+ const raw = option("URL") || process.argv[2] || "";
157
+ if (!raw) fail('用法:make embed URL="https://example.com/article"');
158
+
159
+ const url = await assertPublicHttpUrl(raw);
160
+ const html = await fetchPublic(url, maxRedirects);
161
+ const title = metaContent(html, ["og:title", "twitter:title"]) ?? pageTitle(html);
162
+ const description = metaContent(html, ["og:description", "twitter:description", "description"]);
163
+ const image = metaContent(html, ["og:image", "twitter:image", "twitter:image:src"]);
164
+
165
+ if (image) {
166
+ try {
167
+ const imageUrl = new URL(image, url);
168
+ if (!["http:", "https:"].includes(imageUrl.protocol)) fail("封面地址不是 http(s)。");
169
+ } catch {
170
+ fail("封面地址无效。");
171
+ }
172
+ }
173
+
174
+ const lines = ["```embed", `url: ${url.toString()}`];
175
+ if (title) lines.push(`title: ${yamlScalar(title)}`);
176
+ if (description) lines.push(`description: ${yamlScalar(description)}`);
177
+ if (image) lines.push(`image: ${yamlScalar(new URL(image, url).toString())}`);
178
+ lines.push("```");
179
+
180
+ process.stdout.write(`${lines.join("\n")}\n`);
181
+ if (!title && !description) {
182
+ process.stderr.write("已生成围栏,但页面没有标题或摘要,请手工补全。\n");
183
+ }
184
+ }
185
+
186
+ main().catch((error) => {
187
+ console.error(`拉取失败:${error.message}`);
188
+ process.exitCode = 1;
189
+ });
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const gardenPackageRoot = path.resolve(
6
+ path.dirname(fileURLToPath(import.meta.url)),
7
+ "..",
8
+ );
9
+
10
+ export function gardenSiteRoot() {
11
+ if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
12
+ const yaml = path.join(process.cwd(), "site.config.yaml");
13
+ if (fs.existsSync(yaml)) {
14
+ return path.dirname(fs.realpathSync(yaml));
15
+ }
16
+ return process.cwd();
17
+ }
18
+
19
+ export function gardenCacheDir(root = gardenSiteRoot()) {
20
+ return path.join(root, ".garden");
21
+ }
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const root = path.resolve(process.env.GARDEN_SITE || process.cwd());
6
+ const option = (name) => (process.env[`GARDEN_NEW_${name}`] ?? "").trim();
7
+
8
+ async function main() {
9
+ const title = option("TITLE") || "新文章";
10
+ const date = new Date().toISOString();
11
+ const titleSlug = title.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "");
12
+ const slug = option("SLUG") || titleSlug || `post-${date.slice(0, 10)}-${randomUUID().slice(0, 8)}`;
13
+ const directory = option("DIR");
14
+ const topic = option("TOPIC") || "未分类";
15
+
16
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
17
+ throw new Error("SLUG 只能包含小写字母、数字和连字符,不含 .md 后缀。");
18
+ }
19
+ const segments = directory ? directory.split("/") : [];
20
+ if (segments.some((segment) => !/^[\p{L}\p{N}_-]+$/u.test(segment))) {
21
+ throw new Error("DIR 应为 content/posts 下的相对目录,各级只使用文字、数字、下划线或连字符,例如 go/runtime。");
22
+ }
23
+
24
+ let parent = root;
25
+ for (const segment of ["content", "posts", ...segments]) {
26
+ parent = path.join(parent, segment);
27
+ try {
28
+ await mkdir(parent);
29
+ } catch (error) {
30
+ if (error.code !== "EEXIST") throw error;
31
+ }
32
+ if (!(await lstat(parent)).isDirectory()) {
33
+ throw new Error(`目录不可用(不支持符号链接):${parent}`);
34
+ }
35
+ }
36
+
37
+ const destination = path.join(parent, `${slug}.md`);
38
+ // JSON strings are valid YAML scalars, including quotes, colons and newlines.
39
+ const markdown = [
40
+ "---",
41
+ `title: ${JSON.stringify(title)}`,
42
+ `slug: ${slug}`,
43
+ `date: ${date}`,
44
+ `description: ${JSON.stringify("请填写一句话摘要。")}`,
45
+ `topic: ${JSON.stringify(topic)}`,
46
+ "tags: []",
47
+ "draft: true",
48
+ "---",
49
+ "",
50
+ "## 起因",
51
+ "",
52
+ "从这里开始写正文。",
53
+ "",
54
+ ].join("\n");
55
+ try {
56
+ await writeFile(destination, markdown, { flag: "wx" });
57
+ } catch (error) {
58
+ if (error.code === "EEXIST") {
59
+ throw new Error(`文章已存在,未覆盖:${path.relative(root, destination)}。请换一个 SLUG。`);
60
+ }
61
+ throw error;
62
+ }
63
+
64
+ console.log(`已创建草稿:${path.relative(root, destination)}`);
65
+ console.log(`文章路径:/blog/${[...segments, slug].join("/")}/`);
66
+ console.log("预览:CONTENT_INCLUDE_DRAFTS=1 make dev");
67
+ console.log("发布前请完善摘要、分类与正文,并将 draft 改为 false,再运行 make update。");
68
+ }
69
+
70
+ main().catch((error) => {
71
+ console.error(`创建失败:${error.message}`);
72
+ process.exitCode = 1;
73
+ });
@@ -0,0 +1,182 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const forbiddenDirectoryNames = new Set([
7
+ ".git",
8
+ ".github",
9
+ "app",
10
+ "content",
11
+ "node_modules",
12
+ "scripts",
13
+ "tests",
14
+ ]);
15
+ const forbiddenFileNames = new Set([
16
+ "Makefile",
17
+ "eslint.config.mjs",
18
+ "site.config.yaml",
19
+ "tsconfig.json",
20
+ "vinext.config.ts",
21
+ "wrangler.jsonc",
22
+ ]);
23
+ const forbiddenExtensions = new Set([".map", ".md", ".ts", ".tsx"]);
24
+ const deploymentVersionPlaceholder = "00000000-0000-4000-8000-000000000000";
25
+
26
+ function fail(message) {
27
+ throw new Error(`Pages 产物无效:${message}`);
28
+ }
29
+
30
+ async function assertDirectory(directory, label) {
31
+ let stat;
32
+ try {
33
+ stat = await fs.stat(directory);
34
+ } catch {
35
+ fail(`${label}不存在:${directory}`);
36
+ }
37
+ if (!stat.isDirectory()) fail(`${label}不是目录:${directory}`);
38
+ }
39
+
40
+ async function inspectTree(root, directory = root) {
41
+ const entries = await fs.readdir(directory, { withFileTypes: true });
42
+ for (const entry of entries) {
43
+ const absolutePath = path.join(directory, entry.name);
44
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
45
+ const stat = await fs.lstat(absolutePath);
46
+
47
+ if (stat.isSymbolicLink()) fail(`不允许软链接:${relativePath}`);
48
+ if (forbiddenDirectoryNames.has(entry.name)) {
49
+ fail(`不允许源码目录:${relativePath}`);
50
+ }
51
+ if (entry.name.startsWith(".env")) fail(`不允许环境文件:${relativePath}`);
52
+ if (/^package(?:-lock)?\.json$/u.test(entry.name)) {
53
+ fail(`不允许依赖清单:${relativePath}`);
54
+ }
55
+ if (forbiddenFileNames.has(entry.name)) {
56
+ fail(`不允许源码配置:${relativePath}`);
57
+ }
58
+ if (entry.isFile() && forbiddenExtensions.has(path.extname(entry.name))) {
59
+ fail(`不允许源码或 Source Map:${relativePath}`);
60
+ }
61
+ if (entry.isDirectory()) await inspectTree(root, absolutePath);
62
+ }
63
+ }
64
+
65
+ function normalizeDeploymentVersion(content, replacement) {
66
+ return content
67
+ .replace(
68
+ /("deploymentVersion":")[^"]+(")/gu,
69
+ `$1${replacement}$2`,
70
+ )
71
+ .replace(
72
+ /(\\["]deploymentVersion\\["]:\\["])[^"\\]+(\\["])/gu,
73
+ `$1${replacement}$2`,
74
+ );
75
+ }
76
+
77
+ async function listFiles(root, directory = root) {
78
+ const files = [];
79
+ const entries = await fs.readdir(directory, { withFileTypes: true });
80
+ entries.sort((left, right) => left.name.localeCompare(right.name));
81
+ for (const entry of entries) {
82
+ if (entry.name === ".git") continue;
83
+ const absolutePath = path.join(directory, entry.name);
84
+ if (entry.isDirectory()) {
85
+ files.push(...await listFiles(root, absolutePath));
86
+ } else {
87
+ files.push({
88
+ absolutePath,
89
+ relativePath: path.relative(root, absolutePath).split(path.sep).join("/"),
90
+ });
91
+ }
92
+ }
93
+ return files;
94
+ }
95
+
96
+ async function stabilizeDeploymentVersion(root) {
97
+ const hash = crypto.createHash("sha256");
98
+ const normalizedFiles = new Map();
99
+
100
+ for (const file of await listFiles(root)) {
101
+ const original = await fs.readFile(file.absolutePath);
102
+ let normalized = original;
103
+ if ([".html", ".rsc"].includes(path.extname(file.relativePath))) {
104
+ const text = original.toString("utf8");
105
+ normalized = Buffer.from(
106
+ normalizeDeploymentVersion(text, deploymentVersionPlaceholder),
107
+ "utf8",
108
+ );
109
+ normalizedFiles.set(file.absolutePath, normalized);
110
+ }
111
+ hash.update(file.relativePath);
112
+ hash.update("\0");
113
+ hash.update(normalized);
114
+ hash.update("\0");
115
+ }
116
+
117
+ const digest = hash.digest("hex");
118
+ const version = [
119
+ digest.slice(0, 8),
120
+ digest.slice(8, 12),
121
+ `4${digest.slice(13, 16)}`,
122
+ `8${digest.slice(17, 20)}`,
123
+ digest.slice(20, 32),
124
+ ].join("-");
125
+
126
+ for (const [filePath, content] of normalizedFiles) {
127
+ const stabilized = normalizeDeploymentVersion(content.toString("utf8"), version);
128
+ await fs.writeFile(filePath, stabilized);
129
+ }
130
+ }
131
+
132
+ export async function validateStaticOutput(sourceDirectory) {
133
+ const source = path.resolve(sourceDirectory);
134
+ await assertDirectory(source, "静态产物目录");
135
+ await fs.access(path.join(source, "index.html")).catch(() => {
136
+ fail(`缺少入口文件:${path.join(source, "index.html")}`);
137
+ });
138
+ await inspectTree(source);
139
+ return source;
140
+ }
141
+
142
+ export async function preparePagesOutput(sourceDirectory, destinationDirectory) {
143
+ const source = await validateStaticOutput(sourceDirectory);
144
+ const destination = path.resolve(destinationDirectory);
145
+ await assertDirectory(destination, "Pages 仓库目录");
146
+ await fs.access(path.join(destination, ".git")).catch(() => {
147
+ fail(`目标不是 Git 仓库:${destination}`);
148
+ });
149
+
150
+ const relativeFromDestination = path.relative(destination, source);
151
+ if (!relativeFromDestination.startsWith("..") && !path.isAbsolute(relativeFromDestination)) {
152
+ fail("静态产物目录不能位于目标仓库内部");
153
+ }
154
+
155
+ for (const entry of await fs.readdir(destination, { withFileTypes: true })) {
156
+ if (entry.name === ".git") continue;
157
+ await fs.rm(path.join(destination, entry.name), { recursive: true, force: true });
158
+ }
159
+
160
+ await fs.cp(source, destination, { recursive: true, force: true });
161
+ await fs.writeFile(path.join(destination, ".nojekyll"), "");
162
+ await fs.writeFile(
163
+ path.join(destination, "README.md"),
164
+ "# Garden 静态站点\n\n此仓库只保存 Garden 自动生成的公开静态文件,请勿手工编辑。\n",
165
+ );
166
+ await stabilizeDeploymentVersion(destination);
167
+ }
168
+
169
+ const invokedDirectly = process.argv[1] &&
170
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
171
+
172
+ if (invokedDirectly) {
173
+ const [, , sourceDirectory, destinationDirectory] = process.argv;
174
+ if (!sourceDirectory || process.argv.length > 4) {
175
+ throw new Error("用法:node scripts/prepare-pages-output.mjs <静态产物目录> [Pages 仓库目录]");
176
+ }
177
+ if (destinationDirectory) {
178
+ await preparePagesOutput(sourceDirectory, destinationDirectory);
179
+ } else {
180
+ await validateStaticOutput(sourceDirectory);
181
+ }
182
+ }
@@ -0,0 +1,27 @@
1
+ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL)?.replace(
2
+ /\/+$/u,
3
+ "",
4
+ );
5
+
6
+ if (!siteUrl || siteUrl.includes("example.com")) {
7
+ throw new Error("请先通过 SITE_URL 配置已经公开访问的正式站点地址。");
8
+ }
9
+
10
+ const feedUrl = `${siteUrl}/rss.xml`;
11
+ const endpoint = new URL("https://webmention.app/check");
12
+ endpoint.searchParams.set("url", feedUrl);
13
+
14
+ const response = await fetch(endpoint, {
15
+ method: "POST",
16
+ headers: { Accept: "application/json" },
17
+ });
18
+
19
+ if (!response.ok) {
20
+ throw new Error(
21
+ `发送 Webmentions 失败:${response.status} ${response.statusText}`,
22
+ );
23
+ }
24
+
25
+ const results = await response.json();
26
+ const sent = Array.isArray(results) ? results.length : 0;
27
+ console.log(`Checked ${feedUrl}; discovered ${sent} outgoing Webmention targets.`);
@@ -0,0 +1,48 @@
1
+ name: Garden
2
+ tagline: 写作 · 构建 · 保持好奇
3
+ description: 一个可 fork、可长期维护的 Markdown 个人博客系统。
4
+ locale: zh_CN
5
+
6
+ author:
7
+ name: Your Name
8
+ github: your-github-id
9
+ githubBio: Write a short introduction for your GitHub profile card.
10
+ # 可选:本地没有 GITHUB_TOKEN 时用作置顶仓库降级数据。
11
+ githubPinned: []
12
+
13
+ # 一级页面配置标题和副标题。关于页还可以写 heading / paragraphs / quote / focus / cta。
14
+ pages:
15
+ home:
16
+ title: |-
17
+ 把复杂的事,
18
+ 慢慢想明白。
19
+ subtitle: 在这里记录产品、工程、学习与日常生活中值得慢慢想明白的事。
20
+ blog:
21
+ title: 长期写下去,偶尔回头整理。
22
+ subtitle: 这里是完整文章:有明确主题、有持续论证,也允许自己在更新里修正旧判断。
23
+ thoughts:
24
+ title: 不够写成文章的,也值得留下。
25
+ subtitle: 私人朋友圈式的轻量记录:短文字、照片、声音、影像,以及偶然遇见的链接。
26
+ columns:
27
+ title: 把一个主题,写到足够深入。
28
+ subtitle: 专栏不是标签集合,而是一条有顺序的阅读路径。左侧章节导航会在阅读时始终陪着你。
29
+ about:
30
+ title: 你好,我是 Your Name。
31
+ subtitle: 在这里写下你关心的问题、长期的方向,以及你希望如何与世界保持联系。
32
+ heading: 这个网站为什么存在
33
+ paragraphs:
34
+ - 我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。
35
+ - 所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。
36
+ quote: 写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。
37
+ focus:
38
+ - 将自己的关注方向写在这里
39
+ - 用文章积累可迁移的知识
40
+ - 为长期写作留出稳定的空间
41
+ cta:
42
+ href: /blog
43
+ label: 从博文开始
44
+ garden:
45
+ title: 一个基于 Markdown 的个人博客框架。
46
+ subtitle: ""
47
+
48
+ footer: 保持独立,持续写作。
@@ -0,0 +1,46 @@
1
+ MESSAGE ?= 更新博客
2
+
3
+ .DEFAULT_GOAL := help
4
+
5
+ .PHONY: help new embed skill prepare dev check build update
6
+
7
+ unexport TITLE SLUG DIR TOPIC URL
8
+ export GARDEN_NEW_TITLE := $(value TITLE)
9
+ export GARDEN_NEW_SLUG := $(value SLUG)
10
+ export GARDEN_NEW_DIR := $(value DIR)
11
+ export GARDEN_NEW_TOPIC := $(value TOPIC)
12
+ export GARDEN_EMBED_URL := $(value URL)
13
+
14
+ help:
15
+ @printf "%s\n" "Garden 常用命令:" " make new 创建文章草稿(无需安装 npm 依赖)" ' make new TITLE="文章标题" SLUG=my-post DIR=writing TOPIC="写作"' ' make embed URL="https://example.com" 拉取外部网页摘要' " make skill 安装可交互图表 skill" " make dev 安装依赖并本地预览" " make build 构建公开站点" " make update 检查、提交并触发自动发布"
16
+
17
+ new:
18
+ @node scripts/new-post.mjs
19
+
20
+ embed:
21
+ @npx garden embed
22
+
23
+ skill: prepare
24
+ npx garden skill
25
+
26
+ prepare:
27
+ @if [ ! -d node_modules ]; then npm install; fi
28
+
29
+ dev: prepare
30
+ npx garden dev
31
+
32
+ check: prepare
33
+ npx garden sync && npx garden build
34
+
35
+ build: prepare
36
+ npx garden build
37
+
38
+ update:
39
+ @if git diff --quiet && git diff --cached --quiet && [ -z "$$(git ls-files --others --exclude-standard)" ]; then \
40
+ echo "没有需要发布的改动"; \
41
+ else \
42
+ $(MAKE) check && \
43
+ git add -A && \
44
+ git commit -m "$(MESSAGE)" && \
45
+ git push origin HEAD; \
46
+ fi
@@ -0,0 +1,13 @@
1
+ # 我的 Garden
2
+
3
+ 这个仓库只保存稿件和站点身份:`content/` 与 `site.config.yaml`。页面引擎来自 [Garden](https://github.com/hedon954/garden)。
4
+
5
+ ```bash
6
+ npm install
7
+ make new TITLE="你好,世界" SLUG=hello
8
+ make dev
9
+ ```
10
+
11
+ 第一次 `npx garden dev` 会补齐 Makefile、写稿脚本和图表 skill。Cursor / Codex 打开这个仓库后会读到 `.agents/skills/garden-interactive-chart`。要更新 skill,再运行 `npx garden skill`。
12
+
13
+ 升级引擎:把 `package.json` 里的 `hd-garden` 改到新版本,例如 `^0.4.0`,再运行 `npm install`。
@@ -0,0 +1 @@
1
+ columns: []
File without changes
File without changes
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "my-garden",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=22.13.0"
7
+ },
8
+ "scripts": {
9
+ "dev": "garden dev",
10
+ "build": "garden build",
11
+ "start": "garden start",
12
+ "sync": "garden sync",
13
+ "pages:validate": "garden pages:validate",
14
+ "pages:prepare": "garden pages:prepare"
15
+ },
16
+ "dependencies": {
17
+ "hd-garden": "^0.3.0"
18
+ }
19
+ }
@@ -0,0 +1,46 @@
1
+ name: Garden
2
+ tagline: 写作 · 构建 · 保持好奇
3
+ description: 一个可长期维护的 Markdown 个人博客。
4
+ locale: zh_CN
5
+
6
+ author:
7
+ name: Your Name
8
+ github: your-github-id
9
+ githubBio: Write a short introduction for your GitHub profile card.
10
+ githubPinned: []
11
+
12
+ pages:
13
+ home:
14
+ title: |-
15
+ 把复杂的事,
16
+ 慢慢想明白。
17
+ subtitle: 在这里记录产品、工程、学习与日常生活中值得慢慢想明白的事。
18
+ blog:
19
+ title: 长期写下去,偶尔回头整理。
20
+ subtitle: 这里是完整文章:有明确主题、有持续论证,也允许自己在更新里修正旧判断。
21
+ thoughts:
22
+ title: 不够写成文章的,也值得留下。
23
+ subtitle: 私人朋友圈式的轻量记录:短文字、照片、声音、影像,以及偶然遇见的链接。
24
+ columns:
25
+ title: 把一个主题,写到足够深入。
26
+ subtitle: 专栏不是标签集合,而是一条有顺序的阅读路径。
27
+ about:
28
+ title: 你好,我是 Your Name。
29
+ subtitle: 在这里写下你关心的问题、长期的方向,以及你希望如何与世界保持联系。
30
+ heading: 这个网站为什么存在
31
+ paragraphs:
32
+ - 我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。
33
+ - 所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。
34
+ quote: 写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。
35
+ focus:
36
+ - 将自己的关注方向写在这里
37
+ - 用文章积累可迁移的知识
38
+ - 为长期写作留出稳定的空间
39
+ cta:
40
+ href: /blog
41
+ label: 从博文开始
42
+ garden:
43
+ title: 一个基于 Markdown 的个人博客框架。
44
+ subtitle: ""
45
+
46
+ footer: 保持独立,持续写作。