Skip to content
Open
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/tidy-ideas-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fires `content:afterPublish` hooks for each item published by the scheduled publish-due cron job, and triggers rebuild hooks once per batch instead of per item.
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default defineConfig({
{ label: "AI Tools", slug: "guides/ai-tools" },
{ label: "x402 Payments", slug: "guides/x402-payments" },
{ label: "Preview Mode", slug: "guides/preview" },
{ label: "Scheduled Publishing", slug: "guides/scheduled-publishing" },
{
label: "Internationalization (i18n)",
slug: "guides/internationalization",
Expand Down
167 changes: 167 additions & 0 deletions docs/src/content/docs/guides/scheduled-publishing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
title: Scheduled Publishing
description: Publish content at a future date and trigger frontend rebuilds automatically.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

EmDash supports scheduling content to go live at a specific date and time. When the scheduled time passes, a cron job calls the publish-due endpoint, EmDash publishes the batch, and your configured rebuild hooks fire so your static frontend picks up the new content.

## How It Works

1. An editor sets a **Publish date** on a content item in the admin UI. The item enters `scheduled` status and is excluded from public queries until its time arrives.
2. A cron job calls `POST /_emdash/api/cron/publish-due` on your chosen schedule (e.g. every 5 minutes).
3. EmDash finds all items whose `scheduled_at` has passed, publishes each one, and fires `content:afterPublish` hooks per item.
4. If any items were published, EmDash fires all configured [`rebuildHooks`](/reference/configuration/#rebuildhooks) URLs once for the batch.
5. Your host rebuilds the frontend and the new content goes live.

## Setup

<Steps>

1. **Set `EMDASH_CRON_SECRET`**

Generate a random secret and add it to your environment. This token authorizes calls to the publish-due endpoint.

```bash
openssl rand -hex 32
```

Add the output as `EMDASH_CRON_SECRET` in your deployment environment (Cloudflare Workers secrets, Vercel environment variables, `.env`, etc.).

<Aside type="caution">
In production, the endpoint returns `503` if this secret is not set — it refuses to run unprotected.
</Aside>

2. **Configure `rebuildHooks`** (optional, for static frontends)

Add your host's deploy hook URLs to `astro.config.mjs`. Each URL receives a POST request after a publish batch.

```js title="astro.config.mjs"
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
rebuildHooks: [
process.env.VERCEL_DEPLOY_HOOK_URL,
// Add more hooks here — Netlify, Cloudflare Pages, etc.
].filter(Boolean),
}),
],
});
```

3. **Set up a cron job**

Call `POST /_emdash/api/cron/publish-due` on a schedule. Every 5 minutes is a reasonable default. How you set this up depends on your deployment target.

<Tabs>
<TabItem label="Vercel Cron">

Add a cron to `vercel.json`:

```json title="vercel.json"
{
"crons": [
{
"path": "/_emdash/api/cron/publish-due",
"schedule": "*/5 * * * *"
}
]
}
```

Vercel Cron automatically sets `Authorization: Bearer <CRON_SECRET>` when you name the env var `CRON_SECRET`. EmDash checks `EMDASH_CRON_SECRET` first, then falls back to `CRON_SECRET`, so either name works.

</TabItem>
<TabItem label="Cloudflare Workers">

Add a cron trigger to `wrangler.jsonc`:

```jsonc title="wrangler.jsonc"
{
"triggers": {
"crons": ["*/5 * * * *"]
}
}
```

Then handle the scheduled event in your Worker entry point. EmDash exposes a `handleScheduled()` helper:

```ts title="src/worker.ts"
import { handleScheduled } from "emdash/astro/scheduled";
import { app } from "./app.js";

export default {
fetch: app.fetch,
async scheduled(event, env, ctx) {
ctx.waitUntil(handleScheduled(env));
},
};
```

<Aside>
`handleScheduled` calls the publish-due handler directly without an HTTP round-trip, so no `EMDASH_CRON_SECRET` is needed for this path.
</Aside>

</TabItem>
<TabItem label="Node.js / Self-hosted">

Use any HTTP scheduler (cron, systemd timer, GitHub Actions, etc.) to POST to the endpoint:

```bash
curl -X POST https://cms.example.com/_emdash/api/cron/publish-due \
-H "Authorization: Bearer $EMDASH_CRON_SECRET"
```

Or add a cron entry on the host machine:

```
*/5 * * * * curl -s -X POST https://cms.example.com/_emdash/api/cron/publish-due -H "Authorization: Bearer YOUR_SECRET"
```

</TabItem>
</Tabs>

</Steps>

## Response Format

The endpoint returns JSON describing what was published:

```json
{
"published": 3,
"byCollection": {
"posts": 2,
"announcements": 1
},
"items": [...]
}
```

If nothing was due, it returns `{ "published": 0, "byCollection": {}, "items": [] }` — no rebuild hooks are fired in that case.

## Getting Deploy Hook URLs

<Tabs>
<TabItem label="Vercel">

In the Vercel dashboard: **Project → Settings → Git → Deploy Hooks** → Create hook. Copy the generated URL and set it as `VERCEL_DEPLOY_HOOK_URL` in your CMS environment, then reference it from `rebuildHooks` in `astro.config.mjs`.

</TabItem>
<TabItem label="Netlify">

In the Netlify dashboard: **Site → Site configuration → Build & deploy → Build hooks** → Add build hook. Copy the URL directly into `rebuildHooks`.

</TabItem>
<TabItem label="Cloudflare Pages">

Use a [Cloudflare Pages Deploy Hook](https://developers.cloudflare.com/pages/configuration/deploy-hooks/) from **Pages project → Settings → Builds & deployments → Deploy Hooks**.

</TabItem>
</Tabs>
20 changes: 20 additions & 0 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,25 @@ When not set in config, EmDash reads the `EMDASH_TRUSTED_PROXY_HEADERS` env var
(stricter defaults) rather than trust a spoofable header.
</Aside>

### `rebuildHooks`

**Optional.** Array of URLs to POST to after any content is published or unpublished, including scheduled publishing. Use this to trigger a static site rebuild on Vercel, Netlify, Cloudflare Pages, or any webhook-aware host.

```js
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
rebuildHooks: [
"https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy",
"https://api.netlify.com/build_hooks/abc123",
],
})
```

Requests are fire-and-forget — they are deferred past the response via `after()` so Cloudflare Workers does not cancel in-flight fetches when the isolate closes. Hook failures are logged with the `[emdash]` prefix but never surface as errors to the caller.

For **scheduled publishing**, set a cron job that calls `POST /_emdash/api/cron/publish-due` (see [Scheduled Publishing](/guides/scheduled-publishing/)). That endpoint uses the same rebuild hook pipeline after publishing the batch.

### `maxUploadSize`

**Optional.** Maximum allowed media file upload size in bytes. Applies to both direct multipart uploads and signed-URL uploads. Defaults to `52_428_800` (50 MB).
Expand Down Expand Up @@ -557,6 +576,7 @@ EmDash respects these environment variables:
| `EMDASH_AUTH_SECRET` | Secret for passkey authentication |
| `EMDASH_PREVIEW_SECRET` | Secret for preview token generation |
| `EMDASH_URL` | Remote EmDash URL for schema sync |
| `EMDASH_CRON_SECRET` | Bearer token that authorizes `POST /_emdash/api/cron/publish-due`. Required in production; dev mode allows localhost-only calls without it. Also checked under `CRON_SECRET` for Vercel Cron compatibility. |

Generate an auth secret with:

Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,81 @@ export async function handleContentTranslations(
}
}

// ---------------------------------------------------------------------------
// Publish-due (scheduled content)
// ---------------------------------------------------------------------------

/**
* Publish all content items whose scheduled_at has passed.
*
* Iterates over every collection, finds items where scheduled_at <= now,
* and publishes each one. Returns a count of published items broken down
* by collection, plus the published items themselves so callers can fire
* content:afterPublish hooks per item.
*/
export async function handleContentPublishDue(db: Kysely<Database>): Promise<
ApiResult<{
published: number;
byCollection: Record<string, number>;
items: Array<{ item: ContentItem; collection: string }>;
}>
> {
try {
const collections = await db.selectFrom("_emdash_collections").select("slug").execute();

const byCollection: Record<string, number> = {};
const items: Array<{ item: ContentItem; collection: string }> = [];
let totalPublished = 0;

const repo = new ContentRepository(db);

for (const { slug } of collections) {
const due = await repo.findReadyToPublish(slug);
if (due.length === 0) continue;

const hasSeo = await collectionHasSeo(db, slug);
let count = 0;

for (const item of due) {
try {
const published = await withTransaction(db, async (trx) => {
const txRepo = new ContentRepository(trx);
return txRepo.publish(slug, item.id);
});

await hydrateSeo(db, slug, published, hasSeo);
items.push({ item: published, collection: slug });
count++;
} catch (itemError) {
console.error(
`[publish-due] Failed to publish item "${item.id}" in collection "${slug}":`,
itemError,
);
}
}

if (count > 0) {
byCollection[slug] = count;
totalPublished += count;
}
}

return {
success: true,
data: { published: totalPublished, byCollection, items },
};
} catch (error) {
console.error("Content publish-due error:", error);
return {
success: false,
error: {
code: "CONTENT_PUBLISH_DUE_ERROR",
message: "Failed to publish due content",
},
};
}
}

// ---------------------------------------------------------------------------
// Non-translatable field sync
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
handleContentDiscardDraft,
handleContentCompare,
handleContentTranslations,
handleContentPublishDue,
type TrashedContentItem,
} from "./content.js";

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
entrypoint: resolveRoute("api/content/[collection]/[id]/schedule.ts"),
});

// Cron routes
injectRoute({
pattern: "/_emdash/api/cron/publish-due",
entrypoint: resolveRoute("api/cron/publish-due.ts"),
});

// Revision management routes (for restore, etc.)
injectRoute({
pattern: "/_emdash/api/revisions/[revisionId]",
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,29 @@ export interface EmDashConfig {
/** URL or path to a custom favicon for the admin panel. */
favicon?: string;
};

/**
* Frontend rebuild hook URLs.
*
* An array of URLs that EmDash will POST to (with no body) after any
* content is published — including both manual publishes and scheduled
* content that goes live automatically. Use this to trigger rebuilds of
* static frontends on services like Vercel, Netlify, or Cloudflare Pages.
*
* Each URL receives a POST request. Failures are logged but never surface
* as errors to the publish caller. Requests are fire-and-forget.
*
* @example
* ```ts
* emdash({
* rebuildHooks: [
* "https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy",
* "https://api.netlify.com/build_hooks/abc123",
* ],
* })
* ```
*/
rebuildHooks?: string[];
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
handleContentPublishDue: runtime.handleContentPublishDue.bind(runtime),
handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
handleContentCompare: runtime.handleContentCompare.bind(runtime),
handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
Expand Down
Loading
Loading