Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/content-schedule-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds content scheduling hooks for plugins to react when entries are scheduled or unscheduled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This changeset introduces a new user-facing feature, but the PR description still links the feature request to an internal chat rather than a maintainer-approved Discussion in the Ideas category. AGENTS.md and CONTRIBUTING.md require an approved Discussion for features before merge. Please open or link the approved Discussion so the design, naming, and scope have maintainer sign-off.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already discussed internally.

20 changes: 17 additions & 3 deletions docs/src/content/docs/plugins/creating-plugins/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,18 @@ Runs after trashed content is restored. Requires `content:read` capability.

**Event:** `{ content, collection }` — **Returns:** `Promise<void>`

### `content:afterSchedule`

Runs after content is scheduled for future publishing. Requires `content:read` capability.

**Event:** `{ content, collection }` — **Returns:** `Promise<void>`

### `content:afterUnschedule`

Runs after scheduled content is unscheduled. Requires `content:read` capability.

**Event:** `{ content, collection }` — **Returns:** `Promise<void>`

## Media hooks

### `media:beforeUpload`
Expand Down Expand Up @@ -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 |
Expand Down
30 changes: 29 additions & 1 deletion docs/src/content/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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<string, unknown>;
collection: string;
}
Expand Down
113 changes: 61 additions & 52 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -3320,14 +3334,41 @@ export class EmDashRuntime {
}
}

private runAfterPublishHooks(content: Record<string, unknown>, collection: string): void {
private runDeferredContentHook(
name:
| "content:afterPublish"
| "content:afterUnpublish"
| "content:afterRestore"
| "content:afterSchedule"
| "content:afterUnschedule",
content: Record<string, unknown>,
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);
}
}

Expand All @@ -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);
}
})(),
);
Expand All @@ -3351,56 +3392,24 @@ export class EmDashRuntime {
});
}

private runAfterUnpublishHooks(content: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, collection: string): void {
this.runDeferredContentHook("content:afterUnpublish", content, collection);
}

private runAfterRestoreHooks(content: Record<string, unknown>, 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<void>[] = [];
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
const [pluginId] = pluginKey.split(":");
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
private runAfterScheduleHooks(content: Record<string, unknown>, 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<string, unknown>, collection: string): void {
this.runDeferredContentHook("content:afterUnschedule", content, collection);
}

private async handleSandboxedRoute(
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ export type {
ContentHookEvent,
ContentDeleteEvent,
ContentPublishStateChangeEvent,
ContentRestoreStateChangeEvent,
ContentScheduleStateChangeEvent,
ContentStateChangeEvent,
MediaUploadEvent,
HookResult,
PluginRoute,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,17 @@ import type {
ContentAfterPublishHandler,
ContentAfterRestoreHandler,
ContentAfterSaveHandler,
ContentAfterScheduleHandler,
ContentAfterUnpublishHandler,
ContentAfterUnscheduleHandler,
ContentBeforeDeleteHandler,
ContentBeforeSaveHandler,
ContentDeleteEvent,
ContentHookEvent,
ContentPublishStateChangeEvent,
ContentRestoreStateChangeEvent,
ContentScheduleStateChangeEvent,
ContentStateChangeEvent,
CronEvent,
CronHandler,
EmailAfterSendEvent,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -228,6 +235,9 @@ export type {
ContentDeleteEvent,
ContentHookEvent,
ContentPublishStateChangeEvent,
ContentRestoreStateChangeEvent,
ContentScheduleStateChangeEvent,
ContentStateChangeEvent,
CronEvent,
EmailAfterSendEvent,
EmailBeforeSendEvent,
Expand Down
Loading
Loading