Hooks

En esta página

Los hooks permiten que los plugins ejecuten código en respuesta a eventos. Todos los hooks reciben un objeto de evento y el contexto del plugin, y se declaran al definir el plugin: no existe registro dinámico en tiempo de ejecución.

Esta página trata sobre los plugins sandboxed. Los plugins nativos usan los mismos nombres de hook y tipos de evento, pero emplean el pipeline de hooks en proceso y además pueden registrar page:fragments. Más abajo se describen el rechazo de guardados en el sandbox y el comportamiento ante fallos de los runners aislados.

Firma del hook

Cada manejador de hook recibe dos argumentos:

async (event, ctx) => ReturnType;
  • event — datos sobre lo que acaba de ocurrir (contenido que se está guardando, medio subido, transición del ciclo de vida, etc.)
  • ctx — el PluginContext con almacenamiento, KV, registro y APIs controladas por capabilities

Al asignar la definición a una constante de tipo SandboxedPlugin, event se infiere a partir del nombre del hook (el tipo de evento canónico completo) y ctx como PluginContext, por lo que los manejadores no necesitan anotaciones de parámetros. Exporte esa constante como exportación por defecto. Para referirse a un tipo de evento por su nombre en una función auxiliar, impórtelo desde emdash/plugin.

Configuración del hook

Un hook puede declararse como un manejador simple o envuelto en un objeto de configuración. Prefiera la forma simple, salvo que el plugin también admita deliberadamente la ejecución en proceso y necesite los metadatos descritos a continuación.

Simple

hooks: {
	"content:afterSave": async (event, ctx) => {
		ctx.log.info("Content saved");
	},
},

Configuración completa

hooks: {
	"content:afterSave": {
		priority: 100,
		timeout: 5000,
		handler: async (event, ctx) => {
			ctx.log.info("Content saved");
		},
	},
},

Opciones de configuración

OpciónTipoValor por defectoDescripción
prioritynumber100Orden de ejecución. Los números más bajos se ejecutan primero.
timeoutnumber5000Tiempo máximo de ejecución en milisegundos.
exclusivebooleanfalseSolo un plugin puede ser el proveedor activo. Se usa en email:deliver y comment:moderate.
handlerfunction—La función manejadora del hook. Obligatoria.

Capabilities requeridas

Varios hooks exponen datos protegidos o pueden cambiar una operación. EmDash los registra solo cuando el manifiesto declara la capability correspondiente:

HooksCapabilityMotivo
content:beforeSavecontent:writeEl hook puede reemplazar el contenido enviado.
content:beforePublish, content:beforeSchedule, content:beforeUnpublishhooks.content-policy:registerLos hooks pueden rechazar cambios en el estado de publicación.
Otros hooks content:*content:readSus eventos exponen contenido o identifican una entrada.
media:beforeUploadmedia:writeEl hook puede reemplazar los metadatos de la subida o detenerla.
media:afterUploadmedia:readSu evento expone el elemento multimedia almacenado.
email:beforeSend, email:afterSendhooks.email-events:registerLos hooks inspeccionan eventos del ciclo de vida del correo.
email:deliverhooks.email-transport:registerEl hook se convierte en un proveedor de transporte de correo.
Todos los hooks comment:*users:readLos eventos de comentarios pueden contener información de contacto del autor y metadatos de la solicitud.
page:fragmentshooks.page-fragments:registerEl hook inyecta contenido de página propio (first-party) y es solo nativo.

Los hooks del ciclo de vida, cron y page:metadata no tienen capability de registro. Declare la capability indicada incluso cuando un hook solo lee su evento y no llama a la API ctx correspondiente. La declaración ofrece al operador un aviso de consentimiento preciso, controla la API ctx y es obligatoria cuando el plugin se ejecuta en proceso. Capabilities y seguridad explica el efecto en tiempo de ejecución.

Hooks de ciclo de vida

Se ejecutan durante la instalación, activación, desactivación y eliminación del plugin.

plugin:install

Se ejecuta una vez, cuando el plugin se añade por primera vez a un sitio.

Este ejemplo supone que el manifiesto declara una colección de almacenamiento items:

"plugin:install": async (_event, ctx) => {
	ctx.log.info("Installing plugin...");
	await ctx.settings.set("enabled", true);
	await ctx.storage.items.put("default", { name: "Default Item" });
},

Evento: {} — Devuelve: Promise<void>

plugin:activate

Se ejecuta cuando el plugin se habilita (tras la instalación o al volver a habilitarse).

"plugin:activate": async (_event, ctx) => {
	ctx.log.info("Plugin activated");
},

Evento: {} — Devuelve: Promise<void>

plugin:deactivate

Se ejecuta cuando el plugin se deshabilita (pero no se elimina).

"plugin:deactivate": async (_event, ctx) => {
	ctx.log.info("Plugin deactivated");
},

Evento: {} — Devuelve: Promise<void>

plugin:uninstall

Se ejecuta cuando el plugin se elimina de un sitio.

"plugin:uninstall": async (event, ctx) => {
	ctx.log.info("Uninstalling plugin...");
	if (event.deleteData) {
		while (true) {
			const result = await ctx.storage.items.query({ limit: 100 });
			if (result.items.length === 0) break;
			await ctx.storage.items.deleteMany(result.items.map((item) => item.id));
		}
	}
},

Evento: { deleteData: boolean } — Devuelve: Promise<void>

Hooks de contenido

Se ejecutan durante las operaciones de creación, actualización y eliminación del contenido del sitio.

content:beforeSave

Se ejecuta antes de guardar el contenido. Devuelva el contenido modificado, un resultado de error de hook de sandbox o void para dejarlo sin cambios.

Para rechazar un guardado desde el sandbox, devuelva un resultado de hook con versión y un error SAVE_REJECTED. Establezca reason como texto plano de entre 1 y 500 caracteres. EmDash identifica el plugin y muestra el motivo al editor. Los resultados de error vacíos, demasiado largos, mal formados o desconocidos hacen fallar el guardado con un error de hook genérico.

"content:beforeSave": async (event, ctx) => {
	const { content } = event;
	if (typeof content.title !== "string" || content.title.trim() === "") {
		return {
			__emdashSandboxHookResult: true,
			version: 1,
			error: {
				code: "SAVE_REJECTED",
				reason: "Add a title before saving.",
			},
		};
	}

	content.title = content.title.trim();

	return content;
},

No incluya HTML en reason. El panel de administración muestra el valor como texto.

Desde el proceso anfitrión, lance en su lugar ContentSaveRejectedError (exportado desde emdash). La API devuelve SAVE_REJECTED con su mensaje. Cualquier otra excepción, en cualquiera de los dos modos de ejecución, hace fallar el guardado con una respuesta genérica CONTENT_HOOK_ERROR.

Evento: { content, collection, isNew, id, actor } — Devuelve: contenido modificado, un resultado de error de hook de sandbox o void. En una actualización, id es el ID del elemento existente y content contiene solo los valores de campo enviados; cargue el elemento almacenado con ctx.content.get(event.collection, event.id). El slug de la entrada no forma parte de content, y una clave slug devuelta por el hook falla la validación por ser un campo desconocido. Los guardados autenticados de REST, edición visual y MCP incluyen actor.id y el actor.role numérico. Las escrituras internas sin usuario autenticado omiten actor.

content:afterSave

Se ejecuta después de que el contenido se guarde correctamente. Úselo para efectos secundarios como notificaciones, registro o sincronizaciones externas.

