Hook Reference
Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
Hook Overview
Section titled “Hook Overview”| Hook | Trigger | Can Modify | Exclusive |
|---|---|---|---|
content:beforeSave | Before content is saved | Content data | No |
content:afterSave | After content is saved | Nothing | No |
content:beforeDelete | Before content is deleted | Can cancel | No |
content:afterDelete | After content is deleted | Nothing | No |
media:beforeUpload | Before file is uploaded | File metadata | No |
media:afterUpload | After file is uploaded | Nothing | No |
cron | Scheduled task fires | Nothing | No |
email:beforeSend | Before email delivery | Message, can cancel | No |
email:deliver | Deliver email via transport | Nothing | Yes |
email:afterSend | After successful email delivery | Nothing | No |
comment:beforeCreate | Before comment is stored | Comment, can cancel | No |
comment:moderate | Decide comment approval status | Status | Yes |
comment:afterCreate | After comment is stored | Nothing | No |
comment:afterModerate | After admin changes comment status | Nothing | No |
page:metadata | Rendering public page head | Contribute tags | No |
page:fragments | Rendering public page body | Inject scripts | No |
plugin:install | When plugin is first installed | Nothing | No |
plugin:activate | When plugin is enabled | Nothing | No |
plugin:deactivate | When plugin is disabled | Nothing | No |
plugin:uninstall | When plugin is removed | Nothing | No |
Content Hooks
Section titled “Content Hooks”content:beforeSave
Section titled “content:beforeSave”Runs before content is saved to the database. Use to validate, transform, or enrich content.
import { definePlugin } from "emdash";
export default definePlugin({ id: "my-plugin", version: "1.0.0", hooks: { "content:beforeSave": async (event, ctx) => { const { content, collection, isNew } = event;
// Add timestamps if (isNew) { content.createdBy = "system"; } content.modifiedAt = new Date().toISOString();
// Return modified content return content; }, },});interface ContentHookEvent { content: Record<string, unknown>; // Content data collection: string; // Collection slug isNew: boolean; // True for creates, false for updates}Return Value
Section titled “Return Value”- Return modified content object to apply changes
- Return
voidto pass through unchanged
content:afterSave
Section titled “content:afterSave”Runs after content is saved. Use for side effects like notifications, cache invalidation, or external syncing.
hooks: { "content:afterSave": async (event, ctx) => { const { content, collection, isNew } = event;
if (collection === "posts" && content.status === "published") { // Notify external service await ctx.http?.fetch("https://api.example.com/notify", { method: "POST", body: JSON.stringify({ postId: content.id }), }); } },}Return Value
Section titled “Return Value”No return value expected.
content:beforeDelete
Section titled “content:beforeDelete”Runs before content is deleted. Use to validate deletion or prevent it.
hooks: { "content:beforeDelete": async (event, ctx) => { const { id, collection } = event;
// Prevent deletion of protected content const item = await ctx.content?.get(collection, id); if (item?.data.protected) { return false; // Cancel deletion }
// Allow deletion return true; },}interface ContentDeleteEvent { id: string; // Entry ID collection: string; // Collection slug}Return Value
Section titled “Return Value”- Return
falseto cancel deletion - Return
trueorvoidto allow
content:afterDelete
Section titled “content:afterDelete”Runs after content is deleted. Use for cleanup tasks.
hooks: { "content:afterDelete": async (event, ctx) => { const { id, collection } = event;
// Clean up related data await ctx.storage.relatedItems.delete(`${collection}:${id}`); },}Media Hooks
Section titled “Media Hooks”media:beforeUpload
Section titled “media:beforeUpload”Runs before a file is uploaded. Use to validate, rename, or reject files.
hooks: { "media:beforeUpload": async (event, ctx) => { const { file } = event;
// Reject files over 10MB if (file.size > 10 * 1024 * 1024) { throw new Error("File too large"); }
// Rename file return { name: `${Date.now()}-${file.name}`, type: file.type, size: file.size, }; },}interface MediaUploadEvent { file: { name: string; // Original filename type: string; // MIME type size: number; // Size in bytes };}Return Value
Section titled “Return Value”- Return modified file metadata to apply changes
- Return
voidto pass through unchanged - Throw to reject the upload
media:afterUpload
Section titled “media:afterUpload”Runs after a file is uploaded. Use for processing, thumbnails, or metadata extraction.
hooks: { "media:afterUpload": async (event, ctx) => { const { media } = event;
if (media.mimeType.startsWith("image/")) { // Store image metadata await ctx.kv.set(`media:${media.id}:analyzed`, { processedAt: new Date().toISOString(), }); } },}interface MediaAfterUploadEvent { media: { id: string; filename: string; mimeType: string; size: number | null; url: string; createdAt: string; };}Lifecycle Hooks
Section titled “Lifecycle Hooks”plugin:install
Section titled “plugin:install”Runs when a plugin is first installed. Use for initial setup, creating storage collections, or seeding data.
hooks: { "plugin:install": async (event, ctx) => { // Initialize default settings await ctx.kv.set("settings:enabled", true); await ctx.kv.set("settings:threshold", 100);
ctx.log.info("Plugin installed successfully"); },}plugin:activate
Section titled “plugin:activate”Runs when a plugin is enabled (after install or re-enable).
hooks: { "plugin:activate": async (event, ctx) => { ctx.log.info("Plugin activated"); },}plugin:deactivate
Section titled “plugin:deactivate”Runs when a plugin is disabled.
hooks: { "plugin:deactivate": async (event, ctx) => { ctx.log.info("Plugin deactivated"); },}plugin:uninstall
Section titled “plugin:uninstall”Runs when a plugin is removed. Use for cleanup.
hooks: { "plugin:uninstall": async (event, ctx) => { const { deleteData } = event;
if (deleteData) { // Clean up all plugin data const items = await ctx.kv.list("settings:"); for (const { key } of items) { await ctx.kv.delete(key); } }
ctx.log.info("Plugin uninstalled"); },}interface UninstallEvent { deleteData: boolean; // User chose to delete data}Cron Hook
Section titled “Cron Hook”Fired when a scheduled task executes. Schedule tasks with ctx.cron.schedule().
hooks: { "cron": async (event, ctx) => { if (event.name === "daily-sync") { const data = await ctx.http?.fetch("https://api.example.com/data"); ctx.log.info("Sync complete"); } },}interface CronEvent { name: string; data?: Record<string, unknown>; scheduledAt: string;}Email Hooks
Section titled “Email Hooks”Email hooks form a pipeline: email:beforeSend → email:deliver → email:afterSend.
email:beforeSend
Section titled “email:beforeSend”Capability: email:intercept
Middleware hook that runs before delivery. Transform messages or cancel delivery.
hooks: { "email:beforeSend": async (event, ctx) => { // Add footer to all emails return { ...event.message, text: event.message.text + "\n\n—Sent from My Site", };
// Or return false to cancel delivery },}interface EmailBeforeSendEvent { message: { to: string; subject: string; text: string; html?: string }; source: string;}Return Value
Section titled “Return Value”- Return modified message to transform
- Return
falseto cancel delivery - Return
voidto pass through unchanged
email:deliver
Section titled “email:deliver”Capability: email:provide | Exclusive: Yes
The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.
hooks: { "email:deliver": { exclusive: true, handler: async (event, ctx) => { await sendViaSES(event.message); }, },}email:afterSend
Section titled “email:afterSend”Capability: email:intercept
Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.
hooks: { "email:afterSend": async (event, ctx) => { await ctx.kv.set(`email:log:${Date.now()}`, { to: event.message.to, subject: event.message.subject, }); },}Comment Hooks
Section titled “Comment Hooks”Comment hooks form a pipeline: comment:beforeCreate → comment:moderate → comment:afterCreate. The comment:afterModerate hook fires separately when an admin changes a comment’s status.
comment:beforeCreate
Section titled “comment:beforeCreate”Capability: read:users
Middleware hook before a comment is stored. Enrich, validate, or reject comments.
hooks: { "comment:beforeCreate": async (event, ctx) => { // Reject comments with links if (event.comment.body.includes("http")) { return false; } },}interface CommentBeforeCreateEvent { comment: { collection: string; contentId: string; parentId: string | null; authorName: string; authorEmail: string; authorUserId: string | null; body: string; ipHash: string | null; userAgent: string | null; }; metadata: Record<string, unknown>;}Return Value
Section titled “Return Value”- Return modified event to transform
- Return
falseto reject - Return
voidto pass through
comment:moderate
Section titled “comment:moderate”Capability: read:users | Exclusive: Yes
Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.
hooks: { "comment:moderate": { exclusive: true, handler: async (event, ctx) => { const score = await checkSpam(event.comment); return { status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved", reason: `Spam score: ${score}`, }; }, },}interface CommentModerateEvent { comment: { /* same as beforeCreate */ }; metadata: Record<string, unknown>; collectionSettings: { commentsEnabled: boolean; commentsModeration: "all" | "first_time" | "none"; commentsClosedAfterDays: number; commentsAutoApproveUsers: boolean; }; priorApprovedCount: number;}Return Value
Section titled “Return Value”{ status: "approved" | "pending" | "spam"; reason?: string }comment:afterCreate
Section titled “comment:afterCreate”Capability: read:users
Fire-and-forget hook after a comment is stored. Use for notifications.
hooks: { "comment:afterCreate": async (event, ctx) => { if (event.comment.status === "approved") { await ctx.email?.send({ to: event.contentAuthor?.email, subject: `New comment on "${event.content.title}"`, text: `${event.comment.authorName} commented: ${event.comment.body}`, }); } },}comment:afterModerate
Section titled “comment:afterModerate”Capability: read:users
Fire-and-forget hook when an admin manually changes a comment’s status.
interface CommentAfterModerateEvent { comment: { id: string; /* ... */ }; previousStatus: string; newStatus: string; moderator: { id: string; name: string | null };}Page Hooks
Section titled “Page Hooks”Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
page:metadata
Section titled “page:metadata”Capability: page:inject
Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.
hooks: { "page:metadata": async (event, ctx) => { return [ { kind: "meta", name: "generator", content: "EmDash" }, { kind: "property", property: "og:site_name", content: event.page.siteName }, { kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } }, ]; },}Contribution Types
Section titled “Contribution Types”type PageMetadataContribution = | { kind: "meta"; name: string; content: string; key?: string } | { kind: "property"; property: string; content: string; key?: string } | { kind: "link"; rel: string; href: string; hreflang?: string; key?: string } | { kind: "jsonld"; id?: string; graph: Record<string, unknown> };The key field deduplicates contributions — only the last contribution with a given key is used.
page:fragments
Section titled “page:fragments”Capability: page:inject
Inject scripts or HTML into pages. Only available to trusted (native) plugins.
hooks: { "page:fragments": async (event, ctx) => { return [ { kind: "external-script", placement: "body:end", src: "https://analytics.example.com/script.js", async: true, }, { kind: "inline-script", placement: "head", code: `window.siteId = "abc123";`, }, ]; },}Contribution Types
Section titled “Contribution Types”type PageFragmentContribution = | { kind: "external-script"; placement: "head" | "body:start" | "body:end"; src: string; async?: boolean; defer?: boolean; attributes?: Record<string, string>; key?: string; } | { kind: "inline-script"; placement: "head" | "body:start" | "body:end"; code: string; attributes?: Record<string, string>; key?: string; } | { kind: "html"; placement: "head" | "body:start" | "body:end"; html: string; key?: string; };Hook Configuration
Section titled “Hook Configuration”Hooks accept either a handler function or a configuration object:
hooks: { // Simple handler "content:afterSave": async (event, ctx) => { ... },
// With configuration "content:beforeSave": { priority: 50, // Lower runs first (default: 100) timeout: 10000, // Max execution time in ms (default: 5000) dependencies: [], // Run after these plugins errorPolicy: "abort", // "continue" or "abort" (default) handler: async (event, ctx) => { ... }, },}Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 100 | Execution order (lower = earlier) |
timeout | number | 5000 | Max execution time in milliseconds |
dependencies | string[] | [] | Plugin IDs that must run first |
errorPolicy | string | "abort" | "continue" to ignore errors |
exclusive | boolean | false | Only one plugin can be the active provider (for provider-pattern hooks like email:deliver, comment:moderate) |
Plugin Context
Section titled “Plugin Context”All hooks receive a context object with access to plugin APIs:
interface PluginContext { plugin: { id: string; version: string }; storage: PluginStorage; kv: KVAccess; content?: ContentAccess; media?: MediaAccess; http?: HttpAccess; log: LogAccess; site: { name: string; url: string; locale: string }; url(path: string): string; users?: UserAccess; cron?: CronAccess; email?: EmailAccess;}See Plugin Overview — Plugin Context for capability requirements and method details.
Error Handling
Section titled “Error Handling”Errors in hooks are logged and handled based on errorPolicy:
"abort"(default) — Stop execution, rollback transaction if applicable"continue"— Log error and continue to next hook
hooks: { "content:beforeSave": { errorPolicy: "continue", // Don't block save if this fails handler: async (event, ctx) => { try { await ctx.http?.fetch("https://api.example.com/validate"); } catch (error) { ctx.log.warn("Validation service unavailable", error); } }, },}Execution Order
Section titled “Execution Order”Hooks run in this order:
- Sorted by
priority(ascending) - Plugins with
dependenciesrun after their dependencies - Within same priority, order is deterministic but unspecified
// This runs first (priority 10){ priority: 10, handler: ... }
// This runs second (priority 50){ priority: 50, handler: ... }
// This runs last (default priority 100){ handler: ... }