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