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 JavaScript del plugin en el navegador.

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 de administración 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 envía la interacción de vuelta al plugin.
  6. El plugin devuelve nuevos bloques y el ciclo se repite.

Añada @emdash-cms/blocks y zod al plugin cuando defina una página de 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 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;

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

EmDash valida cada respuesta de página y widget antes de que la administración la renderice. Un bloque no válido, una URL insegura, un enlace a una página del plugin no declarada o una respuesta por encima de los límites de Block Kit hace fallar la solicitud en lugar de llegar al navegador. Una respuesta puede contener hasta 256 KiB, 20 niveles anidados, 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 widget necesite devolver texto para el locale activo del administrador. El host deriva este valor de la cookie de locale de administración o del idioma de la solicitud y verifica la página o widget solicitado contra 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, el locale y la dirección del texto. El locale de administración es independiente de ctx.site.locale, que describe el locale de contenido predeterminado 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 de Block Kit. EmDash construye URLs internas a partir de destinos estructurados, de modo que los plugins no necesitan conocer las rutas de 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, ID de entrada guardada y 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 URLs de imagen relativas a la raíz. Una imagen externa debe usar HTTPS y su nombre de host debe aparecer en allowedHosts del plugin. Un plugin con network:request:unrestricted puede cargar una imagen HTTPS desde cualquier nombre de host. Otras imágenes externas hacen que se rechace la respuesta completa de Block Kit.

Acciones de fila en tablas

Establezca el format de una columna de tabla en element para colocar un botón, enlace o 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 detrás de un 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 sección o en 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 colapsados y llaman a su ruta privada solo cuando un editor los abre.

El siguiente manifiesto añade un panel para publicaciones y una acción de reparación 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 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 entrada guardada, el 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 valores actuales seleccionados, definiciones de campo sanitizadas, 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 campo completo tras 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 cada operación en conjunto contra el esquema actual del servidor, la capacidad, la colección, el selector de campo, el locale, la revisión base, la propiedad, los límites de cantidad y los límites de bytes. El navegador repite las comprobaciones de identidad, generación y campo 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 hecha mientras el plugin trabaja rechaza el resultado completo.

Las acciones del editor solo para guardados permanecen deshabilitadas mientras el formulario tiene cambios sin guardar. Las acciones conscientes del borrador 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 volver a cargar 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 o obsoletos y respuestas por encima de los límites de Block Kit antes de aplicar un efecto.

Tipos de bloque

TypeDescription
headerEncabezado grande en negrita
sectionTexto con elemento accesorio opcional
dividerRegla horizontal
fieldsCuadrícula de etiqueta/valor de dos columnas
tableTabla de datos con formato, ordenación, 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 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 descripción, 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 título o 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 etiquetados que contienen bloques anidados

Tipos de elemento

TypeDescription
buttonBotón de acción con 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 envía una acción
text_inputEntrada de texto de una o varias líneas
number_inputEntrada numérica con min/max
selectSelección desplegable
toggleInterruptor de encendido/apagado
secret_inputEntrada enmascarada para claves API y tokens
checkboxSeleccionar varios valores de una lista fija
comboboxSelección de un solo valor con búsqueda
date_inputValor de fecha
radioElección única de una lista de opciones visible

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

Ayudantes de builder

El paquete @emdash-cms/blocks exporta las mismas formas a través de los objetos builder blocks y elements. Los builders reducen errores de 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 otros 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 }
}

El campo api_key solo aparece cuando auth_enabled está activado. Las condiciones se evalúan en el cliente sin 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 la escritura 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 construir y probar diseños de bloques de forma interactiva.