hd-garden 0.3.9 → 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 (89) 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/components/AdminDashboard.tsx +53 -1
  19. package/app/components/AdminUiPanels.tsx +258 -0
  20. package/app/components/AdminUiShell.tsx +56 -0
  21. package/app/components/MarkdownArticle.tsx +15 -5
  22. package/app/components/UiBlock.tsx +22 -0
  23. package/app/components/UiStudio.tsx +422 -0
  24. package/app/components/ui-studio-keys.ts +3 -0
  25. package/app/globals.css +2 -0
  26. package/app/lib/admin-auth.ts +13 -1
  27. package/app/lib/content.ts +2 -1
  28. package/app/lib/github-content.ts +47 -4
  29. package/app/lib/repository-path.mjs +55 -0
  30. package/app/lib/ui-content.ts +79 -0
  31. package/app/lib/ui-model.ts +145 -0
  32. package/app/raw.d.ts +4 -0
  33. package/app/rss.xml/route.ts +2 -1
  34. package/app/ui-parity/page.tsx +120 -0
  35. package/bin/garden.mjs +71 -0
  36. package/eslint.config.mjs +3 -0
  37. package/package.json +15 -1
  38. package/scripts/build-content.mjs +35 -4
  39. package/scripts/build-ui-preview.mjs +29 -0
  40. package/scripts/check-post.mjs +26 -4
  41. package/scripts/ui-cli.mjs +303 -0
  42. package/tsconfig.json +1 -1
  43. package/ui/core/apply.mjs +81 -0
  44. package/ui/core/catalog.mjs +536 -0
  45. package/ui/core/compile.mjs +63 -0
  46. package/ui/core/demos.mjs +223 -0
  47. package/ui/core/expr.mjs +310 -0
  48. package/ui/core/fence.mjs +201 -0
  49. package/ui/core/fix-prompt.mjs +35 -0
  50. package/ui/core/format.mjs +96 -0
  51. package/ui/core/index.mjs +37 -0
  52. package/ui/core/parse.mjs +229 -0
  53. package/ui/core/scan.mjs +53 -0
  54. package/ui/core/sha256.mjs +66 -0
  55. package/ui/core/starters.mjs +18 -0
  56. package/ui/core/state.mjs +259 -0
  57. package/ui/core/static.mjs +524 -0
  58. package/ui/core/transform.mjs +146 -0
  59. package/ui/core/validate.mjs +665 -0
  60. package/ui/evals/cases.yaml +155 -0
  61. package/ui/preview/dist/garden-ui.css +1182 -0
  62. package/ui/preview/dist/garden-ui.esm.js +367 -0
  63. package/ui/preview/dist/garden-ui.iife.js +367 -0
  64. package/ui/preview/index.tsx +191 -0
  65. package/ui/preview/mermaid-shim.ts +19 -0
  66. package/ui/prompt/build.mjs +24 -0
  67. package/ui/prompt/recipes/code-walkthrough.md +68 -0
  68. package/ui/prompt/recipes/compare.md +48 -0
  69. package/ui/prompt/recipes/converter.md +55 -0
  70. package/ui/prompt/recipes/data-chart.md +57 -0
  71. package/ui/prompt/recipes/inspect-grid.md +45 -0
  72. package/ui/prompt/recipes/param-bench.md +55 -0
  73. package/ui/prompt/recipes/plan.md +45 -0
  74. package/ui/prompt/recipes/stepper.md +52 -0
  75. package/ui/prompt/recipes/trace.md +62 -0
  76. package/ui/prompt/rules.md +104 -0
  77. package/ui/prompt/studio.md +20 -0
  78. package/ui/react/CatalogPage.tsx +166 -0
  79. package/ui/react/components/chart.tsx +470 -0
  80. package/ui/react/components/display.tsx +296 -0
  81. package/ui/react/components/inputs.tsx +268 -0
  82. package/ui/react/components/layout.tsx +236 -0
  83. package/ui/react/components/media.tsx +184 -0
  84. package/ui/react/index.tsx +59 -0
  85. package/ui/react/recipes.ts +25 -0
  86. package/ui/react/runtime.tsx +239 -0
  87. package/ui/react/types.ts +29 -0
  88. package/ui/react/ui.css +1182 -0
  89. package/vite.config.ts +4 -3
