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 互動式建置和測試區塊版面。