EmDash CMS 插件管理后台 UI 与字段组件开发指南:沙箱 Block Kit 与原生 React 双路径
【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址: https://gitcode.com/gh_mirrors/emdas/emdash
EmDash 是一个基于 Astro 的全栈 TypeScript CMS。插件作者可以在不进入浏览器执行沙箱代码的前提下,为管理后台贡献导航页面、仪表盘小组件、编辑器侧栏面板、内容操作按钮与声明式字段组件;也可以作为站点信任依赖,用原生 React 组件深度定制后台。本文以仓库技能文档 admin-ui.md 为主线,结合插件清单校验(manifest-schema.ts)、运行时测试宿主(runtime-host.ts)与真实插件示例(webhook-notifier/emdash-plugin.jsonc),完整讲解两条路径的声明、实现、校验与测试方法。
读完本文,你将掌握:如何用emdash-plugin.jsonc声明后台页面/小组件/编辑器扩展/字段组件;如何编写返回声明式 Block Kit 的admin路由并处理page_load、block_action、form_submit交互;如何理解secret设置的加密与ctx.settings的读写语义;以及何时应该改用原生 React 页面,并遵守后台的 Kumo、本地化、无障碍与 RTL 规则。
两条路径:沙箱声明式 UI 与原生 React UI
EmDash 插件在管理后台 UI 上存在两种完全不同的实现方式,二者必须严格隔离,不能混用:
| 路径 | 后台 UI 形态 | 运行时 | 分发方式 |
|---|---|---|---|
| 沙箱插件(Sandboxed) | 从私有路由返回声明式 Block Kit | 插件代码在沙箱中执行,JavaScript 永远不会运行在浏览器里 | 通过插件 CLI 打包,可从 registry 安装 |
| 原生插件(Native) | 直接导出 React 组件 | 代码随站点编译运行,拥有站点权限 | 仅可作为站点信任依赖,不能从 registry 安装 |
理解这条分界线是开发后台 UI 的第一步:沙箱插件以"声明 + 私有路由"的方式与后台交互,宿主在服务端把交互(page_load/block_action/form_submit)作为routeCtx.input送入沙箱路由,路由返回校验过的 Block Kit 响应,再由宿主渲染成界面。原生插件则直接把 React 组件打包进后台应用,行为更自由,但代价是信任等级完全不同。相关整体框架可参考技能入口 SKILL.md 中的"Choose a format"章节。
在emdash-plugin.jsonc中声明导航页面与仪表盘小组件
沙箱插件的后台导航与小组件卡片统一在清单文件emdash-plugin.jsonc的admin节点下声明:
{ "admin": { "pages": [ { "path": "/settings", "label": "Settings", "icon": "settings" }, { "path": "/reports", "label": "Reports", "icon": "chart" }, ], "widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }], }, }几个关键语义:
- 页面挂载路径:声明为
/settings的页面最终挂载在/_emdash/admin/plugins/<plugin-id>/settings,即宿主会以插件 id 为前缀自动拼接路径。 - 小组件尺寸:
size的合法值为full、half、third三者之一,用于描述仪表盘栅格中的占位宽度。 - 必须提供
admin路由:任何声明了页面或小组件的沙箱插件,都必须在routes中声明名为admin的路由,并确保它是私有、接受 POST JSON 请求、返回 JSON 的契约路由。
最后一点在源码中有硬性校验:manifest-schema.ts中的validateBlockKitAdminRoute会检查:只要admin.pages或admin.widgets非空,且存在名为admin的路由,那么该路由就不能是public,并且必须满足isJsonPostRouteContract(见 routes.ts),即response不能是raw、方法需包含POST、请求体模式为json。
实现admin路由:接收交互并返回 Block Kit
后台向admin路由发送的交互类型有三种:page_load(页面/小组件加载)、block_action(按钮等元素点击)、form_submit(表单提交)。由于routeCtx.input的类型是unknown,生产代码中必须先用 Zod 等工具校验交互结构,再做任何有副作用的操作:
import type { SandboxedPlugin } from "emdash/plugin"; import type { BlockResponse } from "@emdash-cms/blocks"; import { z } from "zod"; const interactionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("page_load"), page: z.string() }), z.object({ type: z.literal("block_action"), action_id: z.string(), block_id: z.string().optional(), value: z.unknown().optional(), }), z.object({ type: z.literal("form_submit"), action_id: z.string(), block_id: z.string().optional(), values: z.object({ enabled: z.boolean() }), }), ]); function settingsForm(enabled: boolean): BlockResponse { return { blocks: [ { type: "header", text: "Settings" }, { type: "form", block_id: "settings", fields: [ { type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled }, ], submit: { action_id: "save", label: "Save" }, }, ], }; } const plugin: SandboxedPlugin = { routes: { admin: { permission: "plugins:manage", handler: async (routeCtx, ctx) => { const parsed = interactionSchema.safeParse(routeCtx.input); if (!parsed.success) return { blocks: [] }; const interaction = parsed.data; if (interaction.type === "form_submit" && interaction.action_id === "save") { await ctx.settings.set("enabled", interaction.values.enabled === true); return { ...settingsForm(interaction.values.enabled === true), toast: { type: "success", message: "Settings saved" }, }; } const enabled = (await ctx.settings.get<boolean>("enabled")) ?? false; return settingsForm(enabled); }, }, }, }; export default plugin;实现要点:
- 先校验再副作用:
routeCtx.input是unknown,未经safeParse不得直接使用其中的字段。 - 返回校验过的 Block Kit:宿主会对返回值做校验,Block Kit 的交互、块与元素的精确形状可进一步阅读 block-kit.md。服务端安全导出的校验器与构建器位于 packages/blocks/src/server.ts,包括
validateBlockResponse、validateBlocks、validateContentEditorActionResponse等。 - 回执反馈:响应中可以携带
toast向后台用户反馈操作结果(如上例保存成功提示)。
blocks类型包(@emdash-cms/blocks)定义了完整的块集合:header、section、divider、fields、table、stats、form、image、context、columns、actions 等,以及 button、link、text_input、number_input、select、toggle、checkbox、radio、secret_input、combobox、date_input 等元素,见 packages/blocks/src/blocks 与 packages/blocks/src/elements。
设置存储:admin.settingsSchema与secret加密
插件 CLI 会把admin.settingsSchema保留在 registry 清单与生成的 descriptor 中,宿主据此自动生成设置表单。沙箱两端(Cloudflare Worker Loader 与 Node/workerd)的ctx.settings都经由与这张表单相同的 options records 路由。
- 读取:
ctx.settings.get("<key>"); - 写入/删除/列出/基于修订版本的操作:使用同一个命名空间,在 Cloudflare 与 Node/workerd 上行为一致。
settingsSchema支持的字段类型在 manifest-schema.ts 中有精确定义:string(可带multiline)、number(可带min/max)、boolean、select(需提供options)、secret、url(可带placeholder)、email,每项都可选label与description。
关于secret类型的设置,必须特别注意:
- 它在后台响应中是只写(write-only)的,不会把已存值回显给表单;
- 在持久化之前会被加密,站点必须提供
EMDASH_ENCRYPTION_KEY; - 密钥缺失、错误或被篡改时系统安全失败(fail closed),不会以明文或降级方式暴露数据;
- 务必把加密密钥列表与数据库备份放在一起,否则丢失密钥将无法解密既有设置;
- 存量代码中的
ctx.kv.get("settings:<key>")读取在 EmDash 0.x 期间保持兼容。
沙箱已保存内容扩展:编辑器面板与操作按钮
编辑器扩展分为两类:面板(editorPanels)与操作(editorActions),同样在emdash-plugin.jsonc的admin节点声明:
{ "admin": { "editorPanels": [ { "id": "health", "title": "Content health", "route": "editor/health", "collections": ["posts"], }, ], "editorActions": [ { "id": "repair", "label": "Repair metadata", "route": "editor/repair", "placement": "overflow", "style": "danger", "confirm": { "title": "Repair?", "text": "This changes the saved entry.", "confirm": "Repair", "deny": "Cancel", }, }, ], }, }路由约束与数据边界
- 每个被引用的路由必须是私有的。EmDash 在调用扩展前会重新加载已保存的条目并检查归属权(ownership)与路由权限。
routeCtx.ui.entry中只包含规范化的 collection、ID、locale 与版本号;宿主不会发送字段值或编辑器未保存状态——也就是说,扩展看到的永远是"已保存的真实状态",而不是编辑器里可能被改动的草稿。
这些约束同样有源码级校验:manifest-schema.ts的validateEditorExtensionRoutes要求每个扩展路由在routes中恰好声明一次、不能是 public、必须满足 POST JSON 契约;editorActionSchema还通过refine强制规定style: "danger"的操作必须携带confirm确认对话框(标题、文本、确认/拒绝按钮文案,长度分别限制在 128 / 1024 / 64 字符内)。面板与操作的数量上限均为 32,id 必须匹配^[a-z][a-z0-9_-]*$。
面板行为
- 面板默认折叠展示;
- 生命周期交互:先收到
panel_load,之后是普通的block_action与form_submit交互; - 每个交互都返回
BlockResponse。
操作按钮行为
- 编辑器存在未保存更改时操作按钮被禁用;
- 操作收到
editor_action交互,返回可选的toast,外加二选一的结构化结果:要么refresh: true(刷新内容),要么一个结构化的navigate目标; - 不允许同时返回刷新与导航,宿主会拒绝这种歧义响应。
用运行时测试宿主验证边界
createPluginRuntimeTestHost().admin专门用于在真实生产边界上演练这些交互,提供以下方法(签名见 runtime-host.ts):
loadEditorPanel(panelId, collection, entryId)—— 触发panel_load;actEditorPanel(panelId, collection, entryId, actionId, { blockId?, value? })—— 触发block_action;submitEditorPanel(panelId, collection, entryId, actionId, values, { blockId? })—— 触发form_submit;invokeEditorAction(actionId, collection, entryId)—— 触发editor_action,返回ContentEditorActionResponse。
测试宿主内部通过dispatchPluginEditorExtensionApiRequest走真实的生产分发路径,并默认以管理员身份发送带X-EmDash-Request: 1头的请求(见 runtime-host.ts),因此能同时验证授权、CSRF、条目归属与 Block Kit 校验等真实边界。
沙箱声明式字段组件:把 Block Kit 元素组合成 JSON 值
Core 与后台含有一条声明式字段组件路径,允许插件通过清单声明一个字段组件,把若干个受支持的 Block Kit 元素组合成一个 JSON 对象值:
{ "admin": { "fieldWidgets": [ { "name": "event-picker", "label": "Event", "fieldTypes": ["json"], "elements": [ { "type": "text_input", "action_id": "eventId", "label": "Event ID" }, { "type": "toggle", "action_id": "featured", "label": "Featured" }, ], }, ], }, }使用方式与数据语义:
- 内容 schema 中的某个字段通过
widget: "pluginId:widgetName"选择该组件(manifest schema 允许其他兼容的fieldTypes,但仓库目前没有端到端测试证明组合对象能通过其他字段类型保存,因此建议使用json字段); - 编辑器保存的值是一个以每个元素的
action_id为键的对象,因此字段类型应选用json。
当前字段组件渲染器(renderer)支持的元素类型为:
text_inputnumber_inputtoggleselectmedia_picker
除此之外的其他 Block Kit 元素类型在该表面上会显示"unsupported-element"(不支持的元素)提示信息。
围绕该路径的工程保障:
emdash-plugin.jsonc接受admin.fieldWidgets(schema 见 manifest-schema.ts),插件 CLI 会把定义通过 bundle manifest 与生成的 descriptor 携带到 registry 安装流程;- 该 artifact 往返(round-trip)由插件 CLI、共享 manifest 与 plugin-test 的测试覆盖;
- 注意:浏览器 E2E fixture 目前仍测试的是原生 React 颜色选择器,而不是 registry 安装的声明式组件——因此在交付前,你需要针对所选元素自行验证真实编辑器中的渲染与值持久化行为。
利用routeCtx.ui处理本地化上下文
沙箱admin路由会收到routeCtx.ui,其中携带**宿主背书(host-attested)**的后台 locale、文本方向(direction)与 surface(表面标识)。使用方式:
- 在运行时用这些信息为 Block Kit 响应挑选本地化文本(例如根据
ui.locale返回不同语言文案); - 清单元数据(manifest metadata)中的
label等是静态字符串;registry 插件目前不会把翻译目录(translation catalogs)交给宿主,所以动态本地化只能依赖运行时响应侧处理。
PluginUiContext类型由@emdash-cms/blocks/server导出(见 packages/blocks/src/server.ts)。测试宿主也允许在PluginRuntimeAdminRequestOptions中传入locale与contentLocale,并通过Cookie: emdash-locale=<locale>模拟后台语言环境。
原生路径:React 页面、小组件与字段
当需求超出声明式能力(例如需要复杂交互组件、定制 Portable Text 块或 Astro 渲染组件)时,原生插件可以设置admin.entry并导出 React 组件:
export const pages = { "/settings": SettingsPage, }; export const widgets = { status: StatusWidget, }; export const fields = { picker: ColorPickerField, };插件定义通过definePlugin指向入口并声明其表面(surfaces):
definePlugin({ id: "color", version: "1.0.0", admin: { entry: "@my-org/plugin-color/admin", pages: [{ path: "/settings", label: "Settings" }], widgets: [{ id: "status", title: "Status", size: "half" }], fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }], }, });原生后台代码必须遵守仓库的Kumo、本地化、无障碍(accessibility)与 RTL规则,因为它运行在站点权限之下、直接进入最终面向用户的界面。同时记住其分发限制:不可从 registry 安装,只能作为站点信任依赖使用。仓库中的 color 插件 与 field-kit 插件 是研究原生 React 字段组件写法的参考实现。
完整示例:webhook-notifier 的后台声明
仓库内真实插件的声明(webhook-notifier/emdash-plugin.jsonc)直观展示了本文全部声明要点的组合:
{ "slug": "webhook-notifier", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", "license": "MIT", "author": { "name": "Matt Kane" }, "description": "Posts to user-configured external URLs when content or media changes.", "capabilities": ["network:request:unrestricted"], "allowedHosts": [], "storage": { "deliveries": { "indexes": ["timestamp", "webhookUrl", "status"] }, }, "admin": { "pages": [{ "path": "/settings", "label": "Webhook Settings", "icon": "send" }], "widgets": [{ "id": "status", "title": "Webhooks", "size": "third" }], }, }该插件声明了一个/settings后台页面(配合一个admin私有路由返回设置表单)与一个third尺寸的状态小组件,同时声明了network:request:unrestricted能力与deliveries存储集合,用于审计与重试状态——admin声明与运行时能力、存储声明各司其职。
测试与验证清单
围绕后台 UI 的测试建议按以下层次展开:
- 生产边界测试:使用
createPluginRuntimeTestHost().admin的loadPage/loadWidget/act/submit覆盖页面与小组件交互;用loadEditorPanel/actEditorPanel/submitEditorPanel/invokeEditorAction覆盖编辑器扩展。运行时宿主会走真实的分发与授权路径(runtime-host.ts),每次使用后记得dispose()。 - 快速传输测试:需要快速验证钩子、路由、清单、能力与 KV 语义时,用
createPluginTestHost()(见 packages/plugin-test/src/index.ts),它通过CloudflareSandboxRunner直接加载配置的插件 bundle。 - manifest 校验:所有后台声明最终都会经过
pluginManifestSchema的 superRefine 校验链(路由唯一性、编辑器扩展路由私有且 POST JSON、danger 操作必带 confirm、Block Kit admin 路由私有),因此提交前可用插件 CLI 的校验命令提前发现问题。
小结
EmDash 插件后台 UI 的核心设计原则是"沙箱代码永不进入浏览器":一切通过声明 + 私有路由 + 校验过的 Block Kit 完成,宿主在服务端执行交互并渲染界面;而原生 React 路径则以更高的信任等级换取更强的表达能力。开发时记住三条红线:routeCtx.input必须先校验再使用;编辑器扩展路由必须私有且只能拿到已保存条目的身份信息;secret设置依赖EMDASH_ENCRYPTION_KEY,密钥丢失即数据不可恢复。围绕这些边界,plugin-test运行时宿主提供了覆盖完整生产链路的测试手段,让插件作者可以在提交前验证页面、小组件、面板、操作与字段组件的每一个交互。
【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址: https://gitcode.com/gh_mirrors/emdas/emdash
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考