hd-garden 0.14.6 → 0.14.7

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 (76) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/app/admin/page.tsx +3 -16
  4. package/app/admin/preview/[...path]/page.tsx +6 -8
  5. package/app/components/AdminDashboard.tsx +3 -258
  6. package/app/components/AdminUiPanels.tsx +5 -7
  7. package/app/components/ContentEmptyState.tsx +2 -10
  8. package/app/components/GardenAdminGuide.tsx +3 -22
  9. package/app/components/GardenContentGuide.tsx +0 -30
  10. package/app/components/MarkdownArticle.tsx +2 -3
  11. package/app/components/PageIntro.tsx +1 -1
  12. package/app/garden-modules.d.ts +2 -19
  13. package/app/globals.css +4 -256
  14. package/app/lib/content.ts +2 -8
  15. package/app/lib/fallback-site-config.ts +2 -6
  16. package/app/lib/garden-docs-meta.ts +1 -1
  17. package/app/lib/github-content.ts +8 -159
  18. package/app/lib/i18n.ts +8 -56
  19. package/app/lib/oss.ts +1 -1
  20. package/app/lib/repository-path.mjs +19 -2
  21. package/app/lib/ui-content.ts +4 -3
  22. package/app/lib/ui-model.ts +2 -0
  23. package/app/rss.xml/route.ts +1 -9
  24. package/app/site.config.ts +0 -1
  25. package/app/sitemap.ts +1 -10
  26. package/app/styles/a2.css +0 -39
  27. package/app/styles/shell.css +0 -9
  28. package/bin/garden.mjs +0 -1
  29. package/docs/handbook/ai-writing.md +2 -2
  30. package/docs/handbook/components/image.md +0 -1
  31. package/docs/handbook/components/link-card.md +0 -1
  32. package/docs/handbook/config.md +3 -8
  33. package/docs/handbook/deploy.md +1 -1
  34. package/docs/handbook/en/ai-writing.md +2 -2
  35. package/docs/handbook/en/components/image.md +0 -1
  36. package/docs/handbook/en/components/link-card.md +0 -1
  37. package/docs/handbook/en/config.md +2 -7
  38. package/docs/handbook/en/deploy.md +1 -1
  39. package/docs/handbook/en/index.md +1 -2
  40. package/docs/handbook/en/start.md +2 -7
  41. package/docs/handbook/en/writing.md +4 -28
  42. package/docs/handbook/index.md +4 -5
  43. package/docs/handbook/start.md +2 -7
  44. package/docs/handbook/writing.md +4 -28
  45. package/editors/dist/{garden-typora-0.14.6.zip → garden-typora-0.14.7.zip} +0 -0
  46. package/editors/dist/typora/manifest.json +1 -1
  47. package/editors/dist/typora/style.css +0 -39
  48. package/editors/dist/vscode/{garden-ui-0.14.6.vsix → garden-ui-0.14.7.vsix} +0 -0
  49. package/package.json +2 -2
  50. package/scripts/build-content.mjs +12 -92
  51. package/site.config.yaml +3 -6
  52. package/templates/site/AGENTS.en.md +2 -3
  53. package/templates/site/AGENTS.md +2 -3
  54. package/templates/site/site.config.en.yaml +1 -6
  55. package/templates/site/site.config.yaml +2 -7
  56. package/ui/core/workbench-eval.mjs +27 -0
  57. package/ui/garden/article-assets.mjs +13 -0
  58. package/ui/garden/post-frontmatter.mjs +31 -0
  59. package/ui/garden/search-index.mjs +4 -6
  60. package/ui/garden/site-nav.mjs +0 -1
  61. package/ui/garden/static-params.mjs +1 -1
  62. package/ui/preview/dist/garden-ui.css +0 -39
  63. package/ui/preview/dist/garden-ui.esm.js +1 -1
  64. package/ui/preview/dist/garden-ui.iife.js +1 -1
  65. package/app/api/admin/thoughts/[id]/route.ts +0 -44
  66. package/app/api/admin/thoughts/route.ts +0 -121
  67. package/app/components/FeedMore.tsx +0 -70
  68. package/app/components/ThoughtCard.tsx +0 -245
  69. package/app/components/ThoughtFeed.tsx +0 -41
  70. package/app/lib/public-thoughts.ts +0 -9
  71. package/app/thoughts/[slug]/page.tsx +0 -86
  72. package/app/thoughts/page/[n]/page.tsx +0 -44
  73. package/app/thoughts/page.tsx +0 -32
  74. package/public/images/garden/admin-studio.png +0 -0
  75. package/templates/site/content/thoughts/.gitkeep +0 -0
  76. package/ui/garden/paging.mjs +0 -30
