hd-garden 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.agents/skills/garden-post-review/SKILL.md +2 -0
  2. package/.agents/skills/garden-ui/SKILL.md +111 -0
  3. package/.env.example +8 -0
  4. package/app/admin/page.tsx +14 -3
  5. package/app/admin/preview/[...path]/page.tsx +88 -0
  6. package/app/admin/ui/blocks/page.tsx +16 -0
  7. package/app/admin/ui/catalog/page.tsx +16 -0
  8. package/app/admin/ui/evals/page.tsx +19 -0
  9. package/app/admin/ui/page.tsx +22 -0
  10. package/app/admin-ui.css +227 -0
  11. package/app/api/admin/distribution/route.ts +4 -2
  12. package/app/api/admin/posts/source/route.ts +21 -0
  13. package/app/api/admin/preview/asset/route.ts +32 -0
  14. package/app/api/admin/thoughts/route.ts +1 -1
  15. package/app/api/admin/ui/blocks/route.ts +19 -0
  16. package/app/api/admin/ui/draft/route.ts +30 -0
  17. package/app/api/auth/github/route.ts +40 -1
  18. package/app/blog/page.tsx +6 -5
  19. package/app/columns/page.tsx +1 -1
  20. package/app/components/AdminDashboard.tsx +53 -1
  21. package/app/components/AdminUiPanels.tsx +258 -0
  22. package/app/components/AdminUiShell.tsx +56 -0
  23. package/app/components/ContentEmptyState.tsx +6 -6
  24. package/app/components/MarkdownArticle.tsx +15 -5
  25. package/app/components/UiBlock.tsx +22 -0
  26. package/app/components/UiStudio.tsx +422 -0
  27. package/app/components/ui-studio-keys.ts +3 -0
  28. package/app/globals.css +2 -0
  29. package/app/lib/admin-auth.ts +13 -1
  30. package/app/lib/content.ts +2 -1
  31. package/app/lib/fallback-site-config.ts +18 -18
  32. package/app/lib/github-content.ts +47 -4
  33. package/app/lib/repository-path.mjs +55 -0
  34. package/app/lib/ui-content.ts +79 -0
  35. package/app/lib/ui-model.ts +145 -0
  36. package/app/raw.d.ts +4 -0
  37. package/app/rss.xml/route.ts +2 -1
  38. package/app/ui-parity/page.tsx +120 -0
  39. package/bin/garden.mjs +71 -0
  40. package/eslint.config.mjs +3 -0
  41. package/package.json +15 -1
  42. package/scripts/build-content.mjs +35 -4
  43. package/scripts/build-ui-preview.mjs +29 -0
  44. package/scripts/check-post.mjs +26 -4
  45. package/scripts/ui-cli.mjs +303 -0
  46. package/site.config.yaml +19 -19
  47. package/templates/site/site.config.yaml +19 -19
  48. package/tsconfig.json +1 -1
  49. package/ui/core/apply.mjs +81 -0
  50. package/ui/core/catalog.mjs +536 -0
  51. package/ui/core/compile.mjs +63 -0
  52. package/ui/core/demos.mjs +223 -0
  53. package/ui/core/expr.mjs +310 -0
  54. package/ui/core/fence.mjs +201 -0
  55. package/ui/core/fix-prompt.mjs +35 -0
  56. package/ui/core/format.mjs +96 -0
  57. package/ui/core/index.mjs +37 -0
  58. package/ui/core/parse.mjs +229 -0
  59. package/ui/core/scan.mjs +53 -0
  60. package/ui/core/sha256.mjs +66 -0
  61. package/ui/core/starters.mjs +18 -0
  62. package/ui/core/state.mjs +259 -0
  63. package/ui/core/static.mjs +524 -0
  64. package/ui/core/transform.mjs +146 -0
  65. package/ui/core/validate.mjs +665 -0
  66. package/ui/evals/cases.yaml +155 -0
  67. package/ui/preview/dist/garden-ui.css +1182 -0
  68. package/ui/preview/dist/garden-ui.esm.js +367 -0
  69. package/ui/preview/dist/garden-ui.iife.js +367 -0
  70. package/ui/preview/index.tsx +191 -0
  71. package/ui/preview/mermaid-shim.ts +19 -0
  72. package/ui/prompt/build.mjs +24 -0
  73. package/ui/prompt/recipes/code-walkthrough.md +68 -0
  74. package/ui/prompt/recipes/compare.md +48 -0
  75. package/ui/prompt/recipes/converter.md +55 -0
  76. package/ui/prompt/recipes/data-chart.md +57 -0
  77. package/ui/prompt/recipes/inspect-grid.md +45 -0
  78. package/ui/prompt/recipes/param-bench.md +55 -0
  79. package/ui/prompt/recipes/plan.md +45 -0
  80. package/ui/prompt/recipes/stepper.md +52 -0
  81. package/ui/prompt/recipes/trace.md +62 -0
  82. package/ui/prompt/rules.md +104 -0
  83. package/ui/prompt/studio.md +20 -0
  84. package/ui/react/CatalogPage.tsx +166 -0
  85. package/ui/react/components/chart.tsx +470 -0
  86. package/ui/react/components/display.tsx +296 -0
  87. package/ui/react/components/inputs.tsx +268 -0
  88. package/ui/react/components/layout.tsx +236 -0
  89. package/ui/react/components/media.tsx +184 -0
  90. package/ui/react/index.tsx +59 -0
  91. package/ui/react/recipes.ts +25 -0
  92. package/ui/react/runtime.tsx +239 -0
  93. package/ui/react/types.ts +29 -0
  94. package/ui/react/ui.css +1182 -0
  95. package/vite.config.ts +4 -3
