O Block Kit do EmDash permite que plugins em sandbox descrevam sua UI de administração como JSON. O host renderiza os blocos — nenhum JavaScript fornecido por 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 admin do plugin. - O plugin retorna um
BlockResponsecontendo um array de blocos. - A administração renderiza os blocos usando o componente
BlockRenderer. - Quando o usuário interage (clica em um botão, envia um formulário), a administração devolve a interação 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 do 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 rota admin a seguir valida a interação, renderiza um formulário ao carregar a página e armazena seus valores ao enviar:
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 handler ainda valida routeCtx.input, porque seu tipo TypeScript é unknown e um chamador pode invocar uma rota de plugin privada fora da página do Block Kit.
O EmDash valida cada resposta de página e widget em sandbox antes que a administração a renderize. Um bloco inválido, uma URL insegura, um link para uma página de plugin não declarada ou uma resposta acima dos limites do Block Kit faz a requisição falhar em vez de chegar ao navegador. Uma resposta pode conter até 256 KiB, 20 níveis de aninhamento, 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 um widget precisar retornar texto para a locale ativa do administrador. O host deriva esse valor do cookie de locale da administração ou do idioma da requisição e verifica a página ou o 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, a locale e a direção do texto. A locale da administração é separada de ctx.site.locale, que descreve a locale de conteúdo padrão do site. Os rótulos do manifesto continuam sendo strings estáticas.
Links de navegação
Use um elemento link para navegar sem despachar uma ação do Block Kit. O EmDash constrói as URLs internas a partir de destinos estruturados, então 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 destinos disponíveis são:
content, com uma coleção, o ID de uma entrada salva e uma locale de conteúdo opcional;plugin-page, com um caminho declarado pelo mesmo plugin;plugin-settings; eexternal, com uma URL HTTP, HTTPS oumailto:absoluta.
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.
As 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 nos allowedHosts do plugin. Um plugin com network:request:unrestricted pode carregar uma imagem HTTPS de qualquer hostname. Outras imagens externas fazem com que a resposta completa do Block Kit seja rejeitada.
Ações de linha em tabelas
Defina o format de uma coluna da tabela como element para colocar um botão, um link ou um 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 opções atrás de um único 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 uma section ou nas 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 só chamam sua rota privada quando um editor os abre.
O manifesto a seguir adiciona um painel para posts e uma ação de reparo com confirmação:
"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.
As 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, a locale de conteúdo e a versão. routeCtx.ui.extensionId identifica a declaração selecionada. Use ctx.content com a capability content:read quando o plugin precisar do conteúdo salvo.
Um painel recebe { type: "panel_load" } quando é aberto. O carregamento do painel nunca inclui dados de rascunho. Suas interações posteriores de botão e formulário usam as formas usuais 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 os valores atuais selecionados, as definições de campo sanitizadas, a 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. Ele permite que uma rota retorne um patch de campos inteiros 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 todas as operações em conjunto em relação ao esquema atual do servidor, à capability, à coleção, ao seletor de campos, à locale, à revisão base, à propriedade e aos limites de contagem e de bytes. O navegador repete as verificações de identidade, geração e campos antes de mostrar uma pré-visualização renderizada pelo host. Aplicar a pré-visualização marca o formulário como alterado e não salva, não cria uma revisão nem executa hooks. Qualquer edição feita enquanto o plugin está trabalhando faz com que o resultado completo seja rejeitado.
As ações do editor que só valem para entradas salvas permanecem desabilitadas enquanto o formulário tiver alterações não salvas. Ações compatíveis com rascunhos podem ser executadas no formulário não salvo. Uma ação recebe { type: "editor_action" } e, quando declarado, o mesmo snapshot de rascunho limitado. 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 destino 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 desatualizados e respostas acima dos limites do Block Kit antes de aplicar um efeito.
Block Kit em plugins nativos
Um plugin nativo pode renderizar páginas e widgets do Block Kit sem incluir React. Declare admin.pages ou admin.widgets em definePlugin(), deixe admin.entry sem definir e adicione uma rota chamada 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" }],
},
});
}
Um plugin nativo usa o Block Kit quando declara páginas, widgets ou extensões do editor e nenhum admin.entry. Definir admin.entry o muda para páginas de administração em React. Declare a rota admin explicitamente: a rota admin implícita existe apenas para bundles em sandbox mais antigos. Um handler nativo recebe um único RouteContext, então a interação chega como ctx.input.
Dois comportamentos descritos acima se aplicam atualmente apenas a plugins em sandbox em páginas de administração e widgets do painel:
ctx.uiéundefinedna página ou no widget de um plugin nativo, então a rota não consegue ler dele a locale da administração nem a direção do texto. Os painéis e as ações nativos do editor recebemctx.ui.- O EmDash não valida a resposta da página ou do widget de um plugin nativo antes que a administração a renderize. Mantenha as respostas nativas dentro dos mesmos tipos de bloco, limites e regras de links e imagens, porque o mesmo renderizador as desenha.
Tipos de bloco
| Tipo | Descrição |
|---|---|
header | Título grande em negrito |
section | Texto com um elemento acessório opcional |
divider | Linha horizontal |
fields | Grade de duas colunas de rótulo/valor |
table | Tabela de dados com formatação, ordenação e 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 esmaecido |
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 que envolve blocos aninhados |
chart | Série temporal de linhas ou barras, ou um gráfico com opções personalizadas |
banner | Mensagem de status ou alerta com um título ou uma 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
| Tipo | Descrição |
|---|---|
button | Botão de ação com caixa de 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 despacha uma ação |
text_input | Entrada de texto de uma linha ou várias linhas |
number_input | Entrada numérica com mínimo/máximo |
select | Seleção em lista suspensa |
toggle | Interruptor liga/desliga |
secret_input | Entrada mascarada para chaves de API e tokens |
checkbox | Seleção de vários valores de uma lista fixa |
combobox | Seleção de um único valor com busca |
date_input | Valor de data |
radio | Escolha única em uma lista de opções visível |
O editor de campos do Portable Text também oferece suporte a repeater e media_picker. Eles não são campos de formulário para a página de administração de um plugin em sandbox.
Carregar as opções de um select a partir de uma rota do plugin
Um select nos fields de um bloco do Portable Text pode definir optionsRoute para preencher seu menu suspenso a partir de uma das rotas do próprio plugin. O mesmo vale para um select aninhado em um repeater nesses campos.
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",
},
],
},
],
},
});
Cada select com optionsRoute chama a rota quando é renderizado, então um bloco com dois desses campos, ou um repeater com vários itens, envia uma requisição para cada um. Recolher e reabrir um item do repeater envia a requisição novamente. A requisição é POST /_emdash/api/plugins/<pluginId>/<optionsRoute>. Ela leva o cabeçalho X-EmDash-Request: 1 e um objeto JSON vazio como corpo. A rota é uma rota de plugin normal, então exige a permissão plugins:manage, a menos que declare outra permission.
O handler retorna { items: Array<{ id: string; name: string }> }. A rota responde com o envelope padrão do EmDash ({ success: true, data: { items: [...] } }, veja Rotas de API), e o editor de blocos lê as opções de data.items. A administração mostra cada item como uma opção, com id como valor armazenado e name como rótulo. Propriedades extras em um item são ignoradas.
Enquanto a requisição é executada, o campo mostra um estado de carregamento. Se a requisição falhar, a resposta não for OK ou a resposta não tiver um array items, o campo recorre ao array estático options. Se esse array estático options estiver vazio, o menu suspenso fica sem opções nesse caso.
optionsRoute só tem efeito no editor de blocos do Portable Text. O renderizador do Block Kit para páginas de administração, widgets e painéis de entradas salvas, e o renderizador de widgets de campo declarativos, leem apenas o array estático options e ignoram optionsRoute. Um select nesses lugares deve listar suas opções de forma estática, e uma resposta do Block Kit deve fornecer pelo menos uma.
Auxiliares de builder
O pacote @emdash-cms/blocks exporta as mesmas formas por meio dos objetos builder blocks e elements. Os builders reduzem erros nos nomes de propriedades e retornam objetos comuns 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
Os campos de formulário podem ser exibidos condicionalmente com base nos valores de outros campos:
{
"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 ao servidor.
secret_input usa has_value: true para indicar que já existe um valor; ele não aceita nem retorna o valor armazenado ao carregar a página. O campo mascara o que é digitado no navegador. Declare a chave correspondente como type: "secret" em admin.settingsSchema e salve-a por meio de ctx.settings para que o EmDash a criptografe. Siga Secret settings antes de armazenar credenciais.
Experimente
Use o Block Playground para criar e testar layouts de blocos de forma interativa.