Hooks

Sur cette page

Les hooks permettent aux plugins d’exécuter du code en réponse à des événements. Tous les hooks reçoivent un objet événement et le contexte du plugin, et sont déclarés au moment de la définition du plugin — il n’y a pas d’enregistrement dynamique à l’exécution.

Cette page couvre les plugins sandboxés. Les plugins natifs utilisent les mêmes noms de hooks et types d’événements, mais ils utilisent le pipeline de hooks in-process et peuvent en plus enregistrer page:fragments. Le rejet de sauvegarde sandboxé et le comportement d’échec du runner isolé sont décrits ci-dessous.

Signature du hook

Chaque gestionnaire de hook prend deux arguments :

async (event, ctx) => ReturnType;
  • event — données sur ce qui vient de se produire (contenu en cours d’enregistrement, média téléversé, transition de cycle de vie, etc.)
  • ctx — le PluginContext avec stockage, KV, journalisation et API protégées par capabilities

Assigner la définition à une constante typée SandboxedPlugin infère event à partir du nom du hook (le type d’événement canonique complet) et ctx comme PluginContext, de sorte que les gestionnaires n’ont pas besoin d’annotations de paramètres. Exportez cette constante en default. Pour référencer un type d’événement par nom dans un helper, importez-le depuis emdash/plugin.

Configuration du hook

Un hook peut être déclaré comme un gestionnaire simple ou encapsulé dans un objet de configuration. Préférez la forme simple sauf si le plugin prend aussi en charge une exécution in-process délibérée et a besoin des métadonnées décrites ci-dessous.

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");
		},
	},
},

Options de configuration

OptionTypeDefaultDescription
prioritynumber100Ordre d’exécution. Les nombres plus bas s’exécutent en premier.
timeoutnumber5000Temps d’exécution maximal en millisecondes.
exclusivebooleanfalseUn seul plugin peut être le fournisseur actif. Utilisé pour email:deliver et comment:moderate.
handlerfunction—La fonction gestionnaire du hook. Obligatoire.

Capabilities requises

Plusieurs hooks exposent des données protégées ou peuvent modifier une opération. EmDash ne les enregistre que lorsque le manifeste déclare la capability correspondante :

HooksCapabilityReason
content:beforeSavecontent:writeLe hook peut remplacer le contenu soumis.
content:beforePublish, content:beforeSchedule, content:beforeUnpublishhooks.content-policy:registerLes hooks peuvent rejeter les changements d’état de publication.
Autres hooks content:*content:readLeurs événements exposent du contenu ou identifient une entrée.
media:beforeUploadmedia:writeLe hook peut remplacer les métadonnées de téléversement ou arrêter le téléversement.
media:afterUploadmedia:readSon événement expose l’élément média stocké.
email:beforeSend, email:afterSendhooks.email-events:registerLes hooks inspectent les événements du cycle de vie de l’e-mail.
email:deliverhooks.email-transport:registerLe hook devient un fournisseur de transport d’e-mail.
Tous les hooks comment:*users:readLes événements de commentaire peuvent contenir les coordonnées de l’auteur et des métadonnées de requête.
page:fragmentshooks.page-fragments:registerLe hook injecte du contenu de page first-party et est réservé aux plugins natifs.

Les hooks de cycle de vie, cron et page:metadata n’ont pas de capability d’enregistrement. Déclarez la capability listée même lorsqu’un hook lit seulement son événement et n’appelle pas l’API ctx correspondante. La déclaration donne à l’opérateur une invite de consentement précise, contrôle l’API ctx et est requise lorsque le plugin s’exécute in-process. Capabilities et sécurité explique l’effet à l’exécution.

Hooks de cycle de vie

S’exécutent pendant l’installation, l’activation, la désactivation et la suppression du plugin.

plugin:install

S’exécute une fois lorsque le plugin est ajouté pour la première fois à un site.

Cet exemple suppose que le manifeste déclare une collection de stockage 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

