# Hooks Reference

Hooks let plugins run code in response to events. Declared in `definePlugin({ hooks })`.

## Signature

```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```

## Configuration

Simple handler or full config:

```typescript
// Simple
hooks: {
	"content:afterSave": async (event, ctx) => {
		ctx.log.info("Saved");
	}
}

// Full config
hooks: {
	"content:afterSave": {
		priority: 100,      // Lower runs first (default: 100)
		timeout: 5000,      // Max execution time ms (default: 5000)
		dependencies: [],   // Plugin IDs that must run first
		errorPolicy: "abort", // "abort" | "continue"
		handler: async (event, ctx) => {
			ctx.log.info("Saved");
		}
	}
}
```

## Lifecycle Hooks

### `plugin:install`

Runs once on first install. Use to seed defaults.

```typescript
"plugin:install": async (_event, ctx) => {
	await ctx.kv.set("settings:enabled", true);
	await ctx.storage.items!.put("default", { name: "Default" });
}
```

Event: `{}`
Returns: `void`

### `plugin:activate`

Runs when plugin is enabled (after install or re-enable).

```typescript
"plugin:activate": async (_event, ctx) => {
	ctx.log.info("Activated");
}
```

Event: `{}`
Returns: `void`

### `plugin:deactivate`

Runs when plugin is disabled (not removed).

```typescript
"plugin:deactivate": async (_event, ctx) => {
	ctx.log.info("Deactivated");
}
```

Event: `{}`
Returns: `void`

### `plugin:uninstall`

Runs when plugin is removed. Only delete data if `event.deleteData` is true.

```typescript
"plugin:uninstall": async (event, ctx) => {
	if (event.deleteData) {
		const result = await ctx.storage.items!.query({ limit: 1000 });
		await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
	}
}
```

Event: `{ deleteData: boolean }`
Returns: `void`

## Content Hooks

### `content:beforeSave`

Runs before save. Return modified content, void to keep unchanged, or throw to cancel.

```typescript
"content:beforeSave": async (event, ctx) => {
	const { content, collection, isNew } = event;

	if (collection === "posts" && !content.title) {
		throw new Error("Posts require a title");
	}

	// Transform
	if (content.slug) {
		content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
	}

	return content;
}
```

Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
Returns: `Record<string, unknown> | void`

### `content:afterSave`

Runs after successful save. Side effects only — logging, notifications, syncing.

```typescript
"content:afterSave": async (event, ctx) => {
	const { content, collection, isNew } = event;
	ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
}
```

Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
Returns: `void`

### `content:beforeDelete`

Runs before delete. Return `false` to cancel, `true` or void to allow.

```typescript
"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: string, collection: string }`
Returns: `boolean | void`

### `content:afterDelete`

Runs after successful delete.

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

Event: `{ id: string, collection: string }`
Returns: `void`

## Media Hooks

### `media:beforeUpload`

Runs before upload. Return modified file info, void to keep, or throw to cancel.

```typescript
"media:beforeUpload": async (event, ctx) => {
	const { file } = event;

	if (!file.type.startsWith("image/")) {
		throw new Error("Only images allowed");
	}

	if (file.size > 10 * 1024 * 1024) {
		throw new Error("Max 10MB");
	}

	return { ...file, name: `${Date.now()}-${file.name}` };
}
```

Event: `{ file: { name: string, type: string, size: number } }`
Returns: `{ name: string, type: string, size: number } | void`

### `media:afterUpload`

Runs after successful upload.

```typescript
"media:afterUpload": async (event, ctx) => {
	ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
}
```

Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }`
Returns: `void`

## Email Hooks

Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.

### `email:beforeSend`

**Requires:** `email:intercept` capability.

Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.

```typescript
definePlugin({
	id: "email-footer",
	capabilities: ["email:intercept"],
	hooks: {
		"email:beforeSend": async (event, ctx) => {
			return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
		},
	},
});
```

Event: `{ message: EmailMessage, source: string }`
Returns: `EmailMessage | false`

### `email:deliver`

**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.

Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.

```typescript
definePlugin({
	id: "emdash-resend",
	capabilities: ["email:provide", "network:fetch"],
	allowedHosts: ["api.resend.com"],
	hooks: {
		"email:deliver": {
			exclusive: true,
			handler: async ({ message }, ctx) => {
				const apiKey = await ctx.kv.get("settings:apiKey");
				await ctx.http!.fetch("https://api.resend.com/emails", {
					method: "POST",
					headers: { Authorization: `Bearer ${apiKey}` },
					body: JSON.stringify({ to: message.to, subject: message.subject, text: message.text }),
				});
			},
		},
	},
});
```

Event: `{ message: EmailMessage, source: string }`
Returns: `void`

### `email:afterSend`

**Requires:** `email:intercept` capability.

Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.

```typescript
definePlugin({
	id: "email-logger",
	capabilities: ["email:intercept"],
	hooks: {
		"email:afterSend": async (event, ctx) => {
			ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
		},
	},
});
```

Event: `{ message: EmailMessage, source: string }`
Returns: `void`

## Cron Hook

### `cron`

Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`.

