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,201 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { sha256Hex } from "./sha256.mjs";
|
|
3
|
+
|
|
4
|
+
export const normalizeNewlines = (text) => text.replace(/\r\n?/gu, "\n");
|
|
5
|
+
|
|
6
|
+
export const shortHash = (text) => sha256Hex(normalizeNewlines(text)).slice(0, 8);
|
|
7
|
+
|
|
8
|
+
export const intentHash = (intent) => shortHash(normalizeNewlines(String(intent ?? "")).trim());
|
|
9
|
+
|
|
10
|
+
/** Hash of the whole fence, from the opening backticks to the closing ones. */
|
|
11
|
+
export const fenceHash = (raw) => shortHash(raw);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* marker: string, lang: string, info: string, start: number, end: number,
|
|
16
|
+
* startLine: number, endLine: number, bodyStartLine: number, closed: boolean,
|
|
17
|
+
* raw: string, body: string,
|
|
18
|
+
* }} Fence
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Line scanner shared by build, CLI, admin and editor plugins. Offsets are
|
|
23
|
+
* character offsets into the original text; lines are 1-based.
|
|
24
|
+
* @param {string} markdown
|
|
25
|
+
* @returns {Fence[]}
|
|
26
|
+
*/
|
|
27
|
+
export function scanFences(markdown) {
|
|
28
|
+
const fences = [];
|
|
29
|
+
const lineRe = /[^\n]*(?:\n|$)/gu;
|
|
30
|
+
let offset = 0;
|
|
31
|
+
let lineNumber = 0;
|
|
32
|
+
let open = null;
|
|
33
|
+
for (const match of markdown.matchAll(lineRe)) {
|
|
34
|
+
const chunk = match[0];
|
|
35
|
+
if (!chunk && offset >= markdown.length) break;
|
|
36
|
+
lineNumber += 1;
|
|
37
|
+
const line = chunk.replace(/\r?\n$/u, "");
|
|
38
|
+
const lineEnd = offset + line.length;
|
|
39
|
+
if (open) {
|
|
40
|
+
const closer = /^ {0,3}(`{3,}|~{3,})\s*$/u.exec(line);
|
|
41
|
+
if (closer && closer[1][0] === open.marker[0] && closer[1].length >= open.marker.length) {
|
|
42
|
+
open.endLine = lineNumber;
|
|
43
|
+
open.end = lineEnd;
|
|
44
|
+
open.closed = true;
|
|
45
|
+
open.raw = markdown.slice(open.start, open.end);
|
|
46
|
+
open.body = open.bodyLines.join("\n");
|
|
47
|
+
delete open.bodyLines;
|
|
48
|
+
fences.push(open);
|
|
49
|
+
open = null;
|
|
50
|
+
} else {
|
|
51
|
+
open.bodyLines.push(line);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
|
|
55
|
+
if (opener && !(opener[1][0] === "`" && opener[2].includes("`"))) {
|
|
56
|
+
const info = opener[2].trim();
|
|
57
|
+
const [lang = "", ...rest] = info.split(/\s+/u);
|
|
58
|
+
open = {
|
|
59
|
+
marker: opener[1],
|
|
60
|
+
lang: lang.toLowerCase(),
|
|
61
|
+
info: rest.join(" "),
|
|
62
|
+
start: offset,
|
|
63
|
+
startLine: lineNumber,
|
|
64
|
+
bodyStartLine: lineNumber + 1,
|
|
65
|
+
bodyLines: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
offset += chunk.length;
|
|
70
|
+
if (offset >= markdown.length && !chunk.endsWith("\n")) break;
|
|
71
|
+
}
|
|
72
|
+
if (open) {
|
|
73
|
+
open.endLine = lineNumber;
|
|
74
|
+
open.end = markdown.length;
|
|
75
|
+
open.closed = false;
|
|
76
|
+
open.raw = markdown.slice(open.start);
|
|
77
|
+
open.body = open.bodyLines.join("\n");
|
|
78
|
+
delete open.bodyLines;
|
|
79
|
+
fences.push(open);
|
|
80
|
+
}
|
|
81
|
+
return fences;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const BLOCK_LANGS = new Set(["ui", "intent", "widget"]);
|
|
85
|
+
|
|
86
|
+
/** @param {string} markdown @returns {Fence[]} */
|
|
87
|
+
export function findBlocks(markdown) {
|
|
88
|
+
return scanFences(markdown).filter((fence) => BLOCK_LANGS.has(fence.lang));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Splits a `ui` fence body into the YAML block header and the body. Returns
|
|
93
|
+
* `bodyLineOffset`, the number of lines before the body inside the fence.
|
|
94
|
+
*/
|
|
95
|
+
export function splitUiBody(body) {
|
|
96
|
+
const lines = normalizeNewlines(body).split("\n");
|
|
97
|
+
if (lines[0]?.trim() !== "---") {
|
|
98
|
+
return { error: "块头要以单独一行 --- 开始", header: null, headerText: "", bodyText: body, bodyLineOffset: 0 };
|
|
99
|
+
}
|
|
100
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
101
|
+
if (closing < 0) {
|
|
102
|
+
return { error: "块头缺少结束的 ---", header: null, headerText: "", bodyText: "", bodyLineOffset: 0 };
|
|
103
|
+
}
|
|
104
|
+
const headerText = lines.slice(1, closing).join("\n");
|
|
105
|
+
let header;
|
|
106
|
+
try {
|
|
107
|
+
header = parseYaml(headerText) ?? {};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
error: `块头不是有效的 YAML:${error instanceof Error ? error.message.split("\n")[0] : "解析失败"}`,
|
|
111
|
+
header: null,
|
|
112
|
+
headerText,
|
|
113
|
+
bodyText: lines.slice(closing + 1).join("\n"),
|
|
114
|
+
bodyLineOffset: closing + 1,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!header || typeof header !== "object" || Array.isArray(header)) {
|
|
118
|
+
return { error: "块头必须是 YAML 对象", header: null, headerText, bodyText: "", bodyLineOffset: closing + 1 };
|
|
119
|
+
}
|
|
120
|
+
return { error: null, header, headerText, bodyText: lines.slice(closing + 1).join("\n"), bodyLineOffset: closing + 1 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Writes `compiled:` by editing only that header line, keeping the author's formatting. */
|
|
124
|
+
export function setCompiled(fenceRaw, hash) {
|
|
125
|
+
const lines = normalizeNewlines(fenceRaw).split("\n");
|
|
126
|
+
const headerStart = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
127
|
+
if (headerStart !== 1) return fenceRaw;
|
|
128
|
+
const headerEnd = lines.findIndex((line, index) => index > headerStart && line.trim() === "---");
|
|
129
|
+
if (headerEnd < 0) return fenceRaw;
|
|
130
|
+
const existing = lines.findIndex((line, index) => index > headerStart && index < headerEnd && /^compiled\s*:/u.test(line));
|
|
131
|
+
if (existing >= 0) lines[existing] = `compiled: ${hash}`;
|
|
132
|
+
else lines.splice(headerEnd, 0, `compiled: ${hash}`);
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read from the raw header text: YAML would coerce hashes like 12e45678 into numbers. */
|
|
137
|
+
export function compiledOf(headerText) {
|
|
138
|
+
const match = /^compiled\s*:\s*["']?([^"'\s]*)["']?\s*$/mu.exec(headerText ?? "");
|
|
139
|
+
return match ? match[1] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function intentOf(fence) {
|
|
143
|
+
if (fence.lang === "intent") return normalizeNewlines(fence.body);
|
|
144
|
+
if (fence.lang === "ui") {
|
|
145
|
+
const { header } = splitUiBody(fence.body);
|
|
146
|
+
return typeof header?.intent === "string" ? header.intent : null;
|
|
147
|
+
}
|
|
148
|
+
if (fence.lang === "widget") {
|
|
149
|
+
try {
|
|
150
|
+
const data = parseYaml(fence.body);
|
|
151
|
+
return typeof data?.intent === "string" ? data.intent : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function idOf(fence) {
|
|
160
|
+
if (fence.lang === "intent") return fence.info.split(/\s+/u)[0] || null;
|
|
161
|
+
if (fence.lang === "ui") {
|
|
162
|
+
const { header } = splitUiBody(fence.body);
|
|
163
|
+
return typeof header?.id === "string" ? header.id : null;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
169
|
+
|
|
170
|
+
const isExternal = (value) => /^(?:[a-z][a-z0-9+.-]*:)?\/\//iu.test(value) || /^[a-z][a-z0-9+.-]*:/iu.test(value);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves a path written inside a post against the post's same-name
|
|
174
|
+
* directory. `markdownPath` is POSIX and relative to content/ or the repo,
|
|
175
|
+
* e.g. `posts/llm/kv.md`. Returns `{ path }` or `{ error }`.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveSidecarPath(markdownPath, relative) {
|
|
178
|
+
if (typeof relative !== "string" || !relative.trim()) return { error: "路径不能为空" };
|
|
179
|
+
const value = relative.trim().replaceAll("\\", "/");
|
|
180
|
+
if (isExternal(value) || value.startsWith("/")) return { error: "只能使用本文同名目录里的相对路径" };
|
|
181
|
+
const markdown = markdownPath.replaceAll("\\", "/");
|
|
182
|
+
if (!markdown.endsWith(".md")) return { error: "稿件路径必须是 .md" };
|
|
183
|
+
const directory = markdown.includes("/") ? markdown.slice(0, markdown.lastIndexOf("/")) : "";
|
|
184
|
+
const stem = markdown.slice(markdown.lastIndexOf("/") + 1, -3);
|
|
185
|
+
const sidecar = directory ? `${directory}/${stem}` : stem;
|
|
186
|
+
const segments = directory ? directory.split("/") : [];
|
|
187
|
+
for (const segment of value.split("/")) {
|
|
188
|
+
if (!segment || segment === ".") continue;
|
|
189
|
+
if (segment === "..") {
|
|
190
|
+
if (!segments.length) return { error: "路径越出了内容目录" };
|
|
191
|
+
segments.pop();
|
|
192
|
+
} else {
|
|
193
|
+
segments.push(segment);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const resolved = segments.join("/");
|
|
197
|
+
if (!resolved.startsWith(`${sidecar}/`) || resolved.length <= sidecar.length + 1) {
|
|
198
|
+
return { error: `只能使用同名目录 ${stem}/ 里的文件` };
|
|
199
|
+
}
|
|
200
|
+
return { path: resolved };
|
|
201
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MAX_BLOCKS = 10;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The prompt the pre-commit hook and the admin hand to an AI. `problems` is a
|
|
5
|
+
* list of `{ file, block, diagnostics }` where block comes from scanMarkdown.
|
|
6
|
+
*/
|
|
7
|
+
export function buildFixPrompt(problems, { standaloneReference = null } = {}) {
|
|
8
|
+
const lines = [
|
|
9
|
+
"请修复下面 Garden 博文里有错误的 ui 块。",
|
|
10
|
+
"",
|
|
11
|
+
"要求:",
|
|
12
|
+
"- 先运行 npx garden ui prompt,读组件目录和规则;仓库里有 garden-ui skill 时按它的流程做。",
|
|
13
|
+
"- 只改列出的块,用 npx garden ui apply <稿件> --id <id> 写回(没有 id 的 intent 用 --intent-hash),不要直接编辑稿件。",
|
|
14
|
+
"- 不改 intent 的原意。修好后运行 npx garden ui check <稿件>,直到通过。",
|
|
15
|
+
"- 如果你不能访问这个仓库,就只回复每块修好后的完整围栏。",
|
|
16
|
+
"",
|
|
17
|
+
];
|
|
18
|
+
problems.forEach((problem, index) => {
|
|
19
|
+
const { file, block, diagnostics } = problem;
|
|
20
|
+
const target = block.id ? `id: ${block.id}` : `intent-hash: ${block.intentHash}`;
|
|
21
|
+
lines.push(`${index + 1}. ${file},第 ${block.startLine}–${block.endLine} 行,${target}`);
|
|
22
|
+
if (block.lang === "intent") {
|
|
23
|
+
lines.push(" - intent-uncompiled:这段想法还没编译,请按原话编译成 ui 块(或判断后写回正文)。");
|
|
24
|
+
}
|
|
25
|
+
for (const item of diagnostics) lines.push(` - 第 ${item.line} 行 ${item.code}:${item.message}`);
|
|
26
|
+
if (index < MAX_BLOCKS) {
|
|
27
|
+
lines.push("", "当前围栏:", "", block.raw, "");
|
|
28
|
+
} else {
|
|
29
|
+
lines.push("");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
if (problems.length > MAX_BLOCKS) lines.push(`(只附了前 ${MAX_BLOCKS} 块的原文,其余按上面的位置去稿件里找。)`, "");
|
|
33
|
+
if (standaloneReference) lines.push("---", "", "以下是 Garden ui 块的完整写作规则、组件参考和配方:", "", standaloneReference);
|
|
34
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
2
|
+
|
|
3
|
+
const significant = (value) => String(Number(value.toPrecision(3)));
|
|
4
|
+
|
|
5
|
+
export function fixed(value, digits = 0) {
|
|
6
|
+
if (!isFiniteNumber(value)) return "—";
|
|
7
|
+
const places = Math.max(0, Math.min(20, Math.trunc(Number(digits) || 0)));
|
|
8
|
+
return value.toFixed(places);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function percent(value, digits = 0) {
|
|
12
|
+
if (!isFiniteNumber(value)) return "—";
|
|
13
|
+
return `${fixed(value * 100, digits)}%`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const binaryUnits = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
17
|
+
|
|
18
|
+
export function bytes(value) {
|
|
19
|
+
if (!isFiniteNumber(value)) return "—";
|
|
20
|
+
const sign = value < 0 ? "-" : "";
|
|
21
|
+
let amount = Math.abs(value);
|
|
22
|
+
let unit = 0;
|
|
23
|
+
while (amount >= 1024 && unit < binaryUnits.length - 1) {
|
|
24
|
+
amount /= 1024;
|
|
25
|
+
unit += 1;
|
|
26
|
+
}
|
|
27
|
+
return `${sign}${significant(amount)} ${binaryUnits[unit]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const decimalUnits = ["", "K", "M", "G", "T"];
|
|
31
|
+
|
|
32
|
+
export function si(value) {
|
|
33
|
+
if (!isFiniteNumber(value)) return "—";
|
|
34
|
+
const sign = value < 0 ? "-" : "";
|
|
35
|
+
let amount = Math.abs(value);
|
|
36
|
+
let unit = 0;
|
|
37
|
+
while (amount >= 1000 && unit < decimalUnits.length - 1) {
|
|
38
|
+
amount /= 1000;
|
|
39
|
+
unit += 1;
|
|
40
|
+
}
|
|
41
|
+
return `${sign}${significant(amount)}${decimalUnits[unit]}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function thousands(value) {
|
|
45
|
+
if (!isFiniteNumber(value)) return "—";
|
|
46
|
+
const [integer, fraction] = String(Math.abs(value)).split(".");
|
|
47
|
+
if (integer.includes("e")) return String(value);
|
|
48
|
+
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/gu, ",");
|
|
49
|
+
return `${value < 0 ? "-" : ""}${grouped}${fraction ? `.${fraction}` : ""}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function float32(value) {
|
|
53
|
+
return typeof value === "number" ? Math.fround(value) : Number.NaN;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bits(value, byteLength, exponentBits) {
|
|
57
|
+
if (typeof value !== "number") return "—";
|
|
58
|
+
const view = new DataView(new ArrayBuffer(byteLength));
|
|
59
|
+
if (byteLength === 8) view.setFloat64(0, value);
|
|
60
|
+
else view.setFloat32(0, value);
|
|
61
|
+
let binary = "";
|
|
62
|
+
for (let index = 0; index < byteLength; index += 1) {
|
|
63
|
+
binary += view.getUint8(index).toString(2).padStart(8, "0");
|
|
64
|
+
}
|
|
65
|
+
return `${binary[0]} ${binary.slice(1, 1 + exponentBits)} ${binary.slice(1 + exponentBits)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const bits64 = (value) => bits(value, 8, 11);
|
|
69
|
+
export const bits32 = (value) => bits(value, 4, 8);
|
|
70
|
+
|
|
71
|
+
const FORMAT_PATTERN = /^(?:number|bytes|si|thousands|percent(?::\d{1,2})?|fixed:\d{1,2})$/u;
|
|
72
|
+
|
|
73
|
+
export function isValidFormat(format) {
|
|
74
|
+
return typeof format === "string" && FORMAT_PATTERN.test(format);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Display rule shared by props, interpolation and static snapshots. */
|
|
78
|
+
export function formatValue(value, format) {
|
|
79
|
+
if (value === undefined || value === null) return "—";
|
|
80
|
+
if (typeof value === "number" && !Number.isFinite(value)) return "—";
|
|
81
|
+
if (typeof value === "boolean") return value ? "是" : "否";
|
|
82
|
+
if (typeof value !== "number") {
|
|
83
|
+
if (Array.isArray(value)) return value.map((item) => formatValue(item)).join("、");
|
|
84
|
+
if (typeof value === "object") return "—";
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
if (!format || format === "number") return String(value);
|
|
88
|
+
if (format === "bytes") return bytes(value);
|
|
89
|
+
if (format === "si") return si(value);
|
|
90
|
+
if (format === "thousands") return thousands(value);
|
|
91
|
+
const percentMatch = /^percent(?::(\d{1,2}))?$/u.exec(format);
|
|
92
|
+
if (percentMatch) return percent(value, Number(percentMatch[1] ?? 0));
|
|
93
|
+
const fixedMatch = /^fixed:(\d{1,2})$/u.exec(format);
|
|
94
|
+
if (fixedMatch) return fixed(value, Number(fixedMatch[1]));
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { applyBlock, ApplyError, classifyReplacement } from "./apply.mjs";
|
|
2
|
+
export { CATALOG_VERSION, catalogReference, catalogSchema, COMPONENTS, COMPONENT_NAMES, GROUPS, TONES, TONE_LABELS } from "./catalog.mjs";
|
|
3
|
+
export { compileUiBlock, jsonFence, PAYLOAD_VERSION, readPayload } from "./compile.mjs";
|
|
4
|
+
export { analyzeExpr, createBudget, evaluate, ExprError, FUNCTION_NAMES } from "./expr.mjs";
|
|
5
|
+
export {
|
|
6
|
+
compiledOf,
|
|
7
|
+
fenceHash,
|
|
8
|
+
findBlocks,
|
|
9
|
+
ID_PATTERN,
|
|
10
|
+
idOf,
|
|
11
|
+
intentHash,
|
|
12
|
+
intentOf,
|
|
13
|
+
normalizeNewlines,
|
|
14
|
+
resolveSidecarPath,
|
|
15
|
+
scanFences,
|
|
16
|
+
setCompiled,
|
|
17
|
+
splitUiBody,
|
|
18
|
+
} from "./fence.mjs";
|
|
19
|
+
export { bits32, bits64, bytes, fixed, float32, formatValue, isValidFormat, percent, si, thousands } from "./format.mjs";
|
|
20
|
+
export { parseUiBody, parseUiBodyPartial } from "./parse.mjs";
|
|
21
|
+
export { blockStatus, scanMarkdown } from "./scan.mjs";
|
|
22
|
+
export { intentFence, STARTERS } from "./starters.mjs";
|
|
23
|
+
export { docToHtml, docToMarkdown, docToText, renderStatic, toDoc } from "./static.mjs";
|
|
24
|
+
export {
|
|
25
|
+
collectDecls,
|
|
26
|
+
computeScope,
|
|
27
|
+
defaultStateValues,
|
|
28
|
+
isPowerOfTwo,
|
|
29
|
+
lookupFrom,
|
|
30
|
+
orderLets,
|
|
31
|
+
parseDataFile,
|
|
32
|
+
runExpect,
|
|
33
|
+
sampleDomain,
|
|
34
|
+
sweepSeries,
|
|
35
|
+
} from "./state.mjs";
|
|
36
|
+
export { collectDataSources, contentForDistribution, contentForFeed, contentToSearchText, transformUiContent } from "./transform.mjs";
|
|
37
|
+
export { checkUiBlock, suggestComponent, withRow } from "./validate.mjs";
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as acorn from "acorn";
|
|
2
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
|
+
import { mdxExpressionFromMarkdown } from "mdast-util-mdx-expression";
|
|
4
|
+
import { mdxJsxFromMarkdown } from "mdast-util-mdx-jsx";
|
|
5
|
+
import { mdxExpression } from "micromark-extension-mdx-expression";
|
|
6
|
+
import { mdxJsx } from "micromark-extension-mdx-jsx";
|
|
7
|
+
import { COMPONENTS } from "./catalog.mjs";
|
|
8
|
+
import { pruneExpr } from "./expr.mjs";
|
|
9
|
+
|
|
10
|
+
const parseOptions = {
|
|
11
|
+
extensions: [
|
|
12
|
+
mdxJsx({ acorn, addResult: true }),
|
|
13
|
+
mdxExpression({ acorn, addResult: true }),
|
|
14
|
+
// Same as MDX: indentation inside elements must not become code blocks.
|
|
15
|
+
{ disable: { null: ["autolink", "codeIndented", "htmlFlow", "htmlText"] } },
|
|
16
|
+
],
|
|
17
|
+
mdastExtensions: [mdxJsxFromMarkdown(), mdxExpressionFromMarkdown()],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const lineOf = (node) => node?.position?.start?.line ?? 1;
|
|
21
|
+
|
|
22
|
+
function errorLine(error) {
|
|
23
|
+
const place = error?.place;
|
|
24
|
+
if (place?.line) return place.line;
|
|
25
|
+
if (place?.start?.line) return place.start.line;
|
|
26
|
+
if (typeof error?.line === "number") return error.line;
|
|
27
|
+
const match = /\((\d+):\d+/u.exec(String(error?.message ?? ""));
|
|
28
|
+
return match ? Number(match[1]) : 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isBlockElement = (node) =>
|
|
32
|
+
node.t === "el" && !(COMPONENTS[node.n]?.inline);
|
|
33
|
+
|
|
34
|
+
function expressionFrom(estree) {
|
|
35
|
+
const statement = estree?.body?.[0];
|
|
36
|
+
if (!statement || statement.type !== "ExpressionStatement" || estree.body.length !== 1) return null;
|
|
37
|
+
return pruneExpr(statement.expression);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses the body of a `ui` fence. Never executes code: expressions become
|
|
42
|
+
* pruned ESTree that only `expr.mjs` interprets.
|
|
43
|
+
*/
|
|
44
|
+
export function parseUiBody(text) {
|
|
45
|
+
const diagnostics = [];
|
|
46
|
+
const report = (code, line, message) => diagnostics.push({ code, line, message, severity: "error" });
|
|
47
|
+
let tree;
|
|
48
|
+
try {
|
|
49
|
+
tree = fromMarkdown(text, parseOptions);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
report("ui-syntax", errorLine(error), String(error?.reason ?? error?.message ?? "无法解析").split("\n")[0]);
|
|
52
|
+
return { root: null, diagnostics };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const convertAttributes = (node) => {
|
|
56
|
+
const props = {};
|
|
57
|
+
for (const attribute of node.attributes) {
|
|
58
|
+
if (attribute.type !== "mdxJsxAttribute") {
|
|
59
|
+
report("ui-syntax", lineOf(node), `<${node.name}> 不能使用展开属性`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (attribute.value === null || attribute.value === undefined) {
|
|
63
|
+
props[attribute.name] = { k: "lit", v: true };
|
|
64
|
+
} else if (typeof attribute.value === "string") {
|
|
65
|
+
props[attribute.name] = { k: "lit", v: attribute.value };
|
|
66
|
+
} else {
|
|
67
|
+
const expression = expressionFrom(attribute.value.data?.estree);
|
|
68
|
+
if (!expression) {
|
|
69
|
+
report("ui-expr", lineOf(node), `<${node.name}> 的 ${attribute.name} 需要一个表达式`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (expression.type === "Literal" && !expression.regex) {
|
|
73
|
+
props[attribute.name] = { k: "lit", v: expression.value, s: attribute.value.value };
|
|
74
|
+
} else if (expression.type === "UnaryExpression" && expression.operator === "-" && expression.argument.type === "Literal" && typeof expression.argument.value === "number") {
|
|
75
|
+
props[attribute.name] = { k: "lit", v: -expression.argument.value, s: attribute.value.value };
|
|
76
|
+
} else {
|
|
77
|
+
props[attribute.name] = { k: "expr", e: expression, s: attribute.value.value };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return props;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const convertInline = (node) => {
|
|
85
|
+
switch (node.type) {
|
|
86
|
+
case "text":
|
|
87
|
+
return [{ t: "text", v: node.value }];
|
|
88
|
+
case "mdxTextExpression":
|
|
89
|
+
case "mdxFlowExpression": {
|
|
90
|
+
const expression = expressionFrom(node.data?.estree);
|
|
91
|
+
if (!expression) {
|
|
92
|
+
if (node.value.trim()) report("ui-expr", lineOf(node), "插值需要一个表达式");
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
return [{ t: "x", e: expression, s: node.value, l: lineOf(node) }];
|
|
96
|
+
}
|
|
97
|
+
case "strong":
|
|
98
|
+
return [{ t: "strong", c: node.children.flatMap(convertInline) }];
|
|
99
|
+
case "emphasis":
|
|
100
|
+
return [{ t: "em", c: node.children.flatMap(convertInline) }];
|
|
101
|
+
case "inlineCode":
|
|
102
|
+
return [{ t: "code", v: node.value }];
|
|
103
|
+
case "break":
|
|
104
|
+
return [{ t: "br" }];
|
|
105
|
+
case "link": {
|
|
106
|
+
const url = String(node.url ?? "");
|
|
107
|
+
if (!/^(?:https:\/\/|\.{0,2}\/|#)/u.test(url) && !/^[\w\u3400-\u9fff./-]+$/u.test(url)) {
|
|
108
|
+
report("ui-markdown", lineOf(node), "链接只能是相对路径或 https 地址");
|
|
109
|
+
return node.children.flatMap(convertInline);
|
|
110
|
+
}
|
|
111
|
+
return [{ t: "link", u: url, c: node.children.flatMap(convertInline) }];
|
|
112
|
+
}
|
|
113
|
+
case "mdxJsxTextElement":
|
|
114
|
+
case "mdxJsxFlowElement":
|
|
115
|
+
return [convertElement(node)];
|
|
116
|
+
case "image":
|
|
117
|
+
case "imageReference":
|
|
118
|
+
report("ui-markdown", lineOf(node), "块里不能写图片语法,插图用 <Image>");
|
|
119
|
+
return [];
|
|
120
|
+
case "html":
|
|
121
|
+
report("ui-markdown", lineOf(node), "块里不能写 HTML");
|
|
122
|
+
return [];
|
|
123
|
+
case "linkReference":
|
|
124
|
+
case "footnoteReference":
|
|
125
|
+
return node.children ? node.children.flatMap(convertInline) : [];
|
|
126
|
+
default:
|
|
127
|
+
report("ui-markdown", lineOf(node), `块里不能写 ${node.type}`);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const trimInline = (nodes) => {
|
|
133
|
+
const output = [...nodes];
|
|
134
|
+
while (output.length && output[0].t === "text" && !output[0].v.trim()) output.shift();
|
|
135
|
+
while (output.length && output.at(-1).t === "text" && !output.at(-1).v.trim()) output.pop();
|
|
136
|
+
if (output[0]?.t === "text") output[0] = { ...output[0], v: output[0].v.replace(/^\s+/u, "") };
|
|
137
|
+
if (output.at(-1)?.t === "text") output[output.length - 1] = { ...output.at(-1), v: output.at(-1).v.replace(/\s+$/u, "") };
|
|
138
|
+
return output;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Block components written inside a paragraph are lifted out of it. */
|
|
142
|
+
const splitParagraph = (inline) => {
|
|
143
|
+
const blocks = [];
|
|
144
|
+
let run = [];
|
|
145
|
+
const flush = () => {
|
|
146
|
+
const trimmed = trimInline(run);
|
|
147
|
+
if (trimmed.length) blocks.push({ t: "p", c: trimmed });
|
|
148
|
+
run = [];
|
|
149
|
+
};
|
|
150
|
+
for (const node of inline) {
|
|
151
|
+
if (isBlockElement(node)) {
|
|
152
|
+
flush();
|
|
153
|
+
blocks.push(node);
|
|
154
|
+
} else {
|
|
155
|
+
run.push(node);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
flush();
|
|
159
|
+
return blocks;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const convertBlock = (node) => {
|
|
163
|
+
switch (node.type) {
|
|
164
|
+
case "paragraph":
|
|
165
|
+
return splitParagraph(node.children.flatMap(convertInline));
|
|
166
|
+
case "mdxJsxFlowElement":
|
|
167
|
+
return [convertElement(node)];
|
|
168
|
+
case "mdxFlowExpression":
|
|
169
|
+
return splitParagraph(convertInline(node));
|
|
170
|
+
case "list":
|
|
171
|
+
return [{
|
|
172
|
+
t: "list",
|
|
173
|
+
o: Boolean(node.ordered),
|
|
174
|
+
s: node.start ?? 1,
|
|
175
|
+
c: node.children.map((item) => ({ t: "li", c: item.children.flatMap(convertBlock) })),
|
|
176
|
+
}];
|
|
177
|
+
case "blockquote":
|
|
178
|
+
return [{ t: "quote", c: node.children.flatMap(convertBlock) }];
|
|
179
|
+
case "thematicBreak":
|
|
180
|
+
return [{ t: "hr" }];
|
|
181
|
+
case "heading":
|
|
182
|
+
report("ui-markdown", lineOf(node), "块里不能写标题,它会打乱文章目录");
|
|
183
|
+
return [];
|
|
184
|
+
case "code":
|
|
185
|
+
report("ui-markdown", lineOf(node), "块里不能写代码围栏,代码用 <Code>");
|
|
186
|
+
return [];
|
|
187
|
+
case "html":
|
|
188
|
+
report("ui-markdown", lineOf(node), "块里不能写 HTML");
|
|
189
|
+
return [];
|
|
190
|
+
case "definition":
|
|
191
|
+
case "footnoteDefinition":
|
|
192
|
+
report("ui-markdown", lineOf(node), `块里不能写 ${node.type}`);
|
|
193
|
+
return [];
|
|
194
|
+
default:
|
|
195
|
+
return splitParagraph(convertInline(node));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function convertElement(node) {
|
|
200
|
+
const name = node.name ?? "";
|
|
201
|
+
if (!name) report("ui-syntax", lineOf(node), "块里不能写空标签 <>");
|
|
202
|
+
let children;
|
|
203
|
+
if (node.type === "mdxJsxTextElement") {
|
|
204
|
+
const inline = node.children.flatMap(convertInline);
|
|
205
|
+
// One-line containers such as <Step>…</Step> hold a paragraph, not loose inline nodes.
|
|
206
|
+
children = COMPONENTS[name]?.children === "text" ? trimInline(inline) : splitParagraph(inline);
|
|
207
|
+
} else {
|
|
208
|
+
children = node.children.flatMap(convertBlock);
|
|
209
|
+
}
|
|
210
|
+
return { t: "el", n: name, p: convertAttributes(node), c: children, l: lineOf(node) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const root = { t: "root", c: tree.children.flatMap(convertBlock) };
|
|
214
|
+
if (/^\s*(?:import|export)\s/mu.test(text)) {
|
|
215
|
+
const line = text.split("\n").findIndex((current) => /^\s*(?:import|export)\s/u.test(current)) + 1;
|
|
216
|
+
report("ui-markdown", line, "块里不能写 import / export");
|
|
217
|
+
}
|
|
218
|
+
return { root, diagnostics };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Best-effort parse of half-written input: drops trailing lines until it parses. */
|
|
222
|
+
export function parseUiBodyPartial(text) {
|
|
223
|
+
const lines = text.split("\n");
|
|
224
|
+
for (let end = lines.length; end > 0; end -= 1) {
|
|
225
|
+
const attempt = parseUiBody(lines.slice(0, end).join("\n"));
|
|
226
|
+
if (attempt.root) return { ...attempt, truncatedAt: end < lines.length ? end : null };
|
|
227
|
+
}
|
|
228
|
+
return parseUiBody("");
|
|
229
|
+
}
|
package/ui/core/scan.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { compiledOf, fenceHash, findBlocks, idOf, intentHash, intentOf, splitUiBody } from "./fence.mjs";
|
|
2
|
+
import { checkUiBlock } from "./validate.mjs";
|
|
3
|
+
|
|
4
|
+
/** Status of one block: uncompiled / stale / error / ok. */
|
|
5
|
+
export function blockStatus(block, check) {
|
|
6
|
+
if (block.lang === "intent") return "uncompiled";
|
|
7
|
+
if (block.lang === "ui") {
|
|
8
|
+
if (check && !check.ok) return "error";
|
|
9
|
+
const { header, headerText } = splitUiBody(block.body);
|
|
10
|
+
const compiled = compiledOf(headerText);
|
|
11
|
+
if (typeof header?.intent === "string" && compiled !== undefined && compiled !== intentHash(header.intent)) return "stale";
|
|
12
|
+
return "ok";
|
|
13
|
+
}
|
|
14
|
+
return "ok";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sectionTitle(lines, startLine) {
|
|
18
|
+
for (let index = startLine - 2; index >= 0; index -= 1) {
|
|
19
|
+
const heading = /^(#{2,6})\s+(.+?)\s*#*$/u.exec(lines[index]);
|
|
20
|
+
if (heading) return heading[2].trim();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Everything `garden ui scan`, the hook, the admin and the editors need to
|
|
27
|
+
* know about the blocks of one Markdown file.
|
|
28
|
+
*/
|
|
29
|
+
export function scanMarkdown(markdown, options = {}) {
|
|
30
|
+
const { markdownPath = null, readData = null, skipData = false } = options;
|
|
31
|
+
const lines = markdown.split(/\r?\n/u);
|
|
32
|
+
return findBlocks(markdown).map((block) => {
|
|
33
|
+
const intent = intentOf(block);
|
|
34
|
+
const check = block.lang === "ui"
|
|
35
|
+
? checkUiBlock(block.body, { lineOffset: block.startLine, markdownPath, readData, skipData })
|
|
36
|
+
: null;
|
|
37
|
+
const header = block.lang === "ui" ? splitUiBody(block.body).header : null;
|
|
38
|
+
return {
|
|
39
|
+
lang: block.lang,
|
|
40
|
+
id: idOf(block),
|
|
41
|
+
status: blockStatus(block, check),
|
|
42
|
+
startLine: block.startLine,
|
|
43
|
+
endLine: block.endLine,
|
|
44
|
+
hash: fenceHash(block.raw),
|
|
45
|
+
intentHash: intent !== null ? intentHash(intent) : null,
|
|
46
|
+
intent,
|
|
47
|
+
caption: typeof header?.caption === "string" ? header.caption : null,
|
|
48
|
+
section: sectionTitle(lines, block.startLine),
|
|
49
|
+
diagnostics: check ? check.diagnostics : [],
|
|
50
|
+
raw: block.raw,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|