S’exécute lorsque le plugin est activé (après l’installation ou lors d’une réactivation).

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

Event: {} — Returns: Promise<void>

plugin:deactivate

S’exécute lorsque le plugin est désactivé (mais pas supprimé).

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

Event: {} — Returns: Promise<void>

plugin:uninstall

S’exécute lorsque le plugin est retiré d’un site.

"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 contenu

S’exécutent pendant les opérations de création, de mise à jour et de suppression du contenu du site.

content:beforeSave

S’exécute avant l’enregistrement du contenu. Renvoyez le contenu modifié, un résultat d’erreur de hook sandbox ou void pour le laisser inchangé.

Pour rejeter une sauvegarde depuis le sandbox, renvoyez un résultat de hook versionné avec une erreur SAVE_REJECTED. Définissez reason en texte brut entre 1 et 500 caractères. EmDash identifie le plugin et montre la raison à l’éditeur. Les résultats d’erreur vides, trop longs, mal formés et inconnus font échouer la sauvegarde avec une erreur de hook générique.

"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;
},

Ne mettez pas de HTML dans reason. L’admin rend la valeur sous forme de texte.

Depuis le processus hôte, lancez plutôt ContentSaveRejectedError (exporté depuis emdash). L’API renvoie SAVE_REJECTED avec votre message. Toute autre exception provenant de l’un ou l’autre mode d’exécution fait échouer la sauvegarde avec une réponse générique CONTENT_HOOK_ERROR.

Event: { content, collection, isNew, id, actor } — Returns: contenu modifié, un résultat d’erreur de hook sandbox ou void. Lors d’une mise à jour, id est l’ID de l’élément existant et content ne contient que les valeurs de champs soumises ; chargez l’élément stocké avec ctx.content.get(event.collection, event.id). Les sauvegardes authentifiées REST, d’édition visuelle et MCP incluent actor.id et le actor.role numérique. Les écritures internes sans utilisateur authentifié omettent actor.

content:afterSave

S’exécute après l’enregistrement réussi du contenu. Utilisez-le pour des effets de bord comme les notifications, la journalisation ou les synchronisations externes.

"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>. Les sauvegardes authentifiées incluent le même instantané optionnel actor que content:beforeSave.

content:beforeDelete

S’exécute avant la suppression du contenu. Renvoyez false pour annuler ; true ou void l’autorise.

"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

Ce hook s’exécute avant qu’une entrée soit déplacée vers la corbeille. La suppression permanente d’une entrée depuis la corbeille ne réexécute pas content:beforeDelete.

content:afterDelete

S’exécute après la suppression réussie du contenu.

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

Event: { id, collection, permanent } — Returns: Promise<void>. permanent vaut false lorsque l’entrée a été déplacée vers la corbeille et true lorsqu’elle a été définitivement supprimée.

Déclarez hooks.content-policy:register pour inspecter et rejeter la publication, la planification ou la dépublication sans recevoir d’accès en lecture, écriture ou action de publication sur le contenu.

Renvoyez void pour autoriser l’action ou { cancel: true, reason } pour la rejeter. La raison doit contenir 1 à 500 caractères de texte brut. Les décisions invalides et les erreurs inattendues abandonnent par défaut sans exposer l’exception. Les rejets explicites renvoient PUBLISH_REJECTED, SCHEDULE_REJECTED ou UNPUBLISH_REJECTED.

Les trois événements contiennent { content, collection, origin, actor? }. origin.source vaut api, mcp, visual-editor, plugin, scheduler ou system ; les origines plugin contiennent aussi pluginId. Les actions humaines authentifiées incluent actor.id, le actor.role numérique et le actor.source correspondant. EmDash n’accepte l’origine visual-editor que depuis le jeton d’action signé et de courte durée intégré dans un rendu de barre d’outils authentifié ; les requêtes API ordinaires ne peuvent pas sélectionner leur origine.