```typescript
definePlugin({
	id: "cleanup",
	hooks: {
		"plugin:activate": async (_event, ctx) => {
			await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
		},
		cron: async (event, ctx) => {
			if (event.name === "daily-cleanup") {
				// ... cleanup logic
			}
		},
	},
});
```

Event: `{ name: string, data?: Record<string, unknown> }`
Returns: `void`

## Public Page Hooks

Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components.

### `page:metadata`

Contributes typed metadata to `<head>` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.

Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.

```typescript
"page:metadata": async (event, ctx) => {
	if (event.page.kind !== "content") return null;

	return [
		{ kind: "meta", name: "author", content: "My Blog" },
		{
			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: PublicPageContext }`
Returns: `PageMetadataContribution | PageMetadataContribution[] | null`

Contribution types:

- `{ kind: "meta", name: string, content: string, key?: string }` — `<meta name="..." content="...">`
- `{ kind: "property", property: string, content: string, key?: string }` — `<meta property="..." content="...">` (OpenGraph)
- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }` — `<link>` tag (HTTP/HTTPS URLs only)
- `{ kind: "jsonld", id?: string, graph: object | object[] }` — `<script type="application/ld+json">`

Dedupe rules: first contribution wins per key. Canonical is singleton.

### `page:fragments` (Trusted Only)

Contributes raw HTML, scripts, or markup to `head`, `body:start`, or `body:end`. **Trusted plugins only.** Sandboxed plugins cannot register this hook — the manifest schema rejects it.

```typescript
"page:fragments": async (event, ctx) => {
	return [
		{
			kind: "external-script",
			placement: "head",
			src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
			async: true,
		},
		{
			kind: "html",
			placement: "body:start",
			html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
		},
	];
}
```

Event: `{ page: PublicPageContext }`
Returns: `PageFragmentContribution | PageFragmentContribution[] | null`

Contribution types:

- `{ kind: "external-script", placement, src, async?, defer?, attributes?, key? }`
- `{ kind: "inline-script", placement, code, attributes?, key? }`
- `{ kind: "html", placement, html, key? }`

Placements: `"head"`, `"body:start"`, `"body:end"`

## Execution Order

1. Lower `priority` values run first
2. Equal priorities: plugin registration order
3. `dependencies` array forces ordering regardless of priority

## Error Handling

- `errorPolicy: "abort"` (default) — pipeline stops, operation may fail
- `errorPolicy: "continue"` — error logged, remaining hooks still run

Use `"continue"` for non-critical operations (analytics, notifications, external syncs).

## Quick Reference

| Hook                   | Trigger              | Capability Required | Return                       |
| ---------------------- | -------------------- | ------------------- | ---------------------------- |
| `plugin:install`       | First install        | —                   | `void`                       |
| `plugin:activate`      | Plugin enabled       | —                   | `void`                       |
| `plugin:deactivate`    | Plugin disabled      | —                   | `void`                       |
| `plugin:uninstall`     | Plugin removed       | —                   | `void`                       |
| `content:beforeSave`   | Before save          | —                   | Modified content or `void`   |
| `content:afterSave`    | After save           | —                   | `void`                       |
| `content:beforeDelete` | Before delete        | —                   | `false` to cancel            |
| `content:afterDelete`  | After delete         | —                   | `void`                       |
| `media:beforeUpload`   | Before upload        | —                   | Modified file info or `void` |
| `media:afterUpload`    | After upload         | —                   | `void`                       |
| `email:beforeSend`     | Before email send    | `email:intercept`   | Modified message or `false`  |
| `email:deliver`        | Email delivery       | `email:provide`     | `void` (exclusive)           |
| `email:afterSend`      | After email send     | `email:intercept`   | `void`                       |
| `cron`                 | Scheduled task fires | —                   | `void`                       |
| `page:metadata`        | Page render          | —                   | Metadata contributions       |
| `page:fragments`       | Page render          | — (trusted only)    | Fragment contributions       |
