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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
export const CATALOG_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
export const TONES = ["plain", "accent", "muted", "new", "cached", "waste", "same"];
|
|
4
|
+
export const TONE_LABELS = {
|
|
5
|
+
plain: "",
|
|
6
|
+
accent: "重点",
|
|
7
|
+
muted: "次要",
|
|
8
|
+
new: "新算",
|
|
9
|
+
cached: "读缓存",
|
|
10
|
+
waste: "浪费",
|
|
11
|
+
same: "相同",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const GROUPS = ["状态与逻辑", "布局", "输入", "展示", "数据", "媒体与代码"];
|
|
15
|
+
|
|
16
|
+
const tone = (values = TONES) => ({ type: "enum", values });
|
|
17
|
+
|
|
18
|
+
const SERIES_PROPS = {
|
|
19
|
+
y: { type: "any", required: true, desc: "纵轴值。函数模式下在每个取样点重算;数据模式下逐行求值" },
|
|
20
|
+
label: { type: "string", desc: "系列名,显示在图例和提示框里" },
|
|
21
|
+
tone: { ...tone(), desc: "颜色" },
|
|
22
|
+
format: { type: "format", desc: "数值格式" },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Children rules:
|
|
27
|
+
* - "none": no children
|
|
28
|
+
* - "content": Markdown and display/input components
|
|
29
|
+
* - "text": inline text and interpolation only
|
|
30
|
+
* - "transparent": validated against the parent's rule
|
|
31
|
+
* - string[]: only these components
|
|
32
|
+
*/
|
|
33
|
+
export const COMPONENTS = {
|
|
34
|
+
State: {
|
|
35
|
+
group: "状态与逻辑",
|
|
36
|
+
phase: 1,
|
|
37
|
+
kind: "state",
|
|
38
|
+
summary: "声明会变的量和默认值,不渲染任何东西",
|
|
39
|
+
phrases: ["可调的值", "默认是"],
|
|
40
|
+
props: {},
|
|
41
|
+
children: "none",
|
|
42
|
+
example: "<State batch={1} seq={4096} />",
|
|
43
|
+
},
|
|
44
|
+
Let: {
|
|
45
|
+
group: "状态与逻辑",
|
|
46
|
+
phase: 1,
|
|
47
|
+
kind: "let",
|
|
48
|
+
summary: "声明由别的量算出来的值,也用来写数据数组",
|
|
49
|
+
phrases: ["公式是", "等于"],
|
|
50
|
+
props: {},
|
|
51
|
+
children: "none",
|
|
52
|
+
example: "<Let mem={2 * 32 * 8 * 128 * seq * batch * 2} />",
|
|
53
|
+
},
|
|
54
|
+
If: {
|
|
55
|
+
group: "状态与逻辑",
|
|
56
|
+
phase: 1,
|
|
57
|
+
summary: "条件为真时渲染里面的内容",
|
|
58
|
+
phrases: ["只有……时才显示"],
|
|
59
|
+
props: { test: { type: "any", required: true, desc: "按真值判断" } },
|
|
60
|
+
children: "transparent",
|
|
61
|
+
example: "<If test={batch > 8}>batch 太大时显存会吃满。</If>",
|
|
62
|
+
},
|
|
63
|
+
Each: {
|
|
64
|
+
group: "状态与逻辑",
|
|
65
|
+
phase: 1,
|
|
66
|
+
summary: "对数组里的每一项重复渲染里面的内容",
|
|
67
|
+
phrases: ["每一项一行", "逐个列出"],
|
|
68
|
+
props: {
|
|
69
|
+
of: { type: "array", required: true, desc: "要遍历的数组,最多 200 项" },
|
|
70
|
+
as: { type: "name", literal: true, required: true, desc: "当前项的名字" },
|
|
71
|
+
index: { type: "name", literal: true, desc: "从 0 开始的序号名" },
|
|
72
|
+
},
|
|
73
|
+
children: "transparent",
|
|
74
|
+
example: "<Each of={range(0, 3)} as=\"i\">第 {i + 1} 项。</Each>",
|
|
75
|
+
},
|
|
76
|
+
Stack: {
|
|
77
|
+
group: "布局",
|
|
78
|
+
phase: 1,
|
|
79
|
+
summary: "上下或横排,用来调整间距",
|
|
80
|
+
phrases: ["上下排", "横排放", "一组"],
|
|
81
|
+
props: {
|
|
82
|
+
gap: { type: "gap", default: 3, desc: "间距,0–4" },
|
|
83
|
+
direction: { type: "enum", values: ["col", "row"], literal: true, default: "col", desc: "col 上下排,row 横排并可换行" },
|
|
84
|
+
},
|
|
85
|
+
children: "content",
|
|
86
|
+
example: "<Stack gap={1}>第一行。\n\n第二行。</Stack>",
|
|
87
|
+
},
|
|
88
|
+
Columns: {
|
|
89
|
+
group: "布局",
|
|
90
|
+
phase: 1,
|
|
91
|
+
summary: "自动换行的多列,窄屏自动叠放",
|
|
92
|
+
phrases: ["并排放", "左右对比", "两栏"],
|
|
93
|
+
props: {
|
|
94
|
+
gap: { type: "gap", default: 3, desc: "列间距,0–4" },
|
|
95
|
+
min: { type: "number", min: 160, max: 400, default: 220, desc: "每列最小宽度(px)" },
|
|
96
|
+
},
|
|
97
|
+
children: "content",
|
|
98
|
+
maxChildren: 4,
|
|
99
|
+
example: "<Columns>\n <Card title=\"A\">左边</Card>\n <Card title=\"B\">右边</Card>\n</Columns>",
|
|
100
|
+
},
|
|
101
|
+
Card: {
|
|
102
|
+
group: "布局",
|
|
103
|
+
phase: 1,
|
|
104
|
+
summary: "带边框的分组",
|
|
105
|
+
phrases: ["一边一个框", "分组"],
|
|
106
|
+
props: {
|
|
107
|
+
title: { type: "string", desc: "卡片标题" },
|
|
108
|
+
tone: { ...tone(["plain", "accent", "muted"]), default: "plain", desc: "边框和标题颜色" },
|
|
109
|
+
},
|
|
110
|
+
children: "content",
|
|
111
|
+
example: "<Card title=\"RwLock\" tone=\"accent\">读者可以一起读。</Card>",
|
|
112
|
+
},
|
|
113
|
+
Tabs: {
|
|
114
|
+
group: "布局",
|
|
115
|
+
phase: 1,
|
|
116
|
+
summary: "标签切换,几个视角轮流看",
|
|
117
|
+
phrases: ["切换着看", "分几个视角"],
|
|
118
|
+
props: { bind: { type: "stateName", literal: true, stateType: "number", desc: "当前标签序号(从 0 开始)" } },
|
|
119
|
+
children: ["Tab"],
|
|
120
|
+
minChildren: 2,
|
|
121
|
+
maxChildren: 6,
|
|
122
|
+
example: "<Tabs>\n <Tab title=\"MHA\">每个头一组 K/V。</Tab>\n <Tab title=\"GQA\">几个头共用一组 K/V。</Tab>\n</Tabs>",
|
|
123
|
+
},
|
|
124
|
+
Tab: {
|
|
125
|
+
group: "布局",
|
|
126
|
+
phase: 1,
|
|
127
|
+
summary: "Tabs 里的一页",
|
|
128
|
+
phrases: [],
|
|
129
|
+
props: { title: { type: "string", required: true, desc: "标签文字" } },
|
|
130
|
+
children: "content",
|
|
131
|
+
parents: ["Tabs"],
|
|
132
|
+
example: "<Tab title=\"MHA\">每个头一组 K/V。</Tab>",
|
|
133
|
+
},
|
|
134
|
+
Steps: {
|
|
135
|
+
group: "布局",
|
|
136
|
+
phase: 1,
|
|
137
|
+
summary: "一步一步看,带上一步、下一步和进度",
|
|
138
|
+
phrases: ["一步步走", "停在某一步", "第几拍"],
|
|
139
|
+
props: {
|
|
140
|
+
bind: { type: "stateName", literal: true, stateType: "number", desc: "当前步序号(从 0 开始)" },
|
|
141
|
+
autoplay: { type: "autoplay", literal: true, default: false, desc: "自动前进的毫秒间隔(≥ 800),默认不自动" },
|
|
142
|
+
},
|
|
143
|
+
children: ["Step"],
|
|
144
|
+
minChildren: 2,
|
|
145
|
+
maxChildren: 24,
|
|
146
|
+
example: "<Steps>\n <Step title=\"Prefill\">一次写满 K/V。</Step>\n <Step title=\"Decode\">每步追加一行。</Step>\n</Steps>",
|
|
147
|
+
},
|
|
148
|
+
Step: {
|
|
149
|
+
group: "布局",
|
|
150
|
+
phase: 1,
|
|
151
|
+
summary: "Steps 里的一步",
|
|
152
|
+
phrases: [],
|
|
153
|
+
props: { title: { type: "string", required: true, desc: "这一步的名字" } },
|
|
154
|
+
children: "content",
|
|
155
|
+
parents: ["Steps"],
|
|
156
|
+
example: "<Step title=\"Prefill\">一次写满 K/V。</Step>",
|
|
157
|
+
},
|
|
158
|
+
Slider: {
|
|
159
|
+
group: "输入",
|
|
160
|
+
phase: 1,
|
|
161
|
+
summary: "拖动改一个数",
|
|
162
|
+
phrases: ["拧", "调", "拖一下", "从 1 到 32"],
|
|
163
|
+
props: {
|
|
164
|
+
bind: { type: "stateName", literal: true, required: true, stateType: "number", desc: "绑定的状态" },
|
|
165
|
+
label: { type: "string", required: true, desc: "名字" },
|
|
166
|
+
min: { type: "number", required: true, desc: "最小值" },
|
|
167
|
+
max: { type: "number", required: true, desc: "最大值,必须大于 min" },
|
|
168
|
+
step: { type: "number", desc: "线性刻度的步长" },
|
|
169
|
+
scale: { type: "enum", values: ["linear", "log"], literal: true, default: "linear", desc: "刻度;log 要求 min > 0" },
|
|
170
|
+
snap: { type: "enum", values: ["none", "int", "pow2"], literal: true, desc: "对数刻度的取值方式" },
|
|
171
|
+
format: { type: "format", desc: "当前值的格式" },
|
|
172
|
+
unit: { type: "string", desc: "跟在值后面的单位" },
|
|
173
|
+
},
|
|
174
|
+
children: "none",
|
|
175
|
+
example: "<Slider bind=\"batch\" label=\"batch\" min={1} max={32} />",
|
|
176
|
+
},
|
|
177
|
+
Choice: {
|
|
178
|
+
group: "输入",
|
|
179
|
+
phase: 1,
|
|
180
|
+
summary: "从几个选项里选一个",
|
|
181
|
+
phrases: ["选一种", "在 A 和 B 之间切换"],
|
|
182
|
+
props: {
|
|
183
|
+
bind: { type: "stateName", literal: true, required: true, desc: "绑定的状态" },
|
|
184
|
+
label: { type: "string", required: true, desc: "名字" },
|
|
185
|
+
options: { type: "array", required: true, desc: "2–8 项;数字、字符串或 { value, label }" },
|
|
186
|
+
style: { type: "enum", values: ["segmented", "select"], literal: true, desc: "分段按钮或下拉" },
|
|
187
|
+
},
|
|
188
|
+
children: "none",
|
|
189
|
+
example: "<Choice bind=\"mode\" label=\"模式\" options={[\"ECB\", \"CBC\"]} />",
|
|
190
|
+
},
|
|
191
|
+
Toggle: {
|
|
192
|
+
group: "输入",
|
|
193
|
+
phase: 1,
|
|
194
|
+
summary: "开或关",
|
|
195
|
+
phrases: ["打开 / 关掉", "加上故障", "切换开关"],
|
|
196
|
+
props: {
|
|
197
|
+
bind: { type: "stateName", literal: true, required: true, stateType: "boolean", desc: "绑定的布尔状态" },
|
|
198
|
+
label: { type: "string", required: true, desc: "名字" },
|
|
199
|
+
},
|
|
200
|
+
children: "none",
|
|
201
|
+
example: "<Toggle bind=\"fault\" label=\"注入故障\" />",
|
|
202
|
+
},
|
|
203
|
+
Button: {
|
|
204
|
+
group: "输入",
|
|
205
|
+
phase: 1,
|
|
206
|
+
summary: "点一下改状态或重置",
|
|
207
|
+
phrases: ["重置", "再来一次"],
|
|
208
|
+
props: {
|
|
209
|
+
set: { type: "object", desc: "键是状态名,值是新值" },
|
|
210
|
+
reset: { type: "boolean", desc: "所有状态回到默认值" },
|
|
211
|
+
tone: { ...tone(["plain", "accent"]), default: "plain", desc: "颜色" },
|
|
212
|
+
},
|
|
213
|
+
children: "text",
|
|
214
|
+
example: "<Button reset>重置</Button>",
|
|
215
|
+
},
|
|
216
|
+
Stepper: {
|
|
217
|
+
group: "输入",
|
|
218
|
+
phase: 1,
|
|
219
|
+
summary: "加减人数或数量,数字写在两个圆钮中间",
|
|
220
|
+
phrases: ["人数可加减", "用加减号调数量"],
|
|
221
|
+
props: {
|
|
222
|
+
bind: { type: "stateName", literal: true, stateType: "number", required: true, desc: "要加减的数字状态" },
|
|
223
|
+
min: { type: "number", required: true, desc: "最小值" },
|
|
224
|
+
max: { type: "number", required: true, desc: "最大值" },
|
|
225
|
+
step: { type: "number", min: 0, default: 1, desc: "每次加减多少" },
|
|
226
|
+
label: { type: "string", desc: "上方的小字,如人数" },
|
|
227
|
+
unit: { type: "string", desc: "数字旁边的单位" },
|
|
228
|
+
},
|
|
229
|
+
children: "none",
|
|
230
|
+
example: "<Stepper bind=\"guests\" label=\"人数\" min={1} max={12} unit=\"人\" />",
|
|
231
|
+
},
|
|
232
|
+
Checklist: {
|
|
233
|
+
group: "输入",
|
|
234
|
+
phase: 1,
|
|
235
|
+
summary: "可勾选的清单,勾选状态不能绑定",
|
|
236
|
+
phrases: ["清单", "一步步勾掉", "待办"],
|
|
237
|
+
props: {
|
|
238
|
+
items: { type: "array", required: true, desc: "1–30 项;字符串或 { title, note, time }" },
|
|
239
|
+
title: { type: "string", desc: "清单标题" },
|
|
240
|
+
},
|
|
241
|
+
children: "none",
|
|
242
|
+
example: "<Checklist items={[\"备料\", \"开火\", \"装盘\"]} />",
|
|
243
|
+
},
|
|
244
|
+
Stat: {
|
|
245
|
+
group: "展示",
|
|
246
|
+
phase: 1,
|
|
247
|
+
summary: "一个醒目的数字",
|
|
248
|
+
phrases: ["显示结果", "算出来是多少"],
|
|
249
|
+
props: {
|
|
250
|
+
label: { type: "string", required: true, desc: "名字" },
|
|
251
|
+
value: { type: "any", required: true, desc: "数值或文字" },
|
|
252
|
+
format: { type: "format", desc: "格式" },
|
|
253
|
+
unit: { type: "string", desc: "单位" },
|
|
254
|
+
hint: { type: "any", desc: "数字下面的一行小字" },
|
|
255
|
+
tone: { ...tone(["plain", "accent", "new"]), default: "plain", desc: "数字的颜色" },
|
|
256
|
+
size: { type: "enum", values: ["sm", "md", "lg"], default: "md", desc: "大小" },
|
|
257
|
+
},
|
|
258
|
+
children: "none",
|
|
259
|
+
example: "<Stat label=\"KV 显存\" value={mem} format=\"bytes\" />",
|
|
260
|
+
},
|
|
261
|
+
Meter: {
|
|
262
|
+
group: "展示",
|
|
263
|
+
phase: 1,
|
|
264
|
+
summary: "条形占比,可以分段显示构成",
|
|
265
|
+
phrases: ["占了多少", "和上限比一比", "构成"],
|
|
266
|
+
props: {
|
|
267
|
+
max: { type: "number", required: true, desc: "满格对应的值" },
|
|
268
|
+
value: { type: "number", desc: "当前值;与 parts 二选一" },
|
|
269
|
+
parts: { type: "array", desc: "分段 { label, value, tone },最多 5 段" },
|
|
270
|
+
label: { type: "string", desc: "名字" },
|
|
271
|
+
format: { type: "format", desc: "格式" },
|
|
272
|
+
tone: { ...tone(), default: "accent", desc: "只有 value 时的填充色" },
|
|
273
|
+
},
|
|
274
|
+
children: "none",
|
|
275
|
+
example: "<Meter label=\"显卡\" value={mem} max={80 * 1073741824} format=\"bytes\" />",
|
|
276
|
+
},
|
|
277
|
+
Caption: {
|
|
278
|
+
group: "展示",
|
|
279
|
+
phase: 1,
|
|
280
|
+
summary: "卡片里的灰色说明,比正文小一号",
|
|
281
|
+
phrases: ["补一句说明", "注明口径"],
|
|
282
|
+
props: {},
|
|
283
|
+
children: "text",
|
|
284
|
+
example: "<Caption>统计期为 2024 年 8 月至 2025 年 8 月。</Caption>",
|
|
285
|
+
},
|
|
286
|
+
Divider: {
|
|
287
|
+
group: "展示",
|
|
288
|
+
phase: 1,
|
|
289
|
+
summary: "一条细分隔线",
|
|
290
|
+
phrases: ["下面隔开"],
|
|
291
|
+
props: {},
|
|
292
|
+
children: "none",
|
|
293
|
+
example: "<Divider />",
|
|
294
|
+
},
|
|
295
|
+
Cite: {
|
|
296
|
+
group: "展示",
|
|
297
|
+
phase: 1,
|
|
298
|
+
inline: true,
|
|
299
|
+
summary: "来源胶囊,带站点图标",
|
|
300
|
+
phrases: ["注明来源", "出处是"],
|
|
301
|
+
props: {
|
|
302
|
+
label: { type: "string", required: true, desc: "来源名" },
|
|
303
|
+
href: { type: "string", desc: "来源链接。写了网址才会显示图标" },
|
|
304
|
+
},
|
|
305
|
+
children: "none",
|
|
306
|
+
example: "<Cite label=\"Alibaba Group\" href=\"https://www.alibabagroup.com\" />",
|
|
307
|
+
},
|
|
308
|
+
Badge: {
|
|
309
|
+
group: "展示",
|
|
310
|
+
phase: 1,
|
|
311
|
+
inline: true,
|
|
312
|
+
summary: "小标签,可以写在句子中间",
|
|
313
|
+
phrases: ["标一下", "打个标记"],
|
|
314
|
+
props: { tone: { ...tone(), default: "muted", desc: "颜色" } },
|
|
315
|
+
children: "text",
|
|
316
|
+
example: "<Badge tone=\"new\">新算</Badge>",
|
|
317
|
+
},
|
|
318
|
+
Table: {
|
|
319
|
+
group: "展示",
|
|
320
|
+
phase: 1,
|
|
321
|
+
summary: "数据表,行来自 Row 或 data",
|
|
322
|
+
phrases: ["列个表", "对照表", "换算表"],
|
|
323
|
+
props: {
|
|
324
|
+
columns: { type: "array", required: true, desc: "表头字符串,或 { label, key, format, align }" },
|
|
325
|
+
data: { type: "array", desc: "数据模式:每个元素是一行对象" },
|
|
326
|
+
rowTone: { type: "any", rowScope: true, desc: "数据模式下逐行求值,返回 tone 或 null" },
|
|
327
|
+
},
|
|
328
|
+
children: ["Row"],
|
|
329
|
+
maxChildren: 50,
|
|
330
|
+
example: "<Table columns={[\"人数\", \"羊腿\"]}>\n <Row cells={[6, \"2.4 kg\"]} />\n</Table>",
|
|
331
|
+
},
|
|
332
|
+
Grid: {
|
|
333
|
+
group: "展示",
|
|
334
|
+
phase: 1,
|
|
335
|
+
summary: "检视格:点一格读这一格的说明",
|
|
336
|
+
phrases: ["格子", "矩阵", "点一格看", "哪些是重复的"],
|
|
337
|
+
props: {
|
|
338
|
+
cols: { type: "array", required: true, desc: "列头,最多 16 列" },
|
|
339
|
+
legend: { type: "object", desc: "键是 tone,值是说明" },
|
|
340
|
+
},
|
|
341
|
+
children: ["Row"],
|
|
342
|
+
minChildren: 1,
|
|
343
|
+
maxChildren: 16,
|
|
344
|
+
example: "<Grid cols={[\"块1\", \"块2\"]}>\n <Row label=\"密文\" cells={[\"X\", \"X\"]} tones=\"same\" />\n</Grid>",
|
|
345
|
+
},
|
|
346
|
+
Row: {
|
|
347
|
+
group: "展示",
|
|
348
|
+
phase: 1,
|
|
349
|
+
summary: "Table 或 Grid 里的一行",
|
|
350
|
+
phrases: [],
|
|
351
|
+
props: {
|
|
352
|
+
cells: { type: "array", required: true, desc: "长度等于列数" },
|
|
353
|
+
label: { type: "any", desc: "行头" },
|
|
354
|
+
tones: { type: "any", desc: "一个 tone 作用于整行,或与 cells 对齐的数组" },
|
|
355
|
+
notes: { type: "array", desc: "与 cells 对齐的说明" },
|
|
356
|
+
},
|
|
357
|
+
children: "none",
|
|
358
|
+
parents: ["Table", "Grid"],
|
|
359
|
+
example: "<Row label=\"明文\" cells={[\"A\", \"B\"]} />",
|
|
360
|
+
},
|
|
361
|
+
Chart: {
|
|
362
|
+
group: "展示",
|
|
363
|
+
phase: 1,
|
|
364
|
+
summary: "曲线和柱状图;sweep 画函数,data 画数据",
|
|
365
|
+
phrases: ["画条曲线", "随 x 变化", "柱状图", "把数据画出来"],
|
|
366
|
+
props: {
|
|
367
|
+
sweep: { type: "stateName", literal: true, stateType: "number", desc: "函数模式:横轴扫过的状态" },
|
|
368
|
+
data: { type: "array", desc: "数据模式:每个元素是一行对象" },
|
|
369
|
+
x: { type: "any", rowScope: true, desc: "数据模式的横轴,逐行求值" },
|
|
370
|
+
from: { type: "number", desc: "函数模式的起点" },
|
|
371
|
+
to: { type: "number", desc: "函数模式的终点" },
|
|
372
|
+
scale: { type: "enum", values: ["linear", "log"], literal: true, desc: "横轴刻度" },
|
|
373
|
+
samples: { type: "number", literal: true, min: 2, max: 200, default: 64, desc: "函数模式取样点数" },
|
|
374
|
+
yScale: { type: "enum", values: ["linear", "log"], default: "linear", desc: "纵轴刻度" },
|
|
375
|
+
yMin: { type: "number", desc: "纵轴下限" },
|
|
376
|
+
yMax: { type: "number", desc: "纵轴上限" },
|
|
377
|
+
xLabel: { type: "string", desc: "横轴名" },
|
|
378
|
+
yLabel: { type: "string", desc: "纵轴名" },
|
|
379
|
+
xFormat: { type: "format", desc: "横轴刻度格式" },
|
|
380
|
+
yFormat: { type: "format", desc: "纵轴刻度格式" },
|
|
381
|
+
height: { type: "number", min: 120, max: 480, default: 200, desc: "高度(px)" },
|
|
382
|
+
},
|
|
383
|
+
children: ["Line", "Area", "Bar", "Point"],
|
|
384
|
+
minChildren: 1,
|
|
385
|
+
maxChildren: 4,
|
|
386
|
+
example: "<Chart sweep=\"seq\">\n <Line y={mem} format=\"bytes\" />\n</Chart>",
|
|
387
|
+
},
|
|
388
|
+
Line: { group: "展示", phase: 1, summary: "折线系列", phrases: [], props: SERIES_PROPS, children: "none", parents: ["Chart"], rowScopeProps: ["y"], example: "<Line y={mem} />" },
|
|
389
|
+
Area: { group: "展示", phase: 1, summary: "面积系列", phrases: [], props: SERIES_PROPS, children: "none", parents: ["Chart"], rowScopeProps: ["y"], example: "<Area y={mem} />" },
|
|
390
|
+
Bar: { group: "展示", phase: 1, summary: "柱状系列,只用于数据模式", phrases: [], props: SERIES_PROPS, children: "none", parents: ["Chart"], rowScopeProps: ["y"], example: "<Bar y={qps} />" },
|
|
391
|
+
Point: { group: "展示", phase: 1, summary: "散点系列,只用于数据模式", phrases: [], props: SERIES_PROPS, children: "none", parents: ["Chart"], rowScopeProps: ["y"], example: "<Point y={latency} />" },
|
|
392
|
+
Pie: {
|
|
393
|
+
group: "展示",
|
|
394
|
+
phase: 1,
|
|
395
|
+
summary: "圆环图,用来看占比",
|
|
396
|
+
phrases: ["画个圆环", "看各项占比"],
|
|
397
|
+
props: { title: { type: "string", desc: "图题" } },
|
|
398
|
+
children: ["Slice"],
|
|
399
|
+
minChildren: 1,
|
|
400
|
+
maxChildren: 8,
|
|
401
|
+
example: "<Pie title=\"收入结构\">\n <Slice label=\"商品销售\" value={78} />\n <Slice label=\"平台服务\" value={22} />\n</Pie>",
|
|
402
|
+
},
|
|
403
|
+
Slice: {
|
|
404
|
+
group: "展示",
|
|
405
|
+
phase: 1,
|
|
406
|
+
summary: "圆环的一块",
|
|
407
|
+
phrases: [],
|
|
408
|
+
props: {
|
|
409
|
+
label: { type: "string", required: true, desc: "这一块的名字" },
|
|
410
|
+
value: { type: "number", required: true, min: 0, desc: "数值,按占比计算" },
|
|
411
|
+
},
|
|
412
|
+
children: "none",
|
|
413
|
+
parents: ["Pie"],
|
|
414
|
+
example: "<Slice label=\"商品销售\" value={78} />",
|
|
415
|
+
},
|
|
416
|
+
Data: {
|
|
417
|
+
group: "数据",
|
|
418
|
+
phase: 2,
|
|
419
|
+
kind: "data",
|
|
420
|
+
summary: "引用同名目录里已有的 CSV / JSON 文件",
|
|
421
|
+
phrases: ["数据在 xxx.csv 里"],
|
|
422
|
+
props: {
|
|
423
|
+
name: { type: "name", literal: true, required: true, desc: "引用时的名字" },
|
|
424
|
+
src: { type: "string", literal: true, required: true, desc: "同名目录里的 .csv 或 .json" },
|
|
425
|
+
},
|
|
426
|
+
children: "none",
|
|
427
|
+
example: "<Data name=\"bench\" src=\"./redis-bench/bench.csv\" />",
|
|
428
|
+
},
|
|
429
|
+
Image: {
|
|
430
|
+
group: "媒体与代码",
|
|
431
|
+
phase: 6,
|
|
432
|
+
summary: "带热点的插图",
|
|
433
|
+
phrases: ["在图上标出各部分", "点图上的部位看说明"],
|
|
434
|
+
props: {
|
|
435
|
+
src: { type: "string", literal: true, required: true, desc: "同名目录里的图片" },
|
|
436
|
+
alt: { type: "string", required: true, desc: "图片说明" },
|
|
437
|
+
ratio: { type: "string", desc: "宽高比,如 4:3" },
|
|
438
|
+
},
|
|
439
|
+
children: ["Hotspot"],
|
|
440
|
+
maxChildren: 12,
|
|
441
|
+
example: "<Image src=\"./bike/bike.png\" alt=\"自行车\">\n <Hotspot x={0.3} y={0.6} title=\"车架\">两个三角形。</Hotspot>\n</Image>",
|
|
442
|
+
},
|
|
443
|
+
Hotspot: {
|
|
444
|
+
group: "媒体与代码",
|
|
445
|
+
phase: 6,
|
|
446
|
+
summary: "Image 上的一个热点",
|
|
447
|
+
phrases: [],
|
|
448
|
+
props: {
|
|
449
|
+
x: { type: "number", required: true, min: 0, max: 1, desc: "相对宽度的位置" },
|
|
450
|
+
y: { type: "number", required: true, min: 0, max: 1, desc: "相对高度的位置" },
|
|
451
|
+
title: { type: "string", required: true, desc: "名字" },
|
|
452
|
+
},
|
|
453
|
+
children: "content",
|
|
454
|
+
parents: ["Image"],
|
|
455
|
+
example: "<Hotspot x={0.3} y={0.6} title=\"车架\">两个三角形。</Hotspot>",
|
|
456
|
+
},
|
|
457
|
+
Code: {
|
|
458
|
+
group: "媒体与代码",
|
|
459
|
+
phase: 6,
|
|
460
|
+
summary: "代码走查,高亮的行随状态变化",
|
|
461
|
+
phrases: ["一行行讲这段代码", "执行到哪一行"],
|
|
462
|
+
props: {
|
|
463
|
+
lang: { type: "string", required: true, desc: "语言名" },
|
|
464
|
+
code: { type: "string", required: true, desc: "代码原文,不超过 60 行" },
|
|
465
|
+
highlight: { type: "any", desc: "要高亮的行,如 \"3-5,8\"" },
|
|
466
|
+
},
|
|
467
|
+
children: "none",
|
|
468
|
+
example: "<Code lang=\"go\" highlight=\"2\" code={`a := 1\nb := a + 1`} />",
|
|
469
|
+
},
|
|
470
|
+
Diagram: {
|
|
471
|
+
group: "媒体与代码",
|
|
472
|
+
phase: 6,
|
|
473
|
+
summary: "Mermaid 状态图,高亮当前节点和边",
|
|
474
|
+
phrases: ["状态机", "沿着流程图走", "现在在哪个状态"],
|
|
475
|
+
props: {
|
|
476
|
+
mermaid: { type: "string", required: true, desc: "Mermaid 源码(graph / flowchart / stateDiagram)" },
|
|
477
|
+
highlight: { type: "array", desc: "要高亮的节点 id" },
|
|
478
|
+
edges: { type: "array", desc: "要高亮的边,写成 from->to" },
|
|
479
|
+
},
|
|
480
|
+
children: "none",
|
|
481
|
+
example: "<Diagram highlight={[\"a\"]} mermaid={`graph LR\n a --> b`} />",
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export const COMPONENT_NAMES = Object.keys(COMPONENTS);
|
|
486
|
+
|
|
487
|
+
/** JSON description for editor plugins and `garden ui schema`. */
|
|
488
|
+
export function catalogSchema() {
|
|
489
|
+
return {
|
|
490
|
+
version: CATALOG_VERSION,
|
|
491
|
+
tones: TONES,
|
|
492
|
+
components: Object.fromEntries(
|
|
493
|
+
Object.entries(COMPONENTS).map(([name, spec]) => [
|
|
494
|
+
name,
|
|
495
|
+
{
|
|
496
|
+
group: spec.group,
|
|
497
|
+
phase: spec.phase,
|
|
498
|
+
summary: spec.summary,
|
|
499
|
+
phrases: spec.phrases,
|
|
500
|
+
props: spec.props,
|
|
501
|
+
children: spec.children,
|
|
502
|
+
parents: spec.parents ?? null,
|
|
503
|
+
example: spec.example,
|
|
504
|
+
},
|
|
505
|
+
]),
|
|
506
|
+
),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Markdown reference that `garden ui prompt` inserts between rules and recipes. */
|
|
511
|
+
export function catalogReference() {
|
|
512
|
+
const lines = ["## 组件参考", ""];
|
|
513
|
+
for (const group of GROUPS) {
|
|
514
|
+
lines.push(`### ${group}`, "");
|
|
515
|
+
for (const [name, spec] of Object.entries(COMPONENTS)) {
|
|
516
|
+
if (spec.group !== group) continue;
|
|
517
|
+
const parents = spec.parents ? `(只能放在 ${spec.parents.join(" / ")} 里)` : "";
|
|
518
|
+
lines.push(`#### ${name}${parents}`, "", spec.summary + (spec.phase > 1 ? `。阶段 ${spec.phase} 起可用` : ""), "");
|
|
519
|
+
if (spec.kind === "state") lines.push("- 任意名字={字面量}:状态名和默认值(数字、字符串或布尔)");
|
|
520
|
+
if (spec.kind === "let") lines.push("- 任意名字={表达式}:派生值");
|
|
521
|
+
for (const [prop, propSpec] of Object.entries(spec.props)) {
|
|
522
|
+
const flags = [propSpec.required ? "必填" : null, propSpec.literal ? "字面量" : null]
|
|
523
|
+
.filter(Boolean)
|
|
524
|
+
.join(",");
|
|
525
|
+
const values = propSpec.values ? `:${propSpec.values.join(" / ")}` : "";
|
|
526
|
+
const fallback = propSpec.default !== undefined ? `,默认 ${JSON.stringify(propSpec.default)}` : "";
|
|
527
|
+
lines.push(`- \`${prop}\`${flags ? `(${flags})` : ""}${values}:${propSpec.desc ?? ""}${fallback}`);
|
|
528
|
+
}
|
|
529
|
+
const children = Array.isArray(spec.children)
|
|
530
|
+
? `子元素:只能是 ${spec.children.join(" / ")}`
|
|
531
|
+
: { none: "没有子元素", content: "子元素:正文和组件", text: "子元素:一段文字", transparent: "子元素:按所在位置的规则" }[spec.children];
|
|
532
|
+
lines.push(`- ${children}`, "", "```text", spec.example, "```", "");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return lines.join("\n");
|
|
536
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveSidecarPath } from "./fence.mjs";
|
|
2
|
+
import { checkUiBlock } from "./validate.mjs";
|
|
3
|
+
|
|
4
|
+
export const PAYLOAD_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
function stripTree(node, resolveImage) {
|
|
7
|
+
if (Array.isArray(node)) return node.map((child) => stripTree(child, resolveImage));
|
|
8
|
+
if (!node || typeof node !== "object") return node;
|
|
9
|
+
const output = {};
|
|
10
|
+
for (const [key, value] of Object.entries(node)) {
|
|
11
|
+
if (key === "l" || key === "s") continue;
|
|
12
|
+
output[key] = value && typeof value === "object" ? stripTree(value, resolveImage) : value;
|
|
13
|
+
}
|
|
14
|
+
if (output.t === "el" && output.n === "Image" && output.p?.src?.k === "lit" && resolveImage) {
|
|
15
|
+
output.p = { ...output.p, src: { k: "lit", v: resolveImage(String(output.p.src.v)) } };
|
|
16
|
+
}
|
|
17
|
+
return output;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks a `ui` fence body and, when it passes, returns the JSON payload the
|
|
22
|
+
* reading page renders. `resolveAsset(sidecarPath, written)` maps Image paths
|
|
23
|
+
* to public URLs.
|
|
24
|
+
*/
|
|
25
|
+
export function compileUiBlock(body, options = {}) {
|
|
26
|
+
const { markdownPath = null, resolveAsset = null } = options;
|
|
27
|
+
const check = checkUiBlock(body, options);
|
|
28
|
+
if (!check.ok) return { payload: null, check };
|
|
29
|
+
const resolveImage = resolveAsset && markdownPath
|
|
30
|
+
? (written) => {
|
|
31
|
+
const resolved = resolveSidecarPath(markdownPath, written);
|
|
32
|
+
return resolved.path ? resolveAsset(resolved.path, written) : written;
|
|
33
|
+
}
|
|
34
|
+
: null;
|
|
35
|
+
return {
|
|
36
|
+
payload: {
|
|
37
|
+
v: PAYLOAD_VERSION,
|
|
38
|
+
id: check.header.id,
|
|
39
|
+
caption: check.header.caption.trim(),
|
|
40
|
+
root: stripTree(check.root, resolveImage),
|
|
41
|
+
data: check.dataValues ?? {},
|
|
42
|
+
},
|
|
43
|
+
check,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Serializes JSON into a fence whose marker is longer than any backtick run inside. */
|
|
48
|
+
export function jsonFence(lang, value) {
|
|
49
|
+
const json = JSON.stringify(value);
|
|
50
|
+
const longest = Math.max(0, ...[...json.matchAll(/`+/gu)].map((match) => match[0].length));
|
|
51
|
+
const marker = "`".repeat(Math.max(3, longest + 1));
|
|
52
|
+
return `${marker}${lang}\n${json}\n${marker}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readPayload(source) {
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(String(source).trim());
|
|
58
|
+
if (!value || value.v !== PAYLOAD_VERSION || !value.root) return null;
|
|
59
|
+
return value;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|