Block Kit

Nesta página

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

  1. O usuário navega até a página de administração de um plugin.
  2. A administração envia uma interação page_load para a rota admin do plugin.
  3. O plugin retorna um BlockResponse contendo um array de blocos.
  4. A administração renderiza os blocos usando o componente BlockRenderer.
  5. Quando o usuário interage (clica em um botão, envia um formulário), a administração devolve a interação ao plugin.
  6. 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.

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; e
  • external, com uma URL HTTP, HTTPS ou mailto: 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 é undefined na 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 recebem ctx.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

TipoDescrição
headerTítulo grande em negrito
sectionTexto com um elemento acessório opcional
dividerLinha horizontal
fieldsGrade de duas colunas de rótulo/valor
tableTabela de dados com formatação, ordenação e paginação
actionsLinha horizontal de botões e controles
statsCartões de métricas do painel com indicadores de tendência
formCampos de entrada com visibilidade condicional e envio
imageImagem em nível de bloco com texto alternativo e título opcional
contextTexto de ajuda pequeno e esmaecido
columnsLayout de 2–3 colunas com blocos aninhados
emptyTítulo de estado vazio com descrição, comando e botões de ação opcionais
accordionSeção recolhível que envolve blocos aninhados
chartSérie temporal de linhas ou barras, ou um gráfico com opções personalizadas
bannerMensagem de status ou alerta com um título ou uma descrição
meterValor numérico exibido em relação a um mínimo e um máximo
codeCódigo TypeScript, TSX, JSONC, Bash ou CSS somente leitura
tabPainéis rotulados contendo blocos aninhados

Tipos de elemento

TipoDescrição
buttonBotão de ação com caixa de diálogo de confirmação opcional
linkNavegação interna ou externa resolvida pelo host
menuBotão que abre uma lista de opções; cada opção despacha uma ação
text_inputEntrada de texto de uma linha ou várias linhas
number_inputEntrada numérica com mínimo/máximo
selectSeleção em lista suspensa
toggleInterruptor liga/desliga
secret_inputEntrada mascarada para chaves de API e tokens
checkboxSeleção de vários valores de uma lista fixa
comboboxSeleção de um único valor com busca
date_inputValor de data
radioEscolha ú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.