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 제한을 초과하는 응답은 브라우저에 도달하지 않고 요청이 실패합니다. 하나의 응답은 최대 256 KiB, 중첩 20단계, 노드 2,000개, 배열당 항목 1,000개, 문자열당 64 KiB를 담을 수 있습니다.

UI 로케일과 방향

페이지나 위젯이 관리자의 활성 로케일에 맞는 텍스트를 반환해야 한다면 routeCtx.ui를 읽으세요. 호스트는 관리자 로케일 쿠키 또는 요청 언어에서 이 값을 도출하고, 요청된 페이지나 위젯을 플러그인 매니페스트와 대조해 검증합니다.

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에는 표면(surface), 로케일, 텍스트 방향이 들어 있습니다. 관리자 로케일은 사이트의 기본 콘텐츠 로케일을 나타내는 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와 함께 새 탭에서 열립니다. link 요소는 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이 전송됩니다. 항목 값은 메뉴 내에서 고유해야 합니다. 요소 셀은 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" }을 받고, 선언된 경우 같은 제한된 초안 스냅샷도 받습니다. 선택 사항인 토스트와 최대 하나의 종료 효과를 담은 객체를 반환하세요.

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가로줄
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_input한 줄 또는 여러 줄 텍스트 입력
number_input최솟값/최댓값이 있는 숫자 입력
select드롭다운 선택
toggle켜기/끄기 스위치
secret_inputAPI 키와 토큰용 마스킹된 입력
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 객체입니다. 이 라우트는 일반 플러그인 라우트이므로, 다른 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 응답에도 최소 하나는 제공해야 합니다.

빌더 헬퍼

@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를 사용해 블록 레이아웃을 대화형으로 만들고 테스트하세요.