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를 읽으세요. 호스트는 이 값을 관리 로케일 쿠키 또는 요청 언어에서 유도하고, 요청된 페이지나 위젯을 플러그인 매니페스트와 대조합니다.
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로 설정하면 각 행에 버튼, 링크 또는 메뉴를 배치할 수 있습니다. 각 행은 열 키 아래에 요소를 저장하며, 값이 없는 행은 셀을 비워 둡니다. 한 버튼 뒤에 여러 선택지를 제공하는 행에는 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 블록, 섹션 accessory, 빈 상태 작업에도 나타날 수 있지만 폼 필드는 아닙니다. 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 제한을 넘는 응답을 거부합니다.
블록 유형
| 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 }
}
api_key 필드는 auth_enabled가 켜져 있을 때만 나타납니다. 조건은 왕복 없이 클라이언트 측에서 평가됩니다.
secret_input은 값이 이미 있음을 보이기 위해 has_value: true를 사용합니다. 페이지 로드 시 저장된 값을 받거나 반환하지 않습니다. 필드는 브라우저에서 입력을 마스킹합니다. 일치하는 키를 admin.settingsSchema에서 type: "secret"으로 선언하고 ctx.settings를 통해 저장하여 EmDash가 암호화하도록 하세요. 자격 증명을 저장하기 전에 Secret settings를 따르세요.
사용해 보기
Block Playground에서 블록 레이아웃을 대화형으로 만들고 테스트할 수 있습니다.