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 en el momento de la definición del plugin: no hay registro dinámico en tiempo de ejecución.

Esta página cubre plugins en sandbox. Los plugins nativos usan los mismos nombres de hook y tipos de evento, pero usan la canalización de hooks en proceso y además pueden registrar page:fragments. El rechazo de guardado en sandbox y el comportamiento de fallo del runner aislado se describen a continuación.

Firma del hook

Cada manejador de hook toma dos argumentos:

async (event, ctx) => ReturnType;
  • event — datos sobre lo que acaba de ocurrir (contenido que se guarda, medios subidos, transición de ciclo de vida, etc.)
  • ctx — el PluginContext con almacenamiento, KV, registro y APIs protegidas por capabilities

Asignar la definición a una constante tipada como SandboxedPlugin infiere event del nombre del hook (el tipo de evento canónico completo) y ctx como PluginContext, de modo que los manejadores no necesitan anotaciones de parámetros. Exporte esa constante como default. Para referenciar un tipo de evento por nombre en un helper, 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 a menos que el plugin también admita ejecución deliberada en proceso y necesite los metadatos descritos abajo.

Simple

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

Full config

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

Opciones de configuración

OptionTypeDefaultDescription
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 para 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 solo los registra cuando el manifiesto declara la capability correspondiente:

HooksCapabilityReason
content:beforeSavecontent:writeEl hook puede reemplazar el contenido enviado.
content:beforePublish, content:beforeSchedule, content:beforeUnpublishhooks.content-policy:registerLos hooks pueden rechazar cambios de estado de publicación.
Otros hooks content:*content:readSus eventos exponen contenido o identifican una entrada.
media:beforeUploadmedia:writeEl hook puede reemplazar metadatos de carga o detener la carga.
media:afterUploadmedia:readSu evento expone el elemento de medios 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 comentario pueden contener información de contacto del autor y metadatos de solicitud.
page:fragmentshooks.page-fragments:registerEl hook inyecta contenido de página de primera parte y es solo nativo.

Los hooks de ciclo de vida, cron y page:metadata no tienen capability de registro. Declare la capability listada incluso cuando un hook solo lee su evento y no llama a la API ctx correspondiente. La declaración da al operador un aviso de consentimiento preciso, controla la API ctx y es necesaria 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 asume 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" });
},

Event: {} — Returns: Promise<void>

plugin:activate

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

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

Event: {} — Returns: 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");
},

Event: {} — Returns: 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));
		}
	}
},

Event: { deleteData: boolean } — Returns: 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 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 versionado con un error SAVE_REJECTED. Establezca reason en 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 y desconocidos fallan 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.",
			},
		};
	}

	if (typeof content.slug === "string") {
		content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
	}

	return content;
},

No ponga HTML en reason. El admin renderiza el valor como texto.

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

Event: { content, collection, isNew, id, actor } — Returns: 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). 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 guarda 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 }),
		});
	}
},

Event: { content, collection, isNew, actor } — Returns: 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 permiten.

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

Event: { id, collection, permanent: false } — Returns: boolean | void

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

content:afterDelete

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

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

Event: { id, collection, permanent } — Returns: 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 o acciones de publicación de contenido.

Devuelva void para permitir la acción o { cancel: true, reason } para rechazarla. El motivo debe contener de 1 a 500 caracteres de texto plano. Las decisiones invá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, actor.role numérico y el actor.source correspondiente. EmDash acepta el origen visual-editor solo desde el token de acción firmado y de corta duración incrustado en un render de barra de herramientas autenticado; las solicitudes API ordinarias no pueden seleccionar 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 requiere un marcador de aprobación antes de que el contenido pueda pasar a vivo:

"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, MCP, de plugin, de sistema y programada. El contenido programado se comprueba de nuevo cuando llega su hora de publicación. Un rechazo del programador desprograma la entrada, almacena el motivo seguro para el público 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 exitosa borra el registro. Un administrador puede descartar un registro obsoleto cuando la entrada o el plugin de política ya no están disponibles.

content:beforeSchedule

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

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

content:beforeUnpublish

Se ejecuta antes de eliminar el contenido en vivo.

content:afterPublish

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

Event: { content, collection } — Returns: Promise<void>

content:afterUnpublish

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

Event: { content, collection } — Returns: Promise<void>

content:afterRestore

Se ejecuta después de restaurar contenido de la papelera. Requiere la capability content:read.

Event: { content, collection } — Returns: Promise<void>

content:afterSchedule

Se ejecuta después de programar contenido para publicación futura. Requiere la capability content:read.

Event: { content, collection } — Returns: Promise<void>

content:afterUnschedule

Se ejecuta después de desprogramar contenido programado. Requiere la capability content:read.

Event: { content, collection } — Returns: Promise<void>

Hooks de medios

media:beforeUpload

Se ejecuta antes de subir un archivo. Devuelva metadatos de archivo modificados o lance 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}` };
},

Event: { file: { name, type, size } } — Returns: archivo modificado o void

media:afterUpload

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

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

Hooks de páginas públicas

Estos permiten que los plugins contribuyan a las páginas públicas renderizadas. Las plantillas optan por incluir los componentes <EmDashHead>, <EmDashBodyStart> y <EmDashBodyEnd> de emdash/ui.

page:metadata

Aporta metadatos tipados a <head>: meta tags, propiedades OpenGraph, rel de <link> en la lista permitida y JSON-LD. Disponible para plugins en sandbox y 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,
		},
	};
},

Event:

{
	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;
	}
}

Returns: PageMetadataContribution | PageMetadataContribution[] | null

Contribution kinds:

KindRendersDedupe key
meta<meta name="..." content="...">key o name
property<meta property="..." content="...">key o property
link<link rel="<allowed value>" href="...">canonical: singleton; alternate: key o hreflang
jsonld<script type="application/ld+json">id (si está presente)

La primera contribución gana para cualquier clave de deduplicación. <EmDashHead> compone las contribuciones en el orden plugins → configuración del sitio → metadatos base de la plantilla, de modo que las contribuciones del plugin anulan todo lo que hay debajo. En páginas de contenido, los valores del panel SEO de la entrada se pliegan en el contexto de la página antes de generar los metadatos base: reemplazan los campos de la plantilla (y son lo que su hook ve en el contexto de la página), mientras que las contribuciones del plugin siguen ganando mediante deduplicación first-wins. El rel de enlace está restringido a una lista permitida bloqueada por seguridad (canonical, alternate, author, license, nlweb, site.standard.document); href debe ser HTTP o HTTPS.

page:fragments

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

Los plugins en sandbox no pueden usar este hook porque su salida se ejecuta como código de primera parte en el navegador del visitante, fuera de cualquier límite de sandbox. Para contribuciones de página seguras en 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 sandbox se ejecuta en proceso, los hooks usan la canalización de hooks compartida:

  1. Los hooks con valores priority más bajos se ejecutan primero.
  2. Con prioridades iguales, los hooks se ejecutan en el orden de registro del plugin.
  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 en sandbox activos en orden de carga. Mantenga los hooks independientes: no exija que un plugin en sandbox se ejecute antes que otro.

Manejo de errores

Los fallos de hooks en sandbox dependen de cuándo se ejecuta el hook:

  • Un error lanzado en content:beforeSave falla el guardado con CONTENT_HOOK_ERROR. Devuelva el sobre SAVE_REJECTED documentado cuando el editor deba ver un motivo de validación específico.
  • Devolver false desde content:beforeDelete detiene el traslado a la papelera. Si ese hook lanza, EmDash registra el error y continúa la eliminación.
  • Los after-hooks de contenido se ejecutan después de que la operación tiene é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 su operación de origen. Use la referencia de hooks para comprobar un valor de retorno específico antes de confiar en el comportamiento de fallo.

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

Timeouts

La canalización de hooks en proceso tiene un valor predeterminado de 5.000 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

HookTriggerReturnExclusive
plugin:installPrimera instalación del pluginvoidNo
plugin:activatePlugin habilitadovoidNo
plugin:deactivatePlugin deshabilitadovoidNo
plugin:uninstallPlugin eliminadovoidNo
content:beforeSaveAntes de guardar contenidoContenido modificado, sobre de rechazo o voidNo
content:afterSaveDespués de guardar contenidovoidNo
content:beforeDeleteAntes de mover contenido a la papelerafalse para cancelar, si no permitirNo
content:afterDeleteDespués de papelera o eliminación permanentevoidNo
content:afterPublishDespués de publicar contenidovoidNo
content:afterUnpublishDespués de despublicar contenidovoidNo
content:afterRestoreDespués de restaurar contenidovoidNo
content:afterScheduleDespués de programar contenidovoidNo
content:afterUnscheduleDespués de desprogramar contenidovoidNo
media:beforeUploadAntes de subir archivoInfo de archivo modificada o voidNo
media:afterUploadDespués de subir archivovoidNo
cronSe dispara una tarea programadavoidNo
email:beforeSendAntes de la entrega de correoMensaje modificado, false o voidNo
email:deliverEntregar correo vía transportevoidYes
email:afterSendDespués de la entrega de correovoidNo
comment:beforeCreateAntes de almacenar el comentarioEvento modificado, false o voidNo
comment:moderateDecidir el estado del comentario{ status, reason? }Yes
comment:afterCreateDespués de almacenar el comentariovoidNo
comment:afterModerateEl admin cambia el estado del comentariovoidNo
page:metadataRender de páginaContribuciones o nullNo
page:fragmentsRender de página (solo nativo)Contribuciones o nullNo

Consulte la referencia de hooks para tipos de evento completos y firmas de manejadores.