package/README.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
- Garden is a Claude-inspired personal blog framework. You write posts, short thoughts, and multi-part columns in Markdown inside your own Git repository, and describe structured or interactive components in a single plain-language sentence.
5
+ Garden is a Claude-inspired personal blog framework. You write posts and multi-part columns in Markdown inside your own Git repository, and describe structured or interactive components in a single plain-language sentence.
6
6
 
7
7
  Your site repository keeps only `site.config.yaml` and `content/`. Rendering, static export, and verification come from the `hd-garden` package.
8
8
 
@@ -96,9 +96,9 @@ The first page that references `.excalidraw` downloads the exporter (Chromium is
96
96
 
97
97
  Start `make dev` and open `/garden/en` (Chinese at `/garden`), or read the markdown sources directly:
98
98
 
99
- - **[Garden Handbook (English)](docs/handbook/en/index.md)** · **[Garden 使用说明(中文)](docs/handbook/index.md)**: [Quick Start](docs/handbook/en/start.md), [Writing with AI](docs/handbook/en/ai-writing.md), [Site & Column Configuration](docs/handbook/en/config.md), [Deploy to GitHub Pages](docs/handbook/en/deploy.md), [Writing Posts & Thoughts](docs/handbook/en/writing.md), [How Reading Layout Works](docs/handbook/en/design.md), [12 Built-in Components](docs/handbook/en/components.md), and [Checking & Shortcuts](docs/handbook/en/workflow.md).
99
+ - **[Garden Handbook (English)](docs/handbook/en/index.md)** · **[Garden 使用说明(中文)](docs/handbook/index.md)**: [Quick Start](docs/handbook/en/start.md), [Writing with AI](docs/handbook/en/ai-writing.md), [Site & Column Configuration](docs/handbook/en/config.md), [Deploy to GitHub Pages](docs/handbook/en/deploy.md), [Writing Posts](docs/handbook/en/writing.md), [How Reading Layout Works](docs/handbook/en/design.md), [12 Built-in Components](docs/handbook/en/components.md), and [Checking & Shortcuts](docs/handbook/en/workflow.md).
100
100
  - **[Quick Start Guide](docs/quick-start.md)** and **[GitHub Pages Deployment](docs/github-pages.md)**: single public repository or private source + public output repository setup, plus Giscus comments.
101
- - **[Admin Service](docs/admin-service.md)**: optional `/admin` server (local, Docker, or Cloudflare Workers) for posting thoughts from the web, uploading media to Alibaba Cloud OSS, and using the `/admin/ui` workbench.
101
+ - **[Admin Service](docs/admin-service.md)**: optional `/admin` server (local, Docker, or Cloudflare Workers) for managing articles, uploading media to Alibaba Cloud OSS, and using the `/admin/ui` workbench.
102
102
 
103
103
  ## License
104
104
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.en.md)
4
4
 
5
- Garden 是一个 Claude 风格的个人博客框架:文章、随想和专栏用 Markdown 写,保存在自己的 Git 仓库里;图表、指标和交互组件用一句自然语言描述就行。
5
+ Garden 是一个 Claude 风格的个人博客框架:文章和专栏用 Markdown 写,保存在自己的 Git 仓库里;图表、指标和交互组件用一句自然语言描述就行。
6
6
 
7
7
  站点仓库只保存你的配置文件(`site.config.yaml`)与稿件(`content/`),页面渲染、静态构建和检查工具由 `hd-garden` 包提供。
8
8
 
@@ -98,9 +98,9 @@ npx garden editors install --yes # 把 Intelligent UI 插件装进
98
98
 
99
99
  本地运行 `make dev` 后访问 `/garden`(英文版在 `/garden/en`),或直接阅读仓库内的文档:
100
100
 
101
- - **[Garden 使用说明(中文)](docs/handbook/index.md)** · **[Garden Handbook (English)](docs/handbook/en/index.md)**:快速开始、[用 AI 写作](docs/handbook/ai-writing.md)、[站点与专栏配置](docs/handbook/config.md)、[发布到 GitHub Pages](docs/handbook/deploy.md)、[写文章与随想](docs/handbook/writing.md)、[阅读排版](docs/handbook/design.md)、[组件](docs/handbook/components.md)(`garden` 围栏,以及链接卡片、来源胶囊和本地图片;说明图是 compose)与 [语法检查](docs/handbook/workflow.md)。
101
+ - **[Garden 使用说明(中文)](docs/handbook/index.md)** · **[Garden Handbook (English)](docs/handbook/en/index.md)**:快速开始、[用 AI 写作](docs/handbook/ai-writing.md)、[站点与专栏配置](docs/handbook/config.md)、[发布到 GitHub Pages](docs/handbook/deploy.md)、[写文章](docs/handbook/writing.md)、[阅读排版](docs/handbook/design.md)、[组件](docs/handbook/components.md)(`garden` 围栏,以及链接卡片、来源胶囊和本地图片;说明图是 compose)与 [语法检查](docs/handbook/workflow.md)。
102
102
  - **[从零开始](docs/quick-start.md)** 与 **[GitHub Pages 部署](docs/github-pages.md)**:单公开仓库或「私有源码仓库 + 公开页面仓库」的 Actions 配置与 Giscus 评论接入。
