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
@@ -11,6 +11,8 @@ description: >-
11
11
 
12
12
  只审用户点名的稿。三步按顺序做,后一步看到的是前一步改完的稿。
13
13
 
14
+ 开始前先运行 `npx garden ui scan content/posts/路径.md`。有 `status` 为 `uncompiled`、`stale` 或 `error` 的块时,先按 [garden-ui](../garden-ui/SKILL.md) 处理完,再进入第 1 步。
15
+
14
16
  硬性检查命令(在站点根目录):
15
17
 
16
18
  ```bash
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: garden-ui
3
+ description: >-
4
+ Compiles the intent blocks in one Garden post into ui blocks (or prose, or a
5
+ widget), recompiles stale or broken ui blocks, and suggests where a post
6
+ could use interaction. Use when the user says 编译这篇 / 编译意图块 / 重编译
7
+ <id> / 这篇哪里适合交互, or pastes a garden ui check fix prompt.
8
+ ---
9
+
10
+ # 编译 Garden 交互块
11
+
12
+ 作者在稿里用 `intent` 围栏写一句想法,这个 skill 把它变成 `ui` 块。`ui` 块的格式、组件和设计规则都在 `npx garden ui prompt` 的输出里,以那份为准,不凭记忆写组件。
13
+
14
+ 所有命令在站点根目录运行。没给稿件路径时只问路径,不扫全库。
15
+
16
+ ## 触发语与范围
17
+
18
+ | 用户说 | 处理哪些块 |
19
+ | --- | --- |
20
+ | 编译这篇:`<路径>`、编译意图块 | 状态是 `uncompiled`、`stale`、`error` 的块 |
21
+ | 重编译 `<id>` | 只处理这一块,不管状态 |
22
+ | 粘贴了 `garden ui check` 的修复提示词 | 提示词里列出的块 |
23
+ | 这篇哪里适合交互 | 只读建议模式,见文末,不改稿 |
24
+
25
+ ## 流程
26
+
27
+ 1. 扫描:
28
+
29
+ ```bash
30
+ npx garden ui scan content/posts/路径.md
31
+ ```
32
+
33
+ stdout 是 JSON。每块有 `lang`、`id`、`status`、`startLine`、`hash`(整段围栏的哈希,写回时当 `--base`)、`intentHash`(原话的哈希)、`intent`(原话)、`caption`、`section`(所在小节标题)和 `diagnostics`。不要自己用正则找围栏。
34
+
35
+ 2. 读规则:
36
+
37
+ ```bash
38
+ npx garden ui prompt
39
+ ```
40
+
41
+ 里面是组件目录、配方和设计规则。按配方的骨架写,不发明目录里没有的组件或属性。
42
+
43
+ 3. 每块读原话和所在小节,三选一:
44
+ - **写回正文**:一句话、一张表或一张 Mermaid 就能说清时。
45
+ - **写 `ui`**:读者需要拖、点、翻才能看懂时。
46
+ - **写 `widget`**:目录表达不了的画布动画、音频、物理模拟等。按 [garden-interactive-chart](../garden-interactive-chart/SKILL.md) 在同名目录写 HTML,并在 `widget` 的 YAML 里加 `intent:` 保留原话。
47
+
48
+ 4. 写 `ui` 时:
49
+ - 原话原样放进块头的 `intent`,一个字都不改。`compiled` 不用写,`apply` 会写。
50
+ - `caption` 摘原话里的判断。原话没有判断句,就根据小节内容补一句,并在回复里说明。
51
+ - 原话里有已知点("batch 1 时是 512 MiB"),写进 `expect`。
52
+ - 数据写在块里。原话提到同名目录里已有的数据文件时,才用 `Data` 引用。
53
+ - 块太大就拆成两块,各自就近放在要讲的段落旁边,不挪到别的文件。
54
+
55
+ 5. 每块用 `apply` 写回,新内容从标准输入给:
56
+
57
+ ````bash
58
+ npx garden ui apply content/posts/路径.md --id <id> --base <hash> <<'GARDEN_UI'
59
+ ```ui
60
+ ---
61
+ id: ...
62
+ caption: ...
63
+ intent: |
64
+ ...
65
+ ---
66
+ ...
67
+ ```
68
+ GARDEN_UI
69
+ ````
70
+
71
+ - 没写 id 的 `intent` 块用 `--intent-hash <intentHash>` 定位;原话重复时改用 `--at <startLine>`。
72
+ - 新内容可以是一个 `ui` 围栏、一个 `widget` 围栏,或一段普通 Markdown(写回正文时)。
73
+ - 成功时 stdout 是 `{"ok":true,...}`。失败时 stderr 是 JSON,按 `code` 处理:
74
+ - `apply-invalid`:新内容没通过校验,按 `diagnostics` 改了再写。
75
+ - `apply-base`:块在扫描之后被改过。重新 `scan`,用新的 `hash`。
76
+ - `apply-not-found` / `apply-ambiguous`:定位不到或不唯一,重新 `scan` 换定位方式。
77
+ - 行号会随前面的写回变化,每写回一块就重新 `scan` 再处理下一块。
78
+
79
+ 6. 全部写完后:
80
+
81
+ ```bash
82
+ npx garden check content/posts/路径.md
83
+ ```
84
+
85
+ 直到 `ok` 为 true。同一块连续三次修不好,停下来,把诊断原样报告给用户。
86
+
87
+ 7. 回复一张表:
88
+
89
+ | id | 选择 | 配方 | 假设 | expect |
90
+ | --- | --- | --- | --- | --- |
91
+ | kv-memory | ui | 参数台 | 默认序列取 4K | batch 1 时 512 MiB |
92
+
93
+ "假设"写原话没说、你替作者定下的东西(默认值、范围、补的 caption)。没有就写"无"。
94
+
95
+ ## 禁止
96
+
97
+ - 直接编辑稿件文件。稿件只经 `apply` 改动;`widget` 的 HTML 文件可以直接创建。
98
+ - 编造原话里没有的数字。需要数字但原话没给时,用原话里的公式算,或者问作者。
99
+ - 使用远程地址(图片、脚本、数据)。
100
+ - 改动目标块之外的正文。
101
+
102
+ ## 建议模式:这篇哪里适合交互
103
+
104
+ 只读,不改稿。先运行 `npx garden ui prompt` 读设计规则,再通读全文,挑出纯文字或贴图说不清的段落:
105
+
106
+ - 公式或数字随参数变化(参数台、换算器)
107
+ - 多步过程(步进器、流程)
108
+ - 两种做法的差别(对比)
109
+ - 一组格子、字节或内存布局(格子图)
110
+
111
+ 每处给出:所在小节、为什么文字不够、一段可以直接粘贴到该段落后面的 `intent` 草稿。草稿只写读者要做什么、看到什么,以及原文里已有的数字,不写组件名。最多 5 处;没有合适的就说没有。
package/.env.example CHANGED
@@ -52,6 +52,14 @@ WEBMENTION_IO_DOMAIN=example.com
52
52
  # OSS_PUBLIC_BASE_URL=https://media.example.com
53
53
  # OSS_MAX_UPLOAD_MB=200
54
54
 
55
+ # 后台的组件工作台:OpenAI 兼容接口。密钥只留在后台服务,不进浏览器和公开构建。
56
+ # UI_MODEL_BASE_URL=https://api.openai.com/v1
57
+ # UI_MODEL_API_KEY=sk-xxxxxxxx
58
+ # UI_MODEL_NAME=gpt-5.6
59
+ # UI_DRAFT_RATE_LIMIT=60
60
+ # 可选:GitHub Enterprise 或本地测试替身的 API 根地址。
61
+ # GITHUB_API_URL=https://api.github.com
62
+
55
63
  # 内容分发:X 使用拥有发帖权限的用户访问令牌;其余平台接入你自己的发布桥接服务。
56
64
  # X_USER_ACCESS_TOKEN=xxxxxxxx
57
65
  # CSDN_SYNC_WEBHOOK=https://publisher.example.com/csdn
@@ -1,11 +1,14 @@
1
1
  import Link from "next/link";
2
2
  import { ArrowSquareOut, GithubLogo, LockKey } from "@phosphor-icons/react/ssr";
3
3
  import { AdminDashboard } from "../components/AdminDashboard";
4
- import { getAdminUser, isGitHubOAuthConfigured } from "../lib/admin-auth";
4
+ import { getAdminUser, isGitHubLoginAvailable } from "../lib/admin-auth";
5
5
  import { columns, posts, thoughts } from "../lib/content";
6
6
  import { isContentRepositoryConfigured, listRepositoryThoughts } from "../lib/github-content";
7
7
  import { getSiteUrl } from "../lib/site";
8
8
  import { siteConfig } from "../site.config";
9
+ import { modelConfig, remainingDrafts } from "../lib/ui-model";
10
+ import { CATALOG_VERSION } from "../../ui/core/catalog.mjs";
11
+ import packageJson from "../../package.json";
9
12
 
10
13
  export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
11
14
 
@@ -45,7 +48,7 @@ export default async function AdminPage({
45
48
 
46
49
  const [user, query, publicSiteUrl] = await Promise.all([getAdminUser(), searchParams, getSiteUrl()]);
47
50
  if (!user) {
48
- const oauthReady = isGitHubOAuthConfigured();
51
+ const oauthReady = isGitHubLoginAvailable();
49
52
  return (
50
53
  <main className="admin-access-page">
51
54
  <div className="admin-access-card">
@@ -57,7 +60,9 @@ export default async function AdminPage({
57
60
  ? `登录账号 @${query.account ?? "未知"} 没有后台权限。`
58
61
  : oauthReady
59
62
  ? "登录后会检查你的 GitHub 用户名是否在允许名单中。"
60
- : "GitHub OAuth 尚未配置;请先设置后台所需的环境变量。"}
63
+ : process.env.GARDEN_DEV === "1"
64
+ ? "本机还没有 GitHub 登录。在终端运行 gh auth login 后,重新启动本地服务。"
65
+ : "GitHub OAuth 尚未配置;请先设置后台所需的环境变量。"}
61
66
  </p>
62
67
  <div className="admin-access-actions">
63
68
  {oauthReady && (
@@ -112,6 +117,12 @@ export default async function AdminPage({
112
117
  catalog={catalog}
113
118
  publicSiteUrl={publicSiteUrl}
114
119
  contentWarning={contentWarning}
120
+ ui={{
121
+ modelName: modelConfig()?.model ?? null,
122
+ remaining: remainingDrafts(user.login),
123
+ engineVersion: packageJson.version,
124
+ catalogVersion: CATALOG_VERSION,
125
+ }}
115
126
  />
116
127
  );
117
128
  }
@@ -0,0 +1,88 @@
1
+ import matter from "gray-matter";
2
+ import Link from "next/link";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { scanFences } from "../../../../ui/core/fence.mjs";
5
+ import { collectDataSources, transformUiContent } from "../../../../ui/core/transform.mjs";
6
+ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } from "../../../components/AdminUiShell";
7
+ import { MarkdownArticle } from "../../../components/MarkdownArticle";
8
+ import { repositoryContentPath } from "../../../lib/repository-path.mjs";
9
+ import { readMarkdown, readText } from "../../../lib/ui-content";
10
+
11
+ export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
12
+ export const metadata = { title: "草稿预览", robots: { index: false, follow: false } };
13
+
14
+ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(value);
15
+
16
+ /** Same-name-directory files go through the authenticated, sandboxed proxy. */
17
+ function proxyAssets(content: string, post: string) {
18
+ const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
19
+ let output = content.replace(/(!\[[^\]]*\]\()<?([^)\s>]+)>?/gu, (match, prefix: string, src: string) =>
20
+ isRelative(src) ? `${prefix}${proxy(src)}` : match,
21
+ );
22
+ for (const fence of [...scanFences(output)].reverse()) {
23
+ if (fence.lang !== "widget") continue;
24
+ try {
25
+ const data = parseYaml(fence.body) as Record<string, unknown> | null;
26
+ if (!data || typeof data.src !== "string" || !isRelative(data.src)) continue;
27
+ const next = `${fence.marker}widget\n${stringifyYaml({ ...data, src: proxy(data.src) }).trimEnd()}\n${fence.marker}`;
28
+ output = `${output.slice(0, fence.start)}${next}${output.slice(fence.end)}`;
29
+ } catch {
30
+ continue;
31
+ }
32
+ }
33
+ return output;
34
+ }
35
+
36
+ export default async function DraftPreviewPage({ params }: { params: Promise<{ path: string[] }> }) {
37
+ if (isStaticExport()) return <StaticAdminNotice />;
38
+ const user = await requireAdminPage();
39
+ const { path: segments } = await params;
40
+ let post: string;
41
+ try {
42
+ post = repositoryContentPath(`${segments.map((segment) => decodeURIComponent(segment)).join("/")}.md`);
43
+ } catch {
44
+ return (
45
+ <AdminUiShell active="" userName={`@${user.login}`}>
46
+ <section className="admin-panel"><p>稿件路径不合法。</p></section>
47
+ </AdminUiShell>
48
+ );
49
+ }
50
+ const file = await readMarkdown(post).catch(() => null);
51
+ if (!file) {
52
+ return (
53
+ <AdminUiShell active="" userName={`@${user.login}`}>
54
+ <section className="admin-panel"><p>找不到 {post}。</p></section>
55
+ </AdminUiShell>
56
+ );
57
+ }
58
+ const parsed = matter(file.source, { engines: { yaml: { parse: (input: string) => parseYaml(input) ?? {} } } });
59
+ const data = parsed.data as Record<string, unknown>;
60
+ const directory = post.slice(0, post.lastIndexOf("/"));
61
+ const dataFiles: Record<string, string | undefined> = {};
62
+ for (const src of collectDataSources(parsed.content)) {
63
+ const target = `${directory}/${src.replace(/^\.\//u, "")}`;
64
+ dataFiles[src] = await readText(target).catch(() => undefined);
65
+ }
66
+ const { content } = transformUiContent(parsed.content.trim(), {
67
+ mode: "preview",
68
+ markdownPath: post,
69
+ readData: (src: string) => dataFiles[src],
70
+ resolveAsset: (_sidecarPath: string, written: string) =>
71
+ `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(written)}`,
72
+ });
73
+ const draft = data.draft === true;
74
+ return (
75
+ <AdminUiShell active="" userName={`@${user.login}`}>
76
+ <section className="admin-panel ui-admin-preview">
77
+ <p className="ui-studio-meta">
78
+ 草稿预览 · {post}{draft ? " · 草稿" : ""} · <Link href="/admin/ui/blocks">回到交互块</Link>
79
+ </p>
80
+ <h1 className="ui-admin-preview-title">{typeof data.title === "string" ? data.title : post}</h1>
81
+ {typeof data.description === "string" ? <p className="ui-studio-meta">{data.description}</p> : null}
82
+ <article className="article-body">
83
+ <MarkdownArticle content={proxyAssets(content, post)} />
84
+ </article>
85
+ </section>
86
+ </AdminUiShell>
87
+ );
88
+ }
@@ -0,0 +1,16 @@
1
+ import { AdminBlocks } from "../../../components/AdminUiPanels";
2
+ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } from "../../../components/AdminUiShell";
3
+ import { getSiteUrl } from "../../../lib/site";
4
+
5
+ export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
6
+ export const metadata = { title: "交互块", robots: { index: false, follow: false } };
7
+
8
+ export default async function UiBlocksPage() {
9
+ if (isStaticExport()) return <StaticAdminNotice />;
10
+ const user = await requireAdminPage();
11
+ return (
12
+ <AdminUiShell active="/admin/ui/blocks" userName={`@${user.login}`}>
13
+ <AdminBlocks siteUrl={(await getSiteUrl()).replace(/\/+$/u, "")} />
14
+ </AdminUiShell>
15
+ );
16
+ }
@@ -0,0 +1,16 @@
1
+ import { AdminCatalog } from "../../../components/AdminUiPanels";
2
+ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } from "../../../components/AdminUiShell";
3
+ import { recipeSources } from "../../../lib/ui-model";
4
+
5
+ export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
6
+ export const metadata = { title: "组件清单", robots: { index: false, follow: false } };
7
+
8
+ export default async function UiCatalogPage() {
9
+ if (isStaticExport()) return <StaticAdminNotice />;
10
+ const user = await requireAdminPage();
11
+ return (
12
+ <AdminUiShell active="/admin/ui/catalog" userName={`@${user.login}`}>
13
+ <AdminCatalog recipes={recipeSources} />
14
+ </AdminUiShell>
15
+ );
16
+ }
@@ -0,0 +1,19 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ import casesSource from "../../../../ui/evals/cases.yaml?raw";
3
+ import { AdminEvals } from "../../../components/AdminUiPanels";
4
+ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } from "../../../components/AdminUiShell";
5
+ import { modelConfig } from "../../../lib/ui-model";
6
+
7
+ export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
8
+ export const metadata = { title: "模型自检", robots: { index: false, follow: false } };
9
+
10
+ export default async function UiEvalsPage() {
11
+ if (isStaticExport()) return <StaticAdminNotice />;
12
+ const user = await requireAdminPage();
13
+ const cases = (parseYaml(casesSource) ?? []) as Array<{ id: string; intent: string; expect: { components?: string[]; assert?: boolean; note?: boolean } }>;
14
+ return (
15
+ <AdminUiShell active="/admin/ui/evals" userName={`@${user.login}`}>
16
+ <AdminEvals cases={cases} configured={Boolean(modelConfig())} />
17
+ </AdminUiShell>
18
+ );
19
+ }
@@ -0,0 +1,22 @@
1
+ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } from "../../components/AdminUiShell";
2
+ import { UiStudio } from "../../components/UiStudio";
3
+ import { modelConfig, recipeSources, remainingDrafts } from "../../lib/ui-model";
4
+
5
+ export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
6
+ export const metadata = { title: "组件工作台", robots: { index: false, follow: false } };
7
+
8
+ export default async function UiStudioPage() {
9
+ if (isStaticExport()) return <StaticAdminNotice />;
10
+ const user = await requireAdminPage();
11
+ const config = modelConfig();
12
+ return (
13
+ <AdminUiShell active="/admin/ui" userName={`@${user.login}`}>
14
+ <UiStudio
15
+ configured={Boolean(config)}
16
+ modelName={config?.model ?? null}
17
+ remaining={remainingDrafts(user.login)}
18
+ recipes={recipeSources}
19
+ />
20
+ </AdminUiShell>
21
+ );
22
+ }
@@ -0,0 +1,227 @@
1
+ /* Admin pages for Intelligent UI: navigation, workbench, lists. */
2
+ .admin-ui-nav {
3
+ display: flex;
4
+ flex-wrap: wrap;
5
+ gap: 4px 18px;
6
+ margin: 0 0 18px;
7
+ padding: 10px 0;
8
+ border-bottom: 1px solid var(--line);
9
+ font-size: 13px;
10
+ }
11
+
12
+ .admin-ui-nav a {
13
+ color: var(--muted);
14
+ text-decoration: none;
15
+ }
16
+
17
+ .admin-ui-nav a[aria-current="page"] {
18
+ color: var(--ink);
19
+ text-decoration: underline;
20
+ text-decoration-color: var(--accent);
21
+ text-underline-offset: 6px;
22
+ }
23
+
24
+ .ui-studio {
25
+ display: grid;
26
+ grid-template-columns: 280px minmax(0, 1fr);
27
+ gap: 28px;
28
+ align-items: start;
29
+ }
30
+
31
+ @media (max-width: 900px) {
32
+ .ui-studio { grid-template-columns: 1fr; }
33
+ }
34
+
35
+ .ui-studio-rail { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
36
+
37
+ .ui-studio-actions { display: flex; gap: 8px; }
38
+ .ui-studio-actions .admin-primary-button,
39
+ .ui-studio-actions .admin-secondary-button { padding: 8px 14px; border-radius: 8px; }
40
+
41
+ .ui-studio-instruction {
42
+ width: 100%;
43
+ min-height: 120px;
44
+ padding: 12px;
45
+ border: 1px solid var(--line);
46
+ border-radius: 12px;
47
+ background: transparent;
48
+ color: var(--ink);
49
+ font: inherit;
50
+ font-size: 14px;
51
+ line-height: 1.6;
52
+ resize: vertical;
53
+ }
54
+
55
+ .ui-studio-instruction:focus-visible { border-color: var(--ink); outline: none; }
56
+
57
+ .ui-studio-meta { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
58
+
59
+ .ui-studio-note {
60
+ margin: 10px 0 0;
61
+ padding: 8px 10px;
62
+ border-left: 2px solid var(--accent);
63
+ background: var(--accent-soft);
64
+ color: var(--ink);
65
+ font-size: 12px;
66
+ white-space: pre-wrap;
67
+ }
68
+
69
+ .ui-studio-message { margin: 10px 0 0; font-size: 12px; }
70
+ .ui-studio-message-error { color: var(--accent); }
71
+ .ui-studio-message-info { color: var(--ink-soft); }
72
+
73
+ .ui-studio-starts,
74
+ .ui-studio-history {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 8px;
78
+ margin-top: 8px;
79
+ padding-top: 12px;
80
+ border-top: 1px solid var(--line);
81
+ font-size: 13px;
82
+ }
83
+
84
+ .ui-studio-starts h3,
85
+ .ui-studio-history h3 { margin: 0; color: var(--ink); font-size: 13px; }
86
+ .ui-studio-starts .admin-quiet-button { align-self: flex-start; padding: 0; color: var(--ink); font-size: 13px; }
87
+
88
+ .ui-studio-starts label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); }
89
+
90
+ .ui-studio-starts select {
91
+ padding: 8px 10px;
92
+ border: 1px solid var(--line);
93
+ border-radius: 8px;
94
+ background: transparent;
95
+ color: var(--ink);
96
+ font: inherit;
97
+ font-size: 13px;
98
+ }
99
+
100
+ .ui-studio-history ol { margin: 0; padding-left: 18px; }
101
+ .ui-studio-history button { padding: 0; border: 0; background: none; color: var(--ink-soft); font: inherit; text-align: left; cursor: pointer; }
102
+
103
+ .ui-studio-stage {
104
+ display: flex;
105
+ flex-direction: column;
106
+ min-width: 0;
107
+ min-height: 640px;
108
+ overflow: hidden;
109
+ border: 1px solid var(--line);
110
+ border-radius: 16px;
111
+ background: var(--paper);
112
+ }
113
+
114
+ .ui-studio-bar {
115
+ display: flex;
116
+ flex-wrap: wrap;
117
+ align-items: center;
118
+ justify-content: space-between;
119
+ gap: 8px;
120
+ padding: 10px 12px;
121
+ border-bottom: 1px solid var(--line);
122
+ }
123
+
124
+ .ui-studio-switch {
125
+ display: inline-flex;
126
+ flex-wrap: wrap;
127
+ gap: 2px;
128
+ margin: 0;
129
+ padding: 2px;
130
+ border-radius: 999px;
131
+ background: color-mix(in srgb, var(--ink) 6%, transparent);
132
+ }
133
+
134
+ .ui-studio-switch button {
135
+ padding: 4px 10px;
136
+ border: 0;
137
+ border-radius: 999px;
138
+ background: transparent;
139
+ color: var(--muted);
140
+ font: inherit;
141
+ font-size: 12px;
142
+ line-height: 18px;
143
+ cursor: pointer;
144
+ }
145
+
146
+ .ui-studio-switch button[aria-selected="true"],
147
+ .ui-studio-switch button[aria-pressed="true"] {
148
+ background: var(--paper);
149
+ color: var(--ink);
150
+ box-shadow: 0 1px 2px rgb(0 0 0 / 8%);
151
+ }
152
+
153
+ .ui-studio-preview {
154
+ flex: 1;
155
+ padding: 28px 24px 40px;
156
+ background: color-mix(in srgb, var(--ink) 3%, var(--paper));
157
+ }
158
+
159
+ .ui-studio-preview[hidden],
160
+ .ui-studio-source[hidden] { display: none; }
161
+ .ui-studio-preview[data-theme="dark"] { background: #171819; }
162
+ .ui-studio-preview[data-width="mobile"] { }
163
+ .ui-studio-preview[data-width="mobile"] .garden-ui { max-width: 375px; margin: 0 auto; }
164
+ .ui-studio-preview .garden-ui { margin: 0; }
165
+ .ui-studio-empty { margin: 48px 0; color: var(--muted); font-size: 14px; text-align: center; }
166
+
167
+ .ui-studio-source { padding: 12px; }
168
+ .ui-studio-editor {
169
+ min-height: 480px;
170
+ max-height: 70vh;
171
+ overflow: auto;
172
+ border: 1px solid var(--line);
173
+ border-radius: 12px;
174
+ background: var(--paper);
175
+ font-size: 13px;
176
+ }
177
+
178
+ .ui-studio-editor .cm-editor { min-height: 480px; }
179
+ .ui-studio-editor .cm-editor.cm-focused { outline: 2px solid var(--ink); outline-offset: -1px; }
180
+ .ui-studio-editor .cm-gutters { border-right: 1px solid var(--line); background: var(--paper); color: var(--faint); }
181
+ .ui-studio-editor .cm-garden-error-line { background: var(--accent-soft); }
182
+
183
+ .ui-studio-diagnostics { margin: 10px 0 0; padding: 0; list-style: none; font-size: 12px; }
184
+ .ui-studio-diagnostics li { border-top: 1px solid var(--line); }
185
+ .ui-studio-diagnostics li[data-severity="warning"] button { color: var(--muted); }
186
+ .ui-studio-diagnostics button { width: 100%; padding: 6px 0; border: 0; background: none; color: var(--accent); font: inherit; text-align: left; cursor: pointer; }
187
+
188
+ .ui-studio-loading { color: var(--muted); }
189
+
190
+ .ui-admin-blocks .ui-studio-switch { margin: 12px 0; }
191
+ .ui-admin-file { padding: 16px 0; border-top: 1px solid var(--line); }
192
+ .ui-admin-file h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 14px; }
193
+
194
+ .ui-admin-table { width: 100%; margin-top: 8px; border-collapse: collapse; font-size: 12px; }
195
+ .ui-admin-table th,
196
+ .ui-admin-table td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
197
+ .ui-admin-table th { color: var(--muted); font-weight: 400; }
198
+
199
+ .ui-admin-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; }
200
+ .ui-admin-actions a,
201
+ .ui-admin-actions button { padding: 0; border: 0; background: none; color: var(--ink); font: inherit; text-decoration: underline; text-decoration-color: var(--line-strong); cursor: pointer; }
202
+
203
+ .ui-admin-status { font-weight: 600; }
204
+ .ui-admin-status-uncompiled,
205
+ .ui-admin-status-error { color: var(--accent); }
206
+ .ui-admin-status-stale { color: #ad6900; }
207
+ .ui-admin-status-ok { color: var(--muted); }
208
+
209
+ .ui-admin-preview-title { margin: 12px 0 4px; font-size: 26px; }
210
+ body:has(.ui-admin-catalog) { background: var(--paper); }
211
+ :root[data-theme="dark"] body:has(.ui-admin-catalog) { background: var(--paper); }
212
+
213
+ .ui-admin-catalog.admin-panel {
214
+ max-width: none;
215
+ margin: 0;
216
+ padding: 8px 0 48px;
217
+ border: 0;
218
+ background: transparent;
219
+ }
220
+
221
+ .ui-admin-catalog .garden-catalog { max-width: none; }
222
+
223
+ .admin-ui-card { display: flex; flex-direction: column; gap: 10px; }
224
+ .admin-ui-card dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; font-size: 13px; }
225
+ .admin-ui-card dt { color: var(--muted); }
226
+ .admin-ui-card dd { margin: 0; color: var(--ink); }
227
+ .admin-ui-card nav { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; }
@@ -1,6 +1,7 @@
1
1
  import { requireAdminApi, requireAdminMutation } from "../../../lib/admin-auth";
