EmDash の Block Kit は、サンドボックスプラグインが管理 UI を JSON として記述できるようにします。ホストがブロックをレンダリングします。プラグイン提供の 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 とは別です。マニフェストラベルは静的文字列のままです。
ナビゲーションリンク
Block Kit アクションをディスパッチせずにナビゲートするには、link 要素を使います。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 に設定すると、各行にボタン、リンク、またはメニューを配置できます。各行は列キーの下に要素を保持し、値がない行はセルを空のままにします。1 つのボタンの背後に複数の選択肢を提供する行には 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 ブロック、セクションのアクセサリ、空状態のアクションにも置けますが、フォームフィールドにはできません。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 も受け取ります。スナップショットには、選択された現在値、サニタイズされたフィールド定義、保存済みアイデンティティ、永続化されたベースリビジョンのみが含まれます。明示的なスラッグには 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" } と、宣言されている場合は同じ境界付き下書きスナップショットを受け取ります。任意のトーストと最大 1 つの終端効果を含むオブジェクトを返します:
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
エントリを再読み込みするには refresh: true、構造化リンクターゲットには navigate、未保存のフィールド変更を提案するには patch を使います。応答は終端効果を組み合わせられません。EmDash は効果を適用する前に、未知のコマンド、安全でないナビゲーション、無効または古いパッチ、Block Kit 制限を超える応答を拒否します。
ブロックタイプ
| Type | Description |
|---|---|
header | 大きな太字の見出し |
section | 任意のアクセサリ要素付きテキスト |
divider | 水平線 |
fields | 2 列のラベル/値グリッド |
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 }
}
api_key フィールドは auth_enabled がオンのときだけ表示されます。条件はラウンドトリップなしでクライアント側で評価されます。
secret_input は値が既に存在することを示すために has_value: true を使います。ページ読み込み時に保存済みの値を受け付けたり返したりしません。フィールドはブラウザでの入力をマスクします。対応するキーを admin.settingsSchema で type: "secret" として宣言し、ctx.settings 経由で保存して EmDash が暗号化するようにします。資格情報を保存する前に Secret settings に従ってください。
試す
Block Playground を使って、ブロックレイアウトを対話的に構築・テストできます。