103
- - **[独立管理后台](docs/admin-service.md)**:可选的 `/admin` 服务(本地、Docker 或 Cloudflare Workers),支持网页发随想、阿里云 OSS 媒体直传与 `/admin/ui` 组件工作台。
103
+ - **[独立管理后台](docs/admin-service.md)**:可选的 `/admin` 服务(本地、Docker 或 Cloudflare Workers),支持文章置顶、阿里云 OSS 媒体上传与 `/admin/ui` 组件工作台。
104
104
 
105
105
  ## 许可证
106
106
 
@@ -2,8 +2,8 @@ import Link from "next/link";
2
2
  import { ArrowSquareOut, GithubLogo } from "@phosphor-icons/react/ssr";
3
3
  import { AdminDashboard } from "../components/AdminDashboard";
4
4
  import { getAdminUser, isGitHubLoginAvailable } from "../lib/admin-auth";
5
- import { columns, posts, thoughts } from "../lib/content";
6
- import { isContentRepositoryConfigured, listRepositoryThoughts } from "../lib/github-content";
5
+ import { columns, posts } from "../lib/content";
6
+ import { isContentRepositoryConfigured } from "../lib/github-content";
7
7
  import { getSiteUrl } from "../lib/site";
8
8
  import { siteConfig } from "../site.config";
9
9
  import { ui } from "../lib/i18n";
@@ -76,17 +76,6 @@ export default async function AdminPage({
76
76
  );
77
77
  }
78
78
 