2
2
  import { findColumnEntry, findPost } from "../../../lib/content";
3
3
  import { absoluteUrl, getSiteUrl } from "../../../lib/site";
4
+ import { contentForDistribution } from "../../../../ui/core/transform.mjs";
4
5
 
5
6
  type Platform = "x" | "csdn" | "zhihu" | "juejin";
6
7
 
@@ -45,7 +46,8 @@ export async function POST(request: Request) {
45
46
  if (!source) return Response.json({ error: "未找到对应内容。" }, { status: 404 });
46
47
 
47
48
  const publicPath = kind === "post" ? `/blog/${source.path}` : `/columns/${source.column}/${source.path}`;
48
- const canonicalUrl = absoluteUrl(publicPath, await getSiteUrl());
49
+ const siteUrl = await getSiteUrl();
50
+ const canonicalUrl = absoluteUrl(publicPath, siteUrl);
49
51
  let status: "queued" | "published" | "needs_credentials" | "failed" = "queued";
50
52
  let externalUrl: string | null = null;
51
53
  let error: string | null = null;
@@ -87,7 +89,7 @@ export async function POST(request: Request) {
87
89
  body: JSON.stringify({
88
90
  platform,
89
91
  title: source.title,
90
- markdown: source.content,
92
+ markdown: contentForDistribution(source.content, { pageUrl: canonicalUrl, siteUrl }),
91
93
  summary: source.description ?? "",
92
94
  canonicalUrl,
93
95
  tags: source.tags ?? [],
@@ -0,0 +1,21 @@
1
+ import { requireAdminApi } from "../../../../lib/admin-auth";
2
+ import { repositoryContentPath } from "../../../../lib/repository-path.mjs";
3
+ import { readMarkdown } from "../../../../lib/ui-content";
4
+
5
+ export async function GET(request: Request) {
6
+ const user = await requireAdminApi();
7
+ if (user instanceof Response) return user;
8
+ let path: string;
9
+ try {
10
+ path = repositoryContentPath(new URL(request.url).searchParams.get("path") ?? "");
11
+ } catch (error) {
12
+ return Response.json({ error: error instanceof Error ? error.message : "稿件路径不合法。" }, { status: 400 });
13
+ }
14
+ try {
15
+ const file = await readMarkdown(path);
16
+ if (!file) return Response.json({ error: "找不到这篇稿件。" }, { status: 404 });
17
+ return Response.json({ path, sha: file.sha, content: file.source });
18
+ } catch (error) {
19
+ return Response.json({ error: error instanceof Error ? error.message : "读取稿件失败。" }, { status: 503 });
20
+ }
21
+ }
@@ -0,0 +1,32 @@
1
+ import { requireAdminApi } from "../../../../lib/admin-auth";
2
+ import { previewAssetType, repositoryContentPath } from "../../../../lib/repository-path.mjs";
3
+ import { readRaw } from "../../../../lib/ui-content";
4
+ import { resolveSidecarPath } from "../../../../../ui/core/fence.mjs";
5
+
6
+ /** Same-name-directory files for the draft preview, behind login and sandboxed. */
7
+ export async function GET(request: Request) {
8
+ const user = await requireAdminApi();
9
+ if (user instanceof Response) return new Response("请先登录后台。", { status: 401 });
10
+ const params = new URL(request.url).searchParams;
11
+ let post: string;
12
+ try {
13
+ post = repositoryContentPath(params.get("post") ?? "");
14
+ } catch {
15
+ return new Response("稿件路径不合法。", { status: 400 });
16
+ }
17
+ const resolved = resolveSidecarPath(post, params.get("path") ?? "");
18
+ if (!resolved.path) return new Response(resolved.error ?? "路径不合法。", { status: 400 });
19
+ const type = previewAssetType(resolved.path);
20
+ if (!type) return new Response("不支持这种文件。", { status: 415 });
21
+ const body = await readRaw(resolved.path).catch(() => null);
22
+ if (!body) return new Response("找不到文件。", { status: 404 });
23
+ const headers = new Headers({
24
+ "Content-Type": type,
25
+ "X-Content-Type-Options": "nosniff",
26
+ "Cache-Control": "private, max-age=60",
27
+ });
28
+ // HTML and SVG can carry scripts: an opaque-origin sandbox keeps them away from the admin.
29
+ if (type.startsWith("text/html")) headers.set("Content-Security-Policy", "sandbox allow-scripts");
30
+ if (type === "image/svg+xml") headers.set("Content-Security-Policy", "sandbox");
31
+ return new Response(body, { headers });
32
+ }
@@ -74,7 +74,7 @@ function parsePayload(payload: ThoughtPayload) {
74
74
  .slice(0, 12)
75
75
  : [];
76
76
  const media = parseMedia(payload.media ?? []);
77
- const status = payload.status === "draft" ? "draft" : "published";
77
+ const status: "draft" | "published" = payload.status === "draft" ? "draft" : "published";
78
78
 
79
79
  if (!title || title.length > 120) return { error: "标题不能为空,且不得超过 120 个字符。" };
80
80
  if (!content || content.length > 20_000) return { error: "正文不能为空,且不得超过 20,000 个字符。" };