Hooks

Auf dieser Seite

Hooks lassen Plugins Code als Reaktion auf Ereignisse ausführen. Alle Hooks erhalten ein Ereignisobjekt und den Plugin-Kontext und werden zur Plugin-Definitionszeit deklariert — es gibt keine dynamische Registrierung zur Laufzeit.

Diese Seite behandelt sandboxed Plugins. Native Plugins verwenden dieselben Hook-Namen und Ereignistypen, nutzen aber die In-Process-Hook-Pipeline und können zusätzlich page:fragments registrieren. Sandboxed Save-Ablehnung und Isolated-Runner-Fehlerverhalten werden unten beschrieben.

Hook-Signatur

Jeder Hook-Handler nimmt zwei Argumente:

async (event, ctx) => ReturnType;
  • event — Daten darüber, was gerade geschehen ist (gespeicherter Inhalt, hochgeladenes Medium, Lebenszyklusübergang usw.)
  • ctx — der PluginContext mit Storage, KV, Logging und capability-geschützten APIs

Wenn die Definition einer SandboxedPlugin-typisierten Konstante zugewiesen wird, wird event aus dem Hook-Namen abgeleitet (der vollständige kanonische Ereignistyp) und ctx als PluginContext, sodass Handler keine Parameterannotationen brauchen. Exportieren Sie diese Konstante als Default. Um einen Ereignistyp namentlich in einem Helper zu referenzieren, importieren Sie ihn aus emdash/plugin.

Hook-Konfiguration

Ein Hook kann als bloßer Handler oder in ein Konfigurationsobjekt gewrappt deklariert werden. Bevorzugen Sie die einfache Form, sofern das Plugin nicht auch absichtliche In-Process-Ausführung unterstützt und die unten beschriebenen Metadaten braucht.

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

Konfigurationsoptionen

OptionTypeDefaultDescription
prioritynumber100Ausführungsreihenfolge. Niedrigere Zahlen laufen zuerst.
timeoutnumber5000Maximale Ausführungszeit in Millisekunden.
exclusivebooleanfalseNur ein Plugin kann der aktive Provider sein. Wird für email:deliver und comment:moderate verwendet.
handlerfunction—Die Hook-Handler-Funktion. Erforderlich.

Erforderliche Capabilities

Mehrere Hooks stellen geschützte Daten bereit oder können eine Operation ändern. EmDash registriert sie nur, wenn das Manifest die passende Capability deklariert:

HooksCapabilityReason
content:beforeSavecontent:writeDer Hook kann übermittelten Inhalt ersetzen.
content:beforePublish, content:beforeSchedule, content:beforeUnpublishhooks.content-policy:registerDie Hooks können Veröffentlichungsstatusänderungen ablehnen.
Andere content:*-Hookscontent:readIhre Ereignisse legen Inhalt offen oder identifizieren einen Eintrag.
media:beforeUploadmedia:writeDer Hook kann Upload-Metadaten ersetzen oder den Upload stoppen.
media:afterUploadmedia:readSein Ereignis legt das gespeicherte Medienelement offen.
email:beforeSend, email:afterSendhooks.email-events:registerDie Hooks prüfen E-Mail-Lebenszyklusereignisse.
email:deliverhooks.email-transport:registerDer Hook wird zu einem E-Mail-Transport-Provider.
Alle comment:*-Hooksusers:readKommentarereignisse können Autoren-Kontaktdaten und Anfragemetadaten enthalten.
page:fragmentshooks.page-fragments:registerDer Hook injiziert First-Party-Seiteninhalt und ist nur für Native Plugins.

Lebenszyklus-Hooks, cron und page:metadata haben keine Registrierungs-Capability. Deklarieren Sie die aufgeführte Capability auch dann, wenn ein Hook sein Ereignis nur liest und die passende ctx-API nicht aufruft. Die Deklaration gibt dem Betreiber eine genaue Einwilligungsaufforderung, steuert die ctx-API und ist erforderlich, wenn das Plugin In-Process läuft. Capabilities und Sicherheit erklärt die Laufzeitwirkung.

Lebenszyklus-Hooks

Laufen während Installation, Aktivierung, Deaktivierung und Entfernung des Plugins.

plugin:install

Läuft einmal, wenn das Plugin erstmals zu einer Website hinzugefügt wird.

Dieses Beispiel setzt voraus, dass das Manifest eine Storage-Collection items deklariert:

"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

