Block Kit

En esta página

El Block Kit de EmDash permite que los plugins en sandbox describan su UI de administración como JSON. El host renderiza los bloques: nunca se ejecuta en el navegador JavaScript suministrado por un plugin.

Cómo funciona

  1. El usuario navega a la página de administración de un plugin.
  2. La administración envía una interacción page_load a la ruta admin del plugin.
  3. El plugin devuelve un BlockResponse que contiene un array de bloques.
  4. La administración renderiza los bloques con el componente BlockRenderer.
  5. Cuando el usuario interactúa (hace clic en un botón, envía un formulario), la administración devuelve la interacción al plugin.
  6. El plugin devuelve nuevos bloques y el ciclo se repite.

Agregue @emdash-cms/blocks y zod al plugin cuando defina una página del Block Kit:

pnpm add @emdash-cms/blocks zod

Declare la página en el manifiesto del plugin para que la administración tenga una entrada de navegación que cargar:

"admin": {
	"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}

La siguiente ruta admin valida la interacción, renderiza un formulario al cargar la página y almacena sus valores al enviarlo:

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;

La ruta admin es privada de forma predeterminada. EmDash envía el encabezado CSRF correcto cuando la administración la llama. El handler sigue validando routeCtx.input porque su tipo de TypeScript es unknown y un llamador puede invocar una ruta de plugin privada fuera de la página del Block Kit.

EmDash valida cada respuesta de página y widget en sandbox antes de que la administración la renderice. Un bloque no válido, una URL insegura, un enlace a una página de plugin no declarada o una respuesta que supere los límites del Block Kit hace fallar la solicitud en lugar de llegar al navegador. Una respuesta puede contener hasta 256 KiB, 20 niveles de anidamiento, 2.000 nodos, 1.000 elementos por array y 64 KiB por cadena.

Locale y dirección de la UI

Lea routeCtx.ui cuando una página o un widget necesite devolver texto para la locale activa del administrador. El host deriva este valor de la cookie de locale de la administración o del idioma de la solicitud, y verifica la página o el widget solicitado con el manifiesto del 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 contiene la superficie, la locale y la dirección del texto. La locale de la administración es independiente de ctx.site.locale, que describe la locale de contenido predeterminada del sitio. Las etiquetas del manifiesto siguen siendo cadenas estáticas.

Enlaces de navegación

Use un elemento link para navegar sin despachar una acción del Block Kit. EmDash construye las URL internas a partir de destinos estructurados, así que los plugins no necesitan conocer las rutas de la administración.

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" },
				},
			],
		},
	],
};

Los destinos disponibles son:

  • content, con una colección, el ID de una entrada guardada y una locale de contenido opcional;
  • plugin-page, con una ruta declarada por el mismo plugin;
  • plugin-settings; y
  • external, con una URL absoluta HTTP, HTTPS o mailto:.

Los enlaces externos se abren en una pestaña nueva con noopener noreferrer. Los elementos link no aceptan action_id y no pueden aparecer como campos de formulario. Use un botón cuando la interacción deba llamar a la ruta del plugin.

Las imágenes de bloque usan la misma política de recursos del navegador. Se permiten URL de imagen relativas a la raíz. Una imagen externa debe usar HTTPS y su nombre de host debe aparecer en los allowedHosts del plugin. Un plugin con network:request:unrestricted puede cargar una imagen HTTPS desde cualquier nombre de host. Las demás imágenes externas hacen que se rechace la respuesta completa del Block Kit.

Acciones de fila en tablas

Establezca el format de una columna de tabla en element para colocar un botón, un enlace o un menú en cada fila. Cada fila almacena el elemento bajo la clave de la columna; una fila sin valor deja la celda vacía. Use un elemento menu cuando una fila ofrezca varias opciones tras un solo botón:

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" },
						],
					},
				},
			],
		},
	],
};

Elegir un elemento del menú envía un block_action con el action_id del menú y el value del elemento. Los valores de los elementos deben ser únicos dentro de un menú. Las celdas de elemento solo aceptan elementos button, link y menu. Un menú también puede aparecer en un bloque actions, como accesorio de una section o en las acciones de estado vacío, pero no como campo de formulario. El builder elements.menu(actionId, label, items, { style }) devuelve la misma forma.

Paneles y acciones de entradas guardadas

