hd-garden 0.3.8 → 0.3.10

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 (95) hide show
  1. package/.agents/skills/garden-post-review/SKILL.md +2 -0
  2. package/.agents/skills/garden-ui/SKILL.md +111 -0
  3. package/.env.example +8 -0
  4. package/app/admin/page.tsx +14 -3
  5. package/app/admin/preview/[...path]/page.tsx +88 -0
  6. package/app/admin/ui/blocks/page.tsx +16 -0
  7. package/app/admin/ui/catalog/page.tsx +16 -0
  8. package/app/admin/ui/evals/page.tsx +19 -0
  9. package/app/admin/ui/page.tsx +22 -0
  10. package/app/admin-ui.css +227 -0
  11. package/app/api/admin/distribution/route.ts +4 -2
  12. package/app/api/admin/posts/source/route.ts +21 -0
  13. package/app/api/admin/preview/asset/route.ts +32 -0
  14. package/app/api/admin/thoughts/route.ts +1 -1
  15. package/app/api/admin/ui/blocks/route.ts +19 -0
  16. package/app/api/admin/ui/draft/route.ts +30 -0
  17. package/app/api/auth/github/route.ts +40 -1
  18. package/app/blog/page.tsx +6 -5
  19. package/app/columns/page.tsx +1 -1
  20. package/app/components/AdminDashboard.tsx +53 -1
  21. package/app/components/AdminUiPanels.tsx +258 -0
  22. package/app/components/AdminUiShell.tsx +56 -0
  23. package/app/components/ContentEmptyState.tsx +6 -6
  24. package/app/components/MarkdownArticle.tsx +15 -5
  25. package/app/components/UiBlock.tsx +22 -0
  26. package/app/components/UiStudio.tsx +422 -0
  27. package/app/components/ui-studio-keys.ts +3 -0
  28. package/app/globals.css +2 -0
  29. package/app/lib/admin-auth.ts +13 -1
  30. package/app/lib/content.ts +2 -1
  31. package/app/lib/fallback-site-config.ts +18 -18
  32. package/app/lib/github-content.ts +47 -4
  33. package/app/lib/repository-path.mjs +55 -0
  34. package/app/lib/ui-content.ts +79 -0
  35. package/app/lib/ui-model.ts +145 -0
  36. package/app/raw.d.ts +4 -0
  37. package/app/rss.xml/route.ts +2 -1
  38. package/app/ui-parity/page.tsx +120 -0
  39. package/bin/garden.mjs +71 -0
  40. package/eslint.config.mjs +3 -0
  41. package/package.json +15 -1
  42. package/scripts/build-content.mjs +35 -4
  43. package/scripts/build-ui-preview.mjs +29 -0
  44. package/scripts/check-post.mjs +26 -4
  45. package/scripts/ui-cli.mjs +303 -0
  46. package/site.config.yaml +19 -19
  47. package/templates/site/site.config.yaml +19 -19
  48. package/tsconfig.json +1 -1
  49. package/ui/core/apply.mjs +81 -0
  50. package/ui/core/catalog.mjs +536 -0
  51. package/ui/core/compile.mjs +63 -0
  52. package/ui/core/demos.mjs +223 -0
  53. package/ui/core/expr.mjs +310 -0
  54. package/ui/core/fence.mjs +201 -0
  55. package/ui/core/fix-prompt.mjs +35 -0
  56. package/ui/core/format.mjs +96 -0
  57. package/ui/core/index.mjs +37 -0
  58. package/ui/core/parse.mjs +229 -0
  59. package/ui/core/scan.mjs +53 -0
  60. package/ui/core/sha256.mjs +66 -0
  61. package/ui/core/starters.mjs +18 -0
  62. package/ui/core/state.mjs +259 -0
  63. package/ui/core/static.mjs +524 -0
  64. package/ui/core/transform.mjs +146 -0
  65. package/ui/core/validate.mjs +665 -0
  66. package/ui/evals/cases.yaml +155 -0
  67. package/ui/preview/dist/garden-ui.css +1182 -0
  68. package/ui/preview/dist/garden-ui.esm.js +367 -0
  69. package/ui/preview/dist/garden-ui.iife.js +367 -0
  70. package/ui/preview/index.tsx +191 -0
  71. package/ui/preview/mermaid-shim.ts +19 -0
  72. package/ui/prompt/build.mjs +24 -0
  73. package/ui/prompt/recipes/code-walkthrough.md +68 -0
  74. package/ui/prompt/recipes/compare.md +48 -0
  75. package/ui/prompt/recipes/converter.md +55 -0
  76. package/ui/prompt/recipes/data-chart.md +57 -0
  77. package/ui/prompt/recipes/inspect-grid.md +45 -0
  78. package/ui/prompt/recipes/param-bench.md +55 -0
  79. package/ui/prompt/recipes/plan.md +45 -0
  80. package/ui/prompt/recipes/stepper.md +52 -0
  81. package/ui/prompt/recipes/trace.md +62 -0
  82. package/ui/prompt/rules.md +104 -0
  83. package/ui/prompt/studio.md +20 -0
  84. package/ui/react/CatalogPage.tsx +166 -0
  85. package/ui/react/components/chart.tsx +470 -0
  86. package/ui/react/components/display.tsx +296 -0
  87. package/ui/react/components/inputs.tsx +268 -0
  88. package/ui/react/components/layout.tsx +236 -0
  89. package/ui/react/components/media.tsx +184 -0
  90. package/ui/react/index.tsx +59 -0
  91. package/ui/react/recipes.ts +25 -0
  92. package/ui/react/runtime.tsx +239 -0
  93. package/ui/react/types.ts +29 -0
  94. package/ui/react/ui.css +1182 -0
  95. package/vite.config.ts +4 -3