Les événements de publication et de planification exposent le brouillon effectif dans content.data et le slug préparé dans content.slug. Les événements de dépublication exposent le contenu actuellement en ligne que l’action supprimerait.

content:beforePublish

Le hook suivant exige un marqueur d’approbation avant que le contenu puisse passer en ligne :

"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." };
	}
},

Ce hook s’exécute avant la publication manuelle, MCP, plugin, système et planifiée. Le contenu planifié est vérifié à nouveau lorsque son heure de publication arrive. Un rejet du planificateur annule la planification, stocke la raison sûre pour le public et liste l’entrée concernée sur le tableau de bord au lieu de réessayer le même rejet permanent à chaque tick du planificateur. Une planification, publication ou suppression réussie efface l’enregistrement. Un administrateur peut ignorer un enregistrement obsolète lorsque l’entrée ou le plugin de politique n’est plus disponible.

content:beforeSchedule

S’exécute avant qu’une entrée reçoive une heure de publication. L’événement contient aussi scheduledAt.

Il n’y a pas de hook content:beforeUnschedule. Un administrateur peut toujours annuler une publication future.

content:beforeUnpublish

S’exécute avant la suppression du contenu en ligne.

content:afterPublish

S’exécute après la promotion du contenu de brouillon à en ligne. Nécessite la capability content:read.

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

content:afterUnpublish

S’exécute après le retour du contenu d’en ligne à brouillon. Nécessite la capability content:read.

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

content:afterRestore

S’exécute après la restauration de contenu depuis la corbeille. Nécessite la capability content:read.

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

content:afterSchedule

S’exécute après la planification du contenu pour une publication future. Nécessite la capability content:read.

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

content:afterUnschedule

S’exécute après l’annulation de la planification du contenu. Nécessite la capability content:read.

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

Hooks de médias

media:beforeUpload

S’exécute avant le téléversement d’un fichier. Renvoyez des métadonnées de fichier modifiées ou lancez une exception pour annuler.

"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: fichier modifié ou void

media:afterUpload

S’exécute après le téléversement réussi d’un fichier.

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

Hooks de pages publiques

Ceux-ci permettent aux plugins de contribuer aux pages publiques rendues. Les templates s’inscrivent en incluant les composants <EmDashHead>, <EmDashBodyStart> et <EmDashBodyEnd> de emdash/ui.

page:metadata

Contribute des métadonnées typées à <head> — balises meta, propriétés OpenGraph, rel <link> autorisés et JSON-LD. Disponible pour les plugins sandboxés et natifs. Le noyau valide, déduplique et rend les contributions ; les plugins renvoient des données structurées, jamais du HTML brut.

"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 ou name
property<meta property="..." content="...">key ou property
link<link rel="<allowed value>" href="...">canonical : singleton ; alternate : key ou hreflang
jsonld<script type="application/ld+json">id (s’il est présent)

La première contribution gagne pour toute clé de déduplication. <EmDashHead> compose les contributions dans l’ordre plugins → paramètres du site → métadonnées de base fournies par le template, de sorte que les contributions de plugin remplacent tout ce qui est en dessous. Sur les pages de contenu, les valeurs du panneau SEO de l’entrée sont intégrées au contexte de page avant la génération des métadonnées de base — elles remplacent les champs fournis par le template (et sont ce que votre hook voit sur le contexte de page), tandis que les contributions de plugin gagnent toujours via la déduplication first-wins. Le rel des liens est limité à une liste d’autorisation verrouillée par sécurité (canonical, alternate, author, license, nlweb, site.standard.document) ; href doit être HTTP ou HTTPS.

page:fragments

Contribute du HTML brut, des scripts ou des feuilles de style aux points d’insertion de page. Plugins natifs uniquement.

Les plugins sandboxés ne peuvent pas utiliser ce hook car sa sortie s’exécute comme du code first-party dans le navigateur du visiteur, hors de toute frontière de sandbox. Pour des contributions de page sûres en sandbox, utilisez page:metadata. Voir Plugins natifs : fragments de page si vous avez besoin de cette surface.