@@ -0,0 +1,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 ")
@@ -11,38 +11,38 @@ export const siteConfig = {
11
11
  },
12
12
  pages: {
13
13
  home: {
14
- title: "把复杂的事,\n慢慢想明白。",
15
- subtitle: "在这里记录产品、工程、学习与日常生活中值得慢慢想明白的事。",
14
+ title: "记一些正在做的事,\n和想清楚的问题。",
15
+ subtitle: "关注软件工程、系统设计与技术实践,也记录偶发的探索和工作笔记。",
16
16
  },
17
17
  blog: {
18
- title: "长期写下去,偶尔回头整理。",
19
- subtitle: "这里是完整文章:有明确主题、有持续论证,也允许自己在更新里修正旧判断。",
18
+ title: "博文",
19
+ subtitle: "阶段性的完整梳理与技术总结。",
20
20
  },
21
21
  thoughts: {
22
- title: "不够写成文章的,也值得留下。",
23
- subtitle: "私人朋友圈式的轻量记录:短文字、照片、声音、影像,以及偶然遇见的链接。",
22
+ title: "随想",
23
+ subtitle: "碎片的念头、偶遇的灵感、短笔记与链接。",
24
24
  },
25
25
  columns: {
26
- title: "把一个主题,写到足够深入。",
27
- subtitle: "专栏不是标签集合,而是一条有顺序的阅读路径。左侧章节导航会在阅读时始终陪着你。",
26
+ title: "专栏",
27
+ subtitle: "按顺序组织的专题系列,沿着清晰的脉络连点成线。",
28
28
  },
29
29
  about: {
30
30
  title: "你好,我是 Your Name。",
31
- subtitle: "在这里写下你关心的问题、长期的方向,以及你希望如何与世界保持联系。",
32
- heading: "这个网站为什么存在",
31
+ subtitle: "欢迎来到我的个人网站。",
32
+ heading: "关于我与这里",
33
33
  paragraphs: [
34
- "我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。",
35
- "所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。",
34
+ "一名工程师 / 创作者。平时主要关注系统开发、技术探索与软件设计。",
35
+ "这里是我的公开工作台与数字自留地。比起社交网络上的碎片动态,我更倾向于把探索过程沉淀为有据可查的代码、设计与长篇笔记。",
36
36
  ],
37
- quote: "写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。",
37
+ quote: "",
38
38
  focus: [
39
- "将自己的关注方向写在这里",
40
- "用文章积累可迁移的知识",
41
- "为长期写作留出稳定的空间",
39
+ "软件工程与系统架构",
40
+ "原生工具与高质量交互",
41
+ "个人长期知识沉淀",
42
42
  ],
43
43
  cta: {
44
44
  href: "/blog",
45
- label: "从博文开始",
45
+ label: "浏览博文",
46
46
  },
47
47
  },
48
48
  garden: {
@@ -50,7 +50,7 @@ export const siteConfig = {
50
50
  subtitle: "",
51
51
  },
52
52
  },
53
- footer: "保持独立,持续写作。",
53
+ footer: "持续记录与构建。",
54
54
  };
55
55
 
56
56
  export const githubUrl = `https://github.com/${siteConfig.author.github}`;
@@ -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
+ }