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.
- package/.agents/skills/garden-post-review/SKILL.md +2 -0
- package/.agents/skills/garden-ui/SKILL.md +111 -0
- package/.env.example +8 -0
- package/app/admin/page.tsx +14 -3
- package/app/admin/preview/[...path]/page.tsx +88 -0
- package/app/admin/ui/blocks/page.tsx +16 -0
- package/app/admin/ui/catalog/page.tsx +16 -0
- package/app/admin/ui/evals/page.tsx +19 -0
- package/app/admin/ui/page.tsx +22 -0
- package/app/admin-ui.css +227 -0
- package/app/api/admin/distribution/route.ts +4 -2
- package/app/api/admin/posts/source/route.ts +21 -0
- package/app/api/admin/preview/asset/route.ts +32 -0
- package/app/api/admin/thoughts/route.ts +1 -1
- package/app/api/admin/ui/blocks/route.ts +19 -0
- package/app/api/admin/ui/draft/route.ts +30 -0
- package/app/api/auth/github/route.ts +40 -1
- package/app/blog/page.tsx +6 -5
- package/app/columns/page.tsx +1 -1
- package/app/components/AdminDashboard.tsx +53 -1
- package/app/components/AdminUiPanels.tsx +258 -0
- package/app/components/AdminUiShell.tsx +56 -0
- package/app/components/ContentEmptyState.tsx +6 -6
- package/app/components/MarkdownArticle.tsx +15 -5
- package/app/components/UiBlock.tsx +22 -0
- package/app/components/UiStudio.tsx +422 -0
- package/app/components/ui-studio-keys.ts +3 -0
- package/app/globals.css +2 -0
- package/app/lib/admin-auth.ts +13 -1
- package/app/lib/content.ts +2 -1
- package/app/lib/fallback-site-config.ts +18 -18
- package/app/lib/github-content.ts +47 -4
- package/app/lib/repository-path.mjs +55 -0
- package/app/lib/ui-content.ts +79 -0
- package/app/lib/ui-model.ts +145 -0
- package/app/raw.d.ts +4 -0
- package/app/rss.xml/route.ts +2 -1
- package/app/ui-parity/page.tsx +120 -0
- package/bin/garden.mjs +71 -0
- package/eslint.config.mjs +3 -0
- package/package.json +15 -1
- package/scripts/build-content.mjs +35 -4
- package/scripts/build-ui-preview.mjs +29 -0
- package/scripts/check-post.mjs +26 -4
- package/scripts/ui-cli.mjs +303 -0
- package/site.config.yaml +19 -19
- package/templates/site/site.config.yaml +19 -19
- package/tsconfig.json +1 -1
- package/ui/core/apply.mjs +81 -0
- package/ui/core/catalog.mjs +536 -0
- package/ui/core/compile.mjs +63 -0
- package/ui/core/demos.mjs +223 -0
- package/ui/core/expr.mjs +310 -0
- package/ui/core/fence.mjs +201 -0
- package/ui/core/fix-prompt.mjs +35 -0
- package/ui/core/format.mjs +96 -0
- package/ui/core/index.mjs +37 -0
- package/ui/core/parse.mjs +229 -0
- package/ui/core/scan.mjs +53 -0
- package/ui/core/sha256.mjs +66 -0
- package/ui/core/starters.mjs +18 -0
- package/ui/core/state.mjs +259 -0
- package/ui/core/static.mjs +524 -0
- package/ui/core/transform.mjs +146 -0
- package/ui/core/validate.mjs +665 -0
- package/ui/evals/cases.yaml +155 -0
- package/ui/preview/dist/garden-ui.css +1182 -0
- package/ui/preview/dist/garden-ui.esm.js +367 -0
- package/ui/preview/dist/garden-ui.iife.js +367 -0
- package/ui/preview/index.tsx +191 -0
- package/ui/preview/mermaid-shim.ts +19 -0
- package/ui/prompt/build.mjs +24 -0
- package/ui/prompt/recipes/code-walkthrough.md +68 -0
- package/ui/prompt/recipes/compare.md +48 -0
- package/ui/prompt/recipes/converter.md +55 -0
- package/ui/prompt/recipes/data-chart.md +57 -0
- package/ui/prompt/recipes/inspect-grid.md +45 -0
- package/ui/prompt/recipes/param-bench.md +55 -0
- package/ui/prompt/recipes/plan.md +45 -0
- package/ui/prompt/recipes/stepper.md +52 -0
- package/ui/prompt/recipes/trace.md +62 -0
- package/ui/prompt/rules.md +104 -0
- package/ui/prompt/studio.md +20 -0
- package/ui/react/CatalogPage.tsx +166 -0
- package/ui/react/components/chart.tsx +470 -0
- package/ui/react/components/display.tsx +296 -0
- package/ui/react/components/inputs.tsx +268 -0
- package/ui/react/components/layout.tsx +236 -0
- package/ui/react/components/media.tsx +184 -0
- package/ui/react/index.tsx +59 -0
- package/ui/react/recipes.ts +25 -0
- package/ui/react/runtime.tsx +239 -0
- package/ui/react/types.ts +29 -0
- package/ui/react/ui.css +1182 -0
- 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(
|
|
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`);
|
package/scripts/check-post.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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