- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
Block Kit 是 EmDash CMS(基于 Astro 的全栈 TypeScript CMS)为沙箱化插件提供的一套声明式 JSON UI 方案:插件通过普通 JSON 描述管理后台页面,由宿主的BlockRenderer负责渲染,插件 JavaScript 完全不进入浏览器。本文以官方参考文档 block-kit.md 为主线,结合@emdash-cms/blocks包的源码与测试,完整讲解块类型、元素类型、交互协议、校验边界与实战写法,读完即可用纯 JSON 或 TypeScript 构建出带表单、表格、图表、条件字段和通知的管理界面。
什么是 Block Kit
Block Kit 是 EmDash 为运行时安装的沙箱插件提供的管理后台 UI 语言。它的核心理念是:
- 声明式:插件 admin 路由返回一个
BlockResponse(包含blocks数组与可选的toast),宿主负责渲染。 - 零浏览器 JavaScript:插件代码在沙箱中运行,宿主浏览器端只渲染 JSON 描述出的块与元素。
- 借鉴 Slack Block Kit 但不等同:概念和命名相似,但块/元素类型与能力不同。
需要区分两种插件形态:受信任插件(在astro.config.ts中声明)可以携带自定义 React 组件;Block Kit 面向运行时安装的沙箱插件。此外,原生插件也会使用 Block Kit 元素实现 Portable Text 块级编辑字段,而 Plugin CLI 与 registry 包不能注册 Portable Text 块类型。
从仓库源码看,@emdash-cms/blocks(package.json,v0.38.0)是独立的包,同时提供客户端与服务端两个入口:
- index.ts(默认入口):导出
BlockRenderer、renderElement、blocks/elements构建器、validateBlocks与全部类型; - server.ts(
@emdash-cms/blocks/server):服务端安全入口,只导出构建器、校验函数与类型,不引入任何 React 组件,插件路由处理器应优先从这里导入。
// 服务端入口:无 React 依赖 import { blocks, elements, validateBlockResponse } from "@emdash-cms/blocks/server";工作原理:一次完整的交互闭环
Block Kit 的交互模型是"无状态往返":
- 用户导航到插件 admin 页面;
- Admin 向插件 admin 路由发送
page_loadinteraction; - 插件返回带
blocks数组的BlockResponse; - Admin 使用
BlockRenderer渲染这些块; - 用户交互(按钮点击、表单提交)→ interaction 被发回插件;
- 插件返回新的块,Admin 重新渲染。
注意一个关键细节:EmDash 只会解析一次请求体,并把它暴露为ctx.input。在路由处理器中应直接读取ctx.input,而不是调用ctx.request.json()——请求体已被消费。BlockInteraction是page_load、block_action、form_submit三类载荷的判别联合(见 types.ts)。
import type { BlockInteraction } from "@emdash-cms/blocks"; routes: { admin: { handler: async (ctx) => { // EmDash parses the request body once and exposes it as ctx.input; // read it directly rather than ctx.request.json() (the body is consumed). // BlockInteraction is the discriminated union of page_load, // block_action, and form_submit payloads. const interaction = ctx.input as BlockInteraction; if (interaction.type === "page_load") { return { blocks: [ { type: "header", text: "My Plugin 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" }, }, ], }; } if (interaction.type === "form_submit" && interaction.action_id === "save") { await ctx.kv.set("settings", interaction.values); return { blocks: [/* updated blocks */], toast: { message: "Settings saved", type: "success" }, }; } }, }, }BlockInteraction的三个成员在 types.ts 中定义:
| 类型 | 载荷字段 | 触发时机 |
|---|---|---|
PageLoad | type: "page_load"、page | 首次进入 admin 页面/面板 |
BlockAction | type: "block_action"、action_id、block_id?、value?、page? | 点击按钮、排序/翻页等 |
FormSubmit | type: "form_submit"、action_id、block_id?、values、page? | 提交表单,values为各字段值对象 |
BlockResponse的结构是{ blocks: Block[]; toast?: { message; type } }(types.ts)。
块类型总览(Block Types)
Block Kit 内置 17 类可用的块(另有tab类型存在但生产校验器不允许,见下文):
| 类型 | 描述 |
|---|---|
header | 大号加粗标题 |
section | 文本,可带可选附件元素 |
divider | 水平分隔线 |
fields | 两列"标签/值"网格 |
table | 数据表格,支持格式化、排序、分页 |
actions | 水平排布的按钮与控件行 |
stats | 仪表盘指标卡片,带趋势指示 |
form | 输入字段,支持条件显隐与提交 |
image | 块级图片,带 alt 文本与可选标题 |
context | 小号弱化帮助文本 |
columns | 2-3 列布局,内部可嵌套块 |
chart | 图表(时间序列折线/柱状、饼图、自定义 ECharts) |
code | 语法高亮代码块 |
meter | 进度/配额仪表条 |
banner | 信息、警告或错误内联消息 |
empty | 空状态,带可选命令与操作 |
accordion | 可折叠区块,内部嵌套块 |
这些类型的 TypeScript 定义集中在 types.ts 的Block联合类型中,每一类都有明确的必填/可选字段。
元素类型总览(Element Types)
块内部的交互与输入单元称为元素:
| 类型 | 描述 |
|---|---|
button | 操作按钮,可带确认对话框 |
link | 宿主解析的导航,不派发 action |
text_input | 单行或多行文本输入 |
number_input | 数字输入,支持 min/max |
select | 下拉选择 |
toggle | 开关 |
secret_input | 掩码输入(API Key、Token) |
checkbox | 多选复选框 |
radio | 单选按钮 |
date_input | 日期选择器 |
combobox | 可搜索的下拉选择 |
repeater | 对象数组,含标量子字段 |
media_picker | 媒体库选择器,存储资产 URL |
元素类型的定义见 types.ts,其中值得注意的细节:
select支持optionsRoute:指向一个返回{ items: Array<{ id, name }> }的插件路由,用于动态填充选项;secret_input的has_value用于表示已保存过值(避免把密钥回显);repeater的子字段被限制为text_input、number_input、select、toggle四种标量元素;media_picker存储的是所选资产的URL 字符串,因此与普通text_input值兼容——替换控件后旧内容仍可正常工作(源码注释明确说明这一点)。
块语法详解
以下所有 JSON 示例均来自官方参考文档,并经过validateBlocks校验测试覆盖(见下文"文档示例被自动测试"一节)。
Header
{ "type": "header", "text": "Settings" }Section
{ "type": "section", "text": "Configure your plugin settings below.", "accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" } }accessory可挂载任意操作元素(按钮、链接等),section必须有text。
Divider
{ "type": "divider" }Fields
{ "type": "fields", "fields": [ { "label": "Status", "value": "Active" }, { "label": "Last Sync", "value": "2 hours ago" } ] }Stats
{ "type": "stats", "items": [ { "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" }, { "label": "Active", "value": "567" } ] }三个易错点:
items— 数组键名是items,不是stats;trend— 取值为"up"、"down"或"neutral",在数值旁渲染方向箭头;description— 数值下方的次级说明行,用于 "+12% vs last week" 这类上下文。
校验器(validation.ts)要求每项label为字符串、value为字符串或数字、trend必须命中TREND_VALUES。
Table
{ "type": "table", "columns": [ { "key": "name", "label": "Name" }, { "key": "status", "label": "Status" }, { "key": "date", "label": "Date" } ], "rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }], "page_action_id": "browse_items", "empty_text": "No items yet." }page_action_id—必填。用户排序或翻页时,Admin 会把该 id 作为block_action的action_id发回插件;empty_text— 当rows为空时展示的占位文本;next_cursor— 设置后渲染"加载更多"控件,用于游标分页。
列定义还支持format("text" | "badge" | "relative_time" | "number" | "code")与sortable布尔值,见 types.ts 的TableColumn。校验器要求columns、rows、page_action_id均必填(validation.ts)。
Actions
{ "type": "actions", "elements": [ { "type": "button", "label": "Save", "action_id": "save", "style": "primary" }, { "type": "button", "label": "Cancel", "action_id": "cancel" } ] }按钮style可取值"primary" | "danger" | "secondary"。
Form
{ "type": "form", "block_id": "settings", "fields": [ { "type": "text_input", "action_id": "name", "label": "Name" }, { "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 }, { "type": "select", "action_id": "theme", "label": "Theme", "options": [ { "label": "Light", "value": "light" }, { "label": "Dark", "value": "dark" } ] }, { "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true }, { "type": "secret_input", "action_id": "api_key", "label": "API Key" } ], "submit": { "label": "Save", "action_id": "save_settings" } }校验器对表单字段有额外约束:link不能作为表单字段;condition必须是{ field, eq }或{ field, neq }之一(validation.ts)。submit对象必填label与action_id。
Columns
{ "type": "columns", "columns": [ [ { "type": "header", "text": "Usage" }, { "type": "meter", "label": "Storage used", "value": 65 } ], [ { "type": "header", "text": "Activity" }, { "type": "context", "text": "Last sync 2 hours ago" } ] ] }columns— 恰好2 或 3 列,每列是一个块数组。校验器会拒绝少于 2 或多于 3 列的响应(validation.ts)。
Chart(时间序列)
{ "type": "chart", "config": { "chart_type": "timeseries", "series": [ { "name": "Requests", "data": [ [1709596800000, 42], [1709600400000, 67], [1709604000000, 53] ], "color": "#086FFF" }, { "name": "Errors", "data": [ [1709596800000, 2], [1709600400000, 5], [1709604000000, 1] ] } ], "x_axis_name": "Time", "y_axis_name": "Count", "style": "line", "gradient": true, "height": 300 } }参数说明:
series[].data—[timestamp_ms, value]元组数组,按时间排序;series[].color— 十六进制颜色(可选,缺省时按系列索引从 Kumo 调色板自动分配);style—"line"(默认)或"bar";gradient— 折线下方填充渐变(默认 false);height— 图表高度像素(默认 350)。
校验器要求每个数据点必须是[number, number]二元组,series不能为空(validation.ts)。
Chart(自定义 ECharts)
饼图、仪表盘或任意 ECharts 可视化:
{ "type": "chart", "config": { "chart_type": "custom", "options": { "series": [ { "type": "pie", "data": [ { "value": 335, "name": "Published" }, { "value": 234, "name": "Draft" }, { "value": 120, "name": "Scheduled" } ] } ] }, "height": 300 } }options— 原始 ECharts option 对象,会原样传给chart.setOption()。
一个安全细节:自定义图表的options会被递归扫描,其中的image://资源与image键会被当作图片 URL 校验(必须 root 相对或 HTTPS 且命中允许主机列表),见 validation.ts 的validateChartResources。
Code
{ "type": "code", "code": "const greeting = \"Hello!\";\nconsole.log(greeting);", "language": "ts" }language—"ts"、"tsx"、"jsonc"、"bash"或"css"(缺省"ts")。
Meter
{ "type": "meter", "label": "Storage used", "value": 65, "custom_value": "6.5 GB / 10 GB" }value— 数值(默认范围 0-100);max/min— 自定义范围(默认 0-100),校验器要求min < max;custom_value— 用自定义字符串替代百分比显示(如 "750 / 1,000")。
Banner
{ "type": "banner", "title": "API key invalid", "description": "Please check your API key in settings.", "variant": "error" }variant—"default"(信息,默认)、"alert"(警告)或"error";title与description至少提供其一(校验器强制)。
Empty
{ "type": "empty", "title": "No submissions", "description": "New submissions appear here.", "command_line": "pnpm run seed", "size": "base", "actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }] }size可取值"sm" | "base" | "lg";command_line显示一条建议的命令;actions是操作元素数组。
不要使用tab块
包导出了TabBlock类型与blocks.tab()构建器(builders.ts),React 渲染器也有 tab 组件(tab.tsx),但生产环境validateBlocks()的允许列表在渲染管线中不包含tab——包含它的 admin 响应会被拒绝。在校验器接受它之前,请不要产出tab块。
Accordion
{ "type": "accordion", "label": "Advanced settings", "default_open": false, "blocks": [{ "type": "context", "text": "Settings visible when expanded" }] }default_open控制默认展开状态,blocks内可嵌套任意合法块。
Repeater 与 media_picker:管理端创作元素
repeater与media_picker是管理端创作元素(admin-authoring elements),不是沙箱 admin 页面form中的普通字段。
repeater捕获对象数组,嵌套字段仅限text_input、number_input、select、toggle四种标量类型:
{ "type": "repeater", "action_id": "items", "label": "Questions", "item_label": "Question", "fields": [ { "type": "text_input", "action_id": "question", "label": "Question" }, { "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true } ] }item_label用于 UI 中的单数标签(如 "FAQ" → "Add FAQ")。源码类型 RepeaterElement 还支持min_items/max_items/initial_value,并注释了两个重要行为:
- 管理端组件从子字段类型播种新行(空字符串/
false),不会使用initial_value预填行; - 运行时块渲染器
renderElement对repeater故意返回null——repeater 值持久化在父块上,由插件自己的运行时组件消费。
media_picker打开媒体库并把所选资产的URL 字符串作为值存储:
{ "type": "media_picker", "action_id": "hero", "label": "Hero image", "mime_type_filter": "image/" }mime_type_filter是 RFC 6838 风格的图片 MIME 前缀或精确类型(如"image/"、"image/png"、"image/svg+xml")。校验器使用正则MEDIA_PICKER_MIME_FILTER_RE(validation.ts)拒绝image/*通配符以及video/、application/pdf等非图片类型,缺省为"image/"。
声明式字段组件(Declarative field widgets)
admin 字段编辑器可以用 Block Kit 元素渲染插件字段组件。schema 字段通过pluginId:widgetName引用;配置声明的标准描述符提供name、label、兼容的fieldTypes与elements。
字段组件渲染器当前仅支持五种元素:
text_inputnumber_inputtoggleselectmedia_picker
它按每个元素的action_id存储一个对象,因此应使用json字段承载组合值;其他字段类型虽被 manifest schema 接受,但没有端到端保存测试覆盖。不支持的其它元素类型会渲染"unsupported element"提示消息。
emdash-plugin.jsonc接受这种字段组件定义,plugin CLI 会把它保留在 registry manifest 与生成的描述符中。注意现状:构件传输链路是有测试的,但仓库的浏览器 E2E 测试仍覆盖原生 React 字段组件而非 registry 声明式组件——对选中的元素请自行验证渲染出的编辑器与保存值。
条件字段(Conditional Fields)
根据其他字段的值显示/隐藏字段。在客户端求值,无网络往返:
{ "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 } }condition的两种合法形态(types.ts):
{ field: string; eq?: unknown }{ field: string; neq?: unknown }
表单条件的渲染行为有专门测试 form-conditions.test.tsx 覆盖。
Builder 辅助函数:用 TypeScript 生成块
手写 JSON 容易出错,@emdash-cms/blocks提供类型安全的构建器。blocks与elements两组函数的完整实现见 builders.ts,全部参数都有类型约束与缺省处理:
import { blocks, elements } from "@emdash-cms/blocks"; const { header, form, section, stats, timeseriesChart, customChart, banner: bannerBlock, empty, accordion, } = blocks; const { textInput, toggle, select, button, repeater, mediaPicker } = elements; return { blocks: [ header("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" }, }), // Timeseries chart timeseriesChart({ series: [ { name: "Page Views", data: [ [Date.now() - 3600000, 100], [Date.now(), 150], ], }, ], yAxisName: "Views", gradient: true, }), // Pie chart via custom ECharts options customChart({ options: { series: [ { type: "pie", data: [ { value: 335, name: "Published" }, { value: 234, name: "Draft" }, ], }, ], }, }), ], };构建器命名采用 camelCase 参数(如blockId、actionId、initialValue),内部转换为 JSON 的 snake_case 字段(block_id、action_id、initial_value),并只在传入时才写入对应键。
按钮确认对话框(Button Confirmations)
危险操作前弹确认框:
{ "type": "button", "label": "Delete All", "action_id": "delete_all", "style": "danger", "confirm": { "title": "Are you sure?", "text": "This cannot be undone.", "confirm": "Delete", "deny": "Cancel" } }ConfirmDialog结构见 types.ts:title、text、confirm、deny全部必填,style若提供则只能是"danger"。
已保存条目的面板与操作(Saved-entry panels and actions)
admin.editorPanels与admin.editorActions指向私有插件路由:
- 面板路由(
editorPanels)返回 Block Kit,接收panel_load、block_action或form_submit; - 操作路由(
editorActions)接收editor_action,且只能返回三个受限字段:
{ toast?: { message: string; type: "success" | "error" | "info" }; refresh?: true; navigate?: LinkTarget; }约束(与校验器validateContentEditorActionResponse完全一致,见 validation.ts):
refresh与navigate二选一,不可同时使用;- 导航复用与
link元素相同的结构化 target 校验器; - 危险操作(danger action)声明必须包含确认对话框;
- 返回未知字段(如
blocks)会被拒绝。
两个表面都通过routeCtx.ui.entry获得宿主重载后的集合信息:collection、已保存条目 ID、内容 locale 与版本号;routeCtx.ui.extensionId标识 manifest 声明。已保存的字段值和未保存的编辑器状态不会包含在内。PluginUiContext的完整形状(admin-page/dashboard-widget与content-editor-panel/content-editor-action两种变体)见 types.ts。
链接与管理员 locale
不要直接返回 admin URL,使用结构化链接目标:
{ "type": "link", "label": "Edit article", "target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" }, "appearance": "primary" }LinkTarget的四种形态(types.ts):
| kind | 说明 |
|---|---|
content | 指向已保存内容,含collection、id、可选locale |
plugin-page | 指向同一插件声明的页面,path必须安全 |
plugin-settings | 插件生成的设置页 |
external | 绝对外部 URL,仅允许http:、https:、mailto:协议 |
行为要点:
- 外部链接在新标签页打开,带
noopener noreferrer; link没有action_id——需要回调插件时请使用button;- 校验器会拒绝带
action_id的 link 元素,plugin-page的 path 必须通过isSafePluginPagePath(禁止./..段),且(提供策略时)必须是该插件已声明的页面(validation.ts)。
Admin 路由通过routeCtx.ui获得宿主认证的 UI 上下文,与 interaction 分开传递:
const { locale, direction, surface } = routeCtx.ui ?? { locale: "en", direction: "ltr", surface: "admin-page", };UI locale 是管理员当前激活的 locale,与ctx.site.locale(站点默认内容 locale)相互独立。运行时页面文案可以用它选择本地化的 Block Kit 文本;manifest 导航标签则是静态的。
Toast 响应
在 blocks 旁返回toast展示通知:
return { blocks: [/* ... */], toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info" };响应校验:安全边界与配额(源码深入)
所有页面与组件响应在渲染前都会经过校验。validateBlockResponse(validation.ts)由两部分组成:先做边界检查,再做结构与策略校验。
BLOCK_RESPONSE_LIMITS(validation.ts)定义了硬性配额:
| 限制项 | 数值 | 说明 |
|---|---|---|
maxBytes | 256 KiB | 序列化后总字节数 |
maxDepth | 20 | 嵌套深度 |
maxNodes | 2,000 | 节点总数 |
maxArrayItems | 1,000 | 每个数组的长度上限 |
maxStringBytes | 64 KiB | 单个字符串字节数 |
maxErrors | 50 | 单次校验最多报告的错误数 |
边界检查实现(validation.ts)是一个显式栈遍历器,逐节点累计计数并实时以TextEncoder计算 UTF-8 字节,同时拒绝bigint等不可 JSON 序列化的值。
图片与外部资源策略(BlockValidationPolicy):
- root 相对图片 URL(以
/开头、非//、不含\)直接接受; - 外部图片必须 HTTPS,且主机名必须命中策略中的
allowedImageHosts(支持*与*.example.com通配); - 在插件层面,这与 manifest 的
network:request+allowedHosts(或network:request:unrestricted)权限声明配合生效。
插件路由应在返回响应前调用服务端的validateBlockResponse(或validateBlocks)获得早期错误反馈;服务端入口 server.ts 同时导出面板/操作交互的专用校验器validateContentEditorPanelInteraction与validateContentEditorActionResponse。
文档示例被自动测试
一个值得注意的工程实践:@emdash-cms/blocks的测试 skill-examples.test.ts 会直接解析本参考文档中的 JSON 代码块,按章节包装成对应块("Block Syntax"→原样、"Conditional Fields"→包进 form、"Button Confirmations"/"Links and admin locale"→包进 actions),逐条断言validateBlocks(...)返回{ valid: true, errors: [] }。这意味着本文中的示例都是可校验、可运行的,你在插件中照抄这些 JSON 结构即可通过宿主校验。
小结
Block Kit 把"插件管理界面"抽象为一次次的"请求-响应"交互循环:插件只负责返回声明式 JSON,宿主负责渲染、事件回传、校验与安全边界。掌握它就能在不接触浏览器 JS 的前提下,为 EmDash 构建完整的设置表单、数据表格、仪表盘图表、条件字段和内容编辑器面板。进一步阅读可参考同目录下的 admin-ui.md(admin 页面与路由声明)、portable-text-blocks.md(Portable Text 块级编辑字段)与 sandbox-boundaries.md(沙箱权限边界),以及@emdash-cms/blocks包的 types.ts、validation.ts、builders.ts 三份核心源码。
- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
相关推荐
EmDash CMS Block Kit 深入指南:用 @emdash-cms/blocks 构建声明式插件 UI
EmDash CMS Block Kit 深入指南:用 @emdash cms/blocks 构建声明式插件 UI 本文围绕 EmDash CMS 插件体系的
CMS后端前端插件系统EmDash Block Kit 开发指南:用 JSON 声明式 UI 构建沙箱插件管理后台
EmDash Block Kit 开发指南:用 JSON 声明式 UI 构建沙箱插件管理后台 导读 Block Kit 是 EmDash 为 沙箱插件 提供的一
CMS后端前端插件系统Stable Diffusion Forge 本地部署:3 个决策点让 AI 绘图数据不出这台机器
Stable Diffusion Forge 本地部署:3 个决策点让 AI 绘图数据不出这台机器 Stable Diffusion Forge 是一个开源的
CMS后端前端插件系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考