Läuft, wenn das Plugin aktiviert wird (nach der Installation oder bei erneuter Aktivierung).

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

Event: {} — Returns: Promise<void>

plugin:deactivate

Läuft, wenn das Plugin deaktiviert wird (aber nicht entfernt).

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

Event: {} — Returns: Promise<void>

plugin:uninstall

Läuft, wenn das Plugin von einer Website entfernt wird.

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

Inhalts-Hooks

Laufen während Create-, Update- und Delete-Operationen an Website-Inhalten.

content:beforeSave

Läuft, bevor Inhalt gespeichert wird. Geben Sie geänderten Inhalt, ein Sandbox-Hook-Fehlerergebnis oder void zurück, um ihn unverändert zu lassen.

Um ein Speichern aus der Sandbox abzulehnen, geben Sie ein versioniertes Hook-Ergebnis mit einem SAVE_REJECTED-Fehler zurück. Setzen Sie reason auf Klartext zwischen 1 und 500 Zeichen. EmDash identifiziert das Plugin und zeigt dem Redakteur den Grund. Leere, zu lange, fehlerhafte und unbekannte Fehlerergebnisse scheitern mit einem generischen Hook-Fehler.

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

Setzen Sie kein HTML in reason. Das Admin rendert den Wert als Text.

Aus dem Host-Prozess werfen Sie stattdessen ContentSaveRejectedError (exportiert aus emdash). Die API gibt SAVE_REJECTED mit Ihrer Nachricht zurück. Jede andere Ausnahme aus beiden Ausführungsmodi scheitert das Speichern mit einer generischen CONTENT_HOOK_ERROR-Antwort.

Event: { content, collection, isNew, id, actor } — Returns: geänderter Inhalt, ein Sandbox-Hook-Fehlerergebnis oder void. Bei einem Update ist id die ID des bestehenden Elements und content enthält nur die übermittelten Feldwerte; laden Sie das gespeicherte Element mit ctx.content.get(event.collection, event.id). Authentifizierte REST-, Visual-Editing- und MCP-Saves enthalten actor.id und die numerische actor.role. Interne Schreibvorgänge ohne authentifizierten Benutzer lassen actor weg.

content:afterSave

Läuft, nachdem Inhalt erfolgreich gespeichert wurde. Verwenden Sie es für Seiteneffekte wie Benachrichtigungen, Logging oder externe Syncs.

"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>. Authentifizierte Saves enthalten denselben optionalen actor-Snapshot wie content:beforeSave.

content:beforeDelete

Läuft, bevor Inhalt gelöscht wird. Geben Sie false zurück, um abzubrechen; true oder void erlaubt es.

"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

Dieser Hook läuft, bevor ein Eintrag in den Papierkorb verschoben wird. Das dauerhafte Entfernen eines Eintrags aus dem Papierkorb führt content:beforeDelete nicht erneut aus.

content:afterDelete

Läuft, nachdem Inhalt erfolgreich gelöscht wurde.

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

Event: { id, collection, permanent } — Returns: Promise<void>. permanent ist false, wenn der Eintrag in den Papierkorb verschoben wurde, und true, wenn der Eintrag dauerhaft entfernt wurde.

Deklarieren Sie hooks.content-policy:register, um Veröffentlichung, Zeitplanung oder Unpublish zu prüfen und abzulehnen, ohne Content-Read-, Write- oder Veröffentlichungsaktionszugriff zu erhalten.

Geben Sie void zurück, um die Aktion zu erlauben, oder { cancel: true, reason }, um sie abzulehnen. Der Grund muss 1–500 Klartextzeichen enthalten. Ungültige Entscheidungen und unerwartete Fehler brechen standardmäßig ab, ohne die Ausnahme preiszugeben. Explizite Ablehnungen geben PUBLISH_REJECTED, SCHEDULE_REJECTED oder UNPUBLISH_REJECTED zurück.

Alle drei Ereignisse enthalten { content, collection, origin, actor? }. origin.source ist api, mcp, visual-editor, plugin, scheduler oder system; Plugin-Origins enthalten auch pluginId. Authentifizierte menschliche Aktionen enthalten actor.id, numerische actor.role und die passende actor.source. EmDash akzeptiert den Origin visual-editor nur vom signierten, kurzlebigen Aktionstoken, das in ein authentifiziertes Toolbar-Render eingebettet ist; gewöhnliche API-Anfragen können ihren Origin nicht wählen.

