O Block Kit do EmDash permite que plugins sandboxed descrevam sua UI de administração como JSON. O host renderiza os blocos — nenhum JavaScript fornecido pelo plugin é executado no navegador.
Como funciona
- O usuário navega até a página de administração de um plugin.
- A administração envia uma interação
page_loadpara a rota de administração do plugin. - O plugin retorna um
BlockResponsecontendo um array de blocos. - A administração renderiza os blocos com o componente
BlockRenderer. - Quando o usuário interage (clica em um botão, envia um formulário), a administração envia a interação de volta ao plugin.
- O plugin retorna novos blocos e o ciclo se repete.
Adicione @emdash-cms/blocks e zod ao plugin quando ele definir uma página Block Kit:
pnpm add @emdash-cms/blocks zod
Declare a página no manifesto do plugin para que a administração tenha uma entrada de navegação para carregar:
"admin": {
"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}
A seguinte rota admin valida a interação, renderiza um formulário no carregamento da página e armazena seus valores no envio:
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;
A rota admin é privada por padrão. O EmDash envia o cabeçalho CSRF correto quando a administração a chama. O manipulador ainda valida routeCtx.input porque seu tipo TypeScript é unknown e um chamador pode invocar uma rota privada do plugin fora da página Block Kit.
O EmDash valida cada resposta de página e widget antes de a administração renderizá-la. Um bloco inválido, URL insegura, link para uma página de plugin não declarada ou resposta acima dos limites do Block Kit falha a solicitação em vez de chegar ao navegador. Uma resposta pode conter até 256 KiB, 20 níveis aninhados, 2.000 nós, 1.000 itens por array e 64 KiB por string.
Locale e direção da UI
Leia routeCtx.ui quando uma página ou widget precisar retornar texto para o locale ativo do administrador. O host deriva esse valor do cookie de locale da administração ou do idioma da solicitação e verifica a página ou widget solicitado em relação ao manifesto do plugin.
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 contém a superfície, o locale e a direção do texto. O locale da administração é separado de ctx.site.locale, que descreve o locale de conteúdo padrão do site. Os rótulos do manifesto permanecem strings estáticas.
Links de navegação
Use um elemento link para navegar sem despachar uma ação Block Kit. O EmDash constrói URLs internas a partir de alvos estruturados, de modo que os plugins não precisam conhecer os caminhos das rotas de administração.
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" },
},
],
},
],
};
Os alvos disponíveis são:
content, com uma coleção, ID de entrada salva e locale de conteúdo opcional;plugin-page, com um caminho declarado pelo mesmo plugin;plugin-settings; eexternal, com uma URL absoluta HTTP, HTTPS oumailto:.
Links externos abrem em uma nova aba com noopener noreferrer. Elementos link não aceitam action_id e não podem aparecer como campos de formulário. Use um botão quando a interação precisar chamar a rota do plugin.
Imagens de bloco usam a mesma política de recursos do navegador. URLs de imagem relativas à raiz são permitidas. Uma imagem externa deve usar HTTPS e seu hostname deve aparecer em allowedHosts do plugin. Um plugin com network:request:unrestricted pode carregar uma imagem HTTPS de qualquer hostname. Outras imagens externas fazem a resposta completa do Block Kit ser rejeitada.
Ações de linha em tabelas
Defina o format de uma coluna da tabela como element para colocar um botão, link ou menu em cada linha. Cada linha armazena o elemento sob a chave da coluna; uma linha sem valor deixa a célula vazia. Use um elemento menu quando uma linha oferecer várias escolhas atrás de um botão:
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" },
],
},
},
],
},
],
};
Escolher um item do menu envia um block_action com o action_id do menu e o value do item. Os valores dos itens devem ser únicos dentro de um menu. Células de elemento aceitam apenas elementos button, link e menu. Um menu também pode aparecer em um bloco actions, como acessório de seção ou em ações de estado vazio, mas não como campo de formulário. O builder elements.menu(actionId, label, items, { style }) retorna a mesma forma.
Painéis e ações de entradas salvas
Declare um painel do editor quando um plugin precisar mostrar informações ao lado de uma entrada salva. Os painéis começam recolhidos e chamam sua rota privada apenas quando um editor os abre.
O manifesto a seguir adiciona um painel para publicações e uma ação de reparo confirmada:
"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",
},
},
],
}
Cada rota referenciada deve ser privada. Sua permission controla quais editores podem invocar a extensão. O host também recarrega a entrada salva e verifica seu proprietário antes de chamar o plugin.
Rotas de extensão do editor recebem um valor routeCtx.ui atestado. Para as superfícies content-editor-panel e content-editor-action, routeCtx.ui.entry contém a coleção, o ID da entrada salva, o locale de conteúdo e a versão. routeCtx.ui.extensionId identifica a declaração selecionada. Use ctx.content com a capacidade content:read quando o plugin precisar de conteúdo salvo.
Um painel recebe { type: "panel_load" } quando abre. O carregamento do painel nunca inclui dados de rascunho. Suas interações posteriores de botão e formulário usam as formas habituais block_action e form_submit. Quando o plugin declara admin.editor-draft:read e a extensão restringe draft.read, uma interação explícita também recebe routeCtx.input.draft. O snapshot contém apenas valores atuais selecionados, definições de campo sanitizadas, identidade salva e a revisão base persistida. Use fields para slugs explícitos, translatable: true para os campos traduzíveis da coleção, ou ambos. O acesso ao rascunho exige uma lista collections explícita.
admin.editor-draft:patch é independente do acesso de leitura. Permite que uma rota retorne um patch de campo completo após uma interação explícita:
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" },
],
},
};
O EmDash valida cada operação em conjunto contra o esquema atual do servidor, capacidade, coleção, seletor de campo, locale, revisão base, propriedade, limites de contagem e limites de bytes. O navegador repete verificações de identidade, geração e campo antes de mostrar uma prévia renderizada pelo host. Aplicar a prévia marca o formulário como modificado e não salva, não cria uma revisão e não executa hooks. Qualquer edição feita enquanto o plugin trabalha rejeita o resultado completo.
Ações do editor apenas para salvos permanecem desabilitadas enquanto o formulário tem alterações não salvas. Ações cientes de rascunho podem ser executadas contra o formulário não salvo. Uma ação recebe { type: "editor_action" } e, quando declarada, o mesmo snapshot de rascunho delimitado. Retorne um objeto contendo um toast opcional e no máximo um efeito terminal:
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
Use refresh: true para recarregar a entrada, navigate com um alvo de link estruturado ou patch para propor alterações de campo não salvas. Uma resposta não pode combinar efeitos terminais. O EmDash rejeita comandos desconhecidos, navegação insegura, patches inválidos ou obsoletos e respostas acima dos limites do Block Kit antes de aplicar um efeito.
Tipos de bloco
| Type | Description |
|---|---|
header | Cabeçalho grande em negrito |
section | Texto com elemento acessório opcional |
divider | Linha horizontal |
fields | Grade rótulo/valor de duas colunas |
table | Tabela de dados com formatação, ordenação, paginação |
actions | Linha horizontal de botões e controles |
stats | Cartões de métricas do painel com indicadores de tendência |
form | Campos de entrada com visibilidade condicional e envio |
image | Imagem em nível de bloco com texto alternativo e título opcional |
context | Texto de ajuda pequeno e atenuado |
columns | Layout de 2–3 colunas com blocos aninhados |
empty | Título de estado vazio com descrição, comando e botões de ação opcionais |
accordion | Seção recolhível envolvendo blocos aninhados |
chart | Série temporal de linhas ou barras, ou gráfico com opções personalizadas |
banner | Mensagem de status ou alerta com título ou descrição |
meter | Valor numérico exibido em relação a um mínimo e um máximo |
code | Código TypeScript, TSX, JSONC, Bash ou CSS somente leitura |
tab | Painéis rotulados contendo blocos aninhados |
Tipos de elemento
| Type | Description |
|---|---|
button | Botão de ação com diálogo de confirmação opcional |
link | Navegação interna ou externa resolvida pelo host |
menu | Botão que abre uma lista de opções; cada opção dispara uma ação |
text_input | Entrada de texto de uma ou várias linhas |
number_input | Entrada numérica com min/max |
select | Seleção suspensa |
toggle | Interruptor liga/desliga |
secret_input | Entrada mascarada para chaves de API e tokens |
checkbox | Selecionar vários valores de uma lista fixa |
combobox | Seleção de valor único pesquisável |
date_input | Valor de data |
radio | Escolha única de uma lista de opções visível |
O editor de campos Portable Text também oferece repeater e media_picker. Eles não são campos de formulário para uma página de administração de plugin sandboxed.
Auxiliares de builder
O pacote @emdash-cms/blocks exporta as mesmas formas pelos objetos builder blocks e elements. Builders reduzem erros de nomes de propriedades e retornam objetos ordinários compatíveis com 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" })]),
],
};
Campos condicionais
Campos de formulário podem ser mostrados condicionalmente com base em outros valores de campo:
{
"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 }
}
O campo api_key só aparece quando auth_enabled está ativado. As condições são avaliadas no cliente sem ida e volta.
secret_input usa has_value: true para mostrar que um valor já existe; não aceita nem retorna o valor armazenado no carregamento da página. O campo mascara a digitação no navegador. Declare a chave correspondente como type: "secret" em admin.settingsSchema e salve-a por ctx.settings para que o EmDash a criptografe. Siga Secret settings antes de armazenar credenciais.
Experimente
Use o Block Playground para construir e testar layouts de blocos de forma interativa.