diff --git a/.changeset/content-schedule-hooks.md b/.changeset/content-schedule-hooks.md new file mode 100644 index 0000000000..9ae9a07910 --- /dev/null +++ b/.changeset/content-schedule-hooks.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds content scheduling hooks for plugins to react when entries are scheduled or unscheduled. diff --git a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx index b9947a91bf..6573fccc4a 100644 --- a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx @@ -221,6 +221,18 @@ Runs after trashed content is restored. Requires `content:read` capability. **Event:** `{ content, collection }` — **Returns:** `Promise` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Requires `content:read` capability. + +**Event:** `{ content, collection }` — **Returns:** `Promise` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Requires `content:read` capability. + +**Event:** `{ content, collection }` — **Returns:** `Promise` + ## Media hooks ### `media:beforeUpload` @@ -385,9 +397,11 @@ Hooks default to 5000ms. Bump the timeout for slower work: | `content:beforeDelete` | Before content delete | `false` to cancel, else allow | No | | `content:afterDelete` | After content delete | `void` | No | | `content:afterPublish` | After content publish | `void` | No | -| `content:afterUnpublish` | After content unpublish | `void` | No | -| `content:afterRestore` | After content restore | `void` | No | -| `media:beforeUpload` | Before file upload | Modified file info or `void` | No | +| `content:afterUnpublish` | After content unpublish | `void` | No | +| `content:afterRestore` | After content restore | `void` | No | +| `content:afterSchedule` | After content schedule | `void` | No | +| `content:afterUnschedule` | After content unschedule | `void` | No | +| `media:beforeUpload` | Before file upload | Modified file info or `void` | No | | `media:afterUpload` | After file upload | `void` | No | | `cron` | Scheduled task fires | `void` | No | | `email:beforeSend` | Before email delivery | Modified message, `false`, or `void` | No | diff --git a/docs/src/content/docs/reference/hooks.mdx b/docs/src/content/docs/reference/hooks.mdx index 180a3be985..b984b3ea4e 100644 --- a/docs/src/content/docs/reference/hooks.mdx +++ b/docs/src/content/docs/reference/hooks.mdx @@ -17,7 +17,11 @@ The following table lists every hook, what triggers it, what it can modify, and | `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 | +| `content:afterPublish` | After content is published | Nothing | No | +| `content:afterUnpublish` | After content is unpublished | Nothing | No | | `content:afterRestore` | After content is restored | Nothing | No | +| `content:afterSchedule` | After content is scheduled | Nothing | No | +| `content:afterUnschedule` | After content is unscheduled | 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 | @@ -165,10 +169,34 @@ hooks: { } ``` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Requires `content:read` capability. + +```ts +hooks: { + "content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); + }, +} +``` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Requires `content:read` capability. + +```ts +hooks: { + "content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); + }, +} +``` + #### Event ```ts -interface ContentPublishStateChangeEvent { +interface ContentStateChangeEvent { content: Record; collection: string; } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index fde5c3bb19..3c3014ac67 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2857,11 +2857,25 @@ export class EmDashRuntime { } async handleContentSchedule(collection: string, id: string, scheduledAt: string) { - return handleContentSchedule(this.db, collection, id, scheduledAt); + const result = await handleContentSchedule(this.db, collection, id, scheduledAt); + + // Run afterSchedule hooks (fire-and-forget) + if (result.success && result.data) { + this.runAfterScheduleHooks(contentItemToRecord(result.data.item), collection); + } + + return result; } async handleContentUnschedule(collection: string, id: string) { - return handleContentUnschedule(this.db, collection, id); + const result = await handleContentUnschedule(this.db, collection, id); + + // Run afterUnschedule hooks (fire-and-forget) + if (result.success && result.data) { + this.runAfterUnscheduleHooks(contentItemToRecord(result.data.item), collection); + } + + return result; } async handleContentCountScheduled(collection: string) { @@ -3320,14 +3334,41 @@ export class EmDashRuntime { } } - private runAfterPublishHooks(content: Record, collection: string): void { + private runDeferredContentHook( + name: + | "content:afterPublish" + | "content:afterUnpublish" + | "content:afterRestore" + | "content:afterSchedule" + | "content:afterUnschedule", + content: Record, + collection: string, + ): void { + const label = name.slice("content:".length); + after(async () => { // Trusted plugins - if (this.hooks.hasHooks("content:afterPublish")) { + if (this.hooks.hasHooks(name)) { try { - await this.hooks.runContentAfterPublish(content, collection); + switch (name) { + case "content:afterPublish": + await this.hooks.runContentAfterPublish(content, collection); + break; + case "content:afterUnpublish": + await this.hooks.runContentAfterUnpublish(content, collection); + break; + case "content:afterRestore": + await this.hooks.runContentAfterRestore(content, collection); + break; + case "content:afterSchedule": + await this.hooks.runContentAfterSchedule(content, collection); + break; + case "content:afterUnschedule": + await this.hooks.runContentAfterUnschedule(content, collection); + break; + } } catch (err) { - console.error("EmDash afterPublish hook error:", err); + console.error(`EmDash ${label} hook error:`, err); } } @@ -3340,9 +3381,9 @@ export class EmDashRuntime { tasks.push( (async () => { try { - await plugin.invokeHook("content:afterPublish", { content, collection }); + await plugin.invokeHook(name, { content, collection }); } catch (err) { - console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err); + console.error(`EmDash: Sandboxed plugin ${pluginId} ${label} error:`, err); } })(), ); @@ -3351,56 +3392,24 @@ export class EmDashRuntime { }); } - private runAfterUnpublishHooks(content: Record, collection: string): void { - // Trusted plugins - if (this.hooks.hasHooks("content:afterUnpublish")) { - this.hooks - .runContentAfterUnpublish(content, collection) - .catch((err) => console.error("EmDash afterUnpublish hook error:", err)); - } - - // Sandboxed plugins - for (const [pluginKey, plugin] of this.sandboxedPlugins) { - const [pluginId] = pluginKey.split(":"); - if (!pluginId || !this.isPluginEnabled(pluginId)) continue; + private runAfterPublishHooks(content: Record, collection: string): void { + this.runDeferredContentHook("content:afterPublish", content, collection); + } - plugin - .invokeHook("content:afterUnpublish", { content, collection }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err), - ); - } + private runAfterUnpublishHooks(content: Record, collection: string): void { + this.runDeferredContentHook("content:afterUnpublish", content, collection); } private runAfterRestoreHooks(content: Record, collection: string): void { - after(async () => { - // Trusted plugins - if (this.hooks.hasHooks("content:afterRestore")) { - try { - await this.hooks.runContentAfterRestore(content, collection); - } catch (err) { - console.error("EmDash afterRestore hook error:", err); - } - } + this.runDeferredContentHook("content:afterRestore", content, collection); + } - // Sandboxed plugins - const tasks: Promise[] = []; - for (const [pluginKey, plugin] of this.sandboxedPlugins) { - const [pluginId] = pluginKey.split(":"); - if (!pluginId || !this.isPluginEnabled(pluginId)) continue; + private runAfterScheduleHooks(content: Record, collection: string): void { + this.runDeferredContentHook("content:afterSchedule", content, collection); + } - tasks.push( - (async () => { - try { - await plugin.invokeHook("content:afterRestore", { content, collection }); - } catch (err) { - console.error(`EmDash: Sandboxed plugin ${pluginId} afterRestore error:`, err); - } - })(), - ); - } - await Promise.allSettled(tasks); - }); + private runAfterUnscheduleHooks(content: Record, collection: string): void { + this.runDeferredContentHook("content:afterUnschedule", content, collection); } private async handleSandboxedRoute( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0d29f75ea..cb8f07701c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -255,6 +255,9 @@ export type { ContentHookEvent, ContentDeleteEvent, ContentPublishStateChangeEvent, + ContentRestoreStateChangeEvent, + ContentScheduleStateChangeEvent, + ContentStateChangeEvent, MediaUploadEvent, HookResult, PluginRoute, diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 711b8eba36..2950f0194c 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -51,12 +51,17 @@ import type { ContentAfterPublishHandler, ContentAfterRestoreHandler, ContentAfterSaveHandler, + ContentAfterScheduleHandler, ContentAfterUnpublishHandler, + ContentAfterUnscheduleHandler, ContentBeforeDeleteHandler, ContentBeforeSaveHandler, ContentDeleteEvent, ContentHookEvent, ContentPublishStateChangeEvent, + ContentRestoreStateChangeEvent, + ContentScheduleStateChangeEvent, + ContentStateChangeEvent, CronEvent, CronHandler, EmailAfterSendEvent, @@ -99,6 +104,8 @@ export interface HookHandlers { "content:afterPublish": ContentAfterPublishHandler; "content:afterUnpublish": ContentAfterUnpublishHandler; "content:afterRestore": ContentAfterRestoreHandler; + "content:afterSchedule": ContentAfterScheduleHandler; + "content:afterUnschedule": ContentAfterUnscheduleHandler; "media:beforeUpload": MediaBeforeUploadHandler; "media:afterUpload": MediaAfterUploadHandler; cron: CronHandler; @@ -228,6 +235,9 @@ export type { ContentDeleteEvent, ContentHookEvent, ContentPublishStateChangeEvent, + ContentRestoreStateChangeEvent, + ContentScheduleStateChangeEvent, + ContentStateChangeEvent, CronEvent, EmailAfterSendEvent, EmailBeforeSendEvent, diff --git a/packages/core/src/plugins/hooks.ts b/packages/core/src/plugins/hooks.ts index a13f79b7d0..a63eab1bf8 100644 --- a/packages/core/src/plugins/hooks.ts +++ b/packages/core/src/plugins/hooks.ts @@ -17,7 +17,7 @@ import type { PluginContext, ContentHookEvent, ContentDeleteEvent, - ContentPublishStateChangeEvent, + ContentStateChangeEvent, MediaUploadEvent, MediaAfterUploadEvent, LifecycleEvent, @@ -33,7 +33,9 @@ import type { ContentAfterDeleteHandler, ContentAfterPublishHandler, ContentAfterRestoreHandler, + ContentAfterScheduleHandler, ContentAfterUnpublishHandler, + ContentAfterUnscheduleHandler, MediaBeforeUploadHandler, MediaAfterUploadHandler, LifecycleHandler, @@ -68,6 +70,8 @@ type HookNameV2 = | "content:afterPublish" | "content:afterUnpublish" | "content:afterRestore" + | "content:afterSchedule" + | "content:afterUnschedule" | "media:beforeUpload" | "media:afterUpload" | "cron" @@ -84,6 +88,13 @@ type HookNameV2 = /** * Map from hook name to handler type — used for type-safe hook retrieval */ +type ContentStateChangeHookName = + | "content:afterPublish" + | "content:afterUnpublish" + | "content:afterRestore" + | "content:afterSchedule" + | "content:afterUnschedule"; + interface HookHandlerMap { "plugin:install": LifecycleHandler; "plugin:activate": LifecycleHandler; @@ -96,6 +107,8 @@ interface HookHandlerMap { "content:afterPublish": ContentAfterPublishHandler; "content:afterUnpublish": ContentAfterUnpublishHandler; "content:afterRestore": ContentAfterRestoreHandler; + "content:afterSchedule": ContentAfterScheduleHandler; + "content:afterUnschedule": ContentAfterUnscheduleHandler; "media:beforeUpload": MediaBeforeUploadHandler; "media:afterUpload": MediaAfterUploadHandler; cron: CronHandler; @@ -224,6 +237,8 @@ export class HookPipeline { this.registerPluginHook(plugin, "content:afterPublish"); this.registerPluginHook(plugin, "content:afterUnpublish"); this.registerPluginHook(plugin, "content:afterRestore"); + this.registerPluginHook(plugin, "content:afterSchedule"); + this.registerPluginHook(plugin, "content:afterUnschedule"); this.registerPluginHook(plugin, "media:beforeUpload"); this.registerPluginHook(plugin, "media:afterUpload"); this.registerPluginHook(plugin, "cron"); @@ -269,6 +284,8 @@ export class HookPipeline { ["content:afterPublish", "content:read"], ["content:afterUnpublish", "content:read"], ["content:afterRestore", "content:read"], + ["content:afterSchedule", "content:read"], + ["content:afterUnschedule", "content:read"], // Media ["media:beforeUpload", "media:write"], ["media:afterUpload", "media:read"], @@ -653,18 +670,19 @@ export class HookPipeline { } /** - * Run content:afterPublish hooks (fire-and-forget). + * Run content state-change hooks that all share the same event shape. */ - async runContentAfterPublish( + private async runContentStateChangeHook( + name: ContentStateChangeHookName, content: Record, collection: string, ): Promise[]> { - const hooks = this.getTypedHooks("content:afterPublish"); + const hooks = this.getTypedHooks(name); const results: HookResult[] = []; for (const hook of hooks) { const { handler } = hook; - const event: ContentPublishStateChangeEvent = { content, collection }; + const event: ContentStateChangeEvent = { content, collection }; const ctx = this.getContext(hook.pluginId); const start = Date.now(); @@ -692,6 +710,16 @@ export class HookPipeline { return results; } + /** + * Run content:afterPublish hooks (fire-and-forget). + */ + async runContentAfterPublish( + content: Record, + collection: string, + ): Promise[]> { + return this.runContentStateChangeHook("content:afterPublish", content, collection); + } + /** * Run content:afterUnpublish hooks (fire-and-forget). */ @@ -699,37 +727,7 @@ export class HookPipeline { content: Record, collection: string, ): Promise[]> { - const hooks = this.getTypedHooks("content:afterUnpublish"); - const results: HookResult[] = []; - - for (const hook of hooks) { - const { handler } = hook; - const event: ContentPublishStateChangeEvent = { content, collection }; - const ctx = this.getContext(hook.pluginId); - const start = Date.now(); - - try { - await this.executeWithTimeout(() => handler(event, ctx), hook.timeout); - results.push({ - success: true, - pluginId: hook.pluginId, - duration: Date.now() - start, - }); - } catch (error) { - results.push({ - success: false, - error: error instanceof Error ? error : new Error(String(error)), - pluginId: hook.pluginId, - duration: Date.now() - start, - }); - - if (hook.errorPolicy === "abort") { - throw error; - } - } - } - - return results; + return this.runContentStateChangeHook("content:afterUnpublish", content, collection); } /** @@ -739,37 +737,27 @@ export class HookPipeline { content: Record, collection: string, ): Promise[]> { - const hooks = this.getTypedHooks("content:afterRestore"); - const results: HookResult[] = []; - - for (const hook of hooks) { - const { handler } = hook; - const event: ContentPublishStateChangeEvent = { content, collection }; - const ctx = this.getContext(hook.pluginId); - const start = Date.now(); - - try { - await this.executeWithTimeout(() => handler(event, ctx), hook.timeout); - results.push({ - success: true, - pluginId: hook.pluginId, - duration: Date.now() - start, - }); - } catch (error) { - results.push({ - success: false, - error: error instanceof Error ? error : new Error(String(error)), - pluginId: hook.pluginId, - duration: Date.now() - start, - }); + return this.runContentStateChangeHook("content:afterRestore", content, collection); + } - if (hook.errorPolicy === "abort") { - throw error; - } - } - } + /** + * Run content:afterSchedule hooks (fire-and-forget). + */ + async runContentAfterSchedule( + content: Record, + collection: string, + ): Promise[]> { + return this.runContentStateChangeHook("content:afterSchedule", content, collection); + } - return results; + /** + * Run content:afterUnschedule hooks (fire-and-forget). + */ + async runContentAfterUnschedule( + content: Record, + collection: string, + ): Promise[]> { + return this.runContentStateChangeHook("content:afterUnschedule", content, collection); } // ========================================================================= diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index c5b3faa881..29548ab7dd 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -128,6 +128,9 @@ export type { ContentHookEvent, ContentDeleteEvent, ContentPublishStateChangeEvent, + ContentRestoreStateChangeEvent, + ContentScheduleStateChangeEvent, + ContentStateChangeEvent, MediaUploadEvent, MediaAfterUploadEvent, LifecycleEvent, @@ -149,6 +152,8 @@ export type { ContentBeforeDeleteHandler, ContentAfterDeleteHandler, ContentAfterRestoreHandler, + ContentAfterScheduleHandler, + ContentAfterUnscheduleHandler, MediaBeforeUploadHandler, MediaAfterUploadHandler, LifecycleHandler, diff --git a/packages/core/src/plugins/manager.ts b/packages/core/src/plugins/manager.ts index f886eddf4e..f1b1188b48 100644 --- a/packages/core/src/plugins/manager.ts +++ b/packages/core/src/plugins/manager.ts @@ -381,6 +381,28 @@ export class PluginManager { return this.hookPipeline!.runContentAfterRestore(content, collection); } + /** + * Run content:afterSchedule hooks across all active plugins + */ + async runContentAfterSchedule( + content: Record, + collection: string, + ): Promise[]> { + this.ensureInitialized(); + return this.hookPipeline!.runContentAfterSchedule(content, collection); + } + + /** + * Run content:afterUnschedule hooks across all active plugins + */ + async runContentAfterUnschedule( + content: Record, + collection: string, + ): Promise[]> { + this.ensureInitialized(); + return this.hookPipeline!.runContentAfterUnschedule(content, collection); + } + /** * Run media:beforeUpload hooks across all active plugins */ diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 6ee59c93f6..20257b43ea 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -98,6 +98,8 @@ export const HOOK_NAMES = [ "content:afterPublish", "content:afterUnpublish", "content:afterRestore", + "content:afterSchedule", + "content:afterUnschedule", "media:beforeUpload", "media:afterUpload", "cron", diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 3fd3c5a43a..397659a6ec 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -725,13 +725,29 @@ export interface ContentDeleteEvent { } /** - * Content publish state change hook event (fired after publish or unpublish) + * Content state-change hook event (fired after publish, unpublish, restore, + * schedule, or unschedule). */ -export interface ContentPublishStateChangeEvent { +export interface ContentStateChangeEvent { content: Record; collection: string; } +/** + * Content publish/unpublish hook event. + */ +export type ContentPublishStateChangeEvent = ContentStateChangeEvent; + +/** + * Content restore hook event. + */ +export type ContentRestoreStateChangeEvent = ContentStateChangeEvent; + +/** + * Content schedule/unschedule hook event. + */ +export type ContentScheduleStateChangeEvent = ContentStateChangeEvent; + /** * Media hook event */ @@ -792,7 +808,17 @@ export type ContentAfterUnpublishHandler = ( ) => Promise; export type ContentAfterRestoreHandler = ( - event: ContentPublishStateChangeEvent, + event: ContentRestoreStateChangeEvent, + ctx: PluginContext, +) => Promise; + +export type ContentAfterScheduleHandler = ( + event: ContentScheduleStateChangeEvent, + ctx: PluginContext, +) => Promise; + +export type ContentAfterUnscheduleHandler = ( + event: ContentScheduleStateChangeEvent, ctx: PluginContext, ) => Promise; @@ -982,6 +1008,10 @@ export interface PluginHooks { | HookConfig | ContentAfterUnpublishHandler; "content:afterRestore"?: HookConfig | ContentAfterRestoreHandler; + "content:afterSchedule"?: HookConfig | ContentAfterScheduleHandler; + "content:afterUnschedule"?: + | HookConfig + | ContentAfterUnscheduleHandler; // Media hooks "media:beforeUpload"?: HookConfig | MediaBeforeUploadHandler; @@ -1302,6 +1332,8 @@ export interface ResolvedPluginHooks { "content:afterPublish"?: ResolvedHook; "content:afterUnpublish"?: ResolvedHook; "content:afterRestore"?: ResolvedHook; + "content:afterSchedule"?: ResolvedHook; + "content:afterUnschedule"?: ResolvedHook; "media:beforeUpload"?: ResolvedHook; "media:afterUpload"?: ResolvedHook; cron?: ResolvedHook; diff --git a/packages/core/tests/unit/plugins/hooks.test.ts b/packages/core/tests/unit/plugins/hooks.test.ts index e59015907c..9b33faf78c 100644 --- a/packages/core/tests/unit/plugins/hooks.test.ts +++ b/packages/core/tests/unit/plugins/hooks.test.ts @@ -472,6 +472,8 @@ describe("HookPipeline", () => { "content:afterPublish": createTestHook("writer", vi.fn()), "content:afterUnpublish": createTestHook("writer", vi.fn()), "content:afterRestore": createTestHook("writer", vi.fn()), + "content:afterSchedule": createTestHook("writer", vi.fn()), + "content:afterUnschedule": createTestHook("writer", vi.fn()), }, }); @@ -483,6 +485,8 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterPublish")).toBe(true); expect(pipeline.hasHooks("content:afterUnpublish")).toBe(true); expect(pipeline.hasHooks("content:afterRestore")).toBe(true); + expect(pipeline.hasHooks("content:afterSchedule")).toBe(true); + expect(pipeline.hasHooks("content:afterUnschedule")).toBe(true); }); it("skips content:afterPublish without content:read capability", () => { @@ -562,6 +566,58 @@ describe("HookPipeline", () => { const pipeline = new HookPipeline([plugin]); expect(pipeline.hasHooks("content:afterRestore")).toBe(true); }); + + it("skips content:afterSchedule without content:read capability", () => { + const plugin = createTestPlugin({ + id: "no-cap", + capabilities: [], + hooks: { + "content:afterSchedule": createTestHook("no-cap", vi.fn()), + }, + }); + + const pipeline = new HookPipeline([plugin]); + expect(pipeline.hasHooks("content:afterSchedule")).toBe(false); + }); + + it("registers content:afterSchedule with content:read capability", () => { + const plugin = createTestPlugin({ + id: "has-cap", + capabilities: ["content:read"], + hooks: { + "content:afterSchedule": createTestHook("has-cap", vi.fn()), + }, + }); + + const pipeline = new HookPipeline([plugin]); + expect(pipeline.hasHooks("content:afterSchedule")).toBe(true); + }); + + it("skips content:afterUnschedule without content:read capability", () => { + const plugin = createTestPlugin({ + id: "no-cap", + capabilities: [], + hooks: { + "content:afterUnschedule": createTestHook("no-cap", vi.fn()), + }, + }); + + const pipeline = new HookPipeline([plugin]); + expect(pipeline.hasHooks("content:afterUnschedule")).toBe(false); + }); + + it("registers content:afterUnschedule with content:read capability", () => { + const plugin = createTestPlugin({ + id: "has-cap", + capabilities: ["content:read"], + hooks: { + "content:afterUnschedule": createTestHook("has-cap", vi.fn()), + }, + }); + + const pipeline = new HookPipeline([plugin]); + expect(pipeline.hasHooks("content:afterUnschedule")).toBe(true); + }); }); describe("capability enforcement — media hooks", () => { diff --git a/packages/core/tests/unit/plugins/schedule-hooks.test.ts b/packages/core/tests/unit/plugins/schedule-hooks.test.ts new file mode 100644 index 0000000000..f8b54d78a1 --- /dev/null +++ b/packages/core/tests/unit/plugins/schedule-hooks.test.ts @@ -0,0 +1,164 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import type { ContentScheduleStateChangeEvent } from "../../../src/plugins/types.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +const { deferredTasks } = vi.hoisted(() => ({ + deferredTasks: [] as Array<() => void | Promise>, +})); + +vi.mock("../../../src/after.js", () => ({ + after: vi.fn((fn: () => void | Promise) => { + deferredTasks.push(fn); + }), +})); + +async function flushLatestDeferredHook(): Promise { + const task = deferredTasks.pop(); + if (task) await task(); +} + +function buildRuntime( + db: Kysely, + afterSchedule: (event: ContentScheduleStateChangeEvent) => void, + afterUnschedule: (event: ContentScheduleStateChangeEvent) => void, +): EmDashRuntime { + const plugin = definePlugin({ + id: "schedule-sync-test", + version: "1.0.0", + capabilities: ["content:read"], + hooks: { + "content:afterSchedule": (event) => { + afterSchedule(event); + }, + "content:afterUnschedule": (event) => { + afterUnschedule(event); + }, + }, + }); + const config: EmDashConfig = {}; + const pipelineFactoryOptions = { db } as const; + const hooks = createHookPipeline([plugin], pipelineFactoryOptions); + const runtimeDeps = { + config, + plugins: [plugin], + // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature + createDialect: (() => { + throw new Error("createDialect not used in this test"); + }) as any, + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; + + return new EmDashRuntime({ + db, + storage: null, + configuredPlugins: [], + sandboxedPlugins: new Map(), + sandboxedPluginEntries: [], + hooks, + enabledPlugins: new Set(), + pluginStates: new Map(), + config, + mediaProviders: new Map(), + mediaProviderEntries: [], + cronExecutor: null, + cronScheduler: null, + emailPipeline: null, + allPipelinePlugins: [plugin], + pipelineFactoryOptions, + runtimeDeps, + pipelineRef: { current: hooks }, + }); +} + +describe("content scheduling hooks", () => { + let db: Kysely; + let repo: ContentRepository; + let afterSchedule: ReturnType; + let afterUnschedule: ReturnType; + let runtime: EmDashRuntime; + + beforeEach(async () => { + deferredTasks.length = 0; + db = await setupTestDatabaseWithCollections(); + repo = new ContentRepository(db); + afterSchedule = vi.fn(); + afterUnschedule = vi.fn(); + runtime = buildRuntime(db, afterSchedule, afterUnschedule); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("fires content:afterSchedule when a draft is scheduled", async () => { + const item = await repo.create({ + type: "post", + slug: "scheduled-post", + status: "draft", + data: { title: "Scheduled post" }, + }); + deferredTasks.length = 0; + const scheduledAt = new Date(Date.now() + 86_400_000).toISOString(); + + const result = await runtime.handleContentSchedule("post", item.id, scheduledAt); + + expect(result.success).toBe(true); + expect(afterSchedule).not.toHaveBeenCalled(); + + await flushLatestDeferredHook(); + + expect(afterSchedule).toHaveBeenCalledTimes(1); + expect(afterSchedule).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "post", + content: expect.objectContaining({ + id: item.id, + status: "scheduled", + scheduledAt, + }), + }), + ); + }); + + it("fires content:afterUnschedule when scheduled content is unscheduled", async () => { + const item = await repo.create({ + type: "post", + slug: "scheduled-post", + status: "draft", + data: { title: "Scheduled post" }, + }); + const scheduledAt = new Date(Date.now() + 86_400_000).toISOString(); + await repo.schedule("post", item.id, scheduledAt); + deferredTasks.length = 0; + + const result = await runtime.handleContentUnschedule("post", item.id); + + expect(result.success).toBe(true); + expect(afterUnschedule).not.toHaveBeenCalled(); + + await flushLatestDeferredHook(); + + expect(afterUnschedule).toHaveBeenCalledTimes(1); + expect(afterUnschedule).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "post", + content: expect.objectContaining({ + id: item.id, + status: "draft", + scheduledAt: null, + }), + }), + ); + }); +}); diff --git a/packages/core/tests/unit/plugins/unpublish-hooks.test.ts b/packages/core/tests/unit/plugins/unpublish-hooks.test.ts new file mode 100644 index 0000000000..5ad7add6bd --- /dev/null +++ b/packages/core/tests/unit/plugins/unpublish-hooks.test.ts @@ -0,0 +1,126 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import type { ContentPublishStateChangeEvent } from "../../../src/plugins/types.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +const { deferredTasks } = vi.hoisted(() => ({ + deferredTasks: [] as Array<() => void | Promise>, +})); + +vi.mock("../../../src/after.js", () => ({ + after: vi.fn((fn: () => void | Promise) => { + deferredTasks.push(fn); + }), +})); + +async function flushLatestDeferredHook(): Promise { + const task = deferredTasks.pop(); + if (task) await task(); +} + +function buildRuntime( + db: Kysely, + afterUnpublish: (event: ContentPublishStateChangeEvent) => void, +): EmDashRuntime { + const plugin = definePlugin({ + id: "unpublish-sync-test", + version: "1.0.0", + capabilities: ["content:read"], + hooks: { + "content:afterUnpublish": (event) => { + afterUnpublish(event); + }, + }, + }); + const config: EmDashConfig = {}; + const pipelineFactoryOptions = { db } as const; + const hooks = createHookPipeline([plugin], pipelineFactoryOptions); + const runtimeDeps = { + config, + plugins: [plugin], + // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature + createDialect: (() => { + throw new Error("createDialect not used in this test"); + }) as any, + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; + + return new EmDashRuntime({ + db, + storage: null, + config: {}, + configuredPlugins: [], + sandboxedPlugins: new Map(), + sandboxedPluginEntries: [], + hooks, + enabledPlugins: new Set(), + pluginStates: new Map(), + mediaProviders: new Map(), + mediaProviderEntries: [], + cronExecutor: null, + cronScheduler: null, + emailPipeline: null, + allPipelinePlugins: [plugin], + pipelineFactoryOptions, + runtimeDeps, + pipelineRef: { current: hooks }, + }); +} + +describe("content unpublish hooks", () => { + let db: Kysely; + let repo: ContentRepository; + let afterUnpublish: ReturnType; + let runtime: EmDashRuntime; + + beforeEach(async () => { + deferredTasks.length = 0; + db = await setupTestDatabaseWithCollections(); + repo = new ContentRepository(db); + afterUnpublish = vi.fn(); + runtime = buildRuntime(db, afterUnpublish); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("defers content:afterUnpublish with the unpublished content item", async () => { + const item = await repo.create({ + type: "post", + slug: "published-post", + status: "published", + data: { title: "Published post" }, + }); + deferredTasks.length = 0; + + const result = await runtime.handleContentUnpublish("post", item.id); + + expect(result.success).toBe(true); + expect(afterUnpublish).not.toHaveBeenCalled(); + + await flushLatestDeferredHook(); + + expect(afterUnpublish).toHaveBeenCalledTimes(1); + expect(afterUnpublish).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "post", + content: expect.objectContaining({ + id: item.id, + slug: "published-post", + status: "draft", + }), + }), + ); + }); +}); diff --git a/skills/creating-plugins/references/hooks.md b/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/skills/creating-plugins/references/hooks.md +++ b/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/blank/.agents/skills/creating-plugins/references/hooks.md b/templates/blank/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/blank/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/blank/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/blog/.agents/skills/creating-plugins/references/hooks.md b/templates/blog/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/blog/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/blog/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/marketing/.agents/skills/creating-plugins/references/hooks.md b/templates/marketing/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/marketing/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/marketing/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/portfolio-cloudflare/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md b/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/portfolio/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/starter-cloudflare/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions | diff --git a/templates/starter/.agents/skills/creating-plugins/references/hooks.md b/templates/starter/.agents/skills/creating-plugins/references/hooks.md index 02fa397f3d..2a1532d8d5 100644 --- a/templates/starter/.agents/skills/creating-plugins/references/hooks.md +++ b/templates/starter/.agents/skills/creating-plugins/references/hooks.md @@ -202,6 +202,32 @@ Runs after trashed content is restored. Side effects only. Event: `{ content: Record, collection: string }` Returns: `void` +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + ## Media Hooks ### `media:beforeUpload` @@ -431,24 +457,26 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | `content:write` | Modified content or `void` | -| `content:afterSave` | After save | `content:read` | `void` | -| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | -| `content:afterDelete` | After delete | `content:read` | `void` | -| `content:afterPublish` | After publish | `content:read` | `void` | -| `content:afterUnpublish` | After unpublish | `content:read` | `void` | -| `content:afterRestore` | After restore | `content:read` | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | -| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | -| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | `content:write` | Modified content or `void` | +| `content:afterSave` | After save | `content:read` | `void` | +| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel | +| `content:afterDelete` | After delete | `content:read` | `void` | +| `content:afterPublish` | After publish | `content:read` | `void` | +| `content:afterUnpublish` | After unpublish | `content:read` | `void` | +| `content:afterRestore` | After restore | `content:read` | `void` | +| `content:afterSchedule` | After schedule | `content:read` | `void` | +| `content:afterUnschedule` | After unschedule | `content:read` | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `void` | +| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` | +| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) | +| `email:afterSend` | After email send | `hooks.email-events:register` | `void` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions |