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
- El usuario navega a la página de administración de un plugin.
- La administración envía una interacción
page_loada la ruta de administración del plugin. - El plugin devuelve un
BlockResponseque contiene un array de bloques. - La administración renderiza los bloques con el componente
BlockRenderer. - 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.
- 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; yexternal, con una URL absoluta HTTP, HTTPS omailto:.
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
| Type | Description |
|---|---|
header | Encabezado grande en negrita |
section | Texto con elemento accesorio opcional |
divider | Regla horizontal |
fields | Cuadrícula de etiqueta/valor de dos columnas |
table | Tabla de datos con formato, ordenación, paginación |
actions | Fila horizontal de botones y controles |
stats | Tarjetas de métricas del panel con indicadores de tendencia |
form | Campos de entrada con visibilidad condicional y envío |
image | Imagen a nivel de bloque con texto alternativo y título opcional |
context | Texto de ayuda pequeño y atenuado |
columns | Diseño de 2–3 columnas con bloques anidados |
empty | Título de estado vacío con descripción, comando y botones de acción opcionales |
accordion | Sección plegable que envuelve bloques anidados |
chart | Serie temporal de líneas o barras, o un gráfico con opciones personalizadas |
banner | Mensaje de estado o alerta con título o descripción |
meter | Valor numérico mostrado frente a un mínimo y un máximo |
code | Código TypeScript, TSX, JSONC, Bash o CSS de solo lectura |
tab | Paneles etiquetados que contienen bloques anidados |
Tipos de elemento
| Type | Description |
|---|---|
button | Botón de acción con diálogo de confirmación opcional |
link | Navegación interna o externa resuelta por el host |
menu | Botón que abre una lista de opciones; cada opción envía una acción |
text_input | Entrada de texto de una o varias líneas |
number_input | Entrada numérica con min/max |
select | Selección desplegable |
toggle | Interruptor de encendido/apagado |
secret_input | Entrada enmascarada para claves API y tokens |
checkbox | Seleccionar varios valores de una lista fija |
combobox | Selección de un solo valor con búsqueda |
date_input | Valor de fecha |
radio | Elecció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.