Ordre d’exécution des hooks

Lorsqu’un plugin au format sandboxé s’exécute in-process, les hooks utilisent le pipeline de hooks partagé :

  1. Les hooks avec des valeurs priority plus basses s’exécutent en premier.
  2. À priorités égales, les hooks s’exécutent dans l’ordre d’enregistrement des plugins.
  3. Les hooks avec dependencies attendent que ces plugins se terminent.
// 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 isolé invoque les plugins sandboxés actifs dans l’ordre de chargement. Gardez les hooks indépendants : n’exigez pas qu’un plugin sandboxé s’exécute avant un autre.

Gestion des erreurs

Les échecs de hooks sandboxés dépendent du moment où le hook s’exécute :

  • Une erreur lancée dans content:beforeSave fait échouer la sauvegarde avec CONTENT_HOOK_ERROR. Renvoyez l’enveloppe SAVE_REJECTED documentée lorsque l’éditeur doit voir une raison de validation spécifique.
  • Renvoyer false depuis content:beforeDelete arrête le déplacement vers la corbeille. Si ce hook lance, EmDash journalise l’erreur et poursuit la suppression.
  • Les after-hooks de contenu s’exécutent après le succès de l’opération. Leurs erreurs sont journalisées et ne peuvent pas annuler l’opération.
  • Les hooks de cycle de vie, médias, e-mail et commentaires suivent le contrat de leur opération d’origine. Utilisez la référence des hooks pour vérifier une valeur de retour spécifique avant de vous fier au comportement d’échec.

Un plugin in-process peut utiliser errorPolicy: "abort" ou "continue" dans la forme de configuration complète. Ce réglage n’est pas un contrôle de récupération portable pour un plugin sandboxé isolé.

Timeouts

Le pipeline de hooks in-process a une valeur par défaut de 5 000 ms et accepte un timeout plus long dans la forme de configuration complète :

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

Référence des hooks

HookTriggerReturnExclusive
plugin:installPremière installation du pluginvoidNo
plugin:activatePlugin activévoidNo
plugin:deactivatePlugin désactivévoidNo
plugin:uninstallPlugin retirévoidNo
content:beforeSaveAvant l’enregistrement du contenuContenu modifié, enveloppe de rejet ou voidNo
content:afterSaveAprès l’enregistrement du contenuvoidNo
content:beforeDeleteAvant le déplacement vers la corbeillefalse pour annuler, sinon autoriserNo
content:afterDeleteAprès corbeille ou suppression permanentevoidNo
content:afterPublishAprès publication du contenuvoidNo
content:afterUnpublishAprès dépublication du contenuvoidNo
content:afterRestoreAprès restauration du contenuvoidNo
content:afterScheduleAprès planification du contenuvoidNo
content:afterUnscheduleAprès annulation de planificationvoidNo
media:beforeUploadAvant le téléversement de fichierInfos fichier modifiées ou voidNo
media:afterUploadAprès le téléversement de fichiervoidNo
cronUne tâche planifiée se déclenchevoidNo
email:beforeSendAvant la livraison de l’e-mailMessage modifié, false ou voidNo
email:deliverLivrer l’e-mail via le transportvoidYes
email:afterSendAprès la livraison de l’e-mailvoidNo
comment:beforeCreateAvant le stockage du commentaireÉvénement modifié, false ou voidNo
comment:moderateDécider le statut du commentaire{ status, reason? }Yes
comment:afterCreateAprès le stockage du commentairevoidNo
comment:afterModerateL’admin change le statut du commentairevoidNo
page:metadataRendu de pageContributions ou nullNo
page:fragmentsRendu de page (natif uniquement)Contributions ou nullNo

Voir la référence des hooks pour les types d’événements complets et les signatures des gestionnaires.