hd-garden 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.agents/skills/garden-post-review/SKILL.md +2 -0
  2. package/.agents/skills/garden-ui/SKILL.md +111 -0
  3. package/.env.example +8 -0
  4. package/app/admin/page.tsx +14 -3
  5. package/app/admin/preview/[...path]/page.tsx +88 -0
  6. package/app/admin/ui/blocks/page.tsx +16 -0
  7. package/app/admin/ui/catalog/page.tsx +16 -0
  8. package/app/admin/ui/evals/page.tsx +19 -0
  9. package/app/admin/ui/page.tsx +22 -0
  10. package/app/admin-ui.css +227 -0
  11. package/app/api/admin/distribution/route.ts +4 -2
  12. package/app/api/admin/posts/source/route.ts +21 -0
  13. package/app/api/admin/preview/asset/route.ts +32 -0
  14. package/app/api/admin/thoughts/route.ts +1 -1
  15. package/app/api/admin/ui/blocks/route.ts +19 -0
  16. package/app/api/admin/ui/draft/route.ts +30 -0
  17. package/app/api/auth/github/route.ts +40 -1
  18. package/app/blog/page.tsx +6 -5
  19. package/app/columns/page.tsx +1 -1
  20. package/app/components/AdminDashboard.tsx +53 -1
  21. package/app/components/AdminUiPanels.tsx +258 -0
  22. package/app/components/AdminUiShell.tsx +56 -0
  23. package/app/components/ContentEmptyState.tsx +6 -6
  24. package/app/components/MarkdownArticle.tsx +15 -5
  25. package/app/components/UiBlock.tsx +22 -0
  26. package/app/components/UiStudio.tsx +422 -0
  27. package/app/components/ui-studio-keys.ts +3 -0
  28. package/app/globals.css +2 -0
  29. package/app/lib/admin-auth.ts +13 -1
  30. package/app/lib/content.ts +2 -1
  31. package/app/lib/fallback-site-config.ts +18 -18
  32. package/app/lib/github-content.ts +47 -4
  33. package/app/lib/repository-path.mjs +55 -0
  34. package/app/lib/ui-content.ts +79 -0
  35. package/app/lib/ui-model.ts +145 -0
  36. package/app/raw.d.ts +4 -0
  37. package/app/rss.xml/route.ts +2 -1
  38. package/app/ui-parity/page.tsx +120 -0
  39. package/bin/garden.mjs +71 -0
  40. package/eslint.config.mjs +3 -0
  41. package/package.json +15 -1
  42. package/scripts/build-content.mjs +35 -4
  43. package/scripts/build-ui-preview.mjs +29 -0
  44. package/scripts/check-post.mjs +26 -4
  45. package/scripts/ui-cli.mjs +303 -0
  46. package/site.config.yaml +19 -19
  47. package/templates/site/site.config.yaml +19 -19
  48. package/tsconfig.json +1 -1
  49. package/ui/core/apply.mjs +81 -0
  50. package/ui/core/catalog.mjs +536 -0
  51. package/ui/core/compile.mjs +63 -0
  52. package/ui/core/demos.mjs +223 -0
  53. package/ui/core/expr.mjs +310 -0
  54. package/ui/core/fence.mjs +201 -0
  55. package/ui/core/fix-prompt.mjs +35 -0
  56. package/ui/core/format.mjs +96 -0
  57. package/ui/core/index.mjs +37 -0
  58. package/ui/core/parse.mjs +229 -0
  59. package/ui/core/scan.mjs +53 -0
  60. package/ui/core/sha256.mjs +66 -0
  61. package/ui/core/starters.mjs +18 -0
  62. package/ui/core/state.mjs +259 -0
  63. package/ui/core/static.mjs +524 -0
  64. package/ui/core/transform.mjs +146 -0
  65. package/ui/core/validate.mjs +665 -0
  66. package/ui/evals/cases.yaml +155 -0
  67. package/ui/preview/dist/garden-ui.css +1182 -0
  68. package/ui/preview/dist/garden-ui.esm.js +367 -0
  69. package/ui/preview/dist/garden-ui.iife.js +367 -0
  70. package/ui/preview/index.tsx +191 -0
  71. package/ui/preview/mermaid-shim.ts +19 -0
  72. package/ui/prompt/build.mjs +24 -0
  73. package/ui/prompt/recipes/code-walkthrough.md +68 -0
  74. package/ui/prompt/recipes/compare.md +48 -0
  75. package/ui/prompt/recipes/converter.md +55 -0
  76. package/ui/prompt/recipes/data-chart.md +57 -0
  77. package/ui/prompt/recipes/inspect-grid.md +45 -0
  78. package/ui/prompt/recipes/param-bench.md +55 -0
  79. package/ui/prompt/recipes/plan.md +45 -0
  80. package/ui/prompt/recipes/stepper.md +52 -0
  81. package/ui/prompt/recipes/trace.md +62 -0
  82. package/ui/prompt/rules.md +104 -0
  83. package/ui/prompt/studio.md +20 -0
  84. package/ui/react/CatalogPage.tsx +166 -0
  85. package/ui/react/components/chart.tsx +470 -0
  86. package/ui/react/components/display.tsx +296 -0
  87. package/ui/react/components/inputs.tsx +268 -0
  88. package/ui/react/components/layout.tsx +236 -0
  89. package/ui/react/components/media.tsx +184 -0
  90. package/ui/react/index.tsx +59 -0
  91. package/ui/react/recipes.ts +25 -0
  92. package/ui/react/runtime.tsx +239 -0
  93. package/ui/react/types.ts +29 -0
  94. package/ui/react/ui.css +1182 -0
  95. package/vite.config.ts +4 -3
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Complete, valid bodies used by the component list. Child-only components
3
+ * (Step, Tab, Row, series, Hotspot) are shown through their parents.
4
+ */
5
+ export const DEMOS = {
6
+ State: {
7
+ caption: "State 声明可调的量,Slider 绑上去就能拧。",
8
+ body: `<State batch={1} />
9
+ <Slider bind="batch" label="batch" min={1} max={32} />
10
+ 现在 batch 是 **{batch}**。`,
11
+ },
12
+ Let: {
13
+ caption: "Let 从状态算出派生值,状态一变就跟着重算。",
14
+ body: `<State seq={4096} />
15
+ <Let mem={2 * 32 * 8 * 128 * seq * 2} />
16
+ <Slider bind="seq" label="序列长度" min={1024} max={32768} scale="log" format="thousands" />
17
+ 序列 {thousands(seq)} 时,KV cache 要 **{bytes(mem)}**。`,
18
+ },
19
+ If: {
20
+ caption: "If 只在条件为真时显示里面的内容。",
21
+ body: `<State batch={4} />
22
+ <Slider bind="batch" label="batch" min={1} max={32} />
23
+ <If test={batch > 16}>
24
+ batch 超过 16,单卡显存会很紧张。
25
+ </If>`,
26
+ },
27
+ Each: {
28
+ caption: "Each 对数组的每一项重复渲染。",
29
+ body: `<State n={3} />
30
+ <Slider bind="n" label="项数" min={1} max={6} />
31
+ <Table columns={["序号", "平方"]}>
32
+ <Each of={range(1, n + 1)} as="i">
33
+ <Row cells={[i, i * i]} />
34
+ </Each>
35
+ </Table>`,
36
+ },
37
+ Stack: {
38
+ caption: "Stack 用来调整一组内容的间距。",
39
+ body: `<Stack gap={1}>
40
+ <Badge tone="new">新算</Badge> 这一步只算新 token。
41
+
42
+ <Badge tone="cached">读缓存</Badge> 过去的 K/V 直接读。
43
+ </Stack>`,
44
+ },
45
+ Columns: {
46
+ caption: "Columns 并排放内容,窄屏自动叠放。",
47
+ body: `<Columns>
48
+ <Card title="Mutex">同一时刻只有一个持有者。</Card>
49
+ <Card title="RwLock" tone="accent">多个读者可以同时持有。</Card>
50
+ </Columns>`,
51
+ },
52
+ Card: {
53
+ caption: "Card 是带边框的分组。",
54
+ body: `<Card title="RwLock" tone="accent">读多写少时,读者可以并行。</Card>`,
55
+ },
56
+ Tabs: {
57
+ caption: "Tabs 让几个视角轮流看。",
58
+ body: `<Tabs>
59
+ <Tab title="MHA">每个查询头都有自己的一组 K/V。</Tab>
60
+ <Tab title="GQA">几个查询头共用一组 K/V,缓存小好几倍。</Tab>
61
+ </Tabs>`,
62
+ },
63
+ Steps: {
64
+ caption: "Steps 一步一步看,带上一步、下一步和进度。",
65
+ body: `<Steps>
66
+ <Step title="Prefill">一次算完全部 prompt 的 K/V。</Step>
67
+ <Step title="Decode 1">新 token 只算自己的 K/V,追加一行。</Step>
68
+ <Step title="Decode 2">再追加一行,其余读缓存。</Step>
69
+ </Steps>`,
70
+ },
71
+ Slider: {
72
+ caption: "Slider 拖动改一个数,对数刻度端点是 2 的幂时只停在 2 的幂上。",
73
+ body: `<State seq={4096} />
74
+ <Slider bind="seq" label="序列长度" min={1024} max={32768} scale="log" format="thousands" />
75
+ 当前:{thousands(seq)}。`,
76
+ },
77
+ Choice: {
78
+ caption: "Choice 从几个选项里选一个。",
79
+ body: `<State heads={8} />
80
+ <Choice bind="heads" label="KV 头数" options={[1, 2, 4, 8, 32]} />
81
+ KV cache 是 MHA 的 **1/{32 / heads}**。`,
82
+ },
83
+ Toggle: {
84
+ caption: "Toggle 打开或关掉一个条件。",
85
+ body: `<State fault={false} />
86
+ <Toggle bind="fault" label="注入故障" />
87
+ {fault ? "第 2 块坏了,之后的块全部解不出来。" : "所有块都能正常解密。"}`,
88
+ },
89
+ Button: {
90
+ caption: "Button 点一下改状态或回到默认值。",
91
+ body: `<State count={0} />
92
+ <Button set={{ count: count + 1 }}>加一</Button> <Button reset>重置</Button>
93
+
94
+ 已经点了 **{count}** 次。`,
95
+ },
96
+ Checklist: {
97
+ caption: "Checklist 是可以一项项勾掉的清单。",
98
+ body: `<Checklist title="上线前" items={[
99
+ { title: "跑完回归测试" },
100
+ { title: "备份数据库", note: "确认能恢复" },
101
+ { time: "22:00", title: "切流量" },
102
+ ]} />`,
103
+ },
104
+ Stat: {
105
+ caption: "Stat 显示一个醒目的数字。",
106
+ body: `<State batch={8} />
107
+ <Let mem={2 * 32 * 8 * 128 * 4096 * batch * 2} />
108
+ <Slider bind="batch" label="batch" min={1} max={32} />
109
+ <Stat label="KV 显存" value={mem} format="bytes" hint={"batch = " + batch} />`,
110
+ },
111
+ Meter: {
112
+ caption: "Meter 显示占比,超出上限时整条变红。",
113
+ body: `<State batch={8} />
114
+ <Let mem={2 * 32 * 8 * 128 * 32768 * batch * 2} />
115
+ <Slider bind="batch" label="batch" min={1} max={32} />
116
+ <Meter label="80 GB 显卡" max={80 * 1073741824} format="bytes" parts={[
117
+ { label: "权重", value: 16 * 1073741824, tone: "muted" },
118
+ { label: "KV cache", value: mem, tone: "accent" },
119
+ ]} />`,
120
+ },
121
+ Badge: {
122
+ caption: "Badge 是可以写在句子中间的小标签。",
123
+ body: `这一格是 <Badge tone="new">新算</Badge>,那一格是 <Badge tone="cached">读缓存</Badge>。`,
124
+ },
125
+ Table: {
126
+ caption: "Table 列出精确数值,行可以来自 Row 或 data。",
127
+ body: `<Let bench={[{ t: 1, q: 98000 }, { t: 4, q: 312000 }, { t: 8, q: 356000 }]} />
128
+ <Table data={bench} rowTone={t == 4 ? "accent" : null} columns={[{ label: "线程", key: "t" }, { label: "QPS", key: "q", format: "thousands" }]} />`,
129
+ },
130
+ Grid: {
131
+ caption: "Grid 是检视格,点一格看这一格的说明。",
132
+ body: `<Grid cols={["块1", "块2", "块3"]} legend={{ same: "密文相同" }}>
133
+ <Row label="明文" cells={["A", "B", "A"]} />
134
+ <Row label="ECB" cells={["X", "Y", "X"]} tones={["same", "plain", "same"]} notes={["和块3相同", "", "和块1相同"]} />
135
+ </Grid>`,
136
+ },
137
+ Chart: {
138
+ caption: "Chart 画函数曲线或数据图,sweep 模式标出当前值。",
139
+ body: `<State batch={4} seq={4096} />
140
+ <Let mem={2 * 32 * 8 * 128 * seq * batch * 2} />
141
+ <Slider bind="batch" label="batch" min={1} max={32} />
142
+ <Slider bind="seq" label="序列长度" min={1024} max={32768} scale="log" format="thousands" />
143
+ <Chart sweep="seq" yLabel="KV 显存">
144
+ <Line y={mem} label="当前 batch" format="bytes" />
145
+ <Line y={mem / batch} label="batch = 1" format="bytes" tone="muted" />
146
+ </Chart>`,
147
+ },
148
+ Data: {
149
+ caption: "Data 引用同名目录里已有的 CSV / JSON;这里用 Let 代替演示。",
150
+ body: `<Let bench={[{ threads: 1, qps: 98000 }, { threads: 2, qps: 181000 }, { threads: 4, qps: 312000 }]} />
151
+ <Chart data={bench} x={threads} xLabel="I/O 线程" yFormat="si">
152
+ <Bar y={qps} label="QPS" />
153
+ </Chart>`,
154
+ },
155
+ Image: {
156
+ caption: "Image 在插图上放热点,点序号看说明。",
157
+ body: `<Image src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 200'><rect width='400' height='200' fill='%23eeeeeb'/><circle cx='110' cy='130' r='50' fill='none' stroke='%2365707d' stroke-width='6'/><circle cx='290' cy='130' r='50' fill='none' stroke='%2365707d' stroke-width='6'/><path d='M110 130 L180 70 L290 130 M180 70 L240 70' stroke='%23202124' stroke-width='6' fill='none'/></svg>" alt="一辆示意的自行车" ratio="2:1">
158
+ <Hotspot x={0.45} y={0.35} title="车架">两个三角形把座椅、车把和脚踏连在一起。</Hotspot>
159
+ <Hotspot x={0.27} y={0.65} title="车轮">辐条把轮毂和轮圈连起来。</Hotspot>
160
+ </Image>`,
161
+ },
162
+ Code: {
163
+ caption: "Code 高亮当前执行到的行,可以随步骤变化。",
164
+ body: `<State step={0} />
165
+ <Steps bind="step">
166
+ <Step title="取中点">先算 mid。</Step>
167
+ <Step title="比较">a[mid] 和目标比大小。</Step>
168
+ </Steps>
169
+ <Code lang="go" highlight={["2", "3"][step]} code={\`for lo <= hi {
170
+ mid := (lo + hi) / 2
171
+ if a[mid] < x {
172
+ lo = mid + 1
173
+ }
174
+ }\`} />`,
175
+ },
176
+ Stepper: {
177
+ caption: "Stepper 用加减号改一个数量,旁边的句子跟着变。",
178
+ body: `<State guests={5} />
179
+ <Let lamb={guests * 0.4} />
180
+ <Stepper bind="guests" label="人数" min={1} max={12} unit="人" />
181
+ <Caption>{guests} 人时,带骨羊腿大约 {lamb} kg。</Caption>`,
182
+ },
183
+ Caption: {
184
+ caption: "Caption 是比正文小一号的灰色说明。",
185
+ body: `<Caption>统计期为 2024 年 8 月至 2025 年 8 月。</Caption>`,
186
+ },
187
+ Divider: {
188
+ caption: "Divider 把上下两段隔开。",
189
+ body: `上面是结论。
190
+ <Divider />
191
+ 下面是口径。`,
192
+ },
193
+ Cite: {
194
+ caption: "Cite 是来源胶囊,写了网址就带站点图标。",
195
+ body: `数字来自年报 <Cite label="Alibaba Group" href="https://www.alibabagroup.com" />`,
196
+ },
197
+ Pie: {
198
+ caption: "Pie 用圆环看各项占比。",
199
+ body: `<Pie title="收入结构">
200
+ <Slice label="商品销售" value={78} />
201
+ <Slice label="平台服务" value={13} />
202
+ <Slice label="物流及其他" value={9} />
203
+ </Pie>`,
204
+ },
205
+ Diagram: {
206
+ caption: "Diagram 用 Mermaid 画图,高亮当前所在的节点。",
207
+ body: `<State step={0} />
208
+ <Steps bind="step">
209
+ <Step title="CLOSED">还没有连接。</Step>
210
+ <Step title="SYN-SENT">发出 SYN,等对方回应。</Step>
211
+ <Step title="ESTABLISHED">握手完成。</Step>
212
+ </Steps>
213
+ <Diagram highlight={[["closed"], ["synsent"], ["established"]][step]} mermaid={\`graph LR
214
+ closed[CLOSED] --> synsent[SYN-SENT] --> established[ESTABLISHED]\`} />`,
215
+ },
216
+ };
217
+
218
+ export function demoFence(name) {
219
+ const demo = DEMOS[name];
220
+ if (!demo) return null;
221
+ const id = `demo-${name.toLowerCase()}`;
222
+ return `\`\`\`ui\n---\nid: ${id}\ncaption: ${demo.caption}\n---\n${demo.body}\n\`\`\``;
223
+ }
@@ -0,0 +1,310 @@
1
+ import { bits32, bits64, bytes, fixed, float32, percent, si, thousands } from "./format.mjs";
2
+
3
+ export class ExprError extends Error {}
4
+
5
+ const MAX_RANGE = 1000;
6
+
7
+ function range(start, end, step = 1) {
8
+ if (![start, end, step].every((value) => typeof value === "number" && Number.isFinite(value))) {
9
+ throw new ExprError("range 的参数必须是有限数字");
10
+ }
11
+ if (step === 0) throw new ExprError("range 的步长不能是 0");
12
+ const values = [];
13
+ for (let value = start; step > 0 ? value < end : value > end; value += step) {
14
+ values.push(value);
15
+ if (values.length > MAX_RANGE) throw new ExprError(`range 最多 ${MAX_RANGE} 项`);
16
+ }
17
+ return values;
18
+ }
19
+
20
+ const numbers = (values) => (Array.isArray(values) ? values.filter((value) => typeof value === "number") : []);
21
+
22
+ const FUNCTIONS = {
23
+ abs: Math.abs,
24
+ min: Math.min,
25
+ max: Math.max,
26
+ floor: Math.floor,
27
+ ceil: Math.ceil,
28
+ round: Math.round,
29
+ trunc: Math.trunc,
30
+ sqrt: Math.sqrt,
31
+ cbrt: Math.cbrt,
32
+ pow: Math.pow,
33
+ exp: Math.exp,
34
+ log: Math.log,
35
+ log2: Math.log2,
36
+ log10: Math.log10,
37
+ sin: Math.sin,
38
+ cos: Math.cos,
39
+ tan: Math.tan,
40
+ atan2: Math.atan2,
41
+ clamp: (value, low, high) => Math.min(Math.max(value, low), high),
42
+ range,
43
+ len: (value) => (Array.isArray(value) || typeof value === "string" ? value.length : 0),
44
+ sum: (values) => numbers(values).reduce((total, value) => total + value, 0),
45
+ avg: (values) => {
46
+ const list = numbers(values);
47
+ return list.length ? list.reduce((total, value) => total + value, 0) / list.length : Number.NaN;
48
+ },
49
+ col: (rows, key) => (Array.isArray(rows) ? rows.map((row) => readMember(row, key)) : []),
50
+ includes: (values, item) => (Array.isArray(values) || typeof values === "string" ? values.includes(item) : false),
51
+ join: (values, separator = "、") => (Array.isArray(values) ? values.map(String).join(String(separator)) : ""),
52
+ fixed,
53
+ percent,
54
+ bytes,
55
+ si,
56
+ thousands,
57
+ float32,
58
+ bits32,
59
+ bits64,
60
+ };
61
+
62
+ const CONSTANTS = { PI: Math.PI, E: Math.E };
63
+
64
+ export const FUNCTION_NAMES = Object.freeze(Object.keys(FUNCTIONS));
65
+ export const RESERVED_NAMES = new Set([...FUNCTION_NAMES, ...Object.keys(CONSTANTS), "undefined", "NaN", "Infinity"]);
66
+
67
+ const BINARY = new Set(["+", "-", "*", "/", "%", "**", "==", "!=", "===", "!==", "<", "<=", ">", ">="]);
68
+ const LOGICAL = new Set(["&&", "||", "??"]);
69
+ const UNARY = new Set(["-", "+", "!"]);
70
+ const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
71
+
72
+ const isPlainObject = (value) => {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
74
+ const prototype = Object.getPrototypeOf(value);
75
+ return prototype === Object.prototype || prototype === null;
76
+ };
77
+
78
+ function readMember(target, key) {
79
+ if (typeof key !== "string" && typeof key !== "number") return undefined;
80
+ const name = String(key);
81
+ if (FORBIDDEN_KEYS.has(name)) throw new ExprError(`不能读取 ${name}`);
82
+ if (Array.isArray(target) || typeof target === "string") {
83
+ if (name === "length") return target.length;
84
+ if (/^\d+$/u.test(name)) return target[Number(name)];
85
+ return undefined;
86
+ }
87
+ if (isPlainObject(target)) return Object.hasOwn(target, name) ? target[name] : undefined;
88
+ if (target === undefined || target === null) throw new ExprError(`不能从空值读取 ${name}`);
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Static check: reports syntax outside the whitelist and returns the free
94
+ * names an expression reads, so validation can resolve them against the block.
95
+ */
96
+ export function analyzeExpr(node) {
97
+ const problems = [];
98
+ const names = new Set();
99
+ const visit = (current) => {
100
+ if (!current || typeof current !== "object") {
101
+ problems.push("表达式不完整");
102
+ return;
103
+ }
104
+ switch (current.type) {
105
+ case "Literal":
106
+ if (current.regex) problems.push("不能使用正则");
107
+ if (typeof current.value === "bigint") problems.push("不能使用 BigInt");
108
+ return;
109
+ case "TemplateLiteral":
110
+ current.expressions.forEach(visit);
111
+ return;
112
+ case "Identifier":
113
+ names.add(current.name);
114
+ return;
115
+ case "ArrayExpression":
116
+ for (const element of current.elements) {
117
+ if (!element) problems.push("数组里不能有空位");
118
+ else if (element.type === "SpreadElement") problems.push("不能使用展开语法 ...");
119
+ else visit(element);
120
+ }
121
+ return;
122
+ case "ObjectExpression":
123
+ for (const property of current.properties) {
124
+ if (property.type !== "Property" || property.kind !== "init" || property.method) {
125
+ problems.push("对象里只能写 键: 值");
126
+ continue;
127
+ }
128
+ if (property.computed) {
129
+ problems.push("对象的键不能是计算出来的");
130
+ continue;
131
+ }
132
+ visit(property.value);
133
+ }
134
+ return;
135
+ case "UnaryExpression":
136
+ if (!UNARY.has(current.operator)) problems.push(`不能使用运算符 ${current.operator}`);
137
+ visit(current.argument);
138
+ return;
139
+ case "BinaryExpression":
140
+ if (!BINARY.has(current.operator)) problems.push(`不能使用运算符 ${current.operator}`);
141
+ visit(current.left);
142
+ visit(current.right);
143
+ return;
144
+ case "LogicalExpression":
145
+ if (!LOGICAL.has(current.operator)) problems.push(`不能使用运算符 ${current.operator}`);
146
+ visit(current.left);
147
+ visit(current.right);
148
+ return;
149
+ case "ConditionalExpression":
150
+ visit(current.test);
151
+ visit(current.consequent);
152
+ visit(current.alternate);
153
+ return;
154
+ case "CallExpression":
155
+ if (current.optional) problems.push("不能使用 ?.()");
156
+ if (current.callee.type !== "Identifier") {
157
+ problems.push("只能直接调用白名单函数,不能调用方法");
158
+ } else if (!Object.hasOwn(FUNCTIONS, current.callee.name)) {
159
+ problems.push(`不能调用 ${current.callee.name}`);
160
+ }
161
+ for (const argument of current.arguments) {
162
+ if (argument.type === "SpreadElement") problems.push("不能使用展开语法 ...");
163
+ else visit(argument);
164
+ }
165
+ return;
166
+ case "MemberExpression":
167
+ if (current.optional) problems.push("不能使用 ?.");
168
+ visit(current.object);
169
+ if (current.computed) visit(current.property);
170
+ else if (FORBIDDEN_KEYS.has(current.property.name)) problems.push(`不能读取 ${current.property.name}`);
171
+ return;
172
+ case "ChainExpression":
173
+ problems.push("不能使用 ?.");
174
+ return;
175
+ default:
176
+ problems.push(`不能使用 ${current.type}`);
177
+ }
178
+ };
179
+ visit(node);
180
+ return { problems, names };
181
+ }
182
+
183
+ export function createBudget(max = 100000) {
184
+ return { steps: 0, max };
185
+ }
186
+
187
+ /**
188
+ * Evaluates a pre-checked ESTree expression. `lookup(name)` returns
189
+ * `{ found, value }`; nothing outside the whitelist is reachable.
190
+ */
191
+ export function evaluate(node, lookup, budget = createBudget()) {
192
+ budget.steps += 1;
193
+ if (budget.steps > budget.max) throw new ExprError("表达式计算步数超出预算");
194
+ switch (node.type) {
195
+ case "Literal":
196
+ return node.value;
197
+ case "TemplateLiteral": {
198
+ let output = "";
199
+ node.quasis.forEach((quasi, index) => {
200
+ output += quasi.value.cooked ?? quasi.value.raw;
201
+ if (index < node.expressions.length) {
202
+ const value = evaluate(node.expressions[index], lookup, budget);
203
+ output += value === undefined || value === null ? "" : String(value);
204
+ }
205
+ });
206
+ return output;
207
+ }
208
+ case "Identifier": {
209
+ if (Object.hasOwn(CONSTANTS, node.name)) return CONSTANTS[node.name];
210
+ if (node.name === "undefined") return undefined;
211
+ if (node.name === "NaN") return Number.NaN;
212
+ if (node.name === "Infinity") return Number.POSITIVE_INFINITY;
213
+ const result = lookup(node.name);
214
+ if (!result.found) throw new ExprError(`未知名字 ${node.name}`);
215
+ return result.value;
216
+ }
217
+ case "ArrayExpression":
218
+ return node.elements.map((element) => evaluate(element, lookup, budget));
219
+ case "ObjectExpression": {
220
+ const output = {};
221
+ for (const property of node.properties) {
222
+ const key = property.key.type === "Identifier" ? property.key.name : String(property.key.value);
223
+ if (FORBIDDEN_KEYS.has(key)) throw new ExprError(`不能使用键 ${key}`);
224
+ output[key] = evaluate(property.value, lookup, budget);
225
+ }
226
+ return output;
227
+ }
228
+ case "UnaryExpression": {
229
+ const value = evaluate(node.argument, lookup, budget);
230
+ if (node.operator === "-") return -value;
231
+ if (node.operator === "+") return +value;
232
+ return !value;
233
+ }
234
+ case "BinaryExpression": {
235
+ const left = evaluate(node.left, lookup, budget);
236
+ const right = evaluate(node.right, lookup, budget);
237
+ switch (node.operator) {
238
+ case "+":
239
+ return left + right;
240
+ case "-":
241
+ return left - right;
242
+ case "*":
243
+ return left * right;
244
+ case "/":
245
+ return left / right;
246
+ case "%":
247
+ return left % right;
248
+ case "**":
249
+ return left ** right;
250
+ case "==":
251
+ case "===":
252
+ return left === right;
253
+ case "!=":
254
+ case "!==":
255
+ return left !== right;
256
+ case "<":
257
+ return left < right;
258
+ case "<=":
259
+ return left <= right;
260
+ case ">":
261
+ return left > right;
262
+ case ">=":
263
+ return left >= right;
264
+ default:
265
+ throw new ExprError(`不能使用运算符 ${node.operator}`);
266
+ }
267
+ }
268
+ case "LogicalExpression": {
269
+ const left = evaluate(node.left, lookup, budget);
270
+ if (node.operator === "&&") return left ? evaluate(node.right, lookup, budget) : left;
271
+ if (node.operator === "||") return left ? left : evaluate(node.right, lookup, budget);
272
+ return left ?? evaluate(node.right, lookup, budget);
273
+ }
274
+ case "ConditionalExpression":
275
+ return evaluate(node.test, lookup, budget)
276
+ ? evaluate(node.consequent, lookup, budget)
277
+ : evaluate(node.alternate, lookup, budget);
278
+ case "CallExpression": {
279
+ const fn = FUNCTIONS[node.callee.name];
280
+ if (!fn) throw new ExprError(`不能调用 ${node.callee.name}`);
281
+ return fn(...node.arguments.map((argument) => evaluate(argument, lookup, budget)));
282
+ }
283
+ case "MemberExpression": {
284
+ const target = evaluate(node.object, lookup, budget);
285
+ const key = node.computed ? evaluate(node.property, lookup, budget) : node.property.name;
286
+ return readMember(target, key);
287
+ }
288
+ default:
289
+ throw new ExprError(`不能使用 ${node.type}`);
290
+ }
291
+ }
292
+
293
+ const KEEP = new Set([
294
+ "type", "value", "raw", "regex", "name", "operator", "prefix", "left", "right", "argument", "test",
295
+ "consequent", "alternate", "callee", "arguments", "object", "property", "computed", "optional",
296
+ "elements", "properties", "key", "kind", "method", "shorthand", "quasis", "expressions", "cooked", "tail",
297
+ ]);
298
+
299
+ /** Drops positions and parser metadata so the tree is small and JSON-safe. */
300
+ export function pruneExpr(node) {
301
+ if (Array.isArray(node)) return node.map(pruneExpr);
302
+ if (!node || typeof node !== "object") return node;
303
+ const output = {};
304
+ for (const [key, value] of Object.entries(node)) {
305
+ if (!KEEP.has(key)) continue;
306
+ if (key === "value" && node.type === "Literal" && typeof value === "bigint") continue;
307
+ output[key] = value && typeof value === "object" ? pruneExpr(value) : value;
308
+ }
309
+ return output;
310
+ }