"content:afterSave": async (event, ctx) => {
	const contentId = String(event.content.id);
	ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${contentId}`, {
		actorId: event.actor?.id,
	});

	if (ctx.http) {
		await ctx.http.fetch("https://api.example.com/webhook", {
			method: "POST",
			body: JSON.stringify({ event: "content:save", id: contentId }),
		});
	}
},

Evento: { content, collection, isNew, actor } — Devuelve: Promise<void>. Los guardados autenticados incluyen la misma instantánea opcional de actor que content:beforeSave.

content:beforeDelete

Se ejecuta antes de eliminar el contenido. Devuelva false para cancelar; true o void lo permite.

"content:beforeDelete": async (event, ctx) => {
	if (event.collection === "pages" && event.id === "home") {
		ctx.log.warn("Cannot delete home page");
		return false;
	}
	return true;
},

Evento: { id, collection, permanent: false } — Devuelve: boolean | void

Este hook se ejecuta antes de que una entrada se mueva a la papelera. Eliminar una entrada de forma permanente desde la papelera no vuelve a ejecutar content:beforeDelete.

content:afterDelete

Se ejecuta después de que el contenido se elimine correctamente.

"content:afterDelete": async (event, ctx) => {
	await ctx.storage.cache.delete(`${event.collection}:${event.id}`);
},

Evento: { id, collection, permanent } — Devuelve: Promise<void>. permanent es false cuando la entrada se movió a la papelera y true cuando se eliminó permanentemente.

Declare hooks.content-policy:register para inspeccionar y rechazar la publicación, la programación o la despublicación sin recibir acceso de lectura, escritura ni de acciones de publicación sobre el contenido.

Devuelva void para permitir la acción o { cancel: true, reason } para rechazarla. El motivo debe contener entre 1 y 500 caracteres de texto plano. Las decisiones no válidas y los errores inesperados abortan por defecto sin exponer la excepción. Los rechazos explícitos devuelven PUBLISH_REJECTED, SCHEDULE_REJECTED o UNPUBLISH_REJECTED.

Los tres eventos contienen { content, collection, origin, actor? }. origin.source es api, mcp, visual-editor, plugin, scheduler o system; los orígenes de plugin también contienen pluginId. Las acciones humanas autenticadas incluyen actor.id, el actor.role numérico y el actor.source correspondiente. EmDash acepta el origen visual-editor únicamente a partir del token de acción firmado y de corta duración incrustado en un renderizado autenticado de la barra de herramientas; las solicitudes ordinarias a la API no pueden elegir su origen.

Los eventos de publicación y programación exponen el borrador efectivo en content.data y el slug preparado en content.slug. Los eventos de despublicación exponen el contenido actualmente en vivo que la acción eliminaría.

content:beforePublish

El siguiente hook exige un marcador de aprobación antes de que el contenido pueda publicarse:

"content:beforePublish": async (event) => {
	const data = event.content.data;
	const approvalStatus =
		typeof data === "object" && data !== null && "approval_status" in data
			? data.approval_status
			: undefined;
	if (approvalStatus !== "approved") {
		return { cancel: true, reason: "Approve this entry before publishing." };
	}
},

Este hook se ejecuta antes de la publicación manual, por MCP, de plugins, del sistema y programada. El contenido programado se comprueba de nuevo cuando llega su hora de publicación. El rechazo de un programador anula la programación de la entrada, guarda el motivo apto para mostrarse públicamente y lista la entrada afectada en el panel, en lugar de reintentar el mismo rechazo permanente en cada tick del programador. Una programación, publicación o eliminación correcta borra el registro. Un administrador puede descartar un registro obsoleto cuando la entrada o el plugin de políticas ya no está disponible.

content:beforeSchedule

Se ejecuta antes de que una entrada reciba una hora de publicación. El evento también contiene scheduledAt.

No existe un hook content:beforeUnschedule. Un administrador siempre puede cancelar una publicación futura.

content:beforeUnpublish

Se ejecuta antes de que se elimine el contenido en vivo.

content:afterPublish

Se ejecuta después de que el contenido pase de borrador a en vivo. Requiere la capability content:read.

Evento: { content, collection } — Devuelve: Promise<void>

content:afterUnpublish

Se ejecuta después de que el contenido vuelva de en vivo a borrador. Requiere la capability content:read.

Evento: { content, collection } — Devuelve: Promise<void>

content:afterRestore

Se ejecuta después de que se restaure contenido de la papelera. Requiere la capability content:read.

Evento: { content, collection } — Devuelve: Promise<void>

content:afterSchedule

Se ejecuta después de que el contenido se programe para publicarse en el futuro. Requiere la capability content:read.

Evento: { content, collection } — Devuelve: Promise<void>

content:afterUnschedule

Se ejecuta después de que se anule la programación de contenido programado. Requiere la capability content:read.

Evento: { content, collection } — Devuelve: Promise<void>

Hooks de medios

media:beforeUpload

Se ejecuta antes de subir un archivo. Devuelva los metadatos del archivo modificados o lance una excepción para cancelar.

"media:beforeUpload": async (event, ctx) => {
	if (!event.file.type.startsWith("image/")) {
		throw new Error("Only images are allowed");
	}
	if (event.file.size > 10 * 1024 * 1024) {
		throw new Error("File too large");
	}
	return { ...event.file, name: `${Date.now()}-${event.file.name}` };
},

Evento: { file: { name, type, size } } — Devuelve: archivo modificado o void

media:afterUpload

Se ejecuta después de que un archivo se suba correctamente.

Evento: { media: { id, filename, mimeType, size, url, createdAt } } — Devuelve: Promise<void>

Hooks de páginas públicas

Permiten que los plugins contribuyan a las páginas públicas renderizadas. Las plantillas lo activan incluyendo los componentes <EmDashHead>, <EmDashBodyStart> y <EmDashBodyEnd> de emdash/ui.

page:metadata

Aporta metadatos tipados a <head>: meta tags, propiedades OpenGraph, <link> rels de una lista de permitidos y JSON-LD. Disponible tanto para plugins sandboxed como nativos. El núcleo valida, deduplica y renderiza las contribuciones; los plugins devuelven datos estructurados, nunca HTML sin procesar.

"page:metadata": async (event, ctx) => {
	if (event.page.kind !== "content") return null;

	return {
		kind: "jsonld",
		id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
		graph: {
			"@context": "https://schema.org",
			"@type": "BlogPosting",
			headline: event.page.pageTitle ?? event.page.title,
			description: event.page.description,
		},
	};
},

Evento:

{
	page: {
		url: string;
		path: string;
		locale: string | null;
		kind: "content" | "custom";
		pageType: string;
		title: string | null;
		pageTitle?: string | null;
		description: string | null;
		canonical: string | null;
		image: string | null;
		content?: { collection: string; id: string; slug: string | null };
		seo?: {
			ogTitle?: string | null;
			ogDescription?: string | null;
			ogImage?: string | null;
			robots?: string | null;
		};
		articleMeta?: {
			publishedTime?: string | null;
			modifiedTime?: string | null;
			author?: string | null;
		};
		siteName?: string;
		breadcrumbs?: Array<{ name: string; url: string }>;
		siteUrl?: string;
	}
}

Devuelve: PageMetadataContribution | PageMetadataContribution[] | null

Tipos de contribución:

TipoRenderizaClave de deduplicación
meta<meta name="..." content="...">key o name
property<meta property="..." content="...">key o property
link<link rel="<allowed value>" href="...">canonical: único; alternate: key o hreflang
jsonld<script type="application/ld+json">id (si existe)

En cualquier clave de deduplicación gana la primera contribución. <EmDashHead> compone las contribuciones en el orden plugins → ajustes del sitio → metadatos base proporcionados por la plantilla, de modo que las contribuciones de los plugins prevalecen sobre todo lo que queda por debajo. En las páginas de contenido, los valores del panel SEO de la entrada se incorporan al contexto de la página antes de generar los metadatos base: sustituyen los campos proporcionados por la plantilla (y son lo que su hook ve en el contexto de la página), mientras que las contribuciones de los plugins siguen ganando gracias a la deduplicación en la que gana el primero. El rel de los enlaces está restringido a una lista de permitidos bloqueada por seguridad (canonical, alternate, author, license, nlweb, site.standard.document); href debe ser HTTP o HTTPS.

page:fragments

Aporta HTML sin procesar, scripts u hojas de estilo a los puntos de inserción de la página. Solo plugins nativos.

Los plugins sandboxed no pueden usar este hook porque su salida se ejecuta como código propio (first-party) en el navegador del visitante, fuera de cualquier límite del sandbox. Para contribuciones de página seguras en el sandbox, use page:metadata. Consulte Plugins nativos: fragmentos de página si necesita esta superficie.

Orden de ejecución de hooks

Cuando un plugin en formato sandboxed se ejecuta en proceso, los hooks usan el pipeline de hooks compartido:

  1. Los hooks con valores de priority más bajos se ejecutan primero.
  2. Con la misma prioridad, los hooks se ejecutan en el orden de registro de los plugins.
  3. Los hooks con dependencies esperan a que esos plugins terminen.
// Plugin A
"content:afterSave": { priority: 50, handler: async () => {} }

// Plugin B
"content:afterSave": { priority: 100, handler: async () => {} }

// Plugin C
"content:afterSave": {
	priority: 200,
	dependencies: ["plugin-a"],   // waits for A even if its priority would normally be later
	handler: async () => {},
}

Un runner de sandbox aislado invoca los plugins sandboxed activos en orden de carga. Mantenga los hooks independientes: no exija que un plugin sandboxed se ejecute antes que otro.

Manejo de errores

Los fallos de los hooks sandboxed dependen de cuándo se ejecuta el hook:

  • Un error lanzado en content:beforeSave hace fallar el guardado con CONTENT_HOOK_ERROR. Devuelva el sobre documentado SAVE_REJECTED cuando el editor deba ver un motivo de validación concreto.
  • Devolver false desde content:beforeDelete detiene el traslado a la papelera. Si ese hook lanza una excepción, EmDash registra el error y continúa con la eliminación.
  • Los hooks posteriores de contenido se ejecutan después de que la operación tenga éxito. Sus errores se registran y no pueden revertir la operación.
  • Los hooks de ciclo de vida, medios, correo y comentarios siguen el contrato de la operación que los origina. Use la Referencia de hooks para comprobar un valor de retorno concreto antes de depender del comportamiento ante fallos.

Un plugin en proceso puede usar errorPolicy: "abort" o "continue" en la forma de configuración completa. Ese ajuste no es un control de recuperación portable para un plugin sandboxed aislado.

Timeouts

El pipeline de hooks en proceso usa por defecto 5000 ms y acepta un timeout más largo en la forma de configuración completa:

"content:afterSave": {
	timeout: 30000,
	handler: async (event, ctx) => {
		// Long-running operation
	},
},

Referencia de hooks

HookDisparadorRetornoExclusivo
plugin:installPrimera instalación del pluginvoidNo
plugin:activatePlugin habilitadovoidNo
plugin:deactivatePlugin deshabilitadovoidNo
plugin:uninstallPlugin eliminadovoidNo
content:beforeSaveAntes de guardar el contenidoContenido modificado, sobre de rechazo o voidNo
content:afterSaveDespués de guardar el contenidovoidNo
content:beforeDeleteAntes de mover el contenido a la papelerafalse para cancelar, si no se permiteNo
content:afterDeleteDespués de mover a la papelera o eliminar permanentementevoidNo
content:afterPublishDespués de publicar el contenidovoidNo
content:afterUnpublishDespués de despublicar el contenidovoidNo
content:afterRestoreDespués de restaurar el contenidovoidNo
content:afterScheduleDespués de programar el contenidovoidNo
content:afterUnscheduleDespués de anular la programación del contenidovoidNo
media:beforeUploadAntes de subir un archivoInformación de archivo modificada o voidNo
media:afterUploadDespués de subir un archivovoidNo
cronSe dispara una tarea programadavoidNo
email:beforeSendAntes de entregar el correoMensaje modificado, false o voidNo
email:deliverEntregar correo mediante transportevoidSí
email:afterSendDespués de entregar el correovoidNo
comment:beforeCreateAntes de almacenar el comentarioEvento modificado, false o voidNo
comment:moderateDecidir el estado del comentario{ status, reason? }Sí
comment:afterCreateDespués de almacenar el comentariovoidNo
comment:afterModerateEl administrador cambia el estado del comentariovoidNo
byline:afterSaveDespués de guardar la firma (byline)voidNo
byline:afterDeleteDespués de eliminar la firma (byline)voidNo
page:metadataRenderizado de la páginaContribuciones o nullNo
page:fragmentsRenderizado de la página (solo nativo)Contribuciones o nullNo

Consulte la Referencia de hooks para ver los tipos de evento completos y las firmas de los manejadores.