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
|
@@ -30,3 +30,58 @@ export function repositoryPostPath(sourcePath) {
|
|
|
30
30
|
}
|
|
31
31
|
return `content/${relativePath}`;
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
const unsafeSegment = (segment) => {
|
|
35
|
+
if (!segment || segment === "." || segment === ".." || segment.includes("\\") || segment.includes("\0")) return true;
|
|
36
|
+
try {
|
|
37
|
+
const decoded = decodeURIComponent(segment);
|
|
38
|
+
return decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0");
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Repository path of a Markdown file the admin may read: inside content/posts
|
|
46
|
+
* or content/thoughts, never escaping it.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} value
|
|
49
|
+
*/
|
|
50
|
+
export function repositoryContentPath(value) {
|
|
51
|
+
const path = String(value ?? "").replace(/^\/+/u, "");
|
|
52
|
+
const withRoot = path.startsWith("content/") ? path : `content/${path}`;
|
|
53
|
+
const segments = withRoot.split("/");
|
|
54
|
+
if (
|
|
55
|
+
segments.length < 3 ||
|
|
56
|
+
!["posts", "thoughts"].includes(segments[1]) ||
|
|
57
|
+
!withRoot.endsWith(".md") ||
|
|
58
|
+
segments.some(unsafeSegment)
|
|
59
|
+
) {
|
|
60
|
+
throw new Error("稿件路径不合法。");
|
|
61
|
+
}
|
|
62
|
+
return withRoot;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ASSET_TYPES = {
|
|
66
|
+
png: "image/png",
|
|
67
|
+
jpg: "image/jpeg",
|
|
68
|
+
jpeg: "image/jpeg",
|
|
69
|
+
gif: "image/gif",
|
|
70
|
+
webp: "image/webp",
|
|
71
|
+
svg: "image/svg+xml",
|
|
72
|
+
html: "text/html; charset=utf-8",
|
|
73
|
+
pdf: "application/pdf",
|
|
74
|
+
csv: "text/csv; charset=utf-8",
|
|
75
|
+
json: "application/json; charset=utf-8",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Content type for a same-name-directory file the draft preview may proxy,
|
|
80
|
+
* or null when the extension is not allowed.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} path
|
|
83
|
+
*/
|
|
84
|
+
export function previewAssetType(path) {
|
|
85
|
+
const extension = String(path).split(".").pop()?.toLowerCase() ?? "";
|
|
86
|
+
return Object.hasOwn(ASSET_TYPES, extension) ? ASSET_TYPES[extension] : null;
|
|
87
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { scanMarkdown } from "../../ui/core/scan.mjs";
|
|
4
|
+
import { canonicalSiteDate } from "../../scripts/site-date.mjs";
|
|
5
|
+
import {
|
|
6
|
+
isContentRepositoryConfigured,
|
|
7
|
+
listContentMarkdown,
|
|
8
|
+
readRepositoryBlob,
|
|
9
|
+
readRepositoryFile,
|
|
10
|
+
readRepositoryRaw,
|
|
11
|
+
} from "./github-content";
|
|
12
|
+
|
|
13
|
+
const matterOptions = { engines: { yaml: { parse: (input: string) => parseYaml(input) ?? {} } } };
|
|
14
|
+
|
|
15
|
+
export const REPOSITORY_REQUIRED = "这一页从内容仓库读取稿件:请配置 CONTENT_REPOSITORY 和 CONTENT_GITHUB_TOKEN。";
|
|
16
|
+
|
|
17
|
+
function requireRepository() {
|
|
18
|
+
if (!isContentRepositoryConfigured()) throw new Error(REPOSITORY_REQUIRED);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readMarkdown(repoPath: string, sha?: string) {
|
|
22
|
+
requireRepository();
|
|
23
|
+
if (sha) return { source: await readRepositoryBlob(sha), sha };
|
|
24
|
+
return readRepositoryFile(repoPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readRaw(repoPath: string): Promise<ArrayBuffer | null> {
|
|
28
|
+
requireRepository();
|
|
29
|
+
return readRepositoryRaw(repoPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readText(repoPath: string) {
|
|
33
|
+
const raw = await readRaw(repoPath);
|
|
34
|
+
return raw ? new TextDecoder().decode(raw) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function frontMatterOf(source: string) {
|
|
38
|
+
try {
|
|
39
|
+
const { data } = matter(source, matterOptions);
|
|
40
|
+
const publish = canonicalSiteDate(data.publishAt ?? data.date);
|
|
41
|
+
return {
|
|
42
|
+
title: typeof data.title === "string" ? data.title : null,
|
|
43
|
+
draft: data.draft === true || (Boolean(publish) && Date.parse(publish as string) > Date.now()),
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return { title: null, draft: false };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type BlockSummary = ReturnType<typeof scanMarkdown>[number];
|
|
51
|
+
export type FileBlocks = { path: string; sha: string; title: string | null; draft: boolean; blocks: BlockSummary[] };
|
|
52
|
+
|
|
53
|
+
const scanCache = new Map<string, FileBlocks>();
|
|
54
|
+
|
|
55
|
+
/** All blocks in the repository, scanned once per blob sha. */
|
|
56
|
+
export async function scanRepositoryBlocks() {
|
|
57
|
+
requireRepository();
|
|
58
|
+
const files = await listContentMarkdown();
|
|
59
|
+
const results: FileBlocks[] = [];
|
|
60
|
+
const queue = [...files];
|
|
61
|
+
const workers = Array.from({ length: 8 }, async () => {
|
|
62
|
+
while (queue.length) {
|
|
63
|
+
const file = queue.shift();
|
|
64
|
+
if (!file) break;
|
|
65
|
+
const cached = scanCache.get(file.sha);
|
|
66
|
+
if (cached) {
|
|
67
|
+
results.push(cached);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const source = await readRepositoryBlob(file.sha);
|
|
71
|
+
const blocks = scanMarkdown(source, { markdownPath: file.path, skipData: true });
|
|
72
|
+
const entry: FileBlocks = { path: file.path, sha: file.sha, ...frontMatterOf(source), blocks };
|
|
73
|
+
scanCache.set(file.sha, entry);
|
|
74
|
+
results.push(entry);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
await Promise.all(workers);
|
|
78
|
+
return results.filter((file) => file.blocks.length).sort((a, b) => a.path.localeCompare(b.path));
|
|
79
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import rules from "../../ui/prompt/rules.md?raw";
|
|
2
|
+
import studio from "../../ui/prompt/studio.md?raw";
|
|
3
|
+
import paramBench from "../../ui/prompt/recipes/param-bench.md?raw";
|
|
4
|
+
import stepper from "../../ui/prompt/recipes/stepper.md?raw";
|
|
5
|
+
import inspectGrid from "../../ui/prompt/recipes/inspect-grid.md?raw";
|
|
6
|
+
import compare from "../../ui/prompt/recipes/compare.md?raw";
|
|
7
|
+
import trace from "../../ui/prompt/recipes/trace.md?raw";
|
|
8
|
+
import converter from "../../ui/prompt/recipes/converter.md?raw";
|
|
9
|
+
import plan from "../../ui/prompt/recipes/plan.md?raw";
|
|
10
|
+
import dataChart from "../../ui/prompt/recipes/data-chart.md?raw";
|
|
11
|
+
import codeWalkthrough from "../../ui/prompt/recipes/code-walkthrough.md?raw";
|
|
12
|
+
import { findBlocks, intentHash, setCompiled, splitUiBody } from "../../ui/core/fence.mjs";
|
|
13
|
+
import { checkUiBlock } from "../../ui/core/validate.mjs";
|
|
14
|
+
import { buildPrompt, RECIPE_FILES } from "../../ui/prompt/build.mjs";
|
|
15
|
+
|
|
16
|
+
const recipeText: Record<string, string> = {
|
|
17
|
+
"param-bench.md": paramBench,
|
|
18
|
+
"stepper.md": stepper,
|
|
19
|
+
"inspect-grid.md": inspectGrid,
|
|
20
|
+
"compare.md": compare,
|
|
21
|
+
"trace.md": trace,
|
|
22
|
+
"converter.md": converter,
|
|
23
|
+
"plan.md": plan,
|
|
24
|
+
"data-chart.md": dataChart,
|
|
25
|
+
"code-walkthrough.md": codeWalkthrough,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const recipeSources = RECIPE_FILES.map((file) => ({ file, markdown: recipeText[file] ?? "" }));
|
|
29
|
+
|
|
30
|
+
const systemPrompt = buildPrompt({ rules, recipes: recipeSources.map((recipe) => recipe.markdown), studio });
|
|
31
|
+
|
|
32
|
+
export type DraftDiagnostic = { code: string; line: number; message: string; severity: "error" | "warning" };
|
|
33
|
+
export type DraftUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
34
|
+
export type DraftResult = {
|
|
35
|
+
fence: string | null;
|
|
36
|
+
note: string | null;
|
|
37
|
+
diagnostics: DraftDiagnostic[];
|
|
38
|
+
rounds: number;
|
|
39
|
+
usage: DraftUsage;
|
|
40
|
+
error?: string;
|
|
41
|
+
raw?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function modelConfig() {
|
|
45
|
+
const baseUrl = process.env.UI_MODEL_BASE_URL;
|
|
46
|
+
const apiKey = process.env.UI_MODEL_API_KEY;
|
|
47
|
+
const model = process.env.UI_MODEL_NAME;
|
|
48
|
+
if (!baseUrl || !apiKey || !model) return null;
|
|
49
|
+
return { baseUrl: baseUrl.replace(/\/+$/u, ""), apiKey, model };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hourlyLimit = () => {
|
|
53
|
+
const value = Number(process.env.UI_DRAFT_RATE_LIMIT ?? 60);
|
|
54
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 60;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Per process: exact for a single Node admin, approximate across Worker isolates.
|
|
58
|
+
const recentDrafts = new Map<string, number[]>();
|
|
59
|
+
|
|
60
|
+
export function remainingDrafts(login: string) {
|
|
61
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
62
|
+
const recent = (recentDrafts.get(login) ?? []).filter((time) => time > cutoff);
|
|
63
|
+
recentDrafts.set(login, recent);
|
|
64
|
+
return Math.max(0, hourlyLimit() - recent.length);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function consumeDraft(login: string) {
|
|
68
|
+
if (remainingDrafts(login) <= 0) return false;
|
|
69
|
+
recentDrafts.get(login)?.push(Date.now());
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Message = { role: "system" | "user" | "assistant"; content: string };
|
|
74
|
+
|
|
75
|
+
async function complete(messages: Message[]) {
|
|
76
|
+
const config = modelConfig();
|
|
77
|
+
if (!config) throw new Error("后台还没接上模型:请配置 UI_MODEL_BASE_URL、UI_MODEL_API_KEY 和 UI_MODEL_NAME。");
|
|
78
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.apiKey}` },
|
|
81
|
+
body: JSON.stringify({ model: config.model, messages, temperature: 0.2 }),
|
|
82
|
+
signal: AbortSignal.timeout(120_000),
|
|
83
|
+
});
|
|
84
|
+
const result = (await response.json().catch(() => ({}))) as {
|
|
85
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
86
|
+
usage?: Partial<DraftUsage>;
|
|
87
|
+
error?: { message?: string };
|
|
88
|
+
};
|
|
89
|
+
if (!response.ok) throw new Error(result.error?.message ?? `模型接口返回 ${response.status}`);
|
|
90
|
+
return { content: result.choices?.[0]?.message?.content ?? "", usage: result.usage ?? {} };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extract(content: string) {
|
|
94
|
+
const fence = findBlocks(content).find((block) => block.lang === "ui" && block.closed);
|
|
95
|
+
const before = fence ? content.slice(0, fence.start) : content;
|
|
96
|
+
const note = /^提醒[::]\s*(.+)$/mu.exec(before)?.[1]?.trim() ?? null;
|
|
97
|
+
return { fence, note };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One workbench generation: create from an instruction, or revise the current
|
|
102
|
+
* fence. Diagnostics go back to the model for up to two repair rounds.
|
|
103
|
+
*/
|
|
104
|
+
export async function draftUiBlock(input: { mode: "create" | "revise"; instruction: string; fence?: string }): Promise<DraftResult> {
|
|
105
|
+
const usage: DraftUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
106
|
+
const messages: Message[] = [
|
|
107
|
+
{ role: "system", content: systemPrompt },
|
|
108
|
+
{
|
|
109
|
+
role: "user",
|
|
110
|
+
content: input.mode === "revise" && input.fence
|
|
111
|
+
? `当前围栏:\n\n${input.fence}\n\n修改要求:${input.instruction}`
|
|
112
|
+
: input.instruction,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
let lastContent = "";
|
|
116
|
+
for (let round = 0; round <= 2; round += 1) {
|
|
117
|
+
const reply = await complete(messages);
|
|
118
|
+
lastContent = reply.content;
|
|
119
|
+
usage.prompt_tokens += reply.usage.prompt_tokens ?? 0;
|
|
120
|
+
usage.completion_tokens += reply.usage.completion_tokens ?? 0;
|
|
121
|
+
usage.total_tokens += reply.usage.total_tokens ?? 0;
|
|
122
|
+
const { fence, note } = extract(reply.content);
|
|
123
|
+
if (!fence) {
|
|
124
|
+
if (round === 2) break;
|
|
125
|
+
messages.push({ role: "assistant", content: reply.content }, { role: "user", content: "回复里没有 ui 围栏。请只回复一个完整的 ui 围栏。" });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// lineOffset 1: in the workbench editor the fence starts on line 1.
|
|
129
|
+
const check = checkUiBlock(fence.body, { lineOffset: 1, skipData: true });
|
|
130
|
+
const errors = (check.diagnostics as DraftDiagnostic[]).filter((item) => item.severity === "error");
|
|
131
|
+
const { header } = splitUiBody(fence.body);
|
|
132
|
+
const stamped = typeof header?.intent === "string" ? setCompiled(fence.raw, intentHash(header.intent)) : fence.raw;
|
|
133
|
+
if (!errors.length || round === 2) {
|
|
134
|
+
return { fence: stamped, note, diagnostics: check.diagnostics as DraftDiagnostic[], rounds: round, usage };
|
|
135
|
+
}
|
|
136
|
+
messages.push(
|
|
137
|
+
{ role: "assistant", content: reply.content },
|
|
138
|
+
{
|
|
139
|
+
role: "user",
|
|
140
|
+
content: `程序校验发现这些问题:\n${errors.map((item) => `- 第 ${item.line} 行 ${item.code}:${item.message}`).join("\n")}\n请修正后重新给出完整的 ui 围栏,格式不变。`,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return { fence: null, note: null, diagnostics: [], rounds: 2, usage, error: "模型没有给出 ui 围栏", raw: lastContent };
|
|
145
|
+
}
|
package/app/raw.d.ts
ADDED
package/app/rss.xml/route.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { marked } from "marked";
|
|
|
2
2
|
import { columnHref, columns, postHref, posts } from "../lib/content";
|
|
3
3
|
import { getPublishedThoughts } from "../lib/public-thoughts";
|
|
4
4
|
import { configuredSiteUrl, siteDescription, siteName } from "../lib/site";
|
|
5
|
+
import { contentForFeed } from "../../ui/core/transform.mjs";
|
|
5
6
|
|
|
6
7
|
const escapeXml = (value: string) =>
|
|
7
8
|
value
|
|
@@ -31,7 +32,7 @@ export async function GET() {
|
|
|
31
32
|
|
|
32
33
|
const items = entries
|
|
33
34
|
.map((entry) => {
|
|
34
|
-
const body = marked.parse(entry.content, {
|
|
35
|
+
const body = marked.parse(contentForFeed(entry.content, { pageUrl: `${origin}${entry.path}`, siteUrl: origin }), {
|
|
35
36
|
async: false,
|
|
36
37
|
gfm: true,
|
|
37
38
|
breaks: true,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const slices = [
|
|
2
|
+
{ label: "商品销售", value: 78.2, color: "#539af8" },
|
|
3
|
+
{ label: "平台与营销服务", value: 9.4, color: "#6bc67f" },
|
|
4
|
+
{ label: "物流及其他服务", value: 12.4, color: "#ef8b57" },
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
function ring(start: number, sweep: number) {
|
|
8
|
+
const cx = 371;
|
|
9
|
+
const cy = 181;
|
|
10
|
+
const outer = 137.28;
|
|
11
|
+
const inner = 70.4;
|
|
12
|
+
const end = start + sweep;
|
|
13
|
+
const point = (radius: number, angle: number) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
|
|
14
|
+
const large = sweep > Math.PI ? 1 : 0;
|
|
15
|
+
const [x0, y0] = point(outer, start);
|
|
16
|
+
const [x1, y1] = point(outer, end);
|
|
17
|
+
const [x2, y2] = point(inner, end);
|
|
18
|
+
const [x3, y3] = point(inner, start);
|
|
19
|
+
return `M ${x0} ${y0} A ${outer} ${outer} 0 ${large} 1 ${x1} ${y1} L ${x2} ${y2} A ${inner} ${inner} 0 ${large} 0 ${x3} ${y3} Z`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Arrow() {
|
|
23
|
+
return (
|
|
24
|
+
<svg className="garden-ui-flow-arrow" viewBox="0 0 24 24" aria-hidden="true">
|
|
25
|
+
<path fill="currentColor" fillRule="evenodd" d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z" clipRule="evenodd" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Loop() {
|
|
31
|
+
return (
|
|
32
|
+
<svg className="garden-ui-flow-loop" viewBox="0 0 24 24" aria-hidden="true">
|
|
33
|
+
<path fill="currentColor" d="M3.06956 10.8763C3.6233 6.43564 7.40965 3 12 3C14.2823 3 16.4028 3.85067 18.0118 5.25439V4C18.0118 3.44772 18.4595 3 19.0118 3C19.5641 3 20.0118 3.44772 20.0118 4V8C20.0118 8.55228 19.5641 9 19.0118 9H15C14.4477 9 14 8.55228 14 8C14 7.44772 14.4477 7 15 7H16.9571C15.6756 5.76379 13.9101 5 12 5C8.43107 5 5.48465 7.67174 5.05419 11.1237C4.98585 11.6718 4.48617 12.0607 3.93813 11.9923C3.39009 11.924 3.00122 11.4243 3.06956 10.8763ZM20.0618 12.0077C20.6099 12.076 20.9987 12.5757 20.9304 13.1237C20.3767 17.5644 16.5903 21 12 21C9.72321 21 7.6076 20.1535 5.99998 18.7559V20C5.99998 20.5523 5.55226 21 4.99998 21C4.44769 21 3.99998 20.5523 3.99998 20V16C3.99998 15.4477 4.44769 15 4.99998 15H8.99998C9.55226 15 9.99998 15.4477 9.99998 16C9.99998 16.5523 9.55226 17 8.99998 17H7.04283C8.32432 18.2362 10.0899 19 12 19C15.5689 19 18.5153 16.3283 18.9458 12.8763C19.0141 12.3282 19.5138 11.9393 20.0618 12.0077Z" />
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function UiParityPage() {
|
|
39
|
+
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
|
40
|
+
let angle = -Math.PI / 2;
|
|
41
|
+
const arcs = slices.map((slice) => {
|
|
42
|
+
const sweep = (slice.value / total) * Math.PI * 2;
|
|
43
|
+
const path = ring(angle, sweep);
|
|
44
|
+
angle += sweep;
|
|
45
|
+
return { ...slice, path };
|
|
46
|
+
});
|
|
47
|
+
const rows = [
|
|
48
|
+
["消费者状态", "不选择拼多多的原因", "对应的经济学概念"],
|
|
49
|
+
["不知道拼多多更便宜", "没有进行跨平台比价", "信息不对称、搜索成本"],
|
|
50
|
+
["知道更便宜,但不放心", "怀疑商品品质、商家或售后", "感知风险、信任成本"],
|
|
51
|
+
["知道更便宜,也相信是正品", "习惯使用天猫,不想迁移", "转换成本、消费惯性"],
|
|
52
|
+
];
|
|
53
|
+
return (
|
|
54
|
+
<main className="garden-ui" lang="en-US" style={{ margin: 0, border: 0, background: "#fff", padding: 32 }}>
|
|
55
|
+
<section id="parity-stat" className="garden-ui-card" style={{ width: 768, marginBottom: 32 }}>
|
|
56
|
+
<p className="garden-ui-kicker">2025 抖音电商发展数据报告</p>
|
|
57
|
+
<p className="garden-ui-title-2xl">+49%</p>
|
|
58
|
+
<p className="garden-ui-subtitle">货架场景成交额同比增长</p>
|
|
59
|
+
<p className="garden-ui-text-sm">统计期为 2024 年 8 月至 2025 年 8 月。</p>
|
|
60
|
+
<p className="garden-ui-caption">
|
|
61
|
+
{"抖音电商官方报告,经新华社报道。该增速不能直接代表抖音电商整体 GMV 增速。"}<a id="parity-cite" className="garden-ui-cite" href="https://www.news.cn">
|
|
62
|
+
<img src="https://www.google.com/s2/favicons?domain=www.news.cn&sz=32" alt="" width={12} height={12} />
|
|
63
|
+
Xinhua Net
|
|
64
|
+
</a>
|
|
65
|
+
</p>
|
|
66
|
+
</section>
|
|
67
|
+
<section id="parity-card" className="garden-ui-card" style={{ width: 378, marginBottom: 32 }}>
|
|
68
|
+
<p className="garden-ui-kicker">2025 年淘宝天猫商品索引规模</p>
|
|
69
|
+
<p className="garden-ui-title-2xl">20 亿+</p>
|
|
70
|
+
<p className="garden-ui-text-xs">商品条目</p>
|
|
71
|
+
<p className="garden-ui-caption">
|
|
72
|
+
{"阿里巴巴披露其 AI 搜索推荐系统索引了超过 20 亿商品条目。"}<a className="garden-ui-cite" href="https://www.alibabagroup.com">
|
|
73
|
+
<img src="https://www.google.com/s2/favicons?domain=www.alibabagroup.com&sz=32" alt="" width={12} height={12} />
|
|
74
|
+
Alibaba Group
|
|
75
|
+
</a>
|
|
76
|
+
</p>
|
|
77
|
+
</section>
|
|
78
|
+
<section id="parity-pie" className="garden-ui-pie" style={{ width: 742, marginBottom: 32 }}>
|
|
79
|
+
<div className="garden-ui-pie-chart">
|
|
80
|
+
<svg viewBox="0 0 742 400" role="img" aria-label="收入结构">
|
|
81
|
+
{arcs.map((arc) => <path key={arc.label} d={arc.path} fill={arc.color} />)}
|
|
82
|
+
</svg>
|
|
83
|
+
<ul className="garden-ui-legend">
|
|
84
|
+
{arcs.map((arc) => (
|
|
85
|
+
<li key={arc.label}><span className="garden-ui-swatch" style={{ background: arc.color }} />{arc.label}</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
</div>
|
|
89
|
+
<p className="garden-ui-pie-note">京东集团2025财年;商品与服务收入的会计确认方式不同。</p>
|
|
90
|
+
</section>
|
|
91
|
+
<section id="parity-table" style={{ width: 768, marginBottom: 32 }}>
|
|
92
|
+
<table className="garden-ui-table" style={{ tableLayout: "fixed" }}>
|
|
93
|
+
<colgroup>
|
|
94
|
+
<col style={{ width: 265.9 }} />
|
|
95
|
+
<col style={{ width: 278.5 }} />
|
|
96
|
+
<col style={{ width: 223.6 }} />
|
|
97
|
+
</colgroup>
|
|
98
|
+
<thead>
|
|
99
|
+
<tr>{rows[0].map((cell) => <th key={cell}>{cell}</th>)}</tr>
|
|
100
|
+
</thead>
|
|
101
|
+
<tbody>
|
|
102
|
+
{rows.slice(1).map((row) => (
|
|
103
|
+
<tr key={row[0]}>{row.map((cell) => <td key={cell}>{cell}</td>)}</tr>
|
|
104
|
+
))}
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</section>
|
|
108
|
+
<section id="parity-flow" className="garden-ui-card garden-ui-flow" style={{ width: 768 }}>
|
|
109
|
+
<p>丰富的商家与商品供给</p>
|
|
110
|
+
<Arrow />
|
|
111
|
+
<p>吸引更多具有不同需求的消费者</p>
|
|
112
|
+
<Arrow />
|
|
113
|
+
<p>更大的潜在成交机会吸引商家</p>
|
|
114
|
+
<Arrow />
|
|
115
|
+
<p>更丰富的供给与交易数据</p>
|
|
116
|
+
<Loop />
|
|
117
|
+
</section>
|
|
118
|
+
</main>
|
|
119
|
+
);
|
|
120
|
+
}
|
package/bin/garden.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
@@ -189,6 +190,42 @@ function ensureSiteKit(destinationRoot = siteRoot) {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
installSkill(destinationRoot, { onlyMissing: true });
|
|
193
|
+
if (process.env.GARDEN_SKIP_HOOKS !== "1") installHook(destinationRoot, { quiet: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const HOOK_MARKER = "# garden: ui check";
|
|
197
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
198
|
+
${HOOK_MARKER}
|
|
199
|
+
if [ ! -d node_modules/hd-garden ]; then
|
|
200
|
+
echo "garden 还没安装(先运行 npm install),这次跳过 ui 检查。" >&2
|
|
201
|
+
exit 0
|
|
202
|
+
fi
|
|
203
|
+
exec npx --no-install garden ui check --staged --quiet
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
/** Installs the pre-commit hook unless the repository already has one. */
|
|
207
|
+
function installHook(destinationRoot = siteRoot, { quiet = false, force = false } = {}) {
|
|
208
|
+
const result = spawnSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"], { cwd: destinationRoot, encoding: "utf8" });
|
|
209
|
+
if (result.status !== 0) {
|
|
210
|
+
if (!quiet) process.stdout.write("这里不是 git 仓库,没有安装提交前检查。\n");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const hookPath = path.resolve(destinationRoot, result.stdout.trim());
|
|
214
|
+
if (fs.existsSync(hookPath)) {
|
|
215
|
+
const current = fs.readFileSync(hookPath, "utf8");
|
|
216
|
+
if (current.includes(HOOK_MARKER) && !force) {
|
|
217
|
+
if (!quiet) process.stdout.write("提交前检查已经装好了。\n");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!current.includes(HOOK_MARKER)) {
|
|
221
|
+
process.stdout.write(`已有 pre-commit 钩子,没有覆盖。请把这一行加进 ${path.relative(destinationRoot, hookPath)}:\n npx --no-install garden ui check --staged --quiet\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
226
|
+
fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
227
|
+
fs.chmodSync(hookPath, 0o755);
|
|
228
|
+
process.stdout.write("已安装提交前检查:提交时会检查暂存稿件里的 ui 块。\n");
|
|
192
229
|
}
|
|
193
230
|
|
|
194
231
|
function prepare() {
|
|
@@ -207,6 +244,31 @@ function sync() {
|
|
|
207
244
|
runNode("scripts/build-content.mjs");
|
|
208
245
|
}
|
|
209
246
|
|
|
247
|
+
function upsertEnv(text, key, value) {
|
|
248
|
+
const pattern = new RegExp(`^${key}=.*$`, "m");
|
|
249
|
+
if (pattern.test(text)) return text.replace(pattern, `${key}=${value}`);
|
|
250
|
+
return `${text.endsWith("\n") || text.length === 0 ? text : `${text}\n`}${key}=${value}\n`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function currentGitHubToken() {
|
|
254
|
+
const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
255
|
+
if (result.status !== 0) return "";
|
|
256
|
+
return result.stdout.trim();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Local dev signs in as the gh user. Production keeps the OAuth app on the worker. */
|
|
260
|
+
function ensureDevGitHubLogin() {
|
|
261
|
+
const local = path.join(siteRoot, ".env.local");
|
|
262
|
+
let text = fs.existsSync(local) ? fs.readFileSync(local, "utf8") : "";
|
|
263
|
+
text = upsertEnv(text, "GARDEN_DEV", "1");
|
|
264
|
+
const token = currentGitHubToken();
|
|
265
|
+
if (token) text = upsertEnv(text, "GITHUB_DEV_TOKEN", token);
|
|
266
|
+
if (!/^GITHUB_SESSION_SECRET=\S+/m.test(text)) {
|
|
267
|
+
text = upsertEnv(text, "GITHUB_SESSION_SECRET", randomBytes(32).toString("base64url"));
|
|
268
|
+
}
|
|
269
|
+
fs.writeFileSync(local, text.endsWith("\n") ? text : `${text}\n`);
|
|
270
|
+
}
|
|
271
|
+
|
|
210
272
|
function runVinext(vinextArgs, { copyDist = false } = {}) {
|
|
211
273
|
sync();
|
|
212
274
|
const cwd = vinextRoot();
|
|
@@ -294,6 +356,8 @@ function help() {
|
|
|
294
356
|
garden embed 拉取外链摘要
|
|
295
357
|
garden init [目录] 创建只含稿和配置的新站点
|
|
296
358
|
garden check <文章.md> 检查一篇博文的硬性结构
|
|
359
|
+
garden ui <子命令> Intelligent UI:scan、check、apply、prompt、schema、catalog
|
|
360
|
+
garden hooks 安装提交前检查(检查暂存稿件里的 ui 块)
|
|
297
361
|
garden skill 把引擎自带的 skill 装进当前站点
|
|
298
362
|
garden pages:validate 检查 dist/client
|
|
299
363
|
garden pages:prepare 写入公开产物仓库
|
|
@@ -311,6 +375,7 @@ switch (command) {
|
|
|
311
375
|
sync();
|
|
312
376
|
break;
|
|
313
377
|
case "dev":
|
|
378
|
+
ensureDevGitHubLogin();
|
|
314
379
|
runVinext(["dev", ...rest]);
|
|
315
380
|
break;
|
|
316
381
|
case "build":
|
|
@@ -328,6 +393,12 @@ switch (command) {
|
|
|
328
393
|
case "check":
|
|
329
394
|
runNode("scripts/check-post.mjs", rest);
|
|
330
395
|
break;
|
|
396
|
+
case "ui":
|
|
397
|
+
runNode("scripts/ui-cli.mjs", rest, { cwd: process.cwd() });
|
|
398
|
+
break;
|
|
399
|
+
case "hooks":
|
|
400
|
+
installHook(siteRoot, { force: rest.includes("--force") });
|
|
401
|
+
break;
|
|
331
402
|
case "init":
|
|
332
403
|
initSite();
|
|
333
404
|
break;
|
package/eslint.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"public/images",
|
|
32
32
|
"scripts",
|
|
33
33
|
"templates",
|
|
34
|
+
"ui",
|
|
34
35
|
"worker",
|
|
35
36
|
".agents",
|
|
36
37
|
".env.example",
|
|
@@ -62,21 +63,33 @@
|
|
|
62
63
|
"admin:local": "npm run start -- --host 0.0.0.0 --port 3000",
|
|
63
64
|
"admin:cloudflare": "npm run build && wrangler deploy --config dist/server/wrangler.json",
|
|
64
65
|
"test": "npm run build && node --test tests/*.test.mjs",
|
|
66
|
+
"ui:build": "node scripts/build-ui-preview.mjs",
|
|
67
|
+
"editors:build": "node editors/vscode/build.mjs && node editors/obsidian/build.mjs && node editors/typora/build.mjs",
|
|
68
|
+
"prepack": "node scripts/build-ui-preview.mjs",
|
|
65
69
|
"lint": "node bin/garden.mjs sync && eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern .garden --ignore-pattern templates"
|
|
66
70
|
},
|
|
67
71
|
"dependencies": {
|
|
68
72
|
"@cloudflare/vite-plugin": "1.37.1",
|
|
73
|
+
"@codemirror/commands": "^6.11.1",
|
|
74
|
+
"@codemirror/state": "^6.7.6",
|
|
75
|
+
"@codemirror/view": "^6.43.14",
|
|
69
76
|
"@phosphor-icons/react": "^2.1.10",
|
|
70
77
|
"@tailwindcss/postcss": "4.2.1",
|
|
71
78
|
"@vitejs/plugin-react": "6.0.4",
|
|
72
79
|
"@vitejs/plugin-rsc": "0.5.30",
|
|
80
|
+
"acorn": "^8.19.0",
|
|
73
81
|
"fuse.js": "^7.5.0",
|
|
74
82
|
"github-slugger": "^2.0.0",
|
|
75
83
|
"gray-matter": "^4.0.3",
|
|
76
84
|
"katex": "^0.16.47",
|
|
77
85
|
"marked": "^16.4.2",
|
|
86
|
+
"mdast-util-from-markdown": "^2.1.0",
|
|
87
|
+
"mdast-util-mdx-expression": "^2.0.1",
|
|
88
|
+
"mdast-util-mdx-jsx": "^3.2.0",
|
|
78
89
|
"mdast-util-to-string": "^4.0.0",
|
|
79
90
|
"mermaid": "^11.16.0",
|
|
91
|
+
"micromark-extension-mdx-expression": "^3.0.1",
|
|
92
|
+
"micromark-extension-mdx-jsx": "^3.0.2",
|
|
80
93
|
"next": "16.3.0",
|
|
81
94
|
"react": "19.2.8",
|
|
82
95
|
"react-dom": "19.2.8",
|
|
@@ -105,6 +118,7 @@
|
|
|
105
118
|
"@types/node": "22.19.19",
|
|
106
119
|
"@types/react": "19.2.17",
|
|
107
120
|
"@types/react-dom": "19.2.3",
|
|
121
|
+
"esbuild": "^0.28.2",
|
|
108
122
|
"eslint": "9.39.4",
|
|
109
123
|
"eslint-config-next": "16.2.12"
|
|
110
124
|
},
|