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— elPluginContextcon 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ón | Tipo | Valor por defecto | Descripción |
|---|---|---|---|
priority | number | 100 | Orden de ejecución. Los números más bajos se ejecutan primero. |
timeout | number | 5000 | Tiempo máximo de ejecución en milisegundos. |
exclusive | boolean | false | Solo un plugin puede ser el proveedor activo. Se usa en email:deliver y comment:moderate. |
handler | function | — | 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:
| Hooks | Capability | Motivo |
|---|---|---|
content:beforeSave | content:write | El hook puede reemplazar el contenido enviado. |
content:beforePublish, content:beforeSchedule, content:beforeUnpublish | hooks.content-policy:register | Los hooks pueden rechazar cambios en el estado de publicación. |
Otros hooks content:* | content:read | Sus eventos exponen contenido o identifican una entrada. |
media:beforeUpload | media:write | El hook puede reemplazar los metadatos de la subida o detenerla. |
media:afterUpload | media:read | Su evento expone el elemento multimedia almacenado. |
email:beforeSend, email:afterSend | hooks.email-events:register | Los hooks inspeccionan eventos del ciclo de vida del correo. |
email:deliver | hooks.email-transport:register | El hook se convierte en un proveedor de transporte de correo. |
Todos los hooks comment:* | users:read | Los eventos de comentarios pueden contener información de contacto del autor y metadatos de la solicitud. |
page:fragments | hooks.page-fragments:register | El 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:
| Tipo | Renderiza | Clave 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:
- Los hooks con valores de
prioritymás bajos se ejecutan primero. - Con la misma prioridad, los hooks se ejecutan en el orden de registro de los plugins.
- Los hooks con
dependenciesesperan 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:beforeSavehace fallar el guardado conCONTENT_HOOK_ERROR. Devuelva el sobre documentadoSAVE_REJECTEDcuando el editor deba ver un motivo de validación concreto. - Devolver
falsedesdecontent:beforeDeletedetiene 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
| Hook | Disparador | Retorno | Exclusivo |
|---|---|---|---|
plugin:install | Primera instalación del plugin | void | No |
plugin:activate | Plugin habilitado | void | No |
plugin:deactivate | Plugin deshabilitado | void | No |
plugin:uninstall | Plugin eliminado | void | No |
content:beforeSave | Antes de guardar el contenido | Contenido modificado, sobre de rechazo o void | No |
content:afterSave | Después de guardar el contenido | void | No |
content:beforeDelete | Antes de mover el contenido a la papelera | false para cancelar, si no se permite | No |
content:afterDelete | Después de mover a la papelera o eliminar permanentemente | void | No |
content:afterPublish | Después de publicar el contenido | void | No |
content:afterUnpublish | Después de despublicar el contenido | void | No |
content:afterRestore | Después de restaurar el contenido | void | No |
content:afterSchedule | Después de programar el contenido | void | No |
content:afterUnschedule | Después de anular la programación del contenido | void | No |
media:beforeUpload | Antes de subir un archivo | Información de archivo modificada o void | No |
media:afterUpload | Después de subir un archivo | void | No |
cron | Se dispara una tarea programada | void | No |
email:beforeSend | Antes de entregar el correo | Mensaje modificado, false o void | No |
email:deliver | Entregar correo mediante transporte | void | Sí |
email:afterSend | Después de entregar el correo | void | No |
comment:beforeCreate | Antes de almacenar el comentario | Evento modificado, false o void | No |
comment:moderate | Decidir el estado del comentario | { status, reason? } | Sí |
comment:afterCreate | Después de almacenar el comentario | void | No |
comment:afterModerate | El administrador cambia el estado del comentario | void | No |
byline:afterSave | Después de guardar la firma (byline) | void | No |
byline:afterDelete | Después de eliminar la firma (byline) | void | No |
page:metadata | Renderizado de la página | Contribuciones o null | No |
page:fragments | Renderizado de la página (solo nativo) | Contribuciones o null | No |
Consulte la Referencia de hooks para ver los tipos de evento completos y las firmas de los manejadores.