@@ -5,6 +5,7 @@ import matter from "gray-matter";
5
5
  import { marked } from "marked";
6
6
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
7
  import { canonicalSiteDate } from "./site-date.mjs";
8
+ import { contentForFeed, contentToSearchText, transformUiContent } from "../ui/core/transform.mjs";
8
9
 
9
10
  function resolveSiteRoot() {
10
11
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
@@ -23,6 +24,8 @@ const gardenDir = path.join(root, ".garden");
23
24
  const output = path.join(gardenDir, "generated-content.ts");
24
25
  const siteConfigOutput = path.join(gardenDir, "site-config.ts");
25
26
  const includeDrafts = process.env.CONTENT_INCLUDE_DRAFTS === "1";
27
+ // Local preview shows broken ui blocks as error cards; public builds fail on them.
28
+ const uiMode = includeDrafts || process.env.GARDEN_CONTENT_MODE === "preview" ? "preview" : "publish";
26
29
  const now = Date.now();
27
30
  const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
28
31
  const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com")
@@ -249,6 +252,26 @@ function rewriteWidgetFences(content, markdownPath, sourcePath) {
249
252
  );
250
253
  }
251
254
 
255
+ function rewriteUiFences(content, markdownPath, sourcePath, lineOffset) {
256
+ const { content: transformed, errors } = transformUiContent(content, {
257
+ mode: uiMode,
258
+ markdownPath: sourcePath.split(path.sep).join("/"),
259
+ lineOffset,
260
+ readData: (src) => {
261
+ const absolute = path.resolve(path.dirname(markdownPath), src);
262
+ return fs.existsSync(absolute) && fs.statSync(absolute).isFile() ? fs.readFileSync(absolute, "utf8") : undefined;
263
+ },
264
+ resolveAsset: (_sidecarPath, written) => copyLocalAsset(written, markdownPath, sourcePath),
265
+ });
266
+ if (errors.length) {
267
+ fail(
268
+ sourcePath,
269
+ `ui 块没有通过检查:\n${errors.map((item) => ` 第 ${item.line} 行 ${item.code}:${item.message}`).join("\n")}\n修好后再发布;可以运行 npx garden ui check ${path.join("content", sourcePath)} 拿到修复提示词。`,
270
+ );
271
+ }
272
+ return transformed;
273
+ }
274
+
252
275
  function normalizeMedia(media, markdownPath, sourcePath) {
253
276
  if (media === undefined || media === null) return undefined;
254
277
  if (typeof media === "string") {
@@ -286,7 +309,7 @@ function normalizeMedia(media, markdownPath, sourcePath) {
286
309
  });
287
310
  }
288
311
 
289
- function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
312
+ function validateAndNormalize(data, content, markdownPath, kind, sourcePath, lineOffset = 0) {
290
313
  const title = requireString(data, "title", sourcePath);
291
314
  const slug = path.basename(markdownPath, ".md");
292
315
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
@@ -355,13 +378,19 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
355
378
  : undefined,
356
379
  media: normalizeMedia(data.media, markdownPath, sourcePath),
357
380
  content: rewriteWidgetFences(
358
- rewriteMarkdownAssets(content.trim(), markdownPath, sourcePath),
381
+ rewriteMarkdownAssets(
382
+ rewriteUiFences(content.trim(), markdownPath, sourcePath, lineOffset + leadingLines(content)),
383
+ markdownPath,
384
+ sourcePath,
385
+ ),
359
386
  markdownPath,
360
387
  sourcePath,
361
388
  ),
362
389
  };
363
390
  }
364
391
 