@@ -0,0 +1,422 @@
1
+ "use client";
2
+
3
+ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
4
+ import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
5
+ import { Decoration, EditorView, keymap, lineNumbers, type DecorationSet } from "@codemirror/view";
6
+ import { useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore } from "react";
7
+ import { compileUiBlock } from "../../ui/core/compile.mjs";
8
+ import { fenceHash, findBlocks, idOf, intentHash, normalizeNewlines, scanFences, setCompiled, splitUiBody } from "../../ui/core/fence.mjs";
9
+ import { renderStatic } from "../../ui/core/static.mjs";
10
+ import { collectDataSources } from "../../ui/core/transform.mjs";
11
+ import { UiFigure, type Diagnostic, type UiPayload } from "../../ui/react";
12
+ import { parseRecipe, type RecipeSource } from "../../ui/react/recipes";
13
+
14
+ import { STUDIO_START_KEY, STUDIO_STORAGE_KEY as STORAGE_KEY } from "./ui-studio-keys";
15
+
16
+ type Origin = { post: string; target: { id?: string; intentHash?: string }; base: string };
17
+ type HistoryItem = { mode: "create" | "revise"; instruction: string; at: string; note?: string | null; tokens?: number; rounds?: number };
18
+ type Usage = { prompt_tokens: number; completion_tokens: number; total_tokens: number };
19
+ type FileBlocks = { path: string; title: string | null; draft: boolean; blocks: Array<{ lang: string; id: string | null; status: string; startLine: number; caption: string | null; intent: string | null; hash: string }> };
20
+
21
+ const setErrorLines = StateEffect.define<number[]>();
22
+ const errorLineField = StateField.define<DecorationSet>({
23
+ create: () => Decoration.none,
24
+ update(value, transaction) {
25
+ let next = value.map(transaction.changes);
26
+ for (const effect of transaction.effects) {
27
+ if (!effect.is(setErrorLines)) continue;
28
+ const builder = new RangeSetBuilder<Decoration>();
29
+ const doc = transaction.state.doc;
30
+ for (const line of [...new Set(effect.value)].sort((a, b) => a - b)) {
31
+ if (line >= 1 && line <= doc.lines) builder.add(doc.line(line).from, doc.line(line).from, Decoration.line({ class: "cm-garden-error-line" }));
32
+ }
33
+ next = builder.finish();
34
+ }
35
+ return next;
36
+ },
37
+ provide: (field) => EditorView.decorations.from(field),
38
+ });
39
+
40
+ const subscribeNothing = () => () => {};
41
+
42
+ function useDebounced<T>(value: T, delay: number) {
43
+ const [debounced, setDebounced] = useState(value);
44
+ useEffect(() => {
45
+ const timer = window.setTimeout(() => setDebounced(value), delay);
46
+ return () => window.clearTimeout(timer);
47
+ }, [value, delay]);
48
+ return debounced;
49
+ }
50
+
51
+ function stamp(text: string) {
52
+ const fence = scanFences(text).find((item) => item.lang === "ui");
53
+ if (!fence) return text;
54
+ const { header } = splitUiBody(fence.body);
55
+ if (typeof header?.intent !== "string") return text;
56
+ return `${text.slice(0, fence.start)}${setCompiled(fence.raw, intentHash(header.intent))}${text.slice(fence.end)}`;
57
+ }
58
+
59
+ function loadSaved() {
60
+ try {
61
+ const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "null") as { text?: string; instruction?: string; history?: HistoryItem[] } | null;
62
+ const start = window.localStorage.getItem(STUDIO_START_KEY);
63
+ if (start) window.localStorage.removeItem(STUDIO_START_KEY);
64
+ return { text: start ?? saved?.text ?? "", instruction: start ? "" : saved?.instruction ?? "", history: saved?.history ?? [] };
65
+ } catch {
66
+ return { text: "", instruction: "", history: [] as HistoryItem[] };
67
+ }
68
+ }
69
+
70
+ type StudioProps = { configured: boolean; modelName: string | null; remaining: number; recipes: RecipeSource[] };
71
+
72
+ export function UiStudio(props: StudioProps) {
73
+ const mounted = useSyncExternalStore(subscribeNothing, () => true, () => false);
74
+ if (!mounted) return <section className="admin-panel ui-studio-loading">正在打开工作台…</section>;
75
+ return <Studio {...props} />;
76
+ }
77
+
78
+ function Studio({ configured, modelName, remaining: initialRemaining, recipes }: StudioProps) {
79
+ const [initial] = useState(loadSaved);
80
+ const [text, setText] = useState(initial.text);
81
+ const [instruction, setInstruction] = useState(initial.instruction);
82
+ const [historyItems, setHistoryItems] = useState<HistoryItem[]>(initial.history);
83
+ const [origin, setOrigin] = useState<Origin | null>(null);
84
+ const [dataFiles, setDataFiles] = useState<Record<string, string>>({});
85
+ const [busy, setBusy] = useState(false);
86
+ const [message, setMessage] = useState<{ tone: "info" | "error"; text: string } | null>(null);
87
+ const [note, setNote] = useState<string | null>(null);
88
+ const [usage, setUsage] = useState<{ usage: Usage; rounds: number } | null>(null);
89
+ const [remaining, setRemaining] = useState(initialRemaining);
90
+ const [theme, setTheme] = useState<"light" | "dark">("light");
91
+ const [width, setWidth] = useState<"desktop" | "mobile">("desktop");
92
+ const [view, setView] = useState<"interactive" | "static">("interactive");
93
+ const [stage, setStage] = useState<"preview" | "source">("preview");
94
+ const [files, setFiles] = useState<FileBlocks[] | null>(null);
95
+ const [pickedFile, setPickedFile] = useState("");
96
+ const editorRef = useRef<HTMLDivElement>(null);
97
+ const viewRef = useRef<EditorView | null>(null);
98
+ const parsedRecipes = useMemo(() => recipes.map(parseRecipe).filter((recipe) => recipe.fence), [recipes]);
99
+
100
+ useEffect(() => {
101
+ if (!editorRef.current) return;
102
+ const editor = new EditorView({
103
+ parent: editorRef.current,
104
+ doc: initial.text,
105
+ extensions: [
106
+ lineNumbers(),
107
+ history(),
108
+ keymap.of([...defaultKeymap, ...historyKeymap]),
109
+ EditorView.lineWrapping,
110
+ errorLineField,
111
+ EditorView.updateListener.of((update) => {
112
+ if (update.docChanged) setText(update.state.doc.toString());
113
+ }),
114
+ ],
115
+ });
116
+ viewRef.current = editor;
117
+ return () => editor.destroy();
118
+ }, [initial.text]);
119
+
120
+ useEffect(() => {
121
+ if (stage === "source") viewRef.current?.requestMeasure();
122
+ }, [stage]);
123
+
124
+ const replaceText = (next: string) => {
125
+ const editor = viewRef.current;
126
+ if (!editor) return;
127
+ editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: next } });
128
+ };
129
+
130
+ useEffect(() => {
131
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ text, instruction, history: historyItems.slice(-30) }));
132
+ }, [text, instruction, historyItems]);
133
+
134
+ const debouncedText = useDebounced(text, 150);
135
+ const compiled = useMemo(() => {
136
+ const fence = scanFences(debouncedText).find((item) => item.lang === "ui");
137
+ if (!fence) {
138
+ return {
139
+ payload: null as UiPayload | null,
140
+ diagnostics: debouncedText.trim()
141
+ ? [{ code: "ui-header", line: 1, message: "结构栏里要有一个 ```ui 围栏", severity: "error" } as Diagnostic]
142
+ : [],
143
+ };
144
+ }
145
+ const result = compileUiBlock(fence.body, {
146
+ lineOffset: fence.startLine,
147
+ markdownPath: origin?.post ?? null,
148
+ readData: (src: string) => dataFiles[src],
149
+ skipData: !origin,
150
+ });
151
+ return { payload: result.payload as UiPayload | null, diagnostics: result.check.diagnostics as Diagnostic[] };
152
+ }, [debouncedText, origin, dataFiles]);
153
+
154
+ const [lastGood, setLastGood] = useState<{ text: string; payload: UiPayload } | null>(null);
155
+ if (compiled.payload && lastGood?.text !== debouncedText) setLastGood({ text: debouncedText, payload: compiled.payload });
156
+ const shown = compiled.payload ?? lastGood?.payload ?? null;
157
+
158
+ useEffect(() => {
159
+ viewRef.current?.dispatch({ effects: setErrorLines.of(compiled.diagnostics.filter((item) => item.severity === "error").map((item) => item.line)) });
160
+ }, [compiled.diagnostics]);
161
+
162
+ const jumpTo = (line: number) => {
163
+ const editor = viewRef.current;
164
+ if (!editor || line < 1 || line > editor.state.doc.lines) return;
165
+ const position = editor.state.doc.line(line).from;
166
+ editor.dispatch({ selection: { anchor: position }, scrollIntoView: true });
167
+ editor.focus();
168
+ };
169
+
170
+ async function generate(mode: "create" | "revise") {
171
+ if (!instruction.trim()) {
172
+ setMessage({ tone: "error", text: "先在指令栏写一句话。" });
173
+ return;
174
+ }
175
+ setBusy(true);
176
+ setMessage(null);
177
+ setNote(null);
178
+ try {
179
+ const response = await fetch("/api/admin/ui/draft", {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify({ mode, instruction, fence: mode === "revise" ? text : undefined }),
183
+ });
184
+ const result = (await response.json()) as { fence?: string | null; note?: string | null; usage?: Usage; rounds?: number; remaining?: number; error?: string; raw?: string };
185
+ if (typeof result.remaining === "number") setRemaining(result.remaining);
186
+ if (!response.ok || !result.fence) {
187
+ setMessage({ tone: "error", text: result.error ?? "生成失败。" });
188
+ if (result.raw) setNote(`模型原文:${result.raw.slice(0, 400)}`);
189
+ return;
190
+ }
191
+ replaceText(result.fence);
192
+ setNote(result.note ?? null);
193
+ if (result.usage) setUsage({ usage: result.usage, rounds: result.rounds ?? 0 });
194
+ setHistoryItems((items) => [...items, { mode, instruction, at: new Date().toISOString(), note: result.note, tokens: result.usage?.total_tokens, rounds: result.rounds }]);
195
+ setMessage({ tone: "info", text: mode === "create" ? "已生成初稿,可以接着用指令或手改调整。Cmd+Z 能撤回。" : "已按指令修改。Cmd+Z 能撤回。" });
196
+ } catch (error) {
197
+ setMessage({ tone: "error", text: error instanceof Error ? error.message : "生成失败。" });
198
+ } finally {
199
+ setBusy(false);
200
+ }
201
+ }
202
+
203
+ async function loadFiles() {
204
+ if (files) return;
205
+ const response = await fetch("/api/admin/ui/blocks");
206
+ const result = (await response.json()) as { files?: FileBlocks[]; error?: string };
207
+ if (!response.ok || !result.files) {
208
+ setMessage({ tone: "error", text: result.error ?? "读取稿件失败。" });
209
+ return;
210
+ }
211
+ setFiles(result.files);
212
+ }
213
+
214
+ async function openBlock(post: string, startLine: number) {
215
+ setMessage(null);
216
+ const response = await fetch(`/api/admin/posts/source?path=${encodeURIComponent(post)}`);
217
+ const result = (await response.json()) as { content?: string; error?: string };
218
+ if (!response.ok || typeof result.content !== "string") {
219
+ setMessage({ tone: "error", text: result.error ?? "读取稿件失败。" });
220
+ return;
221
+ }
222
+ const block = findBlocks(result.content).find((item) => item.startLine === startLine);
223
+ if (!block) {
224
+ setMessage({ tone: "error", text: "稿件已经变了,找不到这一块,刷新交互块列表再试。" });
225
+ return;
226
+ }
227
+ const id = idOf(block);
228
+ const base = fenceHash(block.raw);
229
+ if (block.lang === "intent") {
230
+ const intent = normalizeNewlines(block.body);
231
+ setOrigin({ post, target: id ? { id } : { intentHash: intentHash(intent) }, base });
232
+ setInstruction(intent);
233
+ replaceText("");
234
+ setMessage({ tone: "info", text: "已载入这段想法。按「生成」把它编译成 ui 块。" });
235
+ } else {
236
+ setOrigin({ post, target: id ? { id } : { intentHash: intentHash(splitUiBody(block.body).header?.intent ?? "") }, base });
237
+ replaceText(block.raw);
238
+ setMessage({ tone: "info", text: `已从 ${post} 打开 ${id ?? "这一块"}。改完用「复制写回命令」带回本地。` });
239
+ }
240
+ const sources = collectDataSources(block.raw);
241
+ const loaded: Record<string, string> = {};
242
+ for (const src of sources) {
243
+ const asset = await fetch(`/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(src)}`);
244
+ if (asset.ok) loaded[src] = await asset.text();
245
+ }
246
+ setDataFiles(loaded);
247
+ }
248
+
249
+ const openRequested = useRef(false);
250
+ const openFromUrl = useEffectEvent(() => {
251
+ const params = new URLSearchParams(window.location.search);
252
+ const post = params.get("post");
253
+ const line = Number(params.get("line"));
254
+ if (post && line) void openBlock(post, line);
255
+ });
256
+ useEffect(() => {
257
+ if (openRequested.current) return;
258
+ openRequested.current = true;
259
+ openFromUrl();
260
+ }, []);
261
+
262
+ const copy = async (value: string, label: string) => {
263
+ await navigator.clipboard.writeText(value);
264
+ setMessage({ tone: "info", text: `${label}已复制。` });
265
+ };
266
+
267
+ const stamped = stamp(text);
268
+ const targetFlag = origin?.target.id ? `--id ${origin.target.id}` : origin?.target.intentHash ? `--intent-hash ${origin.target.intentHash}` : "";
269
+ const applyCommand = origin
270
+ ? `npx garden ui apply ${origin.post} ${targetFlag} --base ${origin.base} <<'GARDEN_UI'\n${stamped.trim()}\nGARDEN_UI\n`
271
+ : "";
272
+ const errors = compiled.diagnostics.filter((item) => item.severity === "error");
273
+ const warnings = compiled.diagnostics.filter((item) => item.severity === "warning");
274
+ const pickedBlocks = files?.find((file) => file.path === pickedFile)?.blocks ?? [];
275
+
276
+ return (
277
+ <div className="ui-studio">
278
+ <aside className="ui-studio-rail">
279
+ <textarea
280
+ className="ui-studio-instruction"
281
+ value={instruction}
282
+ onChange={(event) => setInstruction(event.target.value)}
283
+ placeholder="描述这一块要让读者做什么"
284
+ rows={6}
285
+ />
286
+ <div className="ui-studio-actions">
287
+ <button type="button" className="admin-primary-button" disabled={busy || !configured} onClick={() => generate("create")}>{busy ? "正在生成…" : "生成"}</button>
288
+ <button type="button" className="admin-secondary-button" disabled={busy || !configured || !text.trim()} onClick={() => generate("revise")}>修改</button>
289
+ </div>
290
+ <p className="ui-studio-meta">{configured ? `${modelName} · 这一小时还能生成 ${remaining} 次` : "模型未连接,可以手写源码并预览。"}</p>
291
+ {usage ? <p className="ui-studio-meta">上次 {usage.usage.total_tokens} token,修复 {usage.rounds} 轮。</p> : null}
292
+ {note ? <p className="ui-studio-note">{note}</p> : null}
293
+ {message ? <p className={`ui-studio-message ui-studio-message-${message.tone}`} role="status">{message.text}</p> : null}
294
+
295
+ <div className="ui-studio-starts">
296
+ <button
297
+ type="button"
298
+ className="admin-quiet-button"
299
+ onClick={() => {
300
+ replaceText("");
301
+ setInstruction("");
302
+ setOrigin(null);
303
+ setDataFiles({});
304
+ setHistoryItems([]);
305
+ setMessage(null);
306
+ setNote(null);
307
+ setStage("preview");
308
+ }}
309
+ >
310
+ 新建
311
+ </button>
312
+ <label>
313
+ 配方
314
+ <select
315
+ value=""
316
+ onChange={(event) => {
317
+ const recipe = parsedRecipes.find((item) => item.file === event.target.value);
318
+ if (!recipe?.fence) return;
319
+ setOrigin(null);
320
+ setDataFiles({});
321
+ replaceText(recipe.fence);
322
+ setStage("preview");
323
+ setMessage({ tone: "info", text: `已载入「${recipe.title.replace(/^配方:/, "")}」。` });
324
+ }}
325
+ >
326
+ <option value="">从配方打开</option>
327
+ {parsedRecipes.map((recipe) => <option key={recipe.file} value={recipe.file}>{recipe.title.replace(/^配方:/, "")}</option>)}
328
+ </select>
329
+ </label>
330
+ <label>
331
+ 稿件
332
+ <select value={pickedFile} onFocus={() => void loadFiles()} onChange={(event) => setPickedFile(event.target.value)}>
333
+ <option value="">{files ? "从稿件打开" : "载入稿件"}</option>
334
+ {(files ?? []).map((file) => <option key={file.path} value={file.path}>{file.title ?? file.path}{file.draft ? "(草稿)" : ""}</option>)}
335
+ </select>
336
+ </label>
337
+ {pickedBlocks.length ? (
338
+ <label>
339
+ 这一篇
340
+ <select value="" onChange={(event) => void openBlock(pickedFile, Number(event.target.value))}>
341
+ <option value="">选一块</option>
342
+ {pickedBlocks.filter((block) => block.lang !== "widget").map((block) => (
343
+ <option key={block.startLine} value={block.startLine}>
344
+ {`${block.lang === "intent" ? "想法" : "ui"} · ${block.id ?? `第 ${block.startLine} 行`} · ${block.status}`}
345
+ </option>
346
+ ))}
347
+ </select>
348
+ </label>
349
+ ) : null}
350
+ </div>
351
+
352
+ {historyItems.length ? (
353
+ <div className="ui-studio-history">
354
+ <h3>这次</h3>
355
+ <ol>
356
+ {historyItems.slice(-8).map((item, index) => (
357
+ <li key={index}>
358
+ <button type="button" onClick={() => setInstruction(item.instruction)}>{item.instruction.slice(0, 42)}</button>
359
+ </li>
360
+ ))}
361
+ </ol>
362
+ </div>
363
+ ) : null}
364
+ </aside>
365
+
366
+ <section className="ui-studio-stage">
367
+ <div className="ui-studio-bar">
368
+ <div className="ui-studio-switch" role="tablist" aria-label="工作台">
369
+ <button type="button" role="tab" aria-selected={stage === "preview"} onClick={() => setStage("preview")}>预览</button>
370
+ <button type="button" role="tab" aria-selected={stage === "source"} onClick={() => setStage("source")}>源码</button>
371
+ </div>
372
+ {stage === "preview" ? (
373
+ <div className="ui-studio-switch" role="group" aria-label="预览设置">
374
+ <button type="button" aria-pressed={theme === "light"} onClick={() => setTheme("light")}>浅色</button>
375
+ <button type="button" aria-pressed={theme === "dark"} onClick={() => setTheme("dark")}>深色</button>
376
+ <button type="button" aria-pressed={width === "desktop"} onClick={() => setWidth("desktop")}>桌面</button>
377
+ <button type="button" aria-pressed={width === "mobile"} onClick={() => setWidth("mobile")}>手机</button>
378
+ <button type="button" aria-pressed={view === "interactive"} onClick={() => setView("interactive")}>交互</button>
379
+ <button type="button" aria-pressed={view === "static"} onClick={() => setView("static")}>静态</button>
380
+ </div>
381
+ ) : (
382
+ <div className="ui-studio-actions">
383
+ <button type="button" className="admin-secondary-button" disabled={!text.trim()} onClick={() => copy(stamped.trim(), "围栏")}>复制</button>
384
+ {origin ? <button type="button" className="admin-primary-button" disabled={!text.trim() || errors.length > 0} onClick={() => copy(applyCommand, "写回命令")}>写回</button> : null}
385
+ </div>
386
+ )}
387
+ </div>
388
+
389
+ <div className="ui-studio-preview" data-theme={theme} data-width={width} hidden={stage !== "preview"}>
390
+ {shown ? (
391
+ view === "interactive" ? (
392
+ <UiFigure payload={shown} />
393
+ ) : (
394
+ <figure className="garden-ui" lang="en-US">
395
+ <div className="garden-ui-body garden-ui-static" dangerouslySetInnerHTML={{ __html: renderStatic(shown, "html") }} />
396
+ <figcaption>{shown.caption}</figcaption>
397
+ </figure>
398
+ )
399
+ ) : (
400
+ <p className="ui-studio-empty">左边写一句,或者从配方打开。</p>
401
+ )}
402
+ </div>
403
+
404
+ <div className="ui-studio-source" hidden={stage !== "source"}>
405
+ <div ref={editorRef} className="ui-studio-editor" />
406
+ {errors.length || warnings.length ? (
407
+ <ul className="ui-studio-diagnostics">
408
+ {[...errors, ...warnings].map((item, index) => (
409
+ <li key={index} data-severity={item.severity}>
410
+ <button type="button" onClick={() => jumpTo(item.line)}>
411
+ <code>{item.code}</code> 第 {item.line} 行 {item.message}
412
+ </button>
413
+ </li>
414
+ ))}
415
+ </ul>
416
+ ) : text.trim() ? <p className="ui-studio-meta">检查通过。</p> : null}
417
+ {origin ? <p className="ui-studio-meta">写回会替换 {origin.post} 里的这一块。本地那一块改过时,命令会拒绝覆盖。</p> : null}
418
+ </div>
419
+ </section>
420
+ </div>
421
+ );
422
+ }
@@ -0,0 +1,3 @@
1
+ export const STUDIO_STORAGE_KEY = "garden-ui-studio";
2
+ export const STUDIO_START_KEY = "garden-ui-studio-start";
3
+ export const EVALS_STORAGE_KEY = "garden-ui-evals";
package/app/globals.css CHANGED
@@ -1,4 +1,6 @@
1
1
  @import "tailwindcss";
