hd-garden 0.3.9 → 0.3.11
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 +94 -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/components/AdminDashboard.tsx +53 -1
- package/app/components/AdminUiPanels.tsx +258 -0
- package/app/components/AdminUiShell.tsx +56 -0
- 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/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/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
|
@@ -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/tsconfig.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { fenceHash, findBlocks, idOf, intentHash, intentOf, normalizeNewlines, scanFences, setCompiled, splitUiBody } from "./fence.mjs";
|
|
3
|
+
import { checkUiBlock } from "./validate.mjs";
|
|
4
|
+
|
|
5
|
+
export class ApplyError extends Error {
|
|
6
|
+
constructor(code, message, diagnostics = []) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.diagnostics = diagnostics;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function locate(markdown, target) {
|
|
14
|
+
const blocks = findBlocks(markdown);
|
|
15
|
+
let matches = [];
|
|
16
|
+
if (target.id) matches = blocks.filter((block) => idOf(block) === target.id);
|
|
17
|
+
else if (target.at) matches = blocks.filter((block) => block.startLine === Number(target.at));
|
|
18
|
+
else if (target.intentHash) {
|
|
19
|
+
matches = blocks.filter((block) => {
|
|
20
|
+
const intent = intentOf(block);
|
|
21
|
+
return intent !== null && intentHash(intent) === target.intentHash;
|
|
22
|
+
});
|
|
23
|
+
} else {
|
|
24
|
+
throw new ApplyError("apply-target", "要用 --id、--at 或 --intent-hash 指定一块");
|
|
25
|
+
}
|
|
26
|
+
if (!matches.length) throw new ApplyError("apply-not-found", "找不到要替换的块");
|
|
27
|
+
if (matches.length > 1) throw new ApplyError("apply-ambiguous", "匹配到多块,请改用更精确的定位");
|
|
28
|
+
return matches[0];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Classifies replacement text as one `ui` fence, one `widget` fence, or plain Markdown. */
|
|
32
|
+
export function classifyReplacement(text) {
|
|
33
|
+
const trimmed = normalizeNewlines(text).trim();
|
|
34
|
+
const fences = scanFences(trimmed);
|
|
35
|
+
const whole = fences.length === 1 && fences[0].start === 0 && fences[0].end === trimmed.length && fences[0].closed;
|
|
36
|
+
if (whole && fences[0].lang === "ui") return { kind: "ui", text: trimmed, fence: fences[0] };
|
|
37
|
+
if (whole && fences[0].lang === "widget") return { kind: "widget", text: trimmed, fence: fences[0] };
|
|
38
|
+
if (fences.some((fence) => fence.lang === "ui" || fence.lang === "intent")) {
|
|
39
|
+
throw new ApplyError("apply-content", "写回正文时,内容里不能再有 ui 或 intent 围栏");
|
|
40
|
+
}
|
|
41
|
+
return { kind: "markdown", text: trimmed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replaces exactly one block. Everything outside the target fence stays
|
|
46
|
+
* byte-identical; the result is verified before it is returned.
|
|
47
|
+
*/
|
|
48
|
+
export function applyBlock(markdown, target, replacementText, options = {}) {
|
|
49
|
+
const { base = null, markdownPath = null, readData = null } = options;
|
|
50
|
+
const block = locate(markdown, target);
|
|
51
|
+
if (base && fenceHash(block.raw) !== base) {
|
|
52
|
+
throw new ApplyError("apply-base", "本地这一块已经改过,和打开时的版本不一致,没有覆盖");
|
|
53
|
+
}
|
|
54
|
+
const replacement = classifyReplacement(replacementText);
|
|
55
|
+
let text = replacement.text;
|
|
56
|
+
if (replacement.kind === "ui") {
|
|
57
|
+
const check = checkUiBlock(replacement.fence.body, { lineOffset: block.startLine, markdownPath, readData });
|
|
58
|
+
if (!check.ok) throw new ApplyError("apply-invalid", "新的 ui 块没有通过检查", check.diagnostics.filter((item) => item.severity === "error"));
|
|
59
|
+
const { header } = splitUiBody(replacement.fence.body);
|
|
60
|
+
if (typeof header?.intent === "string") text = setCompiled(text, intentHash(header.intent));
|
|
61
|
+
} else if (replacement.kind === "widget") {
|
|
62
|
+
let data = null;
|
|
63
|
+
try {
|
|
64
|
+
data = parseYaml(replacement.fence.body);
|
|
65
|
+
} catch {
|
|
66
|
+
data = null;
|
|
67
|
+
}
|
|
68
|
+
if (!data || typeof data.src !== "string" || !data.src.trim() || typeof data.caption !== "string" || !data.caption.trim()) {
|
|
69
|
+
throw new ApplyError("apply-invalid", "widget 围栏需要 src 和 caption");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const newline = markdown.includes("\r\n") ? "\r\n" : "\n";
|
|
73
|
+
if (newline === "\r\n") text = text.replace(/\n/gu, "\r\n");
|
|
74
|
+
const before = markdown.slice(0, block.start);
|
|
75
|
+
const after = markdown.slice(block.end);
|
|
76
|
+
const next = `${before}${text}${after}`;
|
|
77
|
+
if (!next.startsWith(before) || !next.endsWith(after) || next.length !== before.length + text.length + after.length) {
|
|
78
|
+
throw new ApplyError("apply-verify", "替换后围栏之外的内容发生了变化,已放弃");
|
|
79
|
+
}
|
|
80
|
+
return { markdown: next, kind: replacement.kind, block };
|
|
81
|
+
}
|