392
+ const leadingLines = (content) => (/^\s*/u.exec(content)?.[0].match(/\n/gu) ?? []).length;
393
+
365
394
  function readMarkdownTree(directory, kind) {
366
395
  if (!fs.existsSync(directory)) return [];
367
396
 
@@ -379,12 +408,14 @@ function readMarkdownTree(directory, kind) {
379
408
  },
380
409
  });
381
410
  const sourcePath = path.relative(contentRoot, fullPath);
411
+ const frontMatterLines = raw.slice(0, raw.length - parsed.content.length).split("\n").length - 1;
382
412
  const normalized = validateAndNormalize(
383
413
  parsed.data,
384
414
  parsed.content,
385
415
  fullPath,
386
416
  kind,
387
417
  sourcePath,
418
+ frontMatterLines,
388
419
  );
389
420
  return normalized ? [normalized] : [];
390
421
  });
@@ -591,9 +622,9 @@ function writeRssFeed(fileName, entries) {
591
622
  <link>${escapeXml(`${publicBaseUrl}${entry.path}`)}</link>
592
623
  <guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.path}`)}</guid>
593
624
  <pubDate>${new Date(entry.date).toUTCString()}</pubDate>
594
- <description>${escapeXml(entry.description ?? entry.content.replace(/\s+/gu, " ").slice(0, 180))}</description>
625
+ <description>${escapeXml(entry.description ?? contentToSearchText(entry.content).replace(/\s+/gu, " ").trim().slice(0, 180))}</description>
595
626
  ${(entry.tags ?? []).map((tag) => `<category>${escapeXml(tag)}</category>`).join("")}
596
- <content:encoded><![CDATA[${cdata(marked.parse(entry.content))}]]></content:encoded>
627
+ <content:encoded><![CDATA[${cdata(marked.parse(contentForFeed(entry.content, { pageUrl: `${publicBaseUrl}${entry.path}`, siteUrl: publicBaseUrl })))}]]></content:encoded>
597
628
  </item>`).join("");
598
629
 
