Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
Hook overview
The following table lists every hook, what triggers it, what it can modify, and whether it is exclusive:
| 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
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;
},
},
});
Event
interface ContentHookEvent {
content: Record<string, unknown>; // Content data
collection: string; // Collection slug
isNew: boolean; // True for creates, false for updates
}
Return Value
- Return modified content object to apply changes
- Return
voidto pass through unchanged
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
No return value expected.
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;
},
}
Event
interface ContentDeleteEvent {
id: string; // Entry ID
collection: string; // Collection slug
}
Return Value
- Return
falseto cancel deletion - Return
trueorvoidto allow
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
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,
};
},
}
Event
interface MediaUploadEvent {
file: {
name: string; // Original filename
type: string; // MIME type
size: number; // Size in bytes
};
}
Return Value
- Return modified file metadata to apply changes
- Return
voidto pass through unchanged - Throw to reject the upload
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(),
});
}
},
}
Event
interface MediaAfterUploadEvent {
media: {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
};
}
Lifecycle Hooks
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
Runs when a plugin is enabled (after install or re-enable).
hooks: {
"plugin:activate": async (event, ctx) => {
ctx.log.info("Plugin activated");
},
}
plugin:deactivate
Runs when a plugin is disabled.
hooks: {
"plugin:deactivate": async (event, ctx) => {
ctx.log.info("Plugin deactivated");
},
}
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");
},
}
Event
interface UninstallEvent {
deleteData: boolean; // User chose to delete data
}
Cron Hook
cron
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");
}
},
}
Event
interface CronEvent {
name: string;
data?: Record<string, unknown>;
scheduledAt: string;
}
Email Hooks
Email hooks run in order: email:beforeSend, then email:deliver, then email:afterSend.
email:beforeSend
Capability: hooks.email-events:register
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
},
}
Event
interface EmailBeforeSendEvent {
message: { to: string; subject: string; text: string; html?: string };
source: string;
}
Return Value
- Return modified message to transform
- Return
falseto cancel delivery - Return
voidto pass through unchanged
email:deliver
Capability: hooks.email-transport:register | 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
Capability: hooks.email-events:register
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
Comment hooks run in order: comment:beforeCreate, then comment:moderate, then comment:afterCreate. The comment:afterModerate hook fires separately when an admin changes a comment’s status.
comment:beforeCreate
Capability: users:read
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;
}
},
}
Event
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
- Return modified event to transform
- Return
falseto reject - Return
voidto pass through
comment:moderate
Capability: users:read | 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}`,
};
},
},
}
Event
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
{ status: "approved" | "pending" | "spam"; reason?: string }
comment:afterCreate
Capability: users:read
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
Capability: users:read
Fire-and-forget hook when an admin manually changes a comment’s status.
Event
interface CommentAfterModerateEvent {
comment: { id: string; /* ... */ };
previousStatus: string;
newStatus: string;
moderator: { id: string; name: string | null };
}
Page Hooks
Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
page:metadata
Capability: None required
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
type PageMetadataContribution =
| { kind: "meta"; name: string; content: string; key?: string }
| { kind: "property"; property: string; content: string; key?: string }
| {
kind: "link";
rel: "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document";
href: string;
hreflang?: string;
key?: string;
}
| {
kind: "jsonld";
id?: string;
graph: Record<string, unknown> | Array<Record<string, unknown>>;
};
The key field deduplicates contributions — only the last contribution with a given key is used.
page:fragments
Capability: hooks.page-fragments:register
Inject scripts or HTML into pages. Only available to 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
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
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
| 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
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
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
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: ... }