79
- let initialThoughts: Awaited<ReturnType<typeof listRepositoryThoughts>> = [];
80
- let contentWarning = "";
81
- if (!isContentRepositoryConfigured()) {
82
- contentWarning = "尚未配置内容仓库写入权限;请设置 CONTENT_REPOSITORY 和 CONTENT_GITHUB_TOKEN。";
83
- } else {
84
- try {
85
- initialThoughts = await listRepositoryThoughts();
86
- } catch (error) {
87
- contentWarning = error instanceof Error ? error.message : "无法读取 GitHub 内容仓库。";
88
- }
89
- }
90
79
  const catalog = [
91
80
  ...posts.map((post) => ({
92
81
  kind: "post" as const,
@@ -109,11 +98,9 @@ export default async function AdminPage({
109
98
  return (
110
99
  <AdminDashboard
111
100
  userName={`@${user.login}`}
112
- initialThoughts={initialThoughts}
113
- staticThoughtCount={thoughts.length}
114
101
  catalog={catalog}
115
102
  publicSiteUrl={publicSiteUrl}
116
- contentWarning={contentWarning}
103
+ contentWarning={isContentRepositoryConfigured() ? "" : "尚未配置内容仓库写入权限;请设置 CONTENT_REPOSITORY 和 CONTENT_GITHUB_TOKEN。"}
117
104
  ui={{
118
105
  modelName: modelConfig()?.model ?? null,
119
106
  remaining: remainingDrafts(user.login),
@@ -7,11 +7,12 @@ import { MarkdownArticle } from "../../../components/MarkdownArticle";
7
7
  import { repositoryContentPath } from "../../../lib/repository-path.mjs";
8
8
  import { readMarkdown, readText } from "../../../lib/ui-content";
9
9
  import { ui } from "../../../lib/i18n";
10
- import { formatInlineLink, replaceInlineLinks } from "../../../../ui/garden/markdown-destination.mjs";
10
+ import { rewriteArticleMedia } from "../../../../ui/garden/article-assets.mjs";
11
+ import { resolveSidecarPath } from "../../../../ui/core/fence.mjs";
11
12
  import { STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
12
13
 
13
14
  export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
14
- export const dynamicParams = false;
15
+ export const dynamicParams = process.env.STATIC_EXPORT !== "1";
15
16
  export const metadata = { title: ui().adminTitlePreview, robots: { index: false, follow: false } };
16
17
 
17
18
  /** Draft preview is served by the admin worker. The public static export has no paths here. */
@@ -24,9 +25,7 @@ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(val
24
25
  /** Same-name-directory files go through the authenticated, sandboxed proxy. */
25
26
  function proxyAssets(content: string, post: string) {
26
27
  const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
27
- return replaceInlineLinks(content, (link) =>
28
- link.image && isRelative(link.url) ? formatInlineLink(link, proxy(link.url)) : undefined,
29
- );
28
+ return rewriteArticleMedia(content, (url: string) => isRelative(url) ? proxy(url) : url);
30
29
 
31
30
  }
32
31
 
@@ -54,11 +53,10 @@ export default async function DraftPreviewPage({ params }: { params: Promise<{ p
54
53
  }
55
54
  const parsed = matter(file.source, { engines: { yaml: { parse: (input: string) => parseYaml(input) ?? {} } } });
56
55
  const data = parsed.data as Record<string, unknown>;
57
- const directory = post.slice(0, post.lastIndexOf("/"));
58
56
  const dataFiles: Record<string, string | undefined> = {};
59
57
  for (const src of collectDataSources(parsed.content)) {
60
- const target = `${directory}/${src.replace(/^\.\//u, "")}`;
61
- dataFiles[src] = await readText(target).catch(() => undefined);
58
+ const target = resolveSidecarPath(post, src);
59
+ dataFiles[src] = target.path ? await readText(target.path).catch(() => undefined) : undefined;
62
60
  }
63
61
  const { content } = transformUiContent(parsed.content.trim(), {
64
62
  mode: "preview",
@@ -1,23 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { formatAuthorDate } from "../../scripts/site-date.mjs";
5
- import { useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useState } from "react";
6
5
  import {
7
6
  ArrowSquareOut,
8
- ArrowUpRight,
9
7
  CheckCircle,
10
8
  Copy,
11
- PaperPlaneTilt,
12
- Plus,
13
- Trash,
14
9
  } from "@phosphor-icons/react";
15
- import type { ContentEntry, MediaItem } from "../lib/content";
16
-
17
- type ManagedThought = ContentEntry & {
18
- id: string;
19
- status: "draft" | "published";
20
- };
21
10
 
22
11
  type CatalogEntry = {
23
12
  kind: "post" | "column";
@@ -29,98 +18,6 @@ type CatalogEntry = {
29
18
  pinned?: boolean;
30
19
  };
31
20
 
32
- type AttachmentDraft = Partial<MediaItem> & {
33
- type: MediaItem["type"];
34
- src: string;
35
- };
36
-
37
- const emptyAttachment = (): AttachmentDraft => ({ type: "image", src: "" });
38
-
39
- function MediaFields({
40
- attachments,
41
- onChange,
42
- onUpload,
43
- uploading,
44
- }: {
45
- attachments: AttachmentDraft[];
46
- onChange: (next: AttachmentDraft[]) => void;
47
- onUpload: (index: number, file: File) => void;
48
- uploading: boolean;
49
- }) {
50
- return (
51
- <section className="admin-attachments">
52
- <div className="admin-field-heading">
53
- <div>
54
- <span>附件</span>
55
- <small>一条随想里可以混放图片、音频、视频和链接。</small>
56
- </div>
57
- <button type="button" className="admin-quiet-button" onClick={() => onChange([...attachments, emptyAttachment()])}>
58
- <Plus size={14} /> 添加附件
59
- </button>
60
- </div>
61
- {attachments.map((item, index) => (
62
- <div className="admin-attachment-row" key={index}>
63
- <select
64
- aria-label={`附件 ${index + 1} 类型`}
65
- value={item.type}
66
- onChange={(event) => {
67
- const next = [...attachments];
68
- next[index] = { ...next[index], type: event.target.value as MediaItem["type"] };
69
- onChange(next);
70
- }}
71
- >
72
- <option value="image">图片</option>
73
- <option value="audio">音频</option>
74
- <option value="video">视频</option>
75
- <option value="link">链接</option>
76
- </select>
77
- <input
78
- value={item.src}
79
- onChange={(event) => {
80
- const next = [...attachments];
81
- next[index] = { ...next[index], src: event.target.value };
82
- onChange(next);
83
- }}
84
- placeholder={item.type === "link" ? "https://…" : "https://… 或上传到 OSS"}
85
- aria-label={`附件 ${index + 1} 地址`}
86
- />
87
- {item.type !== "link" && (
88
- <input
89
- type="file"
90
- accept={item.type === "image" ? "image/*" : item.type === "audio" ? "audio/*" : "video/*"}
91
- aria-label={`上传附件 ${index + 1}`}
92
- disabled={uploading}
93
- onChange={(event) => {
94
- const file = event.target.files?.[0];
95
- if (file) onUpload(index, file);
96
- event.currentTarget.value = "";
97
- }}
98
- />
99
- )}
100
- <input
101
- value={item.title ?? ""}
102
- onChange={(event) => {
103
- const next = [...attachments];
104
- next[index] = { ...next[index], title: event.target.value };
105
- onChange(next);
106
- }}
107
- placeholder="标题 / 说明(可选)"
108
- aria-label={`附件 ${index + 1} 标题`}
109
- />
110
- <button
111
- type="button"
112
- className="admin-icon-button"
113
- aria-label={`删除附件 ${index + 1}`}
114
- onClick={() => onChange(attachments.filter((_, attachmentIndex) => attachmentIndex !== index))}
115
- >
116
- <Trash size={16} />
117
- </button>
118
- </div>
119
- ))}
120
- </section>
121
- );
122
- }
123
-
124
21
  type UiOverview = {
125
22
  modelName: string | null;
126
23
  remaining: number;
@@ -168,138 +65,21 @@ function IntelligentUiCard({ ui }: { ui: UiOverview }) {
168
65
 
169
66
  export function AdminDashboard({
170
67
  userName,
171
- initialThoughts,
172
- staticThoughtCount,
173
68
  catalog,
174
69
  publicSiteUrl,
175
70
  contentWarning,
176
71
  ui,
177
72
  }: {
178
73
  userName: string;
179
- initialThoughts: ManagedThought[];
180
- staticThoughtCount: number;
181
74
  catalog: CatalogEntry[];
182
75
  publicSiteUrl: string;
183
76
  contentWarning: string;
184
77
  ui: UiOverview;
185
78
  }) {
186
- const [thoughts, setThoughts] = useState(initialThoughts);
187
- const [title, setTitle] = useState("");
188
- const [content, setContent] = useState("");
189
- const [tags, setTags] = useState("");
190
- const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
191
79
  const [notice, setNotice] = useState("");
192
- const [isSaving, setIsSaving] = useState(false);
193
- const [uploadingCount, setUploadingCount] = useState(0);
194
80
  const [distributionStatus, setDistributionStatus] = useState<Record<string, string>>({});
195
81
  const [catalogEntries, setCatalogEntries] = useState(catalog);
196
82
  const [pinningPath, setPinningPath] = useState<string | null>(null);
197
- const publishedCount = useMemo(
198
- () => thoughts.filter((thought) => thought.status === "published").length,
199
- [thoughts],
200
- );
201
-
202
- async function uploadMedia(index: number, file: File) {
203
- setNotice("");
204
- setUploadingCount((count) => count + 1);
205
- try {
206
- const policyResponse = await fetch("/api/admin/uploads", {
207
- method: "POST",
208
- headers: { "Content-Type": "application/json" },
209
- body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
210
- });
211
- const policy = (await policyResponse.json()) as {
212
- endpoint?: string; key?: string; policy?: string; signature?: string; accessKeyId?: string; publicUrl?: string; error?: string;
213
- };
214
- if (!policyResponse.ok || !policy.endpoint || !policy.key || !policy.policy || !policy.signature || !policy.accessKeyId || !policy.publicUrl) {
215
- throw new Error(policy.error ?? "无法获取 OSS 上传许可。");
216
- }
217
- const form = new FormData();
218
- form.set("key", policy.key);
219
- form.set("OSSAccessKeyId", policy.accessKeyId);
220
- form.set("policy", policy.policy);
221
- form.set("Signature", policy.signature);
222
- form.set("success_action_status", "201");
223
- form.set("Content-Type", file.type);
224
- form.set("file", file);
225
- const uploadResponse = await fetch(policy.endpoint, { method: "POST", body: form });
226
- if (!uploadResponse.ok) throw new Error("OSS 未接受附件上传,请检查 Bucket CORS 和写入策略。");
227
- setAttachments((current) => current.map((item, itemIndex) =>
228
- itemIndex === index ? { ...item, src: policy.publicUrl!, mime: file.type } : item,
229
- ));
230
- setNotice("附件已上传到阿里云 OSS;发布随想后会使用该公开媒体地址。");
231
- } catch (error) {
232
- setNotice(error instanceof Error ? error.message : "上传失败,请稍后重试。");
233
- } finally {
234
- setUploadingCount((count) => Math.max(0, count - 1));
235
- }
236
- }
237
-
238
- async function publishThought(status: "draft" | "published") {
239
- setNotice("");
240
- setIsSaving(true);
241
- const media = attachments.filter((item) => item.src.trim()).map((item) => ({
242
- ...item,
243
- src: item.src.trim(),
244
- ...(item.title?.trim() ? { title: item.title.trim() } : {}),
245
- }));
246
- try {
247
- const response = await fetch("/api/admin/thoughts", {
248
- method: "POST",
249
- headers: { "Content-Type": "application/json" },
250
- body: JSON.stringify({
251
- title,
252
- content,
253
- tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean),
254
- media,
255
- status,
256
- }),
257
- });
258
- const result = (await response.json()) as { thought?: ManagedThought; error?: string };
259
- if (!response.ok || !result.thought) throw new Error(result.error ?? "保存失败。");
260
- setThoughts((current) => [result.thought!, ...current]);
261
- setTitle("");
262
- setContent("");
263
- setTags("");
264
- setAttachments([]);
265
- setNotice(status === "published" ? "随想已提交到 GitHub,Pages 将自动重新发布。" : "草稿已提交到 GitHub,仅后台可见。"
266
- );
267
- } catch (error) {
268
- setNotice(error instanceof Error ? error.message : "保存失败,请稍后重试。");
269
- } finally {
270
- setIsSaving(false);
271
- }
272
- }
273
-
274
- async function updateThought(id: string, status: "draft" | "published") {
275
- const response = await fetch(`/api/admin/thoughts/${id}`, {
276
- method: "PATCH",
277
- headers: { "Content-Type": "application/json" },
278
- body: JSON.stringify({ status }),
279
- });
280
- if (!response.ok) {
281
- setNotice("状态更新失败,请稍后重试。");
282
- return;
283
- }
284
- setThoughts((current) =>
285
- current.map((thought) =>
286
- thought.id === id
287
- ? { ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date }
288
- : thought,
289
- ),
290
- );
291
- }
292
-
293
- async function deleteThought(id: string) {
294
- if (!window.confirm("确定删除这条后台随想吗?此操作无法恢复。")) return;
295
- const response = await fetch(`/api/admin/thoughts/${id}`, { method: "DELETE" });
296
- if (!response.ok) {
297
- setNotice("删除失败,请稍后重试。");
298
- return;
299
- }
300
- setThoughts((current) => current.filter((thought) => thought.id !== id));
301
- }
302
-
303
83
  async function distribute(entry: CatalogEntry, platform: "x" | "csdn" | "zhihu" | "juejin") {
304
84
  const key = `${entry.kind}:${entry.column ?? ""}:${entry.path}:${platform}`;
305
85
  setDistributionStatus((current) => ({ ...current, [key]: "正在发送…" }));
@@ -374,11 +154,9 @@ export function AdminDashboard({
374
154
  </header>
375
155
 
376
156
  <section className="admin-hero">
377
- <h1>写一条随想。</h1>
378
- <p>长文仍在编辑器里改。这里写随想、上传附件、置顶,以及发到其他网站。</p>
157
+ <h1>管理文章。</h1>
158
+ <p>正文仍在编辑器里改。这里置顶文章、管理交互,以及发到其他网站。</p>
379
159
  <p className="admin-quiet-count">
380
- {publishedCount + staticThoughtCount} 条公开随想
381
- {" · "}
382
160
  {catalogEntries.filter((entry) => entry.kind === "post").length} 篇博文
383
161
  {" · "}
384
162
  {catalogEntries.filter((entry) => entry.kind === "column").length} 篇专栏
@@ -390,39 +168,6 @@ export function AdminDashboard({
390
168
  {notice && <p className="admin-notice"><CheckCircle size={16} /> {notice}</p>}
391
169
  {contentWarning && <p className="admin-notice"><CheckCircle size={16} /> {contentWarning}</p>}
392
170
 
393
- <section className="admin-grid">
394
- <div className="admin-panel admin-compose-panel">
395
- <div className="admin-panel-heading">
396
- <h2>写一条</h2>
397
- </div>
398
- <label>标题<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="标题" /></label>
399
- <label>正文<textarea value={content} onChange={(event) => setContent(event.target.value)} placeholder="写下来。可以用 Markdown。" rows={8} /></label>
400
- <label>标签<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="用逗号分开,例如:夜晚, 散步" /></label>
401
- <MediaFields attachments={attachments} onChange={setAttachments} onUpload={uploadMedia} uploading={uploadingCount > 0} />
402
- <div className="admin-actions">
403
- <button type="button" className="admin-secondary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("draft")}>保存草稿</button>
404
- <button type="button" className="admin-primary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("published")}><PaperPlaneTilt size={16} /> {uploadingCount > 0 ? "正在上传附件" : isSaving ? "正在保存" : "发布到博客"}</button>
405
- </div>
406
- </div>
407
-
408
- <div className="admin-panel admin-thoughts-panel">
409
- <div className="admin-panel-heading">
410
- <h2>草稿和已发布</h2>
411
- </div>
412
- <div className="admin-thought-list">
413
- {thoughts.length === 0 ? <p className="admin-empty">还没有在这里写过随想。</p> : thoughts.map((thought) => (
414
- <article key={thought.id}>
415
- <div><span className={`admin-status ${thought.status}`}>{thought.status === "published" ? "已发布" : "草稿"}</span><h3>{thought.title}</h3><p>{thought.content.slice(0, 76)}{thought.content.length > 76 ? "…" : ""}</p></div>
416
- <div className="admin-item-actions">
417
- {thought.status === "published" && <a href={`${publicSiteUrl}/thoughts/${thought.slug}`} target="_blank" rel="noreferrer" aria-label={`查看 ${thought.title}`}><ArrowUpRight size={17} /></a>}
418
- <button type="button" onClick={() => updateThought(thought.id, thought.status === "published" ? "draft" : "published")}>{thought.status === "published" ? "撤回" : "发布"}</button>
419
- <button type="button" aria-label={`删除 ${thought.title}`} onClick={() => deleteThought(thought.id)}><Trash size={16} /></button>
420
- </div>
421
- </article>
422
- ))}</div>
423
- </div>
424
- </section>
425
-
426
171
  <section className="admin-panel admin-distribution-panel">
427
172
  <div className="admin-panel-heading">
428
173
  <h2>置顶,或发到别处</h2>
@@ -4,6 +4,7 @@ import {renderComposePreview} from "./garden/ComposePreview";
4
4
 
5
5
  import Link from "next/link";
6
6
  import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
7
+ import { evaluateWorkbenchDraft } from "../../ui/core/workbench-eval.mjs";
7
8
  import { buildFixPrompt } from "../../ui/core/fix-prompt.mjs";
8
9
  import { CatalogPage, type RecipeSource } from "../../ui/react/CatalogPage";
9
10
  import { EVALS_STORAGE_KEY as EVALS_KEY, STUDIO_START_KEY } from "./ui-studio-keys";
@@ -44,8 +45,7 @@ const STATUS_LABELS: Record<Block["status"], string> = { uncompiled: "未编译"
44
45
  const publicPath = (path: string) => {
45
46
  const posts = /^content\/posts\/(.+)\.md$/u.exec(path);
46
47
  if (posts) return `/blog/${posts[1]}`;
47
- const thoughts = /^content\/thoughts\/(.+)\.md$/u.exec(path);
48
- return thoughts ? `/thoughts/${thoughts[1]}` : null;
48
+ return null;
49
49
  };
50
50
 
51
51
  export function AdminBlocks({ siteUrl }: { siteUrl: string }) {
@@ -191,11 +191,9 @@ export function AdminEvals({ cases, configured }: { cases: EvalCase[]; configure
191
191
  });
192
192
  const body = (await response.json()) as { fence?: string | null; note?: string | null; rounds?: number; diagnostics?: Array<{ severity: string }>; usage?: { total_tokens?: number }; error?: string };
193
193
  const fence = body.fence ?? "";
194
- const used = new Set([...fence.matchAll(/<([A-Z][A-Za-z]*)\b/gu)].map((match) => match[1]));
195
- const valid = response.ok && Boolean(body.fence) && !(body.diagnostics ?? []).some((entry) => entry.severity === "error");
196
- const components = (item.expect.components ?? []).every((name) => used.has(name));
197
- const assert = !item.expect.assert || /\nexpect:/u.test(fence);
198
- const note = !item.expect.note || Boolean(body.note);
194
+ const evaluation = evaluateWorkbenchDraft({ fence, note: body.note }, item.expect);
195
+ const valid = response.ok && evaluation.valid && !(body.diagnostics ?? []).some((entry) => entry.severity === "error");
196
+ const { components, assert, note } = evaluation;
199
197
  collected.push({
200
198
  id: item.id,
201
199
  ok: valid && components && assert && note,
@@ -1,11 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
4
+ import { ArrowRight, Article, Books } from "@phosphor-icons/react/ssr";
5
5
  import { siteHref } from "../lib/site-href";
6
6
  import { ui } from "../lib/i18n";
7
7
 
8
- export function ContentEmptyState({ kind }: { kind: "posts" | "thoughts" | "columns" }) {
8
+ export function ContentEmptyState({ kind }: { kind: "posts" | "columns" }) {
9
9
  const s = ui();
10
10
  const emptyContent = {
11
11
  posts: {
@@ -16,14 +16,6 @@ export function ContentEmptyState({ kind }: { kind: "posts" | "thoughts" | "colu
16
16
  actionHref: "/",
17
17
  actionLabel: s.emptyPostsCta,
18
18
  },
19
- thoughts: {
20
- icon: Quotes,
21
- eyebrow: s.emptyThoughtsEyebrow,
22
- title: s.emptyThoughtsTitle,
23
- description: s.emptyThoughtsDesc,
24
- actionHref: "/blog",
25
- actionLabel: s.emptyThoughtsCta,
26
- },
27
19
  columns: {
28
20
  icon: Books,
29
21
  eyebrow: s.emptyColumnsEyebrow,
@@ -1,11 +1,7 @@
1
1
  const adminJobs = [
2
- {
3
- title: "写随想",
4
- description: "保存草稿,或直接发布。文件写回 content/thoughts/,没有另一份数据库。",
5
- },
6
2
  {
7
3
  title: "上传附件",
8
- description: "图片、音频和视频可以传到对象存储。文章里只留下地址。",
4
+ description: "通过后台上传 API 将图片、音频和视频传到对象存储,文章里只留下地址。",
9
5
  },
10
6
  {
11
7
  title: "置顶",
@@ -23,7 +19,7 @@ const adminJobs = [
23
19
 
24
20
  const adminFlow = [
25
21
  ["用 GitHub 登录", "只有名单里的账号能进来。"],
26
- ["做一件事", "写随想、上传、置顶,或发到别处。"],
22
+ ["做一件事", "上传、置顶,或发到别处。"],
27
23
  ["写回仓库", "后台把改动提交到你的内容仓库。"],
28
24
  ["网站更新", "main 变了之后,页面会重新发布。"],
29
25
  ];
@@ -36,25 +32,10 @@ export function GardenAdminGuide() {
36
32
  <h2 id="garden-admin-title">后台</h2>
37
33
  </div>
38
34
  <p>
39
- 长文继续在编辑器里写。后台只做需要登录的事:随想、附件、置顶、转发,以及文章里的交互。
35
+ 长文继续在编辑器里写。后台只做需要登录的事:附件、置顶、转发,以及文章里的交互。
40
36
  </p>
41
37
  </header>
42
38
 
43
- <figure className="garden-admin-preview">
44
- {/* vinext 的本地静态导出不提供 Next 图片优化端点,截图直接由 public 目录提供。 */}
45
- {/* eslint-disable-next-line @next/next/no-img-element */}
46
- <img
47
- src="/images/garden/admin-studio.png"
48
- alt="Garden 后台:上面写一条随想,下面是草稿和已发布"
49
- width={920}
50
- height={1033}
51
- loading="lazy"
52
- />
53
- <figcaption>
54
- <strong>后台首页</strong>
55
- <span>上面写一条随想。下面是草稿,以及可以置顶或转发的文章。</span>
56
- </figcaption>
57
- </figure>
58
39
 
59
40
  <div className="garden-admin-capabilities">
60
41
  {adminJobs.map((job) => (
@@ -41,21 +41,6 @@ cover: writing/first-post.jpg
41
41
  </icon-list>
42
42
  \`\`\``;
43
43
 
44
- const thoughtSource = `---
45
- title: 雨后的散步
46
- date: 2026-08-03 18:30:00
47
- tags: [生活]
48
- media:
49
- - type: image
50
- src: ./assets/street.jpg
51
- alt: 雨后的街道
52
- - type: audio
53
- src: ./assets/rain.mp3
54
- title: 雨声
55
- ---
56
-
57
- 今天下班后多走了两公里。`;
58
-
59
44
  const columnSource = `columns:
60
45
  - slug: writing-system
61
46
  title: 写作系统
@@ -96,21 +81,6 @@ const guides: Guide[] = [
96
81
  source: postSource,
97
82
  language: "markdown",
98
83
  },
99
- {
100
- id: "write-thought",
101
- label: "随想",
102
- title: "写一条随想",
103
- introduction: "几句话、一张图或一个链接。字段更少。也可以在后台写,写完同样进仓库。",
104
- steps: [
105
- <>新建 <code>content/thoughts/文件名.md</code>,或在后台填写。</>,
106
- <>文件名就是地址。标题和日期写在开头。先存着就标成草稿。</>,
107
- <>图片、音频、视频和链接按顺序放进 <code>media</code>。</>,
108
- ],
109
- result: "它会出现在随想页,并且有自己的链接。",
110
- path: "content/thoughts/walk-after-rain.md",
111
- source: thoughtSource,
112
- language: "markdown",
113
- },
114
84
  {
115
85
  id: "make-column",
116
86
  label: "专栏",
@@ -32,7 +32,7 @@ import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs
32
32
  import { siteConfig, siteLinks } from "@garden/site-config";
33
33
  import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
34
34
  import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
35
- import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts, thoughts } from "../lib/content";
35
+ import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts } from "../lib/content";
36
36
  import { LinkCardRefresh } from "./LinkCardRefresh";
37
37
  import { linkHost } from "../../scripts/link-meta-core.mjs";
38
38
  import { FaviconProvider, SourceMark } from "./garden/patterns";
@@ -52,10 +52,9 @@ const siteRoute = internalLinkResolver({
52
52
  ...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
53
53
  ],
54
54
  routes: [
55
- "/", "/blog", "/columns", "/thoughts", "/tags", "/about", "/garden", "/garden/en",
55
+ "/", "/blog", "/columns", "/tags", "/about", "/garden", "/garden/en",
56
56
  ...posts.map(postHref),
57
57
  ...columns.map(columnHref),
58
- ...thoughts.map((thought) => `/thoughts/${thought.slug}`),
59
58
  ...gardenDocs.map(gardenDocHref),
60
59
  ...gardenDocsEn.map((doc) => siteHref(`/garden/en/${doc.path}`)),
61
60
  ],
@@ -8,7 +8,7 @@ type PageIntroProps = {
8
8
  };
9
9
 
10
10
  /**
11
- * Shared heading for top-level list pages (blog / thoughts / columns).
11
+ * Shared heading for top-level list pages (blog / columns).
12
12
  * Same eyebrow + h1 + deck typography and padding so tab switches do not jump.
13
13
  * Empty subtitle reserves no space.
14
14
  */