Publish- und Schedule-Ereignisse legen den effektiven Entwurf in content.data und den gestuften Slug in content.slug offen. Unpublish-Ereignisse legen den aktuell live stehenden Inhalt offen, den die Aktion entfernen würde.

content:beforePublish

Der folgende Hook erfordert eine Freigabe-Markierung, bevor Inhalt live gehen kann:

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

Dieser Hook läuft vor manueller, MCP-, Plugin-, System- und geplanter Veröffentlichung. Geplanter Inhalt wird erneut geprüft, wenn seine Veröffentlichungszeit eintrifft. Eine Scheduler-Ablehnung hebt die Planung auf, speichert den öffentlichen Grund und listet den betroffenen Eintrag im Dashboard auf, statt dieselbe dauerhafte Ablehnung bei jedem Scheduler-Tick erneut zu versuchen. Ein erfolgreiches Schedule, Publish oder Delete löscht den Datensatz. Ein Administrator kann einen veralteten Datensatz verwerfen, wenn der Eintrag oder das Policy-Plugin nicht mehr verfügbar ist.

content:beforeSchedule

Läuft, bevor ein Eintrag eine Veröffentlichungszeit erhält. Das Ereignis enthält auch scheduledAt.

Es gibt keinen Hook content:beforeUnschedule. Ein Administrator kann eine zukünftige Veröffentlichung immer abbrechen.

content:beforeUnpublish

Läuft, bevor live stehender Inhalt entfernt wird.

content:afterPublish

Läuft, nachdem Inhalt vom Entwurf auf live befördert wurde. Erfordert die Capability content:read.

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

content:afterUnpublish

Läuft, nachdem Inhalt von live auf Entwurf zurückgesetzt wurde. Erfordert die Capability content:read.

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

content:afterRestore

Läuft, nachdem Inhalt aus dem Papierkorb wiederhergestellt wurde. Erfordert die Capability content:read.

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

content:afterSchedule

Läuft, nachdem Inhalt für zukünftige Veröffentlichung geplant wurde. Erfordert die Capability content:read.

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

content:afterUnschedule

Läuft, nachdem geplanter Inhalt ungeplant wurde. Erfordert die Capability content:read.

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

Medien-Hooks

media:beforeUpload

Läuft, bevor eine Datei hochgeladen wird. Geben Sie geänderte Dateimetadaten zurück oder werfen Sie, um abzubrechen.

"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: geänderte Datei oder void

media:afterUpload

Läuft, nachdem eine Datei erfolgreich hochgeladen wurde.

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

Öffentliche Seiten-Hooks

Diese lassen Plugins zu gerenderten öffentlichen Seiten beitragen. Templates opten ein, indem sie die Komponenten <EmDashHead>, <EmDashBodyStart> und <EmDashBodyEnd> aus emdash/ui einbinden.

page:metadata

Trägt typisierte Metadaten zu <head> bei — Meta-Tags, OpenGraph-Eigenschaften, allowlistete <link>-rels und JSON-LD. Verfügbar für sandboxed und native Plugins. Core validiert, dedupliziert und rendert die Beiträge; Plugins geben strukturierte Daten zurück, nie rohes HTML.

"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 oder name
property<meta property="..." content="...">key oder property
link<link rel="<allowed value>" href="...">canonical: Singleton; alternate: key oder hreflang
jsonld<script type="application/ld+json">id (falls vorhanden)

Der erste Beitrag gewinnt für jeden Deduplizierungsschlüssel. <EmDashHead> setzt Beiträge in der Reihenfolge Plugins → Site-Einstellungen → vom Template bereitgestellte Basismetadaten zusammen, sodass Plugin-Beiträge alles darunter überschreiben. Auf Inhaltsseiten werden die SEO-Panel-Werte des Eintrags in den Seitenkontext gefaltet, bevor die Basismetadaten erzeugt werden — sie ersetzen die vom Template bereitgestellten Felder (und sind das, was Ihr Hook im Seitenkontext sieht), während Plugin-Beiträge über First-Wins-Dedup weiterhin gewinnen. Link-rel ist auf eine sicherheitsgesperrte Allowlist beschränkt (canonical, alternate, author, license, nlweb, site.standard.document); href muss HTTP oder HTTPS sein.

page:fragments

Trägt rohes HTML, Skripte oder Stylesheets zu Seiteneinfügepunkten bei. Nur native Plugins.