2
+ @import "../ui/react/ui.css";
3
+ @import "./admin-ui.css";
2
4
 
3
5
  :root {
4
6
  --paper: #f8f8f6;
@@ -23,7 +23,11 @@ function configuredLogins() {
23
23
  }
24
24
 
25
25
  export function isGitHubLoginAllowed(login: string) {
26
- return configuredLogins().has(login.trim().toLowerCase());
26
+ const logins = configuredLogins();
27
+ // Local dev uses the machine's existing gh login. An empty allow-list there
28
+ // means "whoever gh is", not "nobody".
29
+ if (logins.size === 0 && process.env.GARDEN_DEV === "1") return true;
30
+ return logins.has(login.trim().toLowerCase());
27
31
  }
28
32
 
29
33
  export function isGitHubOAuthConfigured() {
@@ -34,6 +38,14 @@ export function isGitHubOAuthConfigured() {
34
38
  );
35
39
  }
36
40
 
41
+ export function isGitHubDevLoginAvailable() {
42
+ return process.env.GARDEN_DEV === "1" && Boolean(process.env.GITHUB_DEV_TOKEN);
43
+ }
44
+
45
+ export function isGitHubLoginAvailable() {
46
+ return isGitHubOAuthConfigured() || isGitHubDevLoginAvailable();
47
+ }
48
+
37
49
  function toBase64Url(value: Uint8Array | string) {
38
50
  const bytes = typeof value === "string" ? encoder.encode(value) : value;
39
51
  let binary = "";
@@ -8,6 +8,7 @@ import {
8
8
  } from "@garden/generated-content";
9
9
  import GithubSlugger from "github-slugger";
10
10
  import { canonicalSiteDate } from "../../scripts/site-date.mjs";
11
+ import { contentToSearchText } from "../../ui/core/transform.mjs";
11
12
  import { toString } from "mdast-util-to-string";
12
13
  import remarkParse from "remark-parse";
13
14
  import { unified } from "unified";
@@ -59,7 +60,7 @@ export const extractHeadings = (markdown: string) => {
59
60
  };
60
61
 
61
62
  const toSearchableText = (markdown: string) =>
62
- markdown
63
+ contentToSearchText(markdown)
63
64
  .replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
64
65
  .replace(/!\[([^\]]*)\]\([^)]+\)/g, " $1 ")
