Hook Reference

On this page

Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.

Hook Overview

HookTriggerCan ModifyExclusive
content:beforeSaveBefore content is savedContent dataNo
content:afterSaveAfter content is savedNothingNo
content:beforeDeleteBefore content is deletedCan cancelNo
content:afterDeleteAfter content is deletedNothingNo
media:beforeUploadBefore file is uploadedFile metadataNo
media:afterUploadAfter file is uploadedNothingNo
cronScheduled task firesNothingNo
email:beforeSendBefore email deliveryMessage, can cancelNo
email:deliverDeliver email via transportNothingYes
email:afterSendAfter successful email deliveryNothingNo
comment:beforeCreateBefore comment is storedComment, can cancelNo
comment:moderateDecide comment approval statusStatusYes
comment:afterCreateAfter comment is storedNothingNo
comment:afterModerateAfter admin changes comment statusNothingNo
page:metadataRendering public page headContribute tagsNo
page:fragmentsRendering public page bodyInject scriptsNo
plugin:installWhen plugin is first installedNothingNo
plugin:activateWhen plugin is enabledNothingNo
plugin:deactivateWhen plugin is disabledNothingNo
plugin:uninstallWhen plugin is removedNothingNo

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 void to 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 false to cancel deletion
  • Return true or void to 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 void to 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 form a pipeline: email:beforeSendemail:deliveremail:afterSend.

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

Event

interface EmailBeforeSendEvent {
	message: { to: string; subject: string; text: string; html?: string };
	source: string;
}

Return Value

  • Return modified message to transform
  • Return false to cancel delivery
  • Return void to pass through unchanged

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

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

Comment hooks form a pipeline: comment:beforeCreatecomment:moderatecomment:afterCreate. The comment:afterModerate hook fires separately when an admin changes a comment’s status.

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

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 false to reject
  • Return void to pass through

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

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

Capability: read:users

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

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

Capability: page:inject

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

OptionTypeDefaultDescription
prioritynumber100Execution order (lower = earlier)
timeoutnumber5000Max execution time in milliseconds
dependenciesstring[][]Plugin IDs that must run first
errorPolicystring"abort""continue" to ignore errors
exclusivebooleanfalseOnly 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:

  1. Sorted by priority (ascending)
  2. Plugins with dependencies run after their dependencies
  3. 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: ... }