Sandboxed Plugins können diesen Hook nicht verwenden, weil seine Ausgabe als First-Party-Code im Browser des Besuchers außerhalb jeder Sandbox-Grenze läuft. Für sandbox-sichere Seitenbeiträge verwenden Sie page:metadata. Siehe Native Plugins: Seitenfragmente, wenn Sie diese Oberfläche brauchen.

Hook-Ausführungsreihenfolge

Wenn ein Plugin im Sandboxed-Format In-Process läuft, verwenden Hooks die gemeinsame Hook-Pipeline:

  1. Hooks mit niedrigeren priority-Werten laufen zuerst.
  2. Bei gleicher Priorität laufen Hooks in der Plugin-Registrierungsreihenfolge.
  3. Hooks mit dependencies warten, bis diese Plugins abgeschlossen sind.
// 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 () => {},
}

Ein Isolated-Sandbox-Runner ruft aktive sandboxed Plugins in Lade-Reihenfolge auf. Halten Sie Hooks unabhängig: verlangen Sie nicht, dass ein sandboxed Plugin vor einem anderen läuft.

Fehlerbehandlung

Sandboxed-Hook-Fehler hängen davon ab, wann der Hook läuft:

  • Ein geworfener content:beforeSave-Fehler scheitert das Speichern mit CONTENT_HOOK_ERROR. Geben Sie den dokumentierten SAVE_REJECTED-Envelope zurück, wenn der Redakteur einen spezifischen Validierungsgrund sehen soll.
  • false von content:beforeDelete stoppt den Umzug in den Papierkorb. Wenn dieser Hook wirft, protokolliert EmDash den Fehler und setzt die Löschung fort.
  • Content-After-Hooks laufen, nachdem die Operation erfolgreich war. Ihre Fehler werden protokolliert und können die Operation nicht zurückrollen.
  • Lebenszyklus-, Medien-, E-Mail- und Kommentar-Hooks folgen dem Vertrag ihrer auslösenden Operation. Verwenden Sie die Hook-Referenz, um einen spezifischen Rückgabewert zu prüfen, bevor Sie sich auf Fehlerverhalten verlassen.

Ein In-Process-Plugin kann errorPolicy: "abort" oder "continue" in der vollständigen Konfigurationsform verwenden. Diese Einstellung ist keine portable Wiederherstellungssteuerung für ein isoliertes sandboxed Plugin.

Timeouts

Die In-Process-Hook-Pipeline hat standardmäßig 5.000 ms und akzeptiert ein längeres timeout in der vollständigen Konfigurationsform:

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

Hook-Referenz

HookTriggerReturnExclusive
plugin:installErste Plugin-InstallationvoidNo
plugin:activatePlugin aktiviertvoidNo
plugin:deactivatePlugin deaktiviertvoidNo
plugin:uninstallPlugin entferntvoidNo
content:beforeSaveVor dem Speichern von InhaltGeänderter Inhalt, Ablehnungs-Envelope oder voidNo
content:afterSaveNach dem Speichern von InhaltvoidNo
content:beforeDeleteBevor Inhalt in den Papierkorb gehtfalse zum Abbrechen, sonst erlaubenNo
content:afterDeleteNach Papierkorb oder dauerhaftem LöschenvoidNo
content:afterPublishNach InhaltsveröffentlichungvoidNo
content:afterUnpublishNach Inhalts-UnpublishvoidNo
content:afterRestoreNach InhaltswiederherstellungvoidNo
content:afterScheduleNach InhaltsplanungvoidNo
content:afterUnscheduleNach Inhalts-UnschedulevoidNo
media:beforeUploadVor Datei-UploadGeänderte Dateiinfo oder voidNo
media:afterUploadNach Datei-UploadvoidNo
cronGeplante Aufgabe feuertvoidNo
email:beforeSendVor E-Mail-ZustellungGeänderte Nachricht, false oder voidNo
email:deliverE-Mail per Transport zustellenvoidYes
email:afterSendNach E-Mail-ZustellungvoidNo
comment:beforeCreateBevor Kommentar gespeichert wirdGeändertes Ereignis, false oder voidNo
comment:moderateKommentarstatus entscheiden{ status, reason? }Yes
comment:afterCreateNachdem Kommentar gespeichert wurdevoidNo
comment:afterModerateAdmin ändert KommentarstatusvoidNo
page:metadataSeiten-RenderBeiträge oder nullNo
page:fragmentsSeiten-Render (nur native)Beiträge oder nullNo

Siehe die Hook-Referenz für vollständige Ereignistypen und Handler-Signaturen.