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— derPluginContextmit 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
| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 100 | Ausführungsreihenfolge. Niedrigere Zahlen laufen zuerst. |
timeout | number | 5000 | Maximale Ausführungszeit in Millisekunden. |
exclusive | boolean | false | Nur ein Plugin kann der aktive Provider sein. Wird für email:deliver und comment:moderate verwendet. |
handler | function | — | 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:
| Hooks | Capability | Reason |
|---|---|---|
content:beforeSave | content:write | Der Hook kann übermittelten Inhalt ersetzen. |
content:beforePublish, content:beforeSchedule, content:beforeUnpublish | hooks.content-policy:register | Die Hooks können Veröffentlichungsstatusänderungen ablehnen. |
Andere content:*-Hooks | content:read | Ihre Ereignisse legen Inhalt offen oder identifizieren einen Eintrag. |
media:beforeUpload | media:write | Der Hook kann Upload-Metadaten ersetzen oder den Upload stoppen. |
media:afterUpload | media:read | Sein Ereignis legt das gespeicherte Medienelement offen. |
email:beforeSend, email:afterSend | hooks.email-events:register | Die Hooks prüfen E-Mail-Lebenszyklusereignisse. |
email:deliver | hooks.email-transport:register | Der Hook wird zu einem E-Mail-Transport-Provider. |
Alle comment:*-Hooks | users:read | Kommentarereignisse können Autoren-Kontaktdaten und Anfragemetadaten enthalten. |
page:fragments | hooks.page-fragments:register | Der 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:
| Kind | Renders | Dedupe 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:
- Hooks mit niedrigeren
priority-Werten laufen zuerst. - Bei gleicher Priorität laufen Hooks in der Plugin-Registrierungsreihenfolge.
- Hooks mit
dependencieswarten, 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 mitCONTENT_HOOK_ERROR. Geben Sie den dokumentiertenSAVE_REJECTED-Envelope zurück, wenn der Redakteur einen spezifischen Validierungsgrund sehen soll. falsevoncontent:beforeDeletestoppt 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
| Hook | Trigger | Return | Exclusive |
|---|---|---|---|
plugin:install | Erste Plugin-Installation | void | No |
plugin:activate | Plugin aktiviert | void | No |
plugin:deactivate | Plugin deaktiviert | void | No |
plugin:uninstall | Plugin entfernt | void | No |
content:beforeSave | Vor dem Speichern von Inhalt | Geänderter Inhalt, Ablehnungs-Envelope oder void | No |
content:afterSave | Nach dem Speichern von Inhalt | void | No |
content:beforeDelete | Bevor Inhalt in den Papierkorb geht | false zum Abbrechen, sonst erlauben | No |
content:afterDelete | Nach Papierkorb oder dauerhaftem Löschen | void | No |
content:afterPublish | Nach Inhaltsveröffentlichung | void | No |
content:afterUnpublish | Nach Inhalts-Unpublish | void | No |
content:afterRestore | Nach Inhaltswiederherstellung | void | No |
content:afterSchedule | Nach Inhaltsplanung | void | No |
content:afterUnschedule | Nach Inhalts-Unschedule | void | No |
media:beforeUpload | Vor Datei-Upload | Geänderte Dateiinfo oder void | No |
media:afterUpload | Nach Datei-Upload | void | No |
cron | Geplante Aufgabe feuert | void | No |
email:beforeSend | Vor E-Mail-Zustellung | Geänderte Nachricht, false oder void | No |
email:deliver | E-Mail per Transport zustellen | void | Yes |
email:afterSend | Nach E-Mail-Zustellung | void | No |
comment:beforeCreate | Bevor Kommentar gespeichert wird | Geändertes Ereignis, false oder void | No |
comment:moderate | Kommentarstatus entscheiden | { status, reason? } | Yes |
comment:afterCreate | Nachdem Kommentar gespeichert wurde | void | No |
comment:afterModerate | Admin ändert Kommentarstatus | void | No |
page:metadata | Seiten-Render | Beiträge oder null | No |
page:fragments | Seiten-Render (nur native) | Beiträge oder null | No |
Siehe die Hook-Referenz für vollständige Ereignistypen und Handler-Signaturen.