EmDash 的 Block Kit 让沙盒插件以 JSON 描述其管理界面。由宿主渲染这些块——浏览器中不会运行任何插件提供的 JavaScript。
工作原理
- 用户导航到插件的管理页面。
- 管理端向插件的 admin 路由发送
page_load交互。 - 插件返回包含块数组的
BlockResponse。 - 管理端使用
BlockRenderer组件渲染这些块。 - 当用户进行交互(点击按钮、提交表单)时,管理端将该交互发回给插件。
- 插件返回新的块,循环重复。
当插件定义 Block Kit 页面时,请将 @emdash-cms/blocks 和 zod 添加到插件:
pnpm add @emdash-cms/blocks zod
在插件清单中声明页面,使管理端有可加载的导航入口:
"admin": {
"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}
以下 admin 路由会校验交互、在页面加载时渲染表单,并在提交时存储其值:
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({ api_url: z.url(), enabled: z.boolean() }),
}),
]);
function renderSettings(): BlockResponse {
return {
blocks: [
{ type: "header", text: "Save Log settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
const plugin: SandboxedPlugin = {
routes: {
admin: {
handler: async (routeCtx, ctx) => {
const parsed = interactionSchema.safeParse(routeCtx.input);
if (!parsed.success) return { blocks: [] };
const interaction = parsed.data;
if (interaction.type === "page_load") {
return renderSettings();
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.settings.set("apiUrl", interaction.values.api_url);
await ctx.settings.set("enabled", interaction.values.enabled);
return {
...renderSettings(),
toast: { message: "Settings saved", type: "success" },
};
}
return { blocks: [] };
},
},
},
};
export default plugin;
admin 路由默认是私有的。管理端调用它时,EmDash 会发送正确的 CSRF 头。处理函数仍然要校验 routeCtx.input,因为它的 TypeScript 类型是 unknown,调用方可以在 Block Kit 页面之外调用私有插件路由。
在管理端渲染之前,EmDash 会校验每个沙盒页面和小组件的响应。无效的块、不安全的 URL、指向未声明插件页面的链接,或超出 Block Kit 限制的响应,都会使请求失败,而不会到达浏览器。一个响应最多可包含 256 KiB、20 层嵌套、2,000 个节点、每个数组 1,000 项,以及每个字符串 64 KiB。
UI 语言环境与方向
当页面或小组件需要返回适配管理员当前语言环境的文本时,请读取 routeCtx.ui。宿主从管理端语言环境 cookie 或请求语言推导此值,并对照插件清单验证所请求的页面或小组件。
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
admin: {
handler: async (routeCtx) => {
if (!routeCtx.ui) return { blocks: [] };
const heading = routeCtx.ui.locale === "ar" ? "حالة المحتوى" : "Content status";
return {
blocks: [{ type: "header", text: heading }],
};
},
},
},
};
export default plugin;
routeCtx.ui 包含界面位置、语言环境和文本方向。管理端语言环境与 ctx.site.locale 是分开的,后者描述站点的默认内容语言环境。清单标签仍是静态字符串。
导航链接
使用 link 元素进行导航,而无需派发 Block Kit 操作。EmDash 会根据结构化目标构造内部 URL,因此插件无需知道管理端路由路径。
return {
blocks: [
{
type: "actions",
elements: [
{
type: "link",
label: "Edit article",
target: { kind: "content", collection: "posts", id: "01K5POSTEXAMPLE", locale: "en" },
appearance: "primary",
},
{
type: "link",
label: "Plugin settings",
target: { kind: "plugin-settings" },
},
],
},
],
};
可用的目标有:
content,包含 collection、已保存条目 ID 和可选的内容语言环境;plugin-page,包含由同一插件声明的路径;plugin-settings;以及external,包含绝对的 HTTP、HTTPS 或mailto:URL。
外部链接在新标签页中打开,并带有 noopener noreferrer。链接元素不接受 action_id,也不能作为表单字段出现。当交互必须调用插件路由时,请使用按钮。
块图像使用相同的浏览器资源策略。允许根相对的图像 URL。外部图像必须使用 HTTPS,且其主机名必须出现在插件的 allowedHosts 中。具有 network:request:unrestricted 的插件可以从任何主机名加载 HTTPS 图像。其他外部图像会导致整个 Block Kit 响应被拒绝。
表格中的行操作
将表格列的 format 设为 element,即可在每一行中放置按钮、链接或菜单。每一行在该列的键下存储元素;没有值的行会让该单元格保持为空。当一行在同一个按钮后提供多个选项时,请使用 menu 元素:
return {
blocks: [
{
type: "table",
page_action_id: "missing_page",
columns: [
{ key: "title", label: "Entry" },
{ key: "languages", label: "Missing" },
{ key: "action", label: "Actions", format: "element" },
],
rows: [
{
title: "Hello world",
languages: "French, Italian",
action: {
type: "menu",
action_id: "translate",
label: "Translate",
items: [
{ label: "French", value: "fr:01K5POSTEXAMPLE" },
{ label: "Italian", value: "it:01K5POSTEXAMPLE" },
],
},
},
],
},
],
};
选择某个菜单项会发送一个 block_action,其中包含菜单的 action_id 和该项的 value。同一菜单内的项值必须唯一。元素单元格只接受 button、link 和 menu 元素。菜单还可以出现在 actions 块中、作为 section 附件,或出现在空状态操作中,但不能作为表单字段。elements.menu(actionId, label, items, { style }) 构建器返回相同的结构。
已保存条目面板与操作
当插件需要在已保存条目旁显示信息时,请声明编辑器面板。面板初始为折叠状态,只有在编辑者打开它们时才会调用其私有路由。
以下清单为文章添加了一个面板和一个需要确认的修复操作:
"admin": {
"editorPanels": [
{
"id": "content-health",
"title": "Content health",
"route": "editor/content-health",
"collections": ["posts"],
"draft": {
"read": { "translatable": true },
"patch": { "fields": ["title", "excerpt", "body"] },
},
},
],
"editorActions": [
{
"id": "repair-metadata",
"label": "Repair metadata",
"route": "editor/repair-metadata",
"placement": "overflow",
"style": "danger",
"confirm": {
"title": "Repair metadata?",
"text": "This changes the saved entry.",
"confirm": "Repair",
"deny": "Cancel",
},
},
],
}
每个被引用的路由都必须是私有的。其 permission 控制哪些编辑者可以调用该扩展。宿主在调用插件之前,还会重新加载已保存条目并检查其所有者。
编辑器扩展路由会收到经过证明的 routeCtx.ui 值。对于 content-editor-panel 和 content-editor-action 界面位置,routeCtx.ui.entry 包含 collection、已保存条目 ID、内容语言环境和版本。routeCtx.ui.extensionId 标识所选的声明。当插件需要已保存内容时,请配合 content:read 能力使用 ctx.content。
面板在打开时会收到 { type: "panel_load" }。面板加载从不包含草稿数据。其后续的按钮和表单交互使用常规的 block_action 和 form_submit 结构。当插件声明了 admin.editor-draft:read 且扩展收窄了 draft.read 时,显式交互还会收到 routeCtx.input.draft。该快照只包含所选的当前值、经过清理的字段定义、已保存的标识,以及已持久化的基础修订版本。使用 fields 指定显式 slug,使用 translatable: true 指定 collection 的可翻译字段,或两者并用。草稿访问需要显式的 collections 列表。
admin.editor-draft:patch 独立于读取访问。它允许路由在显式交互之后返回整字段补丁:
const draft = routeCtx.input.draft;
return {
blocks: [],
patch: {
type: "editor-draft-patch",
operations: [
{ op: "set", field: "title", value: translate(draft.fields.title) },
{ op: "clear", field: "excerpt" },
],
},
};
EmDash 会针对当前服务器 schema、能力、collection、字段选择器、语言环境、基础修订版本、所有权、数量限制和字节限制,一并校验每个操作。浏览器在显示由宿主渲染的预览之前,会重复检查标识、代次和字段。应用该预览会将表单标记为已修改,但不会保存、创建修订版本或运行 hooks。在插件工作期间所做的任何编辑都会使整个结果被拒绝。
当表单有未保存的更改时,仅限已保存条目的编辑器操作保持禁用。支持草稿的操作可以针对未保存的表单运行。操作会收到 { type: "editor_action" },并在已声明时收到相同的有界草稿快照。请返回一个包含可选 toast 和至多一个终结效果的对象:
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
使用 refresh: true 重新加载条目,使用带有结构化链接目标的 navigate,或使用 patch 来提议未保存的字段更改。响应不能组合多个终结效果。在应用效果之前,EmDash 会拒绝未知命令、不安全的导航、无效或过时的补丁,以及超出 Block Kit 限制的响应。
原生插件中的 Block Kit
原生插件无需提供 React 即可渲染 Block Kit 页面和小组件。在 definePlugin() 中声明 admin.pages 或 admin.widgets,保持 admin.entry 未设置,并添加一个名为 admin 的路由:
import { definePlugin } from "emdash";
export function createPlugin() {
return definePlugin({
id: "plugin-status",
version: "0.1.0",
routes: {
admin: {
handler: async (ctx) => {
// Validate ctx.input as in the sandboxed example above.
return { blocks: [{ type: "header", text: "Status" }] };
},
},
},
admin: {
pages: [{ path: "/status", label: "Status", icon: "gauge" }],
},
});
}
当原生插件声明了页面、小组件或编辑器扩展且没有 admin.entry 时,它使用 Block Kit。设置 admin.entry 会将其切换为 React 管理页面。请显式声明 admin 路由:隐式的 admin 路由只为旧版沙盒包而存在。原生处理函数接收单个 RouteContext,因此交互以 ctx.input 的形式到达。
上文描述的两种行为目前只适用于管理页面和仪表盘小组件上的沙盒插件:
- 在原生插件的页面或小组件上,
ctx.ui为undefined,因此路由无法据此读取管理端语言环境或文本方向。原生编辑器面板和操作确实会收到ctx.ui。 - 在管理端渲染之前,EmDash 不会校验原生插件的页面或小组件响应。请让原生响应保持在相同的块类型、限制,以及链接和图像规则之内,因为渲染它们的是同一个渲染器。
块类型
| 类型 | 说明 |
|---|---|
header | 大号粗体标题 |
section | 文本,可带可选的附件元素 |
divider | 水平分隔线 |
fields | 两列标签/值网格 |
table | 带格式化、排序和分页的数据表 |
actions | 一行水平排列的按钮和控件 |
stats | 带趋势指示器的仪表盘指标卡片 |
form | 带条件可见性和提交的输入字段 |
image | 块级图像,带替代文本和可选标题 |
context | 小号的弱化帮助文本 |
columns | 2–3 列布局,包含嵌套的块 |
empty | 空状态标题,可带可选的描述、命令和操作按钮 |
accordion | 包裹嵌套块的可折叠区段 |
chart | 折线或柱状时间序列,或带自定义选项的图表 |
banner | 带标题或描述的状态或警报消息 |
meter | 相对于最小值和最大值显示的数值 |
code | 只读的 TypeScript、TSX、JSONC、Bash 或 CSS 代码 |
tab | 包含嵌套块的带标签面板 |
元素类型
| 类型 | 说明 |
|---|---|
button | 操作按钮,可带可选的确认对话框 |
link | 由宿主解析的内部或外部导航 |
menu | 打开选项列表的按钮;每个选项都会派发一个操作 |
text_input | 单行或多行文本输入 |
number_input | 带最小值/最大值的数字输入 |
select | 下拉选择 |
toggle | 开/关开关 |
secret_input | 用于 API 密钥和令牌的掩码输入 |
checkbox | 从固定列表中选择多个值 |
combobox | 可搜索的单值选择 |
date_input | 日期值 |
radio | 从可见的选项列表中单选 |
Portable Text 字段编辑器还支持 repeater 和 media_picker。它们不是沙盒插件管理页面的表单字段。
从插件路由加载 select 选项
Portable Text 块的 fields 中的 select 可以设置 optionsRoute,从插件自己的某个路由填充其下拉列表。在这些字段中嵌套于 repeater 内的 select 同样适用。
definePlugin({
id: "plugin-cards",
version: "0.1.0",
storage: {
cards: { indexes: ["title"] },
},
routes: {
"cards/list": {
handler: async (ctx) => {
const result = await ctx.storage.cards.query({ limit: 100 });
return {
items: result.items.map((card) => ({ id: card.id, name: card.data.title })),
};
},
},
},
admin: {
portableTextBlocks: [
{
type: "card",
label: "Card",
fields: [
{
type: "select",
action_id: "cardId",
label: "Card",
options: [],
optionsRoute: "cards/list",
},
],
},
],
},
});
每个带有 optionsRoute 的 select 在渲染时都会调用该路由,因此包含两个此类字段的块,或包含多个条目的 repeater,会为每一个字段各发送一次请求。折叠再重新展开 repeater 条目会再次发送请求。该请求为 POST /_emdash/api/plugins/<pluginId>/<optionsRoute>,带有 X-EmDash-Request: 1 请求头,请求体是一个空的 JSON 对象。该路由是普通的插件路由,因此需要 plugins:manage 权限,除非它声明了其他 permission。
处理函数返回 { items: Array<{ id: string; name: string }> }。该路由以标准 EmDash 信封({ success: true, data: { items: [...] } },参见 API 路由)作为响应,块编辑器从 data.items 读取选项。管理端把每个条目显示为一个选项,以 id 作为存储的值、name 作为标签。条目上的额外属性会被忽略。
请求运行期间,字段会显示加载状态。如果请求失败、响应不是 OK,或响应中没有 items 数组,字段会回退到静态的 options 数组。在这种情况下,如果静态的 options 数组为空,下拉列表就没有可选项。
optionsRoute 只在 Portable Text 块编辑器中生效。用于管理页面、小组件和已保存条目面板的 Block Kit 渲染器,以及声明式字段小组件渲染器,只读取静态的 options 数组,并忽略 optionsRoute。这些位置中的 select 必须以静态方式列出选项,并且 Block Kit 响应必须至少为其提供一个。
构建器辅助函数
@emdash-cms/blocks 包通过 blocks 和 elements 构建器对象导出相同的结构。构建器在返回普通的、与 JSON 兼容的对象的同时,减少属性名拼写错误:
import { blocks, elements } from "@emdash-cms/blocks";
const { header, form } = blocks;
const { textInput, toggle, select, link } = elements;
return {
blocks: [
header("SEO Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
blocks.actions([link("Open settings", { kind: "plugin-page", path: "/settings" })]),
],
};
条件字段
表单字段可以根据其他字段的值有条件地显示:
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
只有当 auth_enabled 开启时,api_key 字段才会出现。条件在客户端求值,无需往返请求。
secret_input 使用 has_value: true 表示已存在值;它在页面加载时既不接受也不返回已存储的值。该字段会在浏览器中遮蔽输入内容。请在 admin.settingsSchema 中将对应的键声明为 type: "secret",并通过 ctx.settings 保存,以便 EmDash 对其加密。在存储凭证之前,请遵循 密钥设置。
试用
使用 Block Playground 交互式地构建和测试块布局。