599
630
  fs.writeFileSync(path.join(publicRoot, fileName), `<?xml version="1.0" encoding="UTF-8" ?>
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { build } from "esbuild";
4
+ import { gardenPackageRoot } from "./garden-paths.mjs";
5
+
6
+ const root = gardenPackageRoot;
7
+ const out = path.join(root, "ui", "preview", "dist");
8
+ fs.rmSync(out, { recursive: true, force: true });
9
+ fs.mkdirSync(out, { recursive: true });
10
+
11
+ const shared = {
12
+ entryPoints: [path.join(root, "ui", "preview", "index.tsx")],
13
+ bundle: true,
14
+ minify: true,
15
+ jsx: "automatic",
16
+ platform: "browser",
17
+ target: ["es2020"],
18
+ define: { "process.env.NODE_ENV": '"production"' },
19
+ alias: { mermaid: path.join(root, "ui", "preview", "mermaid-shim.ts") },
20
+ legalComments: "none",
21
+ logLevel: "warning",
22
+ };
23
+
24
+ await build({ ...shared, format: "iife", globalName: "GardenUI", outfile: path.join(out, "garden-ui.iife.js") });
25
+ await build({ ...shared, format: "esm", outfile: path.join(out, "garden-ui.esm.js") });
26
+ fs.copyFileSync(path.join(root, "ui", "react", "ui.css"), path.join(out, "garden-ui.css"));
27
+
28
+ const size = (file) => `${(fs.statSync(path.join(out, file)).size / 1024).toFixed(0)} KB`;
29
+ process.stdout.write(`预览包已生成:garden-ui.iife.js ${size("garden-ui.iife.js")},garden-ui.esm.js ${size("garden-ui.esm.js")},garden-ui.css\n`);
@@ -4,6 +4,7 @@ import matter from "gray-matter";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { canonicalSiteDate } from "./site-date.mjs";
6
6
  import { gardenSiteRoot } from "./garden-paths.mjs";
7
+ import { checkUiBlock } from "../ui/core/validate.mjs";
7
8
 
8
9
  const siteRoot = gardenSiteRoot();
9
10
  const calloutTypes = new Set([
@@ -184,7 +185,22 @@ const checkEmbed = (errors, body, sourcePath, line) => {
184
185
  }
185
186
  };
186
187
 
187
- const scanBody = (errors, content, markdownPath, sourcePath) => {
188
+ const checkUi = (errors, warnings, body, markdownPath, sourcePath, line) => {
189
+ const relative = path.relative(path.join(siteRoot, "content"), markdownPath).split(path.sep).join("/");
190
+ const result = checkUiBlock(body, {
191
+ lineOffset: line,
192
+ markdownPath: relative,
193
+ readData: (src) => {
194
+ const absolute = path.resolve(path.dirname(markdownPath), src);
195
+ return fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
196
+ },
197
+ });
198
+ for (const item of result.diagnostics) {
199
+ (item.severity === "warning" ? warnings : errors).push({ code: item.code, line: item.line, message: `${sourcePath}: ${item.message}` });
200
+ }
201
+ };
202
+
203
+ const scanBody = (errors, content, markdownPath, sourcePath, { warnings = [], draft = false } = {}) => {
188
204
  let fence = null;
189
205
  let previousDepth = 1;
190
206
  const lines = content.split(/\r?\n/u);
@@ -195,6 +211,10 @@ const scanBody = (errors, content, markdownPath, sourcePath) => {
195
211
  if (opener && opener[1][0] === fence.marker && opener[1].length >= fence.marker.length && !opener[2].trim()) {
196
212
  if (fence.lang === "widget") checkWidget(errors, fence.body.join("\n"), markdownPath, sourcePath, fence.line);
197
213
  if (fence.lang === "embed") checkEmbed(errors, fence.body.join("\n"), sourcePath, fence.line);
214
+ if (fence.lang === "ui") checkUi(errors, warnings, fence.body.join("\n"), markdownPath, sourcePath, fence.line);
215
+ if (fence.lang === "intent" && !draft) {
216
+ error(errors, "intent-uncompiled", fence.line, `${sourcePath}: 还有没编译的 intent 块;对 AI 说「编译这篇」`);
217
+ }
198
218
  fence = null;
199
219
  } else {
200
220
  fence.body.push(line);
@@ -238,9 +258,10 @@ const checkFile = (filePath) => {
238
258
  const absolute = path.resolve(siteRoot, filePath);
239
259
  const sourcePath = path.relative(siteRoot, absolute);
240
260
  const errors = [];
261
+ const warnings = [];
241
262
  if (!fs.existsSync(absolute)) {
242
263
  error(errors, "missing-file", 1, `${sourcePath}: 找不到文件`);
243
- return { file: sourcePath, errors };
264
+ return { file: sourcePath, errors, warnings };
244
265
  }
245
266
  const parsed = matter(fs.readFileSync(absolute, "utf8"), matterOptions);
246
267
  const data = parsed.data ?? {};
@@ -266,8 +287,9 @@ const checkFile = (filePath) => {
266
287
  }
267
288
  }
268
289
  checkReferences(data, sourcePath, errors);
269
- scanBody(errors, parsed.content, absolute, sourcePath);
270
- return { file: sourcePath, errors };
290
+ const draft = data.draft === true || (typeof data.publishAt === "string" && Date.parse(canonicalSiteDate(data.publishAt) ?? "") > Date.now());
291
+ scanBody(errors, parsed.content, absolute, sourcePath, { warnings, draft });
292
+ return { file: sourcePath, errors, warnings };
271
293
  };
272
294
 
273
295
  const files = process.argv.slice(2);
@@ -0,0 +1,303 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import matter from "gray-matter";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { applyBlock, ApplyError } from "../ui/core/apply.mjs";
7
+ import { catalogSchema } from "../ui/core/catalog.mjs";
8
+ import { buildFixPrompt } from "../ui/core/fix-prompt.mjs";
9
+ import { scanMarkdown } from "../ui/core/scan.mjs";
10
+ import { buildPrompt, RECIPE_FILES } from "../ui/prompt/build.mjs";
11
+ import { canonicalSiteDate } from "./site-date.mjs";
12
+ import { gardenPackageRoot, gardenSiteRoot } from "./garden-paths.mjs";
13
+
14
+ const siteRoot = gardenSiteRoot();
15
+ const [command, ...rest] = process.argv.slice(2);
16
+
17
+ function fail(message, code = 1) {
18
+ process.stderr.write(`${message}\n`);
19
+ process.exit(code);
20
+ }
21
+
22
+ function flag(name) {
23
+ return rest.includes(`--${name}`);
24
+ }
25
+
26
+ function option(name) {
27
+ const index = rest.indexOf(`--${name}`);
28
+ if (index < 0) return null;
29
+ const value = rest[index + 1];
30
+ if (!value || value.startsWith("--")) fail(`--${name} 需要一个值`);
31
+ return value;
32
+ }
33
+
34
+ function positional() {
35
+ const values = [];
36
+ for (let index = 0; index < rest.length; index += 1) {
37
+ const value = rest[index];
38
+ if (value.startsWith("--")) {
39
+ if (["id", "at", "intent-hash", "base", "file", "out"].includes(value.slice(2))) index += 1;
40
+ continue;
41
+ }
42
+ values.push(value);
43
+ }
44
+ return values;
45
+ }
46
+
47
+ const promptRoot = path.join(gardenPackageRoot, "ui", "prompt");
48
+
49
+ function promptText({ studio = false } = {}) {
50
+ return buildPrompt({
51
+ rules: fs.readFileSync(path.join(promptRoot, "rules.md"), "utf8"),
52
+ recipes: RECIPE_FILES.map((file) => fs.readFileSync(path.join(promptRoot, "recipes", file), "utf8")),
53
+ studio: studio ? fs.readFileSync(path.join(promptRoot, "studio.md"), "utf8") : null,
54
+ });
55
+ }
56
+
57
+ const toPosix = (value) => value.split(path.sep).join("/");
58
+
59
+ function git(args, { input, allowFail = false } = {}) {
60
+ const result = spawnSync("git", args, { cwd: siteRoot, encoding: "utf8", input, maxBuffer: 64 * 1024 * 1024 });
61
+ if (result.status !== 0) {
62
+ if (allowFail) return null;
63
+ fail(`git ${args.join(" ")} 失败:${(result.stderr || "").trim()}`);
64
+ }
65
+ return result.stdout;
66
+ }
67
+
68
+ function isDraft(markdown) {
69
+ try {
70
+ const { data } = matter(markdown, { engines: { yaml: { parse: (input) => parseYaml(input) ?? {} } } });
71
+ if (data.draft === true) return true;
72
+ const publish = canonicalSiteDate(data.publishAt ?? data.date);
73
+ return Boolean(publish) && Date.parse(publish) > Date.now();
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function listContentFiles(directory) {
80
+ if (!fs.existsSync(directory)) return [];
81
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
82
+ const full = path.join(directory, entry.name);
83
+ if (entry.isDirectory()) return listContentFiles(full);
84
+ return entry.name.endsWith(".md") ? [full] : [];
85
+ });
86
+ }
87
+
88
+ /** Files to check, each with a reader for itself and its same-name directory. */
89
+ function targets() {
90
+ if (flag("staged")) {
91
+ const top = git(["rev-parse", "--show-toplevel"]).trim();
92
+ const names = git(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"]).split("\0").filter(Boolean);
93
+ return names
94
+ .filter((name) => name.endsWith(".md"))
95
+ .map((name) => ({ absolute: path.join(top, name), repoPath: name }))
96
+ .filter((item) => toPosix(path.relative(siteRoot, item.absolute)).startsWith("content/"))
97
+ .map((item) => ({
98
+ file: toPosix(path.relative(siteRoot, item.absolute)),
99
+ read: () => git(["show", `:${item.repoPath}`]),
100
+ readSibling: (relative) => {
101
+ const repoRelative = toPosix(path.join(path.dirname(item.repoPath), relative));
102
+ return git(["show", `:${repoRelative}`], { allowFail: true }) ?? undefined;
103
+ },
104
+ }));
105
+ }
106
+ const files = flag("all") ? listContentFiles(path.join(siteRoot, "content")) : positional().map((file) => path.resolve(siteRoot, file));
107
+ return files.map((absolute) => ({
108
+ file: toPosix(path.relative(siteRoot, absolute)),
109
+ read: () => {
110
+ if (!fs.existsSync(absolute)) fail(`找不到文件 ${absolute}`);
111
+ return fs.readFileSync(absolute, "utf8");
112
+ },
113
+ readSibling: (relative) => {
114
+ const target = path.resolve(path.dirname(absolute), relative);
115
+ return fs.existsSync(target) && fs.statSync(target).isFile() ? fs.readFileSync(target, "utf8") : undefined;
116
+ },
117
+ }));
118
+ }
119
+
120
+ function scanTarget(target) {
121
+ const markdown = target.read();
122
+ const blocks = scanMarkdown(markdown, { markdownPath: target.file, readData: target.readSibling });
123
+ return { markdown, blocks, draft: isDraft(markdown) };
124
+ }
125
+
126
+ function copyToClipboard(text) {
127
+ const candidates = process.platform === "darwin"
128
+ ? [["pbcopy", []]]
129
+ : process.platform === "win32"
130
+ ? [["clip", []]]
131
+ : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
132
+ for (const [binary, args] of candidates) {
133
+ const result = spawnSync(binary, args, { input: text });
134
+ if (result.status === 0) return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ function commandScan() {
140
+ const files = targets();
141
+ if (!files.length) fail("用法:garden ui scan content/posts/文章.md");
142
+ const output = files.map((target) => {
143
+ const { blocks, draft } = scanTarget(target);
144
+ return {
145
+ file: target.file,
146
+ draft,
147
+ blocks: blocks.map((block) => Object.fromEntries(Object.entries(block).filter(([key]) => key !== "raw"))),
148
+ };
149
+ });
150
+ process.stdout.write(`${JSON.stringify(output.length === 1 ? output[0] : output, null, 2)}\n`);
151
+ }
152
+
153
+ function commandCheck() {
154
+ const files = targets();
155
+ if (!files.length) {
156
+ if (flag("staged")) process.exit(0);
157
+ fail("用法:garden ui check content/posts/文章.md [--staged] [--all] [--standalone]");
158
+ }
159
+ const problems = [];
160
+ const warnings = [];
161
+ for (const target of files) {
162
+ const { blocks, draft } = scanTarget(target);
163
+ for (const block of blocks) {
164
+ const errors = block.diagnostics.filter((item) => item.severity === "error");
165
+ for (const item of block.diagnostics.filter((entry) => entry.severity === "warning")) {
166
+ warnings.push(`${target.file}:${item.line} ${block.id ?? "-"} ${item.code} ${item.message}`);
167
+ }
168
+ if (block.lang === "intent" && !draft) problems.push({ file: target.file, block, diagnostics: [] });
169
+ else if (errors.length) problems.push({ file: target.file, block, diagnostics: errors });
170
+ }
171
+ }
172
+ for (const line of warnings) process.stderr.write(`提醒 ${line}\n`);
173
+ if (!problems.length) {
174
+ if (!flag("quiet")) process.stdout.write(`ui 检查通过(${files.length} 个文件)\n`);
175
+ return;
176
+ }
177
+ for (const problem of problems) {
178
+ if (problem.block.lang === "intent") {
179
+ process.stderr.write(`${problem.file}:${problem.block.startLine} ${problem.block.id ?? "-"} intent-uncompiled 还有没编译的 intent 块\n`);
180
+ }
181
+ for (const item of problem.diagnostics) {
182
+ process.stderr.write(`${problem.file}:${item.line} ${problem.block.id ?? "-"} ${item.code} ${item.message}\n`);
183
+ }
184
+ }
185
+ const prompt = buildFixPrompt(problems, { standaloneReference: flag("standalone") ? promptText() : null });
186
+ const separator = "─".repeat(24);
187
+ process.stderr.write(`\n${separator} 发给 AI 的修复提示词 ${separator}\n${prompt}${separator}${separator}${"─".repeat(12)}\n`);
188
+ const promptFile = path.join(siteRoot, ".garden", "ui-fix-prompt.md");
189
+ fs.mkdirSync(path.dirname(promptFile), { recursive: true });
190
+ fs.writeFileSync(promptFile, prompt);
191
+ const copied = !flag("no-copy") && process.env.GARDEN_UI_NO_COPY !== "1" && copyToClipboard(prompt);
192
+ process.stderr.write(`已写入 ${toPosix(path.relative(siteRoot, promptFile))}${copied ? ",并已复制到剪贴板" : ""}。确实要先提交,可以用 git commit --no-verify。\n`);
193
+ process.exit(1);
194
+ }
195
+
196
+ function readStdin() {
197
+ try {
198
+ return fs.readFileSync(0, "utf8");
199
+ } catch {
200
+ return "";
201
+ }
202
+ }
203
+
204
+ function commandApply() {
205
+ const [file] = positional();
206
+ if (!file) fail("用法:garden ui apply content/posts/文章.md --id <id> [--base <哈希>] [--file 新内容.md]");
207
+ const absolute = path.resolve(siteRoot, file);
208
+ if (!fs.existsSync(absolute)) fail(`找不到文件 ${file}`);
209
+ const source = option("file");
210
+ const replacement = source ? fs.readFileSync(path.resolve(process.cwd(), source), "utf8") : readStdin();
211
+ if (!replacement.trim()) fail("没有拿到新内容:用 --file 指定,或从标准输入传入");
212
+ const target = { id: option("id"), at: option("at"), intentHash: option("intent-hash") };
213
+ try {
214
+ const markdown = fs.readFileSync(absolute, "utf8");
215
+ const result = applyBlock(markdown, target, replacement, {
216
+ base: option("base"),
217
+ markdownPath: toPosix(path.relative(siteRoot, absolute)),
218
+ readData: (relative) => {
219
+ const sibling = path.resolve(path.dirname(absolute), relative);
220
+ return fs.existsSync(sibling) ? fs.readFileSync(sibling, "utf8") : undefined;
221
+ },
222
+ });
223
+ fs.writeFileSync(absolute, result.markdown);
224
+ process.stdout.write(`${JSON.stringify({ ok: true, file: toPosix(path.relative(siteRoot, absolute)), kind: result.kind, line: result.block.startLine })}\n`);
225
+ } catch (error) {
226
+ if (!(error instanceof ApplyError)) throw error;
227
+ process.stderr.write(`${JSON.stringify({ ok: false, code: error.code, message: error.message, diagnostics: error.diagnostics }, null, 2)}\n`);
228
+ process.exit(1);
229
+ }
230
+ }
231
+
232
+ function openPath(target) {
233
+ const opener = process.platform === "darwin" ? ["open", [target]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", target]] : ["xdg-open", [target]];
234
+ spawnSync(opener[0], opener[1], { stdio: "ignore" });
235
+ }
236
+
237
+ function commandCatalog() {
238
+ const dist = path.join(gardenPackageRoot, "ui", "preview", "dist");
239
+ const script = path.join(dist, "garden-ui.iife.js");
240
+ const style = path.join(dist, "garden-ui.css");
241
+ if (!fs.existsSync(script) || !fs.existsSync(style)) {
242
+ fail("缺少编辑器预览包。在引擎仓库运行 npm run ui:build 后再试。");
243
+ }
244
+ const recipes = RECIPE_FILES.map((file) => ({ file, markdown: fs.readFileSync(path.join(promptRoot, "recipes", file), "utf8") }));
245
+ const json = JSON.stringify(recipes).replaceAll("<", "\\u003c");
246
+ const html = `<!doctype html>
247
+ <html lang="zh-CN">
248
+ <head>
249
+ <meta charset="utf-8">
250
+ <meta name="viewport" content="width=device-width, initial-scale=1">
251
+ <title>Garden 组件清单</title>
252
+ <style>body{margin:0;background:#f8f8f6;color:#202124}main{max-width:760px;margin:0 auto;padding:32px 20px 80px}@media (prefers-color-scheme: dark){body{background:#171819;color:#ededeb}}</style>
253
+ <style>${fs.readFileSync(style, "utf8")}</style>
254
+ </head>
255
+ <body>
256
+ <main id="catalog"></main>
257
+ <script>${fs.readFileSync(script, "utf8").replaceAll("</script", "<\\/script")}</script>
258
+ <script>
259
+ const root = document.getElementById("catalog");
260
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) root.setAttribute("data-theme", "dark");
261
+ GardenUI.renderCatalog(root, { recipes: ${json} });
262
+ </script>
263
+ </body>
264
+ </html>
265
+ `;
266
+ const out = path.resolve(siteRoot, option("out") ?? path.join(".garden", "ui-catalog.html"));
267
+ fs.mkdirSync(path.dirname(out), { recursive: true });
268
+ fs.writeFileSync(out, html);
269
+ process.stdout.write(`组件清单已生成:${toPosix(path.relative(siteRoot, out))}\n`);
270
+ if (!flag("no-open")) openPath(out);
271
+ }
272
+
273
+ switch (command) {
274
+ case "scan":
275
+ commandScan();
276
+ break;
277
+ case "check":
278
+ commandCheck();
279
+ break;
280
+ case "apply":
281
+ commandApply();
282
+ break;
283
+ case "prompt":
284
+ process.stdout.write(promptText({ studio: flag("studio") }));
285
+ break;
286
+ case "schema":
287
+ process.stdout.write(`${JSON.stringify(catalogSchema(), null, 2)}\n`);
288
+ break;
289
+ case "catalog":
290
+ commandCatalog();
291
+ break;
292
+ default:
293
+ fail(`用法:garden ui <scan|check|apply|prompt|schema|catalog>
294
+
295
+ garden ui scan <稿件.md> 列出块、状态、行号和哈希(JSON)
296
+ garden ui check <稿件.md>... | --staged | --all [--standalone]
297
+ 检查 ui / intent 块,失败时给出修复提示词
298
+ garden ui apply <稿件.md> --id <id> [--base <哈希>] [--file 新内容.md]
299
+ 只替换一块,围栏外逐字节不变
300
+ garden ui prompt [--studio] 打印写作规则、组件参考和配方
301
+ garden ui schema 打印组件目录 JSON
302
+ garden ui catalog [--out 文件] [--no-open] 生成离线组件清单并打开`);
303
+ }
package/site.config.yaml CHANGED
@@ -14,35 +14,35 @@ author:
14
14
  pages:
15
15
  home:
16
16
  title: |-
17
- 把复杂的事,
18
- 慢慢想明白。
19
- subtitle: 在这里记录产品、工程、学习与日常生活中值得慢慢想明白的事。
17
+ 记一些正在做的事,
18
+ 和想清楚的问题。
19
+ subtitle: 关注软件工程、系统设计与技术实践,也记录偶发的探索和工作笔记。
20
20
  blog:
21
- title: 长期写下去,偶尔回头整理。
22
- subtitle: 这里是完整文章:有明确主题、有持续论证,也允许自己在更新里修正旧判断。
21
+ title: 博文
22
+ subtitle: 阶段性的完整梳理与技术总结。
23
23
  thoughts:
24
- title: 不够写成文章的,也值得留下。
25
- subtitle: 私人朋友圈式的轻量记录:短文字、照片、声音、影像,以及偶然遇见的链接。
24
+ title: 随想
25
+ subtitle: 碎片的念头、偶遇的灵感、短笔记与链接。
26
26
  columns:
27
- title: 把一个主题,写到足够深入。
28
- subtitle: 专栏不是标签集合,而是一条有顺序的阅读路径。左侧章节导航会在阅读时始终陪着你。
27
+ title: 专栏
28
+ subtitle: 按顺序组织的专题系列,沿着清晰的脉络连点成线。
29
29
  about:
30
30
  title: 你好,我是 Your Name。
31
- subtitle: 在这里写下你关心的问题、长期的方向,以及你希望如何与世界保持联系。
32
- heading: 这个网站为什么存在
31
+ subtitle: 欢迎来到我的个人网站。
32
+ heading: 关于我与这里
33
33
  paragraphs:
34
- - 我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。
35
- - 所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。
36
- quote: 写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。
34
+ - 一名工程师 / 创作者。平时主要关注系统开发、技术探索与软件设计。
35
+ - 这里是我的公开工作台与数字自留地。比起社交网络上的碎片动态,我更倾向于把探索过程沉淀为有据可查的代码、设计与长篇笔记。
36
+ quote: ""
37
37
  focus:
38
- - 将自己的关注方向写在这里
39
- - 用文章积累可迁移的知识
40
- - 为长期写作留出稳定的空间
38
+ - 软件工程与系统架构
39
+ - 原生工具与高质量交互
40
+ - 个人长期知识沉淀
41
41
  cta:
42
42
  href: /blog
43
- label: 从博文开始
43
+ label: 浏览博文
44
44
  garden:
45
45
  title: 一个基于 Markdown 的个人博客框架。
46
46
  subtitle: ""
47
47
 
48
- footer: 保持独立,持续写作。
48
+ footer: 持续记录与构建。
@@ -12,35 +12,35 @@ author:
12
12
  pages:
13
13
  home:
14
14
  title: |-
15
- 把复杂的事,
16
- 慢慢想明白。
17
- subtitle: 在这里记录产品、工程、学习与日常生活中值得慢慢想明白的事。
15
+ 记一些正在做的事,
16
+ 和想清楚的问题。
17
+ subtitle: 关注软件工程、系统设计与技术实践,也记录偶发的探索和工作笔记。
18
18
  blog:
19
- title: 长期写下去,偶尔回头整理。
20
- subtitle: 这里是完整文章:有明确主题、有持续论证,也允许自己在更新里修正旧判断。
19
+ title: 博文
20
+ subtitle: 阶段性的完整梳理与技术总结。
21
21
  thoughts:
22
- title: 不够写成文章的,也值得留下。
23
- subtitle: 私人朋友圈式的轻量记录:短文字、照片、声音、影像,以及偶然遇见的链接。
22
+ title: 随想
23
+ subtitle: 碎片的念头、偶遇的灵感、短笔记与链接。
24
24
  columns:
25
- title: 把一个主题,写到足够深入。
26
- subtitle: 专栏不是标签集合,而是一条有顺序的阅读路径。
25
+ title: 专栏
26
+ subtitle: 按顺序组织的专题系列,沿着清晰的脉络连点成线。
27
27
  about:
28
28
  title: 你好,我是 Your Name。
29
- subtitle: 在这里写下你关心的问题、长期的方向,以及你希望如何与世界保持联系。
30
- heading: 这个网站为什么存在
29
+ subtitle: 欢迎来到我的个人网站。
30
+ heading: 关于我与这里
31
31
  paragraphs:
32
- - 我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。
33
- - 所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。
34
- quote: 写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。
32
+ - 一名工程师 / 创作者。平时主要关注系统开发、技术探索与软件设计。
33
+ - 这里是我的公开工作台与数字自留地。比起社交网络上的碎片动态,我更倾向于把探索过程沉淀为有据可查的代码、设计与长篇笔记。
34
+ quote: ""
35
35
  focus:
36
- - 将自己的关注方向写在这里
37
- - 用文章积累可迁移的知识
38
- - 为长期写作留出稳定的空间
36
+ - 软件工程与系统架构
37
+ - 原生工具与高质量交互
38
+ - 个人长期知识沉淀
39
39
  cta:
40
40
  href: /blog
41
- label: 从博文开始
41
+ label: 浏览博文
42
42
  garden:
43
43
  title: 一个基于 Markdown 的个人博客框架。
44
44
  subtitle: ""
45
45
 
46
- footer: 保持独立,持续写作。
46
+ footer: 持续记录与构建。
package/tsconfig.json CHANGED
@@ -30,5 +30,5 @@
30
30
  ".next/dev/types/**/*.ts",
31
31
  "**/*.mts"
32
32
  ],
33
- "exclude": ["node_modules"]
33
+ "exclude": ["node_modules", "editors"]
34
34
  }