EmDash 的 Block Kit 让沙盒插件以 JSON 描述其管理 UI。由主机渲染这些块——插件提供的 JavaScript 永远不会在浏览器中运行。
工作原理
- 用户导航到插件的管理页面。
- 管理端向插件的管理路由发送
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,带有集合、已保存条目 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" },
],
},
},
],
},
],
};
选择菜单项会发送带有菜单 action_id 和项 value 的 block_action。项 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 包含集合、已保存条目 ID、内容语言环境和版本。routeCtx.ui.extensionId 标识所选声明。当插件需要已保存内容时,与 content:read 能力一起使用 ctx.content。
面板打开时接收 { type: "panel_load" }。面板加载从不包含草稿数据。其后续按钮和表单交互使用通常的 block_action 和 form_submit 形状。当插件声明 admin.editor-draft:read 且扩展收窄 draft.read 时,显式交互还会接收 routeCtx.input.draft。快照仅包含选定的当前值、已清理的字段定义、已保存身份以及持久化的基础修订。对显式 slug 使用 fields,对集合的可翻译字段使用 translatable: true,或两者都用。草稿访问需要显式的 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 对照当前服务器模式、能力、集合、字段选择器、语言环境、基础修订、所有权、数量限制和字节限制一起验证每个操作。浏览器在显示主机渲染的预览之前重复身份、代数和字段检查。应用预览会将表单标记为已修改,并且不会保存、创建修订或运行钩子。插件工作时所做的任何编辑都会拒绝完整结果。
仅针对已保存内容的编辑器操作在表单有未保存更改时保持禁用。感知草稿的操作可以对未保存表单运行。操作接收 { type: "editor_action" },以及在声明时相同的有界草稿快照。返回包含可选 toast 和至多一个终端效果的对象:
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
使用 refresh: true 重新加载条目,使用带有结构化链接目标的 navigate,或使用 patch 提议未保存的字段更改。响应不能组合终端效果。EmDash 在应用效果之前拒绝未知命令、不安全导航、无效或过期补丁,以及超出 Block Kit 限制的响应。
块类型
| Type | Description |
|---|---|
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 | 包含嵌套块的带标签面板 |
元素类型
| Type | Description |
|---|---|
button | 带可选确认对话框的操作按钮 |
link | 主机解析的内部或外部导航 |
menu | 打开选项列表的按钮;每个选项派发一个操作 |
text_input | 单行或多行文本输入 |
number_input | 带 min/max 的数字输入 |
select | 下拉选择 |
toggle | 开/关开关 |
secret_input | 用于 API 密钥和令牌的掩码输入 |
checkbox | 从固定列表中选择多个值 |
combobox | 可搜索的单值选择 |
date_input | 日期值 |
radio | 从可见选项列表中单选 |
Portable Text 字段编辑器还支持 repeater 和 media_picker。它们不是沙盒插件管理页面的表单字段。
构建器辅助函数
@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 加密它。在存储凭据之前,请遵循 Secret settings。
试用
使用 Block Playground 交互式构建和测试块布局。