65
66
  .replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ")
@@ -12,7 +12,8 @@ const matterOptions = {
12
12
  },
13
13
  };
14
14
 
15
- const apiBase = "https://api.github.com";
15
+ // GITHUB_API_URL lets GitHub Enterprise (and local mocks) replace the public API.
16
+ const githubApi = () => (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/+$/u, "");
16
17
 
17
18
  type RepositoryFile = {
18
19
  content?: string;
@@ -30,6 +31,8 @@ export type RepositoryThought = ContentEntry & {
30
31
  id: string;
31
32
  status: "draft" | "published";
32
33
  sha?: string;
34
+ tags: string[];
35
+ media: MediaItem[];
33
36
  };
34
37
 
35
38
  function repositoryConfig() {
@@ -99,6 +102,7 @@ function normalizeThought(source: string, filePath: string, sha?: string): Repos
99
102
  return {
100
103
  id: slug,
101
104
  slug,
105
+ path: slug,
102
106
  title: data.title,
103
107
  date: data.date,
104
108
  tags: isStringArray(data.tags) ? data.tags : [],
@@ -135,7 +139,9 @@ function serializeThought(thought: {
135
139
  async function requestRepository(path: string, init?: RequestInit) {
136
140
  const config = repositoryConfig();
137
141
  if (!config) throw new Error("未配置 CONTENT_REPOSITORY 或 CONTENT_GITHUB_TOKEN。");
138
- const url = new URL(`${apiBase}/repos/${config.repository}/contents/${path}`);
142
+ // Encode each segment: the URL parser would otherwise resolve "%2e%2e" as ".." and treat "?" / "#" as delimiters.
143
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
144
+ const url = new URL(`${githubApi()}/repos/${config.repository}/contents/${encoded}`);
139
145
  if (!init?.method || init.method === "GET") url.searchParams.set("ref", config.branch);
140
146
  return {
141
147
  config,
@@ -160,7 +166,7 @@ async function mutateRepository(
160
166
  });
161
167
  }
162
168
 
163
- async function readRepositoryFile(path: string) {
169
+ export async function readRepositoryFile(path: string) {
164
170
  const { response } = await requestRepository(path);
165
171
  if (response.status === 404) return null;
166
172
  if (!response.ok) throw new Error("无法读取 GitHub 内容仓库。");
@@ -195,7 +201,7 @@ export async function listRepositoryThoughts() {
195
201
  });
196
202
  }
197
203
 
198
- export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "sourcePath" | "draft" | "sha" | "mediaType">) {
204
+ export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "path" | "sourcePath" | "draft" | "sha" | "mediaType">) {
199
205
  const path = `content/thoughts/${input.slug}.md`;
200
206
  const existing = await readRepositoryFile(path);
201
207
  if (existing) throw new Error("这个随想 slug 已存在,请稍后重试。");
@@ -261,3 +267,40 @@ export async function updateRepositoryPostPinned(input: {
261
267
  if (!response.ok) throw new Error("GitHub 未接受这次置顶状态更新。");
262
268
  return { slug: input.slug, pinned: input.pinned };
263
269
  }
270
+
271
+ const blobCache = new Map<string, string>();
272
+
273
+ /** Every Markdown file under content/ with its blob sha, from one tree request. */
274
+ export async function listContentMarkdown() {
275
+ const config = repositoryConfig();
276
+ if (!config) throw new Error("未配置 CONTENT_REPOSITORY 或 CONTENT_GITHUB_TOKEN。");
277
+ const url = `${githubApi()}/repos/${config.repository}/git/trees/${encodeURIComponent(config.branch)}?recursive=1`;
278
+ const response = await fetch(url, { headers: headers(config.token), cache: "no-store" });
279
+ if (!response.ok) throw new Error("无法读取 GitHub 仓库目录。");
280
+ const tree = (await response.json()) as { tree?: Array<{ path: string; type: string; sha: string }> };
281
+ return (tree.tree ?? [])
282
+ .filter((entry) => entry.type === "blob" && /^content\/(?:posts|thoughts)\/.+\.md$/u.test(entry.path))
283
+ .map((entry) => ({ path: entry.path, sha: entry.sha }));
284
+ }
285
+
286
+ /** Blob text by sha; blobs never change, so they are cached for the process lifetime. */
287
+ export async function readRepositoryBlob(sha: string) {
288
+ const cached = blobCache.get(sha);
289
+ if (cached !== undefined) return cached;
290
+ const config = repositoryConfig();
291
+ if (!config) throw new Error("未配置 CONTENT_REPOSITORY 或 CONTENT_GITHUB_TOKEN。");
292
+ const response = await fetch(`${githubApi()}/repos/${config.repository}/git/blobs/${sha}`, { headers: headers(config.token), cache: "no-store" });
293
+ if (!response.ok) throw new Error("无法读取 GitHub 文件内容。");
294
+ const blob = (await response.json()) as { content?: string; encoding?: string };
295
+ const text = blob.encoding === "base64" && blob.content ? base64ToUtf8(blob.content) : "";
296
+ blobCache.set(sha, text);
297
+ return text;
298
+ }
299
+
300
+ /** Raw bytes of a repository file (images, HTML, data) for the draft preview proxy. */
301
+ export async function readRepositoryRaw(path: string) {
302
+ const { response } = await requestRepository(path, { headers: { Accept: "application/vnd.github.raw" } });
303
+ if (response.status === 404) return null;
304
+ if (!response.ok) throw new Error("无法读取 GitHub 文件。");
305
+ return response.arrayBuffer();
306
+ }
@@ -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
+ }