Declare un panel del editor cuando un plugin necesite mostrar información junto a una entrada guardada. Los paneles empiezan contraídos y solo llaman a su ruta privada cuando un editor los abre.

El siguiente manifiesto agrega un panel para posts y una acción de reparación con confirmación:

"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 ruta referenciada debe ser privada. Su permission controla qué editores pueden invocar la extensión. El host también vuelve a cargar la entrada guardada y comprueba su propietario antes de llamar al plugin.

Las rutas de extensión del editor reciben un valor routeCtx.ui atestiguado. Para las superficies content-editor-panel y content-editor-action, routeCtx.ui.entry contiene la colección, el ID de la entrada guardada, la locale de contenido y la versión. routeCtx.ui.extensionId identifica la declaración seleccionada. Use ctx.content con la capacidad content:read cuando el plugin necesite contenido guardado.

Un panel recibe { type: "panel_load" } cuando se abre. La carga del panel nunca incluye datos de borrador. Sus interacciones posteriores de botón y formulario usan las formas habituales block_action y form_submit. Cuando el plugin declara admin.editor-draft:read y la extensión restringe draft.read, una interacción explícita también recibe routeCtx.input.draft. La instantánea contiene solo los valores actuales seleccionados, las definiciones de campo saneadas, la identidad guardada y la revisión base persistida. Use fields para slugs explícitos, translatable: true para los campos traducibles de la colección, o ambos. El acceso al borrador requiere una lista collections explícita.

admin.editor-draft:patch es independiente del acceso de lectura. Permite que una ruta devuelva un parche de campos completos después de una interacción 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" },
		],
	},
};

EmDash valida todas las operaciones en conjunto con el esquema actual del servidor, la capacidad, la colección, el selector de campos, la locale, la revisión base, la propiedad y los límites de cantidad y de bytes. El navegador repite las comprobaciones de identidad, generación y campos antes de mostrar una vista previa renderizada por el host. Aplicar la vista previa marca el formulario como modificado y no guarda, no crea una revisión ni ejecuta hooks. Cualquier edición realizada mientras el plugin está trabajando hace que se rechace el resultado completo.

Las acciones del editor que solo funcionan con entradas guardadas permanecen deshabilitadas mientras el formulario tenga cambios sin guardar. Las acciones compatibles con borradores pueden ejecutarse contra el formulario sin guardar. Una acción recibe { type: "editor_action" } y, cuando se declara, la misma instantánea de borrador acotada. Devuelva un objeto que contenga un toast opcional y como máximo un efecto terminal:

return {
	toast: { type: "success", message: "Metadata repaired" },
	refresh: true,
};

Use refresh: true para recargar la entrada, navigate con un destino de enlace estructurado o patch para proponer cambios de campo sin guardar. Una respuesta no puede combinar efectos terminales. EmDash rechaza comandos desconocidos, navegación insegura, parches no válidos u obsoletos y respuestas que superen los límites del Block Kit antes de aplicar un efecto.

Block Kit en plugins nativos

Un plugin nativo puede renderizar páginas y widgets del Block Kit sin incluir React. Declare admin.pages o admin.widgets en definePlugin(), deje admin.entry sin establecer y agregue una ruta llamada 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" }],
		},
	});
}

Un plugin nativo usa el Block Kit cuando declara páginas, widgets o extensiones del editor y no declara admin.entry. Establecer admin.entry lo cambia a páginas de administración de React. Declare la ruta admin de forma explícita: la ruta admin implícita existe solo para paquetes en sandbox más antiguos. Un handler nativo recibe un único RouteContext, por lo que la interacción llega como ctx.input.

Dos comportamientos descritos arriba se aplican actualmente solo a plugins en sandbox en páginas de administración y widgets del panel:

  • ctx.ui es undefined en la página o el widget de un plugin nativo, así que la ruta no puede leer de él la locale de la administración ni la dirección del texto. Los paneles y las acciones nativos del editor sí reciben ctx.ui.
  • EmDash no valida la respuesta de la página o del widget de un plugin nativo antes de que la administración la renderice. Mantenga las respuestas nativas dentro de los mismos tipos de bloque, límites y reglas de enlaces e imágenes, porque las dibuja el mismo renderizador.

Tipos de bloque

TipoDescripción
headerEncabezado grande en negrita
sectionTexto con un elemento accesorio opcional
dividerRegla horizontal
fieldsCuadrícula de dos columnas de etiqueta/valor
tableTabla de datos con formato, ordenación y paginación
actionsFila horizontal de botones y controles
statsTarjetas de métricas del panel con indicadores de tendencia
formCampos de entrada con visibilidad condicional y envío
imageImagen a nivel de bloque con texto alternativo y un título opcional
contextTexto de ayuda pequeño y atenuado
columnsDiseño de 2–3 columnas con bloques anidados
emptyTítulo de estado vacío con una descripción, un comando y botones de acción opcionales
accordionSección plegable que envuelve bloques anidados
chartSerie temporal de líneas o barras, o un gráfico con opciones personalizadas
bannerMensaje de estado o alerta con un título o una descripción
meterValor numérico mostrado frente a un mínimo y un máximo
codeCódigo TypeScript, TSX, JSONC, Bash o CSS de solo lectura
tabPaneles con etiqueta que contienen bloques anidados

Tipos de elemento

TipoDescripción
buttonBotón de acción con un cuadro de diálogo de confirmación opcional
linkNavegación interna o externa resuelta por el host
menuBotón que abre una lista de opciones; cada opción despacha una acción
text_inputEntrada de texto de una línea o multilínea
number_inputEntrada numérica con mínimo/máximo
selectSelección desplegable
toggleInterruptor de encendido/apagado
secret_inputEntrada enmascarada para claves de API y tokens
checkboxSelección de varios valores de una lista fija
comboboxSelección de un único valor con búsqueda
date_inputValor de fecha
radioElección única de una lista de opciones visible

El editor de campos de Portable Text también admite repeater y media_picker. No son campos de formulario para la página de administración de un plugin en sandbox.

Cargar las opciones de un select desde una ruta del plugin

Un select en los fields de un bloque de Portable Text puede establecer optionsRoute para rellenar su desplegable desde una de las rutas del propio plugin. Lo mismo vale para un select anidado en un repeater dentro de esos 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 con optionsRoute llama a la ruta cuando se renderiza, de modo que un bloque con dos campos de este tipo, o un repeater con varios elementos, envía una solicitud por cada uno. Contraer y volver a abrir un elemento de un repeater vuelve a enviar la solicitud. La solicitud es POST /_emdash/api/plugins/<pluginId>/<optionsRoute>. Lleva la cabecera X-EmDash-Request: 1 y un objeto JSON vacío como cuerpo. La ruta es una ruta de plugin normal, así que necesita el permiso plugins:manage a menos que declare otro permission.

El manejador devuelve { items: Array<{ id: string; name: string }> }. La ruta responde con el sobre estándar de EmDash ({ success: true, data: { items: [...] } }, consulte Rutas de API), y el editor de bloques lee las opciones de data.items. La administración muestra cada elemento como una opción, con id como valor almacenado y name como etiqueta. Las propiedades adicionales de un elemento se ignoran.

Mientras se ejecuta la solicitud, el campo muestra un estado de carga. Si la solicitud falla, la respuesta no es correcta o la respuesta no tiene un array items, el campo recurre al array estático options. Si ese array estático options está vacío, el desplegable se queda sin opciones en ese caso.

optionsRoute solo surte efecto en el editor de bloques de Portable Text. El renderizador de Block Kit para páginas de administración, widgets y paneles de entradas guardadas, y el renderizador de widgets de campo declarativos, leen únicamente el array estático options e ignoran optionsRoute. Un select en esos lugares debe enumerar sus opciones de forma estática, y una respuesta de Block Kit debe darle al menos una.

Ayudantes de builder

El paquete @emdash-cms/blocks exporta las mismas formas mediante los objetos builder blocks y elements. Los builders reducen los errores en los nombres de propiedades y devuelven objetos ordinarios compatibles con 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 condicionales

Los campos de formulario pueden mostrarse de forma condicional según los valores de otros 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 }
}

El campo api_key solo aparece cuando auth_enabled está activado. Las condiciones se evalúan en el cliente, sin viaje de ida y vuelta.

secret_input usa has_value: true para indicar que ya existe un valor; no acepta ni devuelve el valor almacenado al cargar la página. El campo enmascara lo que se escribe en el navegador. Declare la clave correspondiente como type: "secret" en admin.settingsSchema y guárdela mediante ctx.settings para que EmDash la cifre. Siga Secret settings antes de almacenar credenciales.

Pruébelo

Use el Block Playground para crear y probar diseños de bloques de forma interactiva.