Block Kit

このページ

EmDash の Block Kit を使うと、サンドボックス化されたプラグインが管理 UI を JSON で記述できます。ブロックはホストがレンダリングするため、プラグインが提供する JavaScript がブラウザーで実行されることはありません。

仕組み

  1. ユーザーがプラグインの管理ページに移動します。
  2. 管理画面がプラグインの admin ルートに page_load インタラクションを送信します。
  3. プラグインが、ブロックの配列を含む BlockResponse を返します。
  4. 管理画面が BlockRenderer コンポーネントでブロックをレンダリングします。
  5. ユーザーが操作(ボタンのクリック、フォームの送信)を行うと、管理画面はそのインタラクションをプラグインに送り返します。
  6. プラグインが新しいブロックを返し、このサイクルが繰り返されます。

プラグインで 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 の制限を超えるレスポンスは、ブラウザーに届く代わりにリクエストが失敗します。1 つのレスポンスに含められるのは、最大 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:絶対 URL の HTTP、HTTPS、または mailto: を指定します。

外部リンクは noopener noreferrer 付きで新しいタブで開きます。link 要素は 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 が送信されます。項目の値は、メニュー内で一意でなければなりません。要素セルが受け付けるのは、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 も受け取ります。このスナップショットには、選択された現在の値、サニタイズされたフィールド定義、保存済みの識別情報、永続化されたベースリビジョンのみが含まれます。明示的なスラッグには 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 の制限を超えるレスポンスを拒否します。

ネイティブプラグインでの 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 ルートは、古いサンドボックスバンドルのためだけに存在します。ネイティブハンドラーは 1 つの RouteContext を受け取るため、インタラクションは ctx.input として届きます。

上で説明した 2 つの挙動は、現在、管理ページとダッシュボードウィジェットにおけるサンドボックス化されたプラグインにのみ適用されます。

  • ネイティブプラグインのページやウィジェットでは ctx.ui が undefined のため、ルートはそこから管理画面のロケールやテキストの方向を読み取れません。ネイティブのエディターパネルとアクションは ctx.ui を受け取ります。
  • EmDash は、管理画面がレンダリングする前に、ネイティブプラグインのページやウィジェットのレスポンスを検証しません。同じレンダラーが描画するため、ネイティブのレスポンスも、同じブロックタイプ、制限、リンクと画像のルールの範囲内に収めてください。

ブロックタイプ

タイプ説明
header大きな太字の見出し
sectionテキスト(省略可能なアクセサリー要素付き)
divider水平線
fields2 列のラベル/値グリッド
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_input1 行または複数行のテキスト入力
number_input最小値/最大値を指定できる数値入力
selectドロップダウン選択
toggleオン/オフのスイッチ
secret_inputAPI キーとトークン向けのマスクされた入力
checkbox固定リストから複数の値を選択
combobox検索可能な単一値の選択
date_input日付の値
radio表示されたオプションリストから 1 つを選択

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 は、レンダリング時にそのルートを呼び出します。そのため、そのようなフィールドを 2 つ持つブロックや、複数の項目を持つ repeater では、フィールドごとに 1 回ずつリクエストが送られます。repeater の項目を折りたたんでから再度開くと、リクエストが再送されます。リクエストは POST /_emdash/api/plugins/<pluginId>/<optionsRoute> です。リクエストには X-EmDash-Request: 1 ヘッダーと、本文として空の JSON オブジェクトが付きます。このルートは通常のプラグインルートであり、別の permission を宣言していない限り、plugins:manage 権限が必要です。

ハンドラーは { 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 のレスポンスでも少なくとも 1 つ指定する必要があります。

ビルダーヘルパー

@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 がそれを暗号化します。認証情報を保存する前に、シークレットの設定に従ってください。

試す

Block Playground を使うと、ブロックのレイアウトをインタラクティブに構築してテストできます。