## File: README.md

# EmDash

A full-stack TypeScript CMS built on [Astro](https://astro.build/) and [Cloudflare](https://www.cloudflare.com/). EmDash takes the ideas that made WordPress dominant -- extensibility, admin UX, a plugin ecosystem -- and rebuilds them on serverless, type-safe foundations. Plugins run in sandboxed Worker isolates, solving the fundamental security problem with WordPress's plugin architecture.

## Get Started

> [!IMPORTANT]
> EmDash depends on Dynamic Workers to run secure sandboxed plugins. Dynamic Workers are currently only available on paid accounts. [Upgrade your account](https://www.cloudflare.com/plans/developer-platform/) (starting at $5/mo) or comment out the `worker_loaders` block of your `wrangler.jsonc` configuration file to disable plugins.

```bash
npm create emdash@latest
```

Or deploy directly to your Cloudflare account:

[](https://deploy.workers.cloudflare.com/?url=https://github.com/emdash-cms/templates/tree/main/blog-cloudflare)

EmDash runs on Cloudflare (D1 + R2 + Workers) or any Node.js server with SQLite. No PHP, no separate hosting tier -- just deploy your Astro site.

## Templates

EmDash ships with three starter templates:



| ### Blog

[](assets/templates/blog/latest/)

A classic blog with sidebar widgets, search, and RSS.

- Categories & tags
- Full-text search
- Comment-ready
- RSS feed
- Dark / light mode | ### Marketing

[](assets/templates/marketing/latest/)

A conversion-focused landing page with pricing and contact form.

- Hero with CTAs
- Feature grid
- Pricing cards
- FAQ and contact form
- Dark / light mode | ### Portfolio

[](assets/templates/portfolio/latest/)

A visual portfolio for showcasing creative work.

- Project grid
- Tag filtering
- Case study pages
- RSS feed
- Dark / light mode |
| --- | --- | --- |



## Why EmDash?

**WordPress was built for a different era.** Running WordPress today means managing PHP alongside JavaScript, layering caches to get acceptable performance, and knowing that [96% of WordPress security vulnerabilities come from plugins](https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/). EmDash is what WordPress would look like if you started from scratch with today's tools.

**Sandboxed plugins.** WordPress plugins have full access to the database, filesystem, and user data. A single vulnerable plugin can compromise the entire site. EmDash plugins run in isolated [Worker sandboxes](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) via Dynamic Worker Loaders, each with a declared capability manifest. A plugin that requests `read:content` and `email:send` can do exactly that and nothing else.

```typescript
export default () =>
	definePlugin({
		id: "notify-on-publish",
		capabilities: ["read:content", "email:send"],
		hooks: {
			"content:afterSave": async (event, ctx) => {
				if (event.content.status !== "published") return;
				await ctx.email.send({
					to: "editors@example.com",
					subject: `New post: ${event.content.title}`,
				});
			},
		},
	});
```

**Structured content, not serialized HTML.** WordPress stores rich text as HTML with metadata embedded in comments -- tying your content to its DOM representation. EmDash uses [Portable Text](https://www.portabletext.org/), a structured JSON format that decouples content from presentation. Your content can render as a web page, a mobile app, an email, or an API response without parsing HTML.

**Built for agents.** EmDash ships with agent skills for building plugins and themes, a CLI that lets agents manage content and schema programmatically, and a built-in [MCP server](https://modelcontextprotocol.io/) so AI tools like Claude and ChatGPT can interact with your site directly.

**Runs anywhere.** EmDash uses portable abstractions at every layer -- Kysely for SQL, S3 API for storage -- that work with SQLite, D1, Turso, PostgreSQL, R2, AWS S3, or local files. It runs best on Cloudflare, but it's not locked to it.

## How It Works

EmDash is an Astro integration. Add it to your config and you get a complete CMS: admin panel, REST API, authentication, media library, and plugin system.

```typescript
// astro.config.mjs
import emdash from "emdash/astro";
import { d1 } from "emdash/db";

export default defineConfig({
	integrations: [emdash({ database: d1() })],
});
```

Content types are defined in the database, not in code. Non-developers create and modify collections through the admin UI. Each collection gets a real SQL table with typed columns. Developers generate TypeScript types from the live schema:

```bash
npx emdash types
```

Query content using Astro's Live Collections -- no rebuilds, no separate API:

```astro
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---

{posts.map((post) => <article>{post.data.title}</article>)}
```

## Features

**Content** -- Blog posts, pages, custom content types. Rich text editing via TipTap with Portable Text storage. Revisions, drafts, scheduled publishing, full-text search (FTS5), inline visual editing.

**Admin** -- Full admin panel with visual schema builder, media library (drag-drop uploads via signed URLs), navigation menus, taxonomies, widgets, and a WordPress import wizard.

**Auth** -- Passkey-first (WebAuthn) with OAuth and magic link fallbacks. Role-based access control: Administrator, Editor, Author, Contributor.

**Plugins** -- `definePlugin()` API with lifecycle hooks, KV storage, settings, admin pages, dashboard widgets, custom block types, and API routes. Sandboxed execution on Cloudflare via Dynamic Worker Loaders.

**Agents** -- Skill files for AI-assisted plugin and theme development. CLI for programmatic site management. Built-in MCP server for direct AI tool integration.

**WordPress migration** -- Import posts, pages, media, and taxonomies from WXR exports, the WordPress REST API, or WordPress.com. Agent skills help port plugins and themes.

## Portable Platforms

| Layer    | Cloudflare                  | Also works with                                     |
| -------- | --------------------------- | --------------------------------------------------- |
| Database | D1                          | SQLite, Turso/libSQL, PostgreSQL                    |
| Storage  | R2                          | AWS S3, any S3-compatible service, local filesystem |
| Sessions | KV                          | Redis, file-based                                   |
| Plugins  | Worker isolates (sandboxed) | In-process (safe mode)                              |

## Status

EmDash is in **beta preview**. We welcome contributions, feedback, plugins, themes, and ideas.

```bash
npm create emdash@latest
```

See the [documentation](https://docs.emdashcms.com/) for guides, API reference, and plugin development.

## Development

This is a pnpm monorepo. To contribute:

```bash
git clone https://github.com/emdash-cms/emdash.git && cd emdash
pnpm install
pnpm build
```

Run the demo (Node.js + SQLite, no Cloudflare account needed):

```bash
pnpm --filter emdash-demo seed
pnpm --filter emdash-demo dev
```

Open the admin at [http://localhost:4321/\_emdash/admin](http://localhost:4321/_emdash/admin).

```bash
pnpm test          # run all tests
pnpm typecheck     # type check
pnpm lint:quick    # fast lint (< 1s)
pnpm format        # format with oxfmt
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.

## Repository Structure

```
packages/
  core/           Astro integration, APIs, admin UI, CLI
  auth/           Authentication library
  blocks/         Portable Text block definitions
  cloudflare/     Cloudflare adapter (D1, R2, Worker Loader)
  plugins/        First-party plugins (forms, embeds, SEO, audit-log, etc.)
  create-emdash/  npm create emdash scaffolding
  gutenberg-to-portable-text/  WordPress block converter

templates/        Starter templates (blog, marketing, portfolio, starter, blank)
demos/            Development and example sites
docs/             Documentation site (Starlight)
```

---

## File: demos/plugins-demo/README.md

# EmDash Plugins Demo

This demo showcases EmDash's plugin system with plugins that demonstrate the hook architecture.

## Plugins Included

### 1. Audit Log Plugin (`@emdash-cms/plugin-audit-log`)

Tracks all content changes for compliance.

- **Hooks:**
  - `content:beforeSave` (priority 1) - captures "before" state
  - `content:afterSave` (priority 200) - logs final state
  - `content:beforeDelete` (priority 200) - logs deletions
  - `media:afterUpload` (priority 200) - logs uploads
- **Features:**
  - Create/update/delete tracking
  - Before/after state comparison
  - Admin history page

### 2. Webhook Notifier Plugin (`@emdash-cms/plugin-webhook-notifier`)

Posts JSON payloads to external webhook URLs on content/media events.

- **Hooks:** `content:afterSave`, `content:afterDelete`, `media:afterUpload` (priority 210)
- **Features:**
  - Retry with exponential backoff
  - Admin-configurable settings (URL, secret token)
  - SSRF protection
  - Delivery tracking

### 3. Embeds Plugin (`@emdash-cms/plugin-embeds`)

Provides Portable Text block types for embedding external content.

- **Features:**
  - YouTube, Vimeo, Twitter/X, Bluesky, Mastodon embeds
  - Link previews (Open Graph)
  - GitHub Gist embeds

### 4. API Test Plugin (`@emdash-cms/plugin-api-test`)

Exercises all v2 plugin APIs for testing.

- **Features:**
  - Routes for each plugin API (kv, storage, content, media, http)
  - Combined `test/all` route

## Running the Demo

```bash
# Install dependencies
pnpm install

# Seed sample content
pnpm seed

# Start development server
pnpm dev

# Open browser
open http://localhost:4321
```

## Testing Plugin Hooks

1. Open the admin at `http://localhost:4321/_emdash/admin`
2. Create a new post with a title like "Hello World! Testing Plugins"
3. Watch the console output to see hooks executing:
   - `[audit-log] + create content/posts/post-xxx`

---

## File: demos/cloudflare/README.md

# EmDash Cloudflare Demo

This demo shows EmDash running on Cloudflare Workers with D1 database.

Uses Astro 7 + `@astrojs/cloudflare` v14 which runs the real `workerd` runtime in development.

## Setup

1. Start the dev server:

```bash
pnpm dev
```

EmDash runs migrations automatically on first request — no manual migration or DB-create step needed. Wrangler provisions the D1 database on first deploy.

2. Open http://localhost:4321/\_emdash/admin

## Edge HTML cache (Workers Caching)

This demo uses **native Workers Caching**, not the legacy EmDash `cloudflareCache()` helper:

| Piece                | Where                                                              |
| -------------------- | ------------------------------------------------------------------ |
| Platform cache on    | `wrangler.jsonc` → `"cache": { "enabled": true }`                  |
| Astro cache provider | `cacheCloudflare()` from `@astrojs/cloudflare/cache`               |
| Public page TTLs     | `routeRules` in `astro.config.mjs`                                 |
| Purge                | `cache.purge()` from `cloudflare:workers` (no zone ID / API token) |

Do **not** copy `cloudflareCache()` from `@emdash-cms/cloudflare` for new sites. That path stores responses in the Cache API and invalidates via the zone REST purge API (`CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`). It is a legacy stopgap; see [Deploy to Cloudflare → Workers Cache](https://docs.emdashcms.com/deployment/cloudflare#workers-cache).

Object/query caching (`objectCache: kvCache({ binding: "CACHE" })`) is a separate layer and is optional.

## Preview

After building, you can preview with the real Workers runtime:

```bash
pnpm build
pnpm preview
```

## Deployment

```bash
pnpm deploy
```

This builds and deploys to Cloudflare Workers. EmDash handles migrations automatically on startup.

## Notes

- `astro dev` uses `workerd` (the real Workers runtime) — development matches production
- `wrangler types` runs automatically before dev/build to generate TypeScript types for bindings
- No `platformProxy` config needed — Astro handles this automatically

---

## File: docs/src/content/docs/deployment/cloudflare.mdx

---
title: Deploy to Cloudflare
---

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

Cloudflare Workers provides a fast, globally distributed runtime for EmDash. This guide covers deploying with D1 for the database and R2 for media storage.

## Prerequisites

- A Cloudflare account
- Wrangler CLI installed (`npm install wrangler`)
- Authenticated with Cloudflare (`wrangler login`)

## Configure Bindings

Provision the production D1 database and R2 bucket, then create `wrangler.jsonc` in your project root with bindings for their immutable IDs and names. Database provisioning is separate from applying EmDash's schema migrations.

```jsonc title="wrangler.jsonc"
{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "my-emdash-site",
	"compatibility_date": "2025-01-15",
	"compatibility_flags": ["nodejs_compat"],

	"d1_databases": [
		{
			"binding": "DB",
			"database_name": "emdash-db",
			"database_id": "00000000-0000-0000-0000-000000000000",
		},
	],

	"r2_buckets": [
		{
			"binding": "MEDIA",
			"bucket_name": "emdash-media",
		},
	],
}
```

These are the bindings you configure yourself. The `@astrojs/cloudflare` adapter adds more of its own when it generates the deployed Worker config. One of them is the `IMAGES` binding that media transforms use — see [Image Transformation](#image-transformation).

## Configure EmDash

The following Astro configuration uses the D1 and R2 bindings.

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import emdash from "emdash/astro";
import { d1, r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
	output: "server",
	adapter: cloudflare(),
	integrations: [
		react(), // Required — the admin UI is a React app
		emdash({
			database: d1({ binding: "DB" }),
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});
```

## Migrate and Deploy

Runtime migrations remain automatic by default. For deployment-managed migrations, build the Worker and inspect the provisioned D1 target using its account and database UUID.

```bash
pnpm build
pnpm exec emdash migrate --status --json \
  --account-id "$CLOUDFLARE_ACCOUNT_ID" \
  --d1 "$D1_DATABASE_ID"
```

After reviewing and recording the reported target fingerprint, apply the migrations and deploy the same build.

```bash
pnpm exec emdash migrate \
  --account-id "$CLOUDFLARE_ACCOUNT_ID" \
  --d1 "$D1_DATABASE_ID" \
  --expected-target-fingerprint "$EMDASH_TARGET_FINGERPRINT"
pnpm exec wrangler deploy
```

The migration job requires `CLOUDFLARE_API_TOKEN` with D1 Edit permission. Serialize jobs by account and database UUID. See [Manage Core Database Migrations](/deployment/core-migrations/) for provisioning, CI concurrency, runtime modes, and recovery guidance.

If the database is empty (no collections) and the setup wizard hasn't been completed, EmDash also applies a seed file on first boot. The seed is read at build time from `.emdash/seed.json`, the path in `package.json#emdash.seed`, or `seed/seed.json` — whichever is found first — and inlined into the bundle. If none is present, a built-in default seed is used. Subsequent deploys against an existing database leave its content alone.

To change the schema or content model of a site that is already deployed, see [Evolving a Deployed Site](/deployment/schema-evolution/).

## Scheduled Publishing

On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from Worker Cron Triggers. New Cloudflare templates include both required schedules automatically. When updating an existing project, configure distinct general and Media Usage lanes:

```ts title="src/worker.ts"
import handler, {
	createScheduledHandler,
	PluginBridge,
} from "@emdash-cms/cloudflare/worker";

export { PluginBridge };

export default {
	...handler,
	scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
```

By default, `*/2 * * * *` runs Media Usage maintenance and every other expression runs general maintenance. Then add both Cron Triggers to `wrangler.jsonc`:

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

To use different schedules, set the corresponding `generalCron` or `mediaUsageCron` option in `createScheduledHandler()` and use the same expression in `wrangler.jsonc`.

<Aside type="caution">
	Without the general trigger, scheduled publishing and plugin cron do not run. Without the dedicated Media Usage trigger, automatic historical reconciliation cannot progress. Local `astro dev` still uses the in-process scheduler.
</Aside>

## Deploy

Deploy to Cloudflare Workers:

```bash
wrangler deploy
```

Your site is now live at `https://my-emdash-site.<your-subdomain>.workers.dev`.

## Read Replicas

For globally distributed sites, enable D1 read replication to route read queries to nearby replicas instead of always hitting the primary database. This significantly reduces latency for visitors far from the primary region.

```js title="astro.config.mjs"
emdash({
	database: d1({
		binding: "DB",
		session: "auto",
	}),
	storage: r2({ binding: "MEDIA" }),
}),
```

You also need to enable read replication on the D1 database itself in the Cloudflare dashboard or via the REST API.

<Aside type="caution">
	Read replica sessions are incompatible with the
	[`global_fetch_strictly_public`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)
	compatibility flag. With that flag in your `wrangler.jsonc`, the internal request the D1 Sessions
	API makes to route queries to replicas is silently blocked, and every SSR request hangs until the
	Worker is killed — with nothing in the logs (`outcome: "canceled"`, no exceptions). The hang may
	only begin once replicas finish provisioning, so it can pass an initial post-deploy check and
	surface hours later. If your Worker needs that flag (for example, to fetch its own origin), keep
	`session` disabled. See [#1273](https://github.com/emdash-cms/emdash/issues/1273).
</Aside>

See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.

## Object Cache

To reduce read load on D1, cache content and configuration query results in Cloudflare KV. Reads are served from KV instead of querying the database on every request:

```js title="astro.config.mjs"
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";

emdash({
	database: d1({ binding: "DB" }),
	storage: r2({ binding: "MEDIA" }),
	objectCache: kvCache({ binding: "CACHE" }),
}),
```

See [Object Cache](/deployment/object-cache/) for KV setup, options, and invalidation behavior.

## Workers Cache

Cloudflare's [Workers Cache](https://developers.cloudflare.com/workers/cache/) puts an edge cache **in front of** your Worker: matching requests are served without running your Worker at all.

### Enable it

1. Turn on the platform cache in `wrangler.jsonc`:

```jsonc title="wrangler.jsonc"
{
	"cache": {
		"enabled": true,
	},
}
```

2. Use Astro's Cloudflare cache provider so route rules / `Astro.cache` set the right headers and invalidation uses native `cache.purge()`:

```js title="astro.config.mjs"
import { cacheCloudflare } from "@astrojs/cloudflare/cache";

export default defineConfig({
	adapter: cloudflare(),
	cache: {
		provider: cacheCloudflare(),
	},
	routeRules: {
		"/": { maxAge: 300, swr: 86400 },
		// …
	},
});
```

With `cacheCloudflare()`, the `@astrojs/cloudflare` adapter also injects `"cache": { "enabled": true }` into the generated Wrangler config when it is missing — listing it explicitly in your own `wrangler.jsonc` keeps the intent obvious.

3. Purge from the Worker with the platform API (no Cloudflare REST credentials):

```ts
import { cache } from "cloudflare:workers";

await cache.purge({ purgeEverything: true });
// or: await cache.purge({ tags: ["posts"] });
```

EmDash admin and API responses already send `Cache-Control: private, no-store` and are never stored. Public pages control their own caching through `Cache-Control` / `routeRules` / `Astro.cache`.

Two things to know before enabling it:

1. **Responses without a `Cache-Control` header are still cached.** Workers Cache applies RFC 9111 heuristic freshness — a `200` without any header is cached for 2 hours. Give every custom route an explicit `Cache-Control` (use `private, no-store` for anything session-dependent).
2. **Cached pages are shared with logged-in editors.** The cache runs before your Worker, so it cannot bypass based on request cookies. A logged-in editor may receive the cached anonymous variant of a public page — without the visual editing toolbar — until the entry expires. Editor-rendered responses themselves are never stored (they carry `private, no-store`), so nothing leaks in the other direction.

### Not the same as `cloudflareCache()` from `@emdash-cms/cloudflare`

| | **Preferred: Workers Caching** | **Legacy: `cloudflareCache()`** |
| --- | --- | --- |
| Config | `"cache": { "enabled": true }` + `cacheCloudflare()` from `@astrojs/cloudflare/cache` | `cache: { provider: cloudflareCache() }` from `@emdash-cms/cloudflare` |
| Storage | Platform Workers Caching | Cache API (`caches.open` / `put` / `match`) |
| Purge | `cache.purge()` from `cloudflare:workers` | Zone REST `POST /zones/{id}/purge_cache` |
| Secrets | None for purge | `CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN` |

Use the preferred path for new sites. Keep `cloudflareCache()` only if you already depend on its Cache API behavior.

Also do not confuse either of those with **object cache** (`objectCache: kvCache({ binding: "CACHE" })`), which caches database query results in KV — a separate layer under the Worker.

## Custom Domain

Add a custom domain in the Cloudflare dashboard:

1. Go to **Workers & Pages** > your worker
2. Click **Custom Domains** > **Add Custom Domain**
3. Enter your domain and follow the DNS setup instructions

## Public R2 Access

To serve media directly from R2 (recommended for performance):

1. In the Cloudflare dashboard, go to **R2** > your bucket
2. Click **Settings** > **Public access**
3. Enable public access and note the public URL
4. Update your storage config:

```js title="astro.config.mjs"
storage: r2({
  binding: "MEDIA",
  publicUrl: "https://pub-xxx.r2.dev"
}),
```

<Aside type="tip">
	For production, connect a custom domain to your R2 bucket for better URLs and caching control.
</Aside>

## Image Transformation

EmDash resizes and re-encodes R2 media inside the Worker, through Cloudflare's `IMAGES` binding. The `Image` component from `emdash/ui` and images in rich text both render through the image endpoint EmDash installs under the Cloudflare adapter. For media on the internal `/_emdash/api/media/file/…` route, that endpoint reads the source bytes straight from the R2 binding, without an HTTP fetch. Those transforms keep working behind Cloudflare Access and with `global_fetch_strictly_public`. Media served from a bucket URL — see [Public R2 Access](#public-r2-access) — takes the adapter's own transform endpoint instead, which fetches the file over HTTP before transforming it.

You do not have to declare the binding. `@astrojs/cloudflare` adds it to the Worker config it generates during `astro build`, the same way it adds `cache` for Workers Caching. It does so whenever the runtime image service is `cloudflare-binding`: `imageService` unset, the string itself, or `{ runtime: "cloudflare-binding" }`. Every other value — `"passthrough"`, `"compile"`, `"cloudflare"`, `"custom"` — leaves the binding out. Listing it in your own `wrangler.jsonc` keeps the intent obvious:

```jsonc title="wrangler.jsonc"
{
	"images": {
		"binding": "IMAGES",
	},
}
```

To see what a deploy actually gets, read the generated config rather than `wrangler.jsonc`. A build writes `.wrangler/deploy/config.json`, which points `wrangler deploy` at the merged file (`dist/server/wrangler.json` by default). Look for an `images` entry there.

<Aside type="caution">
	What a missing binding looks like depends on the media URL. On the internal route the endpoint
	serves the original file unchanged: pages still render and nothing is logged, but every image is
	the full-size original rather than a variant scaled to the space it occupies. From a bucket URL
	the request fails with a 500 instead, because the adapter's transform endpoint calls the binding
	without a fallback. `imageService: "passthrough"` is the deliberate way out — EmDash then leaves
	the adapter's passthrough endpoint in place rather than wrapping it.
</Aside>

Cloudflare bills these transforms as [Images transformations](https://developers.cloudflare.com/images/pricing/#images-transformed). Each unique combination of source image and parameters is billed once per calendar month, and repeat requests within that month are free. The Images Free plan covers 5,000 unique transformations per month. Past that limit, cached transformations are still served, but new ones return a `9422` error and the image request fails.

## Cloudflare Access Authentication

If your organization uses Cloudflare Access, you can use it as the authentication provider instead of passkeys, giving single sign-on through your existing identity provider. The following configuration enables it:

```js title="astro.config.mjs"
emdash({
  database: d1({ binding: "DB" }),
  storage: r2({ binding: "MEDIA" }),
  auth: access({
    teamDomain: "myteam.cloudflareaccess.com",
    audience: "your-app-audience-tag",
    roleMapping: {
      "Admins": 50,
      "Editors": 40,
    },
  }),
}),
```

See the [Authentication guide](/guides/authentication#cloudflare-access) for full configuration options.

## Cloudflare AI Search

The AI Search plugin indexes published EmDash content and adds smart search interface to your site.

<Steps>
1. Register the plugin in the `plugins` array passed to EmDash:

	```js title="astro.config.mjs"
	import { aiSearch } from "@emdash-cms/cloudflare/plugins";

	// ...
	plugins: [
		formsPlugin(),
		aiSearch(),
	],
	```

2. Add the AI Search namespace binding to your Worker configuration:

	```jsonc title="wrangler.jsonc"
	{
		"ai_search_namespaces": [
			{
				"binding": "AI_SEARCH",
				"namespace": "default",
			},
		],
	}
	```

3. Create the search endpoint used by the search interface:

	```ts title="src/pages/api/ai-search/search.ts"
	export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search";
	```

4. Add the search interface to your site layout. The trigger slot can contain any button that fits your site's design:

	```astro title="src/layouts/Base.astro"
	---
	import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro";
	---

	<AISearchSnippet apiUrl="/api/ai-search" placeholder="Search...">
		Search
	</AISearchSnippet>
	```

5. Deploy the site:

	```bash
	pnpm exec wrangler deploy
	```

6. Open **Cloudflare AI Search** in the EmDash admin panel, select the collections to index, and click **Sync All Content**.

	This initial sync is required: the plugin's content hooks only fire for content created or updated after it was enabled, so anything published beforehand stays missing from the index until you run a full sync.
</Steps>

Content published or updated after setup is kept in sync automatically. The same page shows indexing progress.

## Email

On Workers, the only built-in `email:deliver` handler is a dev console stub, so
email-dependent flows — magic-link login, team invites, and comment
notifications — fail with **"Email is not configured"** in production. The
`cloudflareEmail()` plugin delivers real email through
[Cloudflare Email Sending](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)
using a native `send_email` Worker binding, with no external API keys.

### 1. Onboard a sender domain

In the Cloudflare dashboard, go to **Email** and verify the domain (or address)
you send from. Email Sending rejects messages from unverified senders.

### 2. Add the binding

Declare a `send_email` binding in `wrangler.jsonc`:

```jsonc title="wrangler.jsonc"
{
	"send_email": [{ "name": "EMAIL" }],
}
```

### 3. Register the provider

Add the plugin to your `emdash()` integration:

```js title="astro.config.mjs"
import { d1, r2 } from "@emdash-cms/cloudflare";
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";

emdash({
	database: d1({ binding: "DB" }),
	storage: r2({ binding: "MEDIA" }),
	plugins: [
		cloudflareEmail({
			from: { email: "cms@mails.example.com", name: "My Site CMS" },
			replyTo: "hello@example.com", // optional
			binding: "EMAIL", // optional, defaults to "EMAIL"
		}),
	],
}),
```

### 4. Activate and select it

Deploy, then activate the plugin under **Admin → Extensions** and choose it as
the provider under **Settings → Email**.

#### Options

| Option    | Type                         | Default      | Description                                                            |
| --------- | ---------------------------- | ------------ | --------------------------------------------------------------------- |
| `from`    | `string \| { email, name? }` | — (required) | Sender address on a domain onboarded for Email Sending.               |
| `replyTo` | `string`                     | —            | Optional Reply-To, useful when `from` is a no-reply subdomain address. |
| `binding` | `string`                     | `"EMAIL"`    | Name of the `send_email` binding in `wrangler.jsonc`.                 |

<Aside type="note">
	BCC and attachments are out of scope (not modeled in EmDash's `EmailMessage`).
	After you activate the plugin under **Admin → Extensions**, EmDash selects it
	automatically if it is the only active email provider; with more than one,
	choose the one you want under **Settings → Email**. Under `astro dev` the
	built-in console provider counts as a second provider, so in dev you always
	pick there.
</Aside>

## Environment Variables

### Recommended: encryption key

`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
rest (webhook tokens, Turnstile keys, etc.). The key is validated on
startup; plugin secret encryption uses it once enabled. Set it on
every deployment so secrets are protected without a later config
change.

The key is provided by you and never stored in the database; only
encrypted ciphertext is. Losing it means losing every secret encrypted
with it.

Generate a key and store it as a Worker secret with the following commands:

```bash
npx emdash secrets generate
wrangler secret put EMDASH_ENCRYPTION_KEY
```

<Aside type="caution">
Never commit the encryption key to your repository, and back it up
somewhere durable (a password manager, KMS, or your team's secret
store). The plain text value lives only in your environment.
</Aside>

### Optional: stable-value overrides

EmDash auto-generates the preview HMAC secret and commenter-IP hash
salt and persists them in the database on first use. The env vars
below are overrides for cases where you need to pin the value
yourself — for example, when a preview Worker in a separate process
needs to share the secret with your main site.

| Variable | Purpose |
| -------- | ------- |
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
| `EMDASH_AUTH_SECRET` | Optional. If set, it is used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |

Access environment variables in your configuration using `import.meta.env` or the Cloudflare `env` binding.

For the complete inventory of every secret EmDash uses — including storage locations, rotation steps, and what breaks when a key is lost — see [Secrets & Key Management](/deployment/secrets/).

## Preview Deployments

Deploy a preview branch:

```bash
wrangler deploy --env preview
```

Add an environment section to `wrangler.jsonc`:

```jsonc
{
	"env": {
		"preview": {
			"d1_databases": [
				{
					"binding": "DB",
					"database_name": "emdash-db-preview",
				},
			],
		},
	},
}
```

## Troubleshooting

### "D1 binding not found"

Verify the binding name in `wrangler.jsonc` matches your database configuration:

```js
// Must match: d1({ binding: "DB" })
"binding": "DB"
```

### "R2 binding not found"

Check that the R2 bucket is correctly bound:

```js
// Must match: r2({ binding: "MEDIA" })
"binding": "MEDIA"
```

### Migration errors

If you see schema errors, tail the Worker logs (`wrangler tail`) and reproduce the error to capture the underlying message — then file an issue with that output.

---

## File: docs/src/content/docs/deployment/database.mdx

---
title: Database Options
---

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

EmDash supports multiple database backends. Choose based on your deployment target.

## Overview

| Database       | Best For                       | Deployment                 |
| -------------- | ------------------------------ | -------------------------- |
| **D1**         | Cloudflare Workers             | Edge, globally distributed |
| **Hyperdrive** | PostgreSQL on Cloudflare Workers | Edge, existing Postgres    |
| **PostgreSQL** | Production Node.js             | Any platform with Postgres |
| **libSQL**     | Remote databases               | Edge or Node.js            |
| **SQLite**     | Node.js, local dev             | Single server              |

## Cloudflare D1

D1 is Cloudflare's serverless SQLite database. Use it when deploying to Cloudflare Workers.

```js title="astro.config.mjs"
import { d1 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: d1({ binding: "DB" }),
		}),
	],
});
```

### Configuration

| Option           | Type     | Default              | Description                           |
| ---------------- | -------- | -------------------- | ------------------------------------- |
| `binding`        | `string` | —                    | D1 binding name from `wrangler.jsonc` |
| `session`        | `string` | `"disabled"`         | Read replication mode (see below)     |
| `bookmarkCookie` | `string` | `"__em_d1_bookmark"` | Cookie name for session bookmarks     |

### Setup

<Tabs>
  <TabItem label="wrangler.jsonc">
    ```jsonc
    {
      "d1_databases": [
        {
          "binding": "DB",
          "database_name": "emdash-db"
        }
      ]
    }
    ```
  </TabItem>
  <TabItem label="wrangler.toml">
    ```toml
    [[d1_databases]]
    binding = "DB"
    database_name = "emdash-db"
    ```
  </TabItem>
</Tabs>

<Aside>
	EmDash runs core migrations automatically by default on the first request after deployment, or a
	deployment can apply them from the build manifest before traffic. You must provision the D1
	database separately; see [Manage Core Database Migrations](/deployment/core-migrations/).
</Aside>

### Read Replicas

D1 supports [read replication](https://developers.cloudflare.com/d1/configuration/read-replication/) to lower read latency for globally distributed sites. When enabled, read queries are routed to nearby replicas instead of always hitting the primary database.

EmDash uses the D1 Sessions API to manage this transparently. Enable it with the `session` option:

```js title="astro.config.mjs"
import { d1 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: d1({
				binding: "DB",
				session: "auto",
			}),
		}),
	],
});
```

#### Session Modes

| Mode             | Behavior                                                                                           |
| ---------------- | -------------------------------------------------------------------------------------------------- |
| `"disabled"`     | No sessions. All queries go to primary. Default.                                                   |
| `"auto"`         | Anonymous requests read from the nearest replica. Authenticated users get read-your-writes consistency via bookmark cookies. |
| `"primary-first"`| Like `"auto"`, but the first query always goes to the primary. Use for sites with very frequent writes. |

#### How It Works

- **Anonymous visitors** get `first-unconstrained` — reads go to the nearest replica for the lowest latency. Since anonymous users never write, they don't need consistency guarantees.
- **Authenticated users** (editors, authors) get bookmark-based sessions. After a write, a bookmark cookie ensures the next request sees at least that state.
- **Write requests** (`POST`, `PUT`, `DELETE`) always start at the primary database.
- **Build-time queries** (Astro content collections) bypass sessions entirely and use the primary directly.

<Aside type="tip">
	Read replication must also be enabled on the D1 database itself via the Cloudflare dashboard or
	REST API. The `session` option only controls EmDash's client-side session management.
</Aside>

<Aside type="caution">
	Sessions are incompatible with the
	[`global_fetch_strictly_public`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)
	compatibility flag: the internal request the D1 Sessions API makes to route queries to replicas
	is silently blocked, and every SSR request hangs with nothing in the logs. The hang may only
	begin once replicas finish provisioning, so it can pass an initial post-deploy check. If your
	Worker needs that flag, keep `session` disabled. See
	[#1273](https://github.com/emdash-cms/emdash/issues/1273).
</Aside>

<Aside>
	Anonymous visitors may see data that is a few seconds stale. For a CMS this is expected — content
	changes are not real-time, and if you have route caching enabled the response is already stale by
	definition.
</Aside>

## libSQL

libSQL is a fork of SQLite that supports remote connections. Use it when you need a remote database without Cloudflare D1.

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

export default defineConfig({
	integrations: [
		emdash({
			database: libsql({
				url: process.env.LIBSQL_DATABASE_URL,
				authToken: process.env.LIBSQL_AUTH_TOKEN,
			}),
		}),
	],
});
```

### Configuration

| Option                  | Type     | Description                                                        |
| ----------------------- | -------- | ------------------------------------------------------------------ |
| `url`                   | `string` | Database URL (`libsql://...` or `file:...`)                        |
| `authToken`             | `string` | Runtime auth token for remote databases (optional for local)       |
| `migrationAuthTokenEnv` | `string` | Migration token variable name (default `TURSO_AUTH_TOKEN`)          |

### Local Development

Use a local libSQL file during development:

```js
database: libsql({ url: "file:./data.db" });
```

## PostgreSQL

PostgreSQL is supported for Node.js deployments that need a full relational database.

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

export default defineConfig({
	integrations: [
		emdash({
			database: postgres({
				connectionString: process.env.DATABASE_URL,
			}),
		}),
	],
});
```

### Configuration

You can connect with a connection string or individual parameters:

```js
// Connection string
database: postgres({
	connectionString: "postgres://user:password@localhost:5432/emdash",
});

// Individual parameters
database: postgres({
	host: "localhost",
	port: 5432,
	database: "emdash",
	user: "emdash",
	password: process.env.DB_PASSWORD,
	ssl: true,
});
```

| Option             | Type      | Description                          |
| ------------------ | --------- | ------------------------------------ |
| `connectionString` | `string`  | PostgreSQL connection URL            |
| `host`             | `string`  | Database host                        |
| `port`             | `number`  | Database port                        |
| `database`         | `string`  | Database name                        |
| `user`             | `string`  | Database user                        |
| `password`         | `string`  | Database password                    |
| `ssl`              | `boolean` | Enable SSL                           |
| `pool.min`         | `number`  | Minimum pool connections (default 0) |
| `pool.max`         | `number`  | Maximum pool connections (default 10)|
| `migrationConnectionStringEnv` | `string` | Migration connection-string variable name (default `DATABASE_URL`) |

### Database role requirements

EmDash creates and updates its own PostgreSQL tables. Core migrations create and alter system and collection tables, content types create `ec_*` tables, and adding or removing a field alters its collection table. The configured PostgreSQL role therefore needs schema authority for the lifetime of the site, not only during initial setup.

Use one canonical role for EmDash. It needs:

- `CONNECT` on the database;
- `USAGE` and `CREATE` on the active schema;
- ownership of every EmDash table and function, either directly or through membership with `INHERIT` in the owning role; and
- `SELECT`, `INSERT`, `UPDATE`, and `DELETE` on those tables.

It does not need to be a superuser, have `CREATEDB` or `CREATEROLE`, or create extensions. PostgreSQL does not provide an `ALTER` or `DROP` table grant: those operations belong to the object owner and roles that inherit its privileges. Granting `ALL` on a table to a different role does not make that role an owner. EmDash does not run `SET ROLE`, so membership configured without inheritance is not sufficient.

<Aside type="caution" title="Keep the role stable">
	PostgreSQL assigns each object to the role that creates it. Use a non-expiring role for the primary EmDash connection: remove any TTL or automatic expiration that would delete the role and replace it with another. Rotating the password for the same role is safe after updating the connection credentials, but changing the user in `DATABASE_URL` or a Hyperdrive configuration does not transfer existing objects. A replacement role may be able to read and write rows but still fail on the next migration or content-type change with `must be owner of table`.
</Aside>

Most installations can use the database's existing schema, commonly `public`. This is the simplest option when the database is dedicated to EmDash. In the examples below, `emdash_app` is the login role in EmDash's connection string; use an existing provider role or create a dedicated login. Grant it access with an administrative connection, substituting your database, schema, and role names:

```sql
GRANT CONNECT ON DATABASE app TO emdash_app;
GRANT USAGE, CREATE ON SCHEMA public TO emdash_app;
```

These grants let the role create new objects. They do not change the owner of existing tables; see [Repair mixed PostgreSQL ownership](#repair-mixed-postgresql-ownership).

EmDash uses PostgreSQL's active `current_schema()`. It does not create a schema or set `search_path`, so verify the connection before deployment:

```sql
SELECT
  current_database(),
  session_user,
  current_user,
  current_schema(),
  current_setting('search_path');
```

#### Optional: use a dedicated schema

Use a dedicated schema when EmDash shares a database with another application or when you want its objects isolated from `public`. This is optional and is easiest to configure before the first EmDash setup. A database dedicated to EmDash does not need a separate schema.

Assuming the canonical `emdash_app` role already exists, create and select its schema with an administrative connection:

```sql
GRANT CONNECT ON DATABASE app TO emdash_app;
CREATE SCHEMA emdash AUTHORIZATION emdash_app;
ALTER ROLE emdash_app IN DATABASE app SET search_path = emdash;
```

This does not move an existing installation from `public` or repair mixed ownership. Existing sites should keep their current schema and follow [Repair mixed PostgreSQL ownership](#repair-mixed-postgresql-ownership) instead.

### Connection Pooling

The adapter uses `pg.Pool` under the hood. Tune pool size based on your deployment:

```js
database: postgres({
	connectionString: process.env.DATABASE_URL,
	pool: { min: 2, max: 20 },
});
```

<Aside>
	PostgreSQL requires the `pg` package. Install it as a dependency:
	```bash
	pnpm add pg
	```
</Aside>

## Hyperdrive

Use the `hyperdrive()` adapter to run EmDash on **Cloudflare Workers** backed by an existing PostgreSQL — or Postgres-compatible (e.g. PlanetScale Postgres) — database. [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) pools and accelerates the connection over Cloudflare's network; EmDash's PostgreSQL dialect runs the queries.

```js title="astro.config.mjs"
import { hyperdrive, r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: hyperdrive({ binding: "HYPERDRIVE" }),
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});
```

### Requirements

- `pg >= 8.16.3` installed in your site (`pnpm add pg`)
- `compatibility_flags: ["nodejs_compat"]`
- `compatibility_date >= "2024-09-23"`

### Setup

First [prepare the PostgreSQL role](#database-role-requirements). Then create the Hyperdrive configuration with that role's connection string and add the binding to your Wrangler config:

```sh
wrangler hyperdrive create emdash-db \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full" \
  --caching-disabled
```

<Tabs>
  <TabItem label="wrangler.jsonc">
    ```jsonc
    {
      "hyperdrive": [
        {
          "binding": "HYPERDRIVE",
          "id": "<your-hyperdrive-id>"
        }
      ]
    }
    ```
  </TabItem>
  <TabItem label="wrangler.toml">
    ```toml
    [[hyperdrive]]
    binding = "HYPERDRIVE"
    id = "<your-hyperdrive-id>"
    ```
  </TabItem>
</Tabs>

<Aside type="caution" title="Disable query caching on the primary binding">
	Hyperdrive's query cache is **on by default and must be turned off** for the primary EmDash binding. EmDash runs its own caching layer and relies on read-after-write consistency — the admin and setup wizard write a row and immediately read it back. With caching on, Hyperdrive can serve the pre-write result within its TTL, which corrupts setup (e.g. "collection already exists", then half-created tables) and shows editors stale content. Disable it:

    ```sh
    wrangler hyperdrive update  --caching-disabled
    # or at create time: wrangler hyperdrive create ... --caching-disabled
    ```

</Aside>

<Aside type="tip" title="Co-locate the Worker with your database">
	The request path makes multiple round trips, so pair the deploy with a [Smart Placement](https://developers.cloudflare.com/workers/configuration/smart-placement/) hint to run the Worker in the Cloudflare data center closest to your database's region:

    ```jsonc
    // wrangler.jsonc
    { "placement": { "region": "aws:us-east-1" } }
    ```

</Aside>

### Configuration

| Option                         | Type     | Default                                                        | Description                                                                                                      |
| ------------------------------ | -------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `binding`                      | `string` | `"HYPERDRIVE"`                                                 | Primary (caching-disabled) Hyperdrive binding name                                                               |
| `cachedBinding`                | `string` | —                                                              | Optional caching-enabled binding for anonymous reads (see below)                                                 |
| `preferUncachedAfterWriteMs`   | `number` | `60000`\*                                                      | After a content publish, prefer `binding` for this many ms on anonymous public reads (match Hyperdrive `max_age`) |
| `migrationConnectionStringEnv` | `string` | `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_`      | Environment variable containing the direct PostgreSQL origin URL for `emdash migrate`                            |
| `max`                          | `number` | `5`                                                            | Max size of the in-Worker connection pool to Hyperdrive                                                          |

\*Default `60000` applies only when `cachedBinding` is set; ignored otherwise.

### Serving anonymous reads from cache

By default you disable Hyperdrive caching entirely, because the admin and writes need read-after-write consistency. But **anonymous public requests using `GET` or `HEAD`** can tolerate a short staleness window. If that trade-off is acceptable, run **two Hyperdrive configurations over the same database**: one with caching off (the primary `binding`) and one with caching on (`cachedBinding`). EmDash routes those anonymous public requests through the cache-enabled binding and every other request through the uncached primary.

```sh
# Primary — caching OFF (used by admin, auth'd requests, writes, migrations)
wrangler hyperdrive create emdash-db \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full" \
  --caching-disabled

# Cached — SAME database role and connection string, caching ON
wrangler hyperdrive create emdash-db-cached \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full"
```

```jsonc title="wrangler.jsonc"
{
	"hyperdrive": [
		{ "binding": "HYPERDRIVE", "id": "<caching-disabled-id>" },
		{ "binding": "HYPERDRIVE_CACHED", "id": "<caching-enabled-id>" }
	]
}
```

```js title="astro.config.mjs"
database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" });
```

This is the [two-configuration pattern](https://developers.cloudflare.com/hyperdrive/configuration/query-caching/#disable-caching) Cloudflare documents for caching. EmDash decides which binding to use per request:

- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`, **except** for a short window after a content publish (default 60s; set `preferUncachedAfterWriteMs` to your Hyperdrive `max_age`) when EmDash prefers the uncached `binding` so a rebuild cannot reseed edge/object caches from still-stale Hyperdrive results.
- **Authenticated requests** (editors, authors) → uncached `binding`.
- **Mutation requests** (`POST`, `PUT`, `PATCH`, `DELETE`, including anonymous ones) → uncached `binding`.
- **Any request under `/_emdash`** (admin, setup, auth, internal APIs), even an anonymous `GET` → uncached `binding`.
- **Runtime migrations and cold-start** → always the primary `binding`.
- **Deployment-managed migrations** → connect directly to the PostgreSQL origin using `migrationConnectionStringEnv`; they never use either Hyperdrive binding.

<Aside>
	The `/_emdash` carve-out matters: the setup wizard and login-state checks are
	anonymous `GET`s that read a row written moments earlier. Caching them would
	loop the setup wizard or show stale auth state, so they always use the
	uncached primary.
</Aside>

<Aside type="caution">
	Both configurations should use the **same PostgreSQL role**, database, and schema. If you later replace the primary configuration with a different role, PostgreSQL does not transfer the existing objects with it. Set `preferUncachedAfterWriteMs` to your cached config's `max_age` (default 60s) so post-publish public rebuilds read fresh SQL. Outside that window, anonymous public reads can still be up to `max_age` stale relative to non-content writes. Only opt in if that trade-off is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
</Aside>

<Aside type="note">
	Cross-isolate post-write routing uses the configured distributed Object Cache backend to share the last-content-write timestamp. Without a distributed Object Cache, the prefer-uncached window is known only to the Worker isolate that handled the write.
</Aside>

<Aside>
	**Sandboxed plugins are D1-only.** The sandbox plugin bridge talks to a D1 binding directly, independent of the configured adapter, so sandboxed plugins aren't available on a Hyperdrive deployment.
</Aside>

#### Optional: use a separate cached role

Migrations, setup, authenticated requests, and explicit write requests always use the primary `binding`. A separate role for `cachedBinding` does not need schema ownership or `CREATE`, but it needs `CONNECT`, schema `USAGE`, and `SELECT` on every table used by the public site.

Anonymous public `GET` and `HEAD` requests can also record redirect hits and 404s. To preserve those features, the cached role additionally needs `UPDATE` on `_emdash_redirects` and `SELECT`, `INSERT`, `UPDATE`, and `DELETE` on `_emdash_404_log`. Plugins or application code that writes during a public `GET` or `HEAD` may require more. Use the same role for both bindings unless you have tested the site with a restricted cached role.

Add the cached role after EmDash has completed its initial migrations. The examples below use the optional `emdash` schema; substitute your active schema, such as `public`. Create the login and database settings with your provider's administrative role:

```sql
CREATE ROLE emdash_cached LOGIN PASSWORD 'replace-with-a-secret';
GRANT CONNECT ON DATABASE app TO emdash_cached;
ALTER ROLE emdash_cached IN DATABASE app SET search_path = emdash;
```

Then connect as `emdash_app`, the schema and table owner, to grant access to existing and future tables:

```sql
GRANT USAGE ON SCHEMA emdash TO emdash_cached;
GRANT SELECT ON ALL TABLES IN SCHEMA emdash TO emdash_cached;
GRANT UPDATE ON emdash._emdash_redirects TO emdash_cached;
GRANT SELECT, INSERT, UPDATE, DELETE ON emdash._emdash_404_log TO emdash_cached;

ALTER DEFAULT PRIVILEGES IN SCHEMA emdash
  GRANT SELECT ON TABLES TO emdash_cached;
```

Connect with both roles and verify they report the same `current_database()` and `current_schema()` before enabling `cachedBinding`. On a shared schema, `GRANT SELECT ON ALL TABLES` also exposes unrelated tables. Grant access to individual EmDash tables instead, and update those grants when collections or other schema objects are added.

## Repair mixed PostgreSQL ownership

If a site has used multiple PostgreSQL users, first choose the canonical role that the primary EmDash connection will continue using. Take a backup and stop schema changes while repairing ownership.

Inspect every table in the active schema:

```sql
SELECT
  n.nspname AS schema_name,
  c.relname AS table_name,
  pg_get_userbyid(c.relowner) AS owner
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
  AND c.relkind IN ('r', 'p')
ORDER BY c.relname;
```

EmDash objects include `_emdash_*` and `_plugin_*` system tables, `ec_*` collection tables, and unprefixed tables such as `content_taxonomies`, `media`, `options`, `revisions`, and `taxonomies`. In a dedicated EmDash schema, every application table should have the canonical owner.

EmDash also creates PostgreSQL functions used by media-usage triggers. Inspect function ownership and retain each function's argument signature for the repair command:

```sql
SELECT
  n.nspname AS schema_name,
  p.proname AS function_name,
  pg_get_function_identity_arguments(p.oid) AS arguments,
  pg_get_userbyid(p.proowner) AS owner
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
WHERE n.nspname = current_schema()
ORDER BY p.proname, arguments;
```

Transfer each mismatched object with a superuser or provider role that can change its ownership, always using schema-qualified names:

```sql
ALTER TABLE emdash.content_taxonomies OWNER TO emdash_app;
ALTER TABLE emdash.ec_posts OWNER TO emdash_app;
ALTER FUNCTION emdash.emdash_media_usage_capture_work() OWNER TO emdash_app;
```

Use the argument list returned by the inventory query inside each `ALTER FUNCTION` statement. Changing a table's owner also covers its attached indexes, constraints, and triggers, but not their independent trigger functions. Repeat both inventory queries until every EmDash table and function reports the canonical owner, then connect as that role and verify `current_schema()` before starting the application.

For a non-superuser to transfer ownership, it must own or inherit ownership of the object, be able to `SET ROLE` to the new owner, and the new owner must have `CREATE` on the schema. Managed PostgreSQL providers may require their administrative role to perform the transfer.

<Aside type="caution" title="Do not reassign unrelated objects">
	`REASSIGN OWNED` changes every object owned by a role in the current database, including objects unrelated to EmDash, and can also affect shared objects. Use targeted `ALTER TABLE ... OWNER TO ...` statements unless the old role was dedicated exclusively to this EmDash database.
</Aside>

## SQLite

SQLite with better-sqlite3 is the simplest option for Node.js deployments.

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

export default defineConfig({
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
		}),
	],
});
```

### Configuration

| Option | Type     | Description                   |
| ------ | -------- | ----------------------------- |
| `url`  | `string` | File path with `file:` prefix |

### File Path

The `url` must start with `file:`:

```js
// Relative path
database: sqlite({ url: "file:./data/emdash.db" });

// Absolute path
database: sqlite({ url: "file:/var/data/emdash.db" });

// From environment variable
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` });
```

<Aside>
	SQLite requires a persistent filesystem. It does not work on platforms with ephemeral storage
	without additional configuration.
</Aside>

## Migrations

EmDash runs core migrations automatically by default for every supported dialect. Astro build and sync also emit a validated, secret-free `.emdash/migrations.json`, which `emdash migrate` can apply before deployment. SQLite, libSQL, PostgreSQL, D1, and the direct PostgreSQL origin behind Hyperdrive have deployment executors.

See [Manage Core Database Migrations](/deployment/core-migrations/) for target credentials, CI serialization, `auto`/`check`/`manual` runtime policy, and recovery from unknown records or ambiguous D1 writes.

For PostgreSQL, runtime migrations run through the configured connection; Hyperdrive runtime migrations always use its primary binding. Deployment-managed Hyperdrive migrations connect directly to the PostgreSQL origin. Core migrations may create tables, indexes, and functions, alter or drop columns and constraints, and update existing rows. A role that can connect and modify rows but does not own the existing EmDash objects is not sufficient. The setup wizard cannot repair missing database privileges because runtime migrations run before setup.

If the database is empty (no collections) and the setup wizard has not been completed, EmDash also applies a seed file on first boot. The seed is read from `.emdash/seed.json`, the path in `package.json#emdash.seed`, or `seed/seed.json` — whichever is found first — and inlined into the build at compile time. If none is present, a built-in default seed is used. Subsequent boots against an existing database leave its content alone.

## Environment-Based Configuration

Use different databases per environment:

```js title="astro.config.mjs"
import { sqlite, libsql, postgres } from "emdash/db";
import { d1 } from "@emdash-cms/cloudflare";

const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });

export default defineConfig({
	integrations: [emdash({ database })],
});
```

The choice can also key off an environment variable instead of the build mode:

```js
const database = process.env.DATABASE_URL
	? postgres({ connectionString: process.env.DATABASE_URL })
	: sqlite({ url: "file:./data.db" });
```

---

## File: docs/src/content/docs/deployment/nodejs.mdx

---
title: Deploy to Node.js
---

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

EmDash runs on any Node.js 22+ hosting platform. This guide uses SQLite with local or S3-compatible storage; libSQL and PostgreSQL work the same way on Node.js — see [Database Options](/deployment/database/).

## Prerequisites

- Node.js v22.12.0 or higher
- A Node.js hosting provider or VPS

## Configuration

Configure EmDash for Node.js deployment:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import emdash, { local, s3 } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	output: "server",
	adapter: node({ mode: "standalone" }),
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data/emdash.db" }),
			storage: local({
				directory: "./data/uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

## Build and Run

1. Build the project:

   ```bash
   npm run build
   ```

2. Start the server:

   ```bash
   node ./dist/server/entry.mjs
   ```

The server runs on `http://localhost:4321` by default. Migrations are applied on the first request. If the database is empty and setup hasn't been completed, your seed file (or the built-in default if you don't have one) is also applied on that first request.

## Production Storage

For production, use S3-compatible storage instead of local filesystem:

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

export default defineConfig({
	integrations: [
		emdash({
			database: sqlite({ url: `file:${process.env.DATABASE_PATH}` }),
			storage: s3({
				endpoint: process.env.S3_ENDPOINT,
				bucket: process.env.S3_BUCKET,
				accessKeyId: process.env.S3_ACCESS_KEY_ID,
				secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
				publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
			}),
		}),
	],
});
```

<Aside>
	S3-compatible storage works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
</Aside>

## Docker

Add a `.dockerignore` to keep the build context small:

```plain title=".dockerignore"
node_modules
dist
.git
```

Create a `Dockerfile`:

```dockerfile title="Dockerfile"
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

RUN mkdir -p data

ENV HOST=0.0.0.0
ENV PORT=4321

EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
```

The seed file is read at build time and inlined into the bundle, so it does not need to be copied into the runtime image. Migrations run on the first request after a deploy; the seed applies only when the database has no collections and setup hasn't been completed — existing data is never overwritten.

<Aside>
	This Dockerfile builds **your own EmDash site** — a project that has `emdash` as a dependency. It is
	not interchangeable with the `Dockerfile` in the root of the EmDash repository, which builds the whole
	pnpm monorepo and packages the blog template from source.
</Aside>

<Aside type="caution" title="Native dependencies behind restrictive networks">
	EmDash's SQLite driver (`better-sqlite3`) installs a prebuilt binary downloaded from GitHub Releases.
	If your build environment blocks that download (corporate proxy, offline mirror), the install falls
	back to compiling from source and fails with a `gyp ERR! find Python` error, because slim/alpine Node
	images ship no compiler toolchain.
</Aside>

If you hit that error, install the toolchain in the builder stage before `npm ci`:

```dockerfile
# Alpine-based images (node:22-alpine)
RUN apk add --no-cache python3 make g++

# Debian-based images (node:22-slim)
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
    && rm -rf /var/lib/apt/lists/*
```

Build the image and run the container:

```bash
docker build -t my-emdash-site .
docker run -p 4321:4321 -v emdash-data:/app/data my-emdash-site
```

A Docker Compose file manages the same container with a named volume:

```yaml title="compose.yaml"
services:
  emdash:
    build: .
    ports:
      - "4321:4321"
    volumes:
      - emdash-data:/app/data
    restart: unless-stopped

volumes:
  emdash-data:
```

Start the stack in the background:

```bash
docker compose up -d
```

## Environment Variables

### Recommended: encryption key

`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
rest. The key is validated on startup; plugin secret encryption uses
it once enabled. Set it on every deployment so secrets are protected
without a later config change.

Generate a key and add the result to your environment:

```bash
npx emdash secrets generate  # add the result to your environment
```

The key is provided by you and never stored in the database; only
encrypted ciphertext is. Back it up somewhere durable (a password
manager, KMS, or your team's secret store) — losing it means losing
every secret encrypted with it.

### Optional: stable-value overrides

EmDash auto-generates the preview HMAC secret and commenter-IP hash
salt and persists them in the database on first use. The env vars
below pin them to a value you control — useful when a separate
process needs to share a secret with your main site.

| Variable | Description |
| -------- | ----------- |
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
| `EMDASH_AUTH_SECRET` | Optional. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |

### Database and Storage

| Variable               | Description             | Example                    |
| ---------------------- | ----------------------- | -------------------------- |
| `DATABASE_PATH`        | Path to SQLite database | `/data/emdash.db`        |
| `HOST`                 | Server host             | `0.0.0.0`                  |
| `PORT`                 | Server port             | `4321`                     |
| `S3_ENDPOINT`          | S3 endpoint URL         | `https://xxx.r2.cloudflarestorage.com` |
| `S3_BUCKET`            | S3 bucket name          | `my-media-bucket`          |
| `S3_ACCESS_KEY_ID`     | S3 access key           | `AKIA...`                  |
| `S3_SECRET_ACCESS_KEY` | S3 secret key           | `...`                      |
| `S3_PUBLIC_URL`        | Public URL for media    | `https://cdn.example.com`  |

<Aside type="caution">
Never commit secrets to your repository. Use your platform's secret management (environment variables, secret stores, etc.) for production.
</Aside>

## Persistent Storage

SQLite requires persistent disk storage. Ensure your hosting platform provides:

- A mounted volume or persistent disk
- Write access to the database directory
- Backup mechanisms for the database file

<Aside type="caution">
	Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.
</Aside>

## Health Checks

Add a health check endpoint for load balancers:

```astro title="src/pages/health.ts"
export const GET = () => {
  return new Response("OK", { status: 200 });
};
```

Configure your platform to check `/health` for liveness probes.

---

## File: docs/src/content/docs/deployment/storage.mdx

---
title: Storage Options
---

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

EmDash stores uploaded media (images, documents, videos) in a configurable storage backend. Choose based on your deployment platform and requirements.

## Overview

| Storage        | Best For           | Features                    |
| -------------- | ------------------ | --------------------------- |
| **R2 Binding** | Cloudflare Workers | Zero-config, fast           |
| **S3**         | Any platform       | Signed uploads, CDN support |
| **Local**      | Development        | Simple filesystem storage   |

## Cloudflare R2 (Binding)

Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.

```js title="astro.config.mjs"
import emdash from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});
```

### Configuration

| Option      | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `binding`   | `string` | R2 binding name from `wrangler.jsonc` |
| `publicUrl` | `string` | Optional public URL for the bucket    |

### Setup

Add the R2 binding to your Wrangler configuration:

<Tabs>
  <TabItem label="wrangler.jsonc">
    ```jsonc
    {
      "r2_buckets": [
        {
          "binding": "MEDIA",
          "bucket_name": "emdash-media"
        }
      ]
    }
    ```
  </TabItem>
  <TabItem label="wrangler.toml">
    ```toml
    [[r2_buckets]]
    binding = "MEDIA"
    bucket_name = "emdash-media"
    ```
  </TabItem>
</Tabs>

### Public Access

For public media URLs, enable public access on your R2 bucket:

1. Go to Cloudflare Dashboard > R2 > your bucket
2. Enable public access under Settings
3. Add the public URL to your config:

```js
storage: r2({
	binding: "MEDIA",
	publicUrl: "https://pub-xxxx.r2.dev",
});
```

<Aside type="tip">
	Connect a custom domain to R2 for branded URLs like `https://media.example.com`.
</Aside>

<Aside type="note">
	R2 bindings do not support signed upload URLs. If you need direct client uploads, use the S3
	adapter with R2 credentials instead.
</Aside>

## S3-Compatible Storage

The S3 adapter works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.

<Aside type="caution" title="Install the AWS SDK first">
	EmDash uses the AWS SDK at runtime for the S3 adapter but does not bundle it — core is
	deliberately SDK-agnostic so R2-only and local-only deployments stay lean. Install the SDK
	in your project before using `s3()`:

    ```sh
    pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
    ```

    If you skip this step, `astro build` will fail with `Rollup failed to resolve import
    "@aws-sdk/client-s3"`. The R2 binding adapter (`r2()`) and local adapter do not need the
    SDK and are unaffected.

</Aside>

The following configuration points EmDash at an S3-compatible bucket:

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

export default defineConfig({
	integrations: [
		emdash({
			storage: s3({
				endpoint: process.env.S3_ENDPOINT,
				bucket: process.env.S3_BUCKET,
				accessKeyId: process.env.S3_ACCESS_KEY_ID,
				secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
				region: "auto", // Optional, defaults to "auto"
				publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
			}),
		}),
	],
});
```

### Configuration

| Option            | Type     | Required | Description                    |
| ----------------- | -------- | -------- | ------------------------------ |
| `endpoint`        | `string` | yes      | S3 endpoint URL                |
| `bucket`          | `string` | yes      | Bucket name                    |
| `accessKeyId`     | `string` | no\*     | Access key                     |
| `secretAccessKey` | `string` | no\*     | Secret key                     |
| `region`          | `string` | no       | Region (default: `"auto"`)     |
| `publicUrl`       | `string` | no       | Optional CDN or public URL     |

\* Both `accessKeyId` and `secretAccessKey` must be provided together, or both omitted.

### Resolving S3 config from environment variables

Any field omitted from `s3({...})` is read from the matching `S3_*` environment variable
when the process starts. This lets you build a container image once and inject credentials
at boot without a rebuild. Explicit values in `s3({...})` always take precedence over
environment variables.

| Environment variable   | Field             | Notes                              |
| ---------------------- | ----------------- | ---------------------------------- |
| `S3_ENDPOINT`          | `endpoint`        | Must be a valid `http`/`https` URL |
| `S3_BUCKET`            | `bucket`          |                                    |
| `S3_ACCESS_KEY_ID`     | `accessKeyId`     |                                    |
| `S3_SECRET_ACCESS_KEY` | `secretAccessKey` |                                    |
| `S3_REGION`            | `region`          | Defaults to `"auto"`               |
| `S3_PUBLIC_URL`        | `publicUrl`       | Optional CDN prefix                |

Environment variables are read from `process.env` when the process starts. This is a
Node-only feature.

Calling `s3()` with no arguments reads every field from the `S3_*` environment variables:

```js title="astro.config.mjs — runtime environment variable example"
import emdash, { s3 } from "emdash/astro";

export default defineConfig({
	integrations: [
		emdash({
			// s3() with no args: all fields from S3_* environment variables
			storage: s3(),

			// Or mix: override one field, rest from environment
			// storage: s3({ publicUrl: "https://cdn.example.com" }),
		}),
	],
});
```

<Aside type="note" title="Cloudflare Workers">
	This runtime resolution does not work on Cloudflare Workers. Worker secrets and variables
	are exposed through the `env` parameter of the fetch handler (or `import { env } from
	"cloudflare:workers"`), not through `process.env` — even with the `nodejs_compat` flag
	enabled. For Workers deployments:

    - If you are using R2, use the [`r2()` adapter](#cloudflare-r2-binding) from
      `@emdash-cms/cloudflare`. This is the recommended path.
    - If you need a non-R2 S3-compatible backend on Workers, pass explicit values to
      `s3({...})` in `astro.config.mjs`. Note that `astro.config.mjs` runs at build time,
      so Worker secrets are not directly accessible there; you will need to inject values
      through your build pipeline.

</Aside>

### R2 via S3 API

Use S3 credentials with R2 for features like signed upload URLs:

```js
storage: s3({
	endpoint: "https://<account-id>.r2.cloudflarestorage.com",
	bucket: "emdash-media",
	accessKeyId: process.env.R2_ACCESS_KEY_ID,
	secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
	publicUrl: "https://pub-xxxx.r2.dev",
});
```

Generate R2 API credentials in the Cloudflare dashboard under R2 > Manage R2 API Tokens.

### MinIO

Point the S3 adapter at a MinIO endpoint with its access credentials:

```js
storage: s3({
	endpoint: "https://minio.example.com",
	bucket: "emdash-media",
	accessKeyId: process.env.MINIO_ACCESS_KEY,
	secretAccessKey: process.env.MINIO_SECRET_KEY,
	publicUrl: "https://minio.example.com/emdash-media",
});
```

## Local Filesystem

Use local storage for development. Files are stored in a directory on disk.

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

export default defineConfig({
	integrations: [
		emdash({
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

### Configuration

| Option      | Type     | Description                     |
| ----------- | -------- | ------------------------------- |
| `directory` | `string` | Directory path for file storage |
| `baseUrl`   | `string` | Base URL for serving files      |

The `baseUrl` should match EmDash's media file endpoint (`/_emdash/api/media/file`) unless you configure a custom static file server.

<Aside type="caution">
	Local storage does not support signed upload URLs. Files are uploaded through the server, which
	may be slower for large files.
</Aside>

## Environment-Based Configuration

Switch storage backends based on environment:

```js title="astro.config.mjs"
import emdash, { s3, local } from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";

const storage = import.meta.env.PROD
	? r2({ binding: "MEDIA" })
	: local({
			directory: "./uploads",
			baseUrl: "/_emdash/api/media/file",
		});

export default defineConfig({
	integrations: [emdash({ storage })],
});
```

## Signed Uploads

The S3 adapter supports signed upload URLs, allowing clients to upload directly to storage without passing through your server. This improves performance for large files.

Signed uploads are automatic when using the S3 adapter. The admin interface uses them when available.

Adapters that support signed uploads:

- **S3** (including R2 via S3 API)

Adapters that do not support signed uploads:

- **R2 binding** (use S3 adapter with R2 credentials instead)
- **Local**

## Storage Interface

All storage adapters implement the same interface:

```typescript
interface Storage {
	upload(options: {
		key: string;
		body: Buffer | Uint8Array | ReadableStream;
		contentType: string;
	}): Promise;

	download(key: string): Promise<DownloadResult>;
	delete(key: string): Promise<void>;
	exists(key: string): Promise;
	list(options?: ListOptions): Promise<ListResult>;
	getSignedUploadUrl(options: SignedUploadOptions): Promise<SignedUploadUrl>;
	getPublicUrl(key: string): string;
}
```

This consistency allows switching storage backends without changing application code.

---

## File: docs/src/content/docs/contributing/architecture.mdx

---
title: Architecture (internals)
---

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

This page is for people working **on** EmDash, not building a site with it. It documents internal mechanics — table layouts, the Astro integration, the request path, code generation. None of it is needed to use EmDash. If you are building a site, read [Architecture](/concepts/architecture/) and the [Content Model](/concepts/content-model/) instead.

## The Astro integration

EmDash runs as an Astro integration from the `emdash` package. At build time it:

- Injects the admin SPA and REST API routes with Astro's `injectRoute` API. Nothing is copied into the user's project. The injected paths are:

  | Path pattern                        | Purpose                               |
  | ----------------------------------- | ------------------------------------- |
  | `/_emdash/admin/[...path]`          | Admin panel SPA                       |
  | `/_emdash/api/manifest`             | Admin manifest (collections, plugins) |
  | `/_emdash/api/content/[collection]` | Content entry CRUD                    |
  | `/_emdash/api/media/*`              | Media library operations              |
  | `/_emdash/api/schema/*`             | Schema management                     |
  | `/_emdash/api/settings`             | Site settings                         |
  | `/_emdash/api/menus/*`              | Navigation menus                      |
  | `/_emdash/api/taxonomies/*`         | Categories, tags, custom taxonomies   |

- Generates virtual modules so the bundler can resolve and tree-shake configuration and plugin code:

  | Module                         | Purpose                             |
  | ------------------------------ | ----------------------------------- |
  | `virtual:emdash/config`        | Database and storage configuration  |
  | `virtual:emdash/dialect`       | Database dialect factory            |
  | `virtual:emdash/plugin-admins` | Static imports for plugin admin UIs |

- Provides the Live Collections loader, manages migrations, and opens the storage connection.

## Database-first schema

Schema definitions live in the database, not in code. Two system tables track structure.

`_emdash_collections` holds one row per collection:

```sql
CREATE TABLE _emdash_collections (
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,        -- "posts", "products"
  label TEXT NOT NULL,              -- "Blog Posts"
  label_singular TEXT,              -- "Post"
  description TEXT,
  icon TEXT,
  supports JSON,                    -- ["drafts", "revisions", "preview"]
  source TEXT,                      -- how it was created
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);
```

The `source` column records provenance: `manual` (admin UI), `template:<name>` (seed file), `import:wordpress` (importer), or `discovered` (auto-detected from existing tables).

`_emdash_fields` holds one row per field, linked to its collection:

```sql
CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- column name
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- field type
  column_type TEXT NOT NULL,        -- TEXT, REAL, INTEGER, JSON
  required INTEGER DEFAULT 0,
  unique_field INTEGER DEFAULT 0,
  default_value TEXT,
  validation JSON,
  widget TEXT,
  options JSON,
  sort_order INTEGER,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(collection_id, slug)
);
```

### Per-collection content tables

Each collection gets its own table, prefixed `ec_`. A `products` collection with `title` and `price` fields produces:

```sql
CREATE TABLE ec_products (
  -- System columns, always present
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE,
  status TEXT DEFAULT 'draft',
  author_id TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  published_at TEXT,
  deleted_at TEXT,                  -- soft delete
  version INTEGER DEFAULT 1,        -- optimistic locking

  -- Content columns, from field definitions
  title TEXT NOT NULL,
  price REAL
);
```

Real columns (rather than one table with a JSON blob) give proper indexing, working foreign keys, a schema database tools can inspect, and no per-field JSON parsing.

The concerns stay separated:

| Concern  | Location                 | Tables                                  |
| -------- | ------------------------ | --------------------------------------- |
| Schema   | System tables            | `_emdash_collections`, `_emdash_fields` |
| Content  | Per-collection tables    | `ec_posts`, `ec_products`, …            |
| Media    | Separate table + storage | `media` table + R2/S3                   |
| Settings | Options table            | `options` with a `site:` prefix         |

### Runtime schema changes

Adding a field through the admin UI runs three steps:

<Steps>

1. Insert a record into `_emdash_fields`.
2. Run `ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>`.
3. Regenerate the Zod schema used for validation.

</Steps>

SQLite supports add, rename, and drop column (drop requires SQLite 3.35+) at runtime. Changing a column's type is not supported in place, so EmDash rebuilds the table transparently: create a new table, copy rows, drop the old table, rename the new one.

### Runtime validation

EmDash builds Zod schemas from the field definitions at startup and validates every create and update against them:

```ts
function buildSchema(fields: Field[]): ZodSchema {
	const shape: Record<string, ZodType> = {};
	for (const field of fields) {
		let zodType = fieldTypeToZod(field.type);
		if (field.required) zodType = zodType.required();
		if (field.validation?.min !== undefined) zodType = zodType.min(field.validation.min);
		shape[field.slug] = zodType;
	}
	return z.object(shape);
}
```

## Data layer

EmDash uses [Kysely](https://kysely.dev) for type-safe SQL across every supported database (SQLite, libSQL, Cloudflare D1, and PostgreSQL). The dialect is selected by `virtual:emdash/dialect` from the configuration the site passes to the integration.

## Live Collections loader

Content is served at runtime through Astro's [Live Collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/). `emdashLoader()` implements Astro's `LiveLoader` interface and is registered as a single `_emdash` collection:

```ts title="src/live.config.ts"
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```

The single `_emdash` collection wraps every content type; the loader filters by type when `getEmDashCollection("posts")` is called.

## Request paths

A content request from a page:

<Steps>

1. Astro receives the request and runs the page component.
2. `getEmDashCollection()` calls Astro's `getLiveCollection()`.
3. `emdashLoader` queries the relevant `ec_*` table through Kysely.
4. Rows are mapped to Astro's entry format (`id`, `slug`, `data`).
5. The component renders.

</Steps>

An admin request:

<Steps>

1. Middleware validates the session token.
2. The API route runs CRUD through a repository.
3. Lifecycle hooks fire (for example `content:beforeSave`).
4. Kysely executes the SQL.
5. The route returns JSON to the admin SPA.

</Steps>

## Admin panel internals

The admin is one React island. Astro serves the shell and enforces authentication in middleware; everything inside is client-side, built on TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap, and Kumo (Cloudflare's Base UI + Tailwind design system).

The shell route gates access in middleware:

```ts
export async function onRequest({ request, locals }, next) {
	const session = await getSession(request);
	if (request.url.includes("/_emdash/admin")) {
		if (!session?.user) return redirect("/_emdash/admin/login");
		locals.user = session.user;
	}
	return next();
}
```

### Manifest-driven UI

The admin hardcodes nothing about collections or plugins. It fetches `GET /_emdash/api/manifest`, which returns the collections, plugins, and taxonomies the requesting user may access, filtered by role:

```json
{
	"collections": [
		{
			"slug": "posts",
			"label": "Blog Posts",
			"icon": "file-text",
			"supports": ["drafts", "revisions", "preview"],
			"fields": [{ "slug": "title", "type": "string", "required": true }]
		}
	],
	"plugins": [{ "id": "audit-log", "label": "Audit Log" }],
	"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
	"version": "abc123"
}
```

Navigation, forms, and field editors are generated from this manifest, so schema and plugin changes appear without an admin rebuild, and Zod schemas stay server-side.

### Plugin admin UIs

Plugin admin entry points are collected into a generated virtual module of static imports so the bundler can resolve and tree-shake them:

```ts title="virtual:emdash/plugin-admins (generated)"
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";

export const pluginAdmins = { seo: pluginAdmin0 };
```

### Rich text conversion

Portable Text fields edit in TipTap (ProseMirror). Content is converted at the load and save boundaries by `portableTextToProsemirror()` and `prosemirrorToPortableText()`. Unknown blocks from plugins or imports are preserved as read-only placeholders.

### Signed uploads

Media uploads use direct-to-storage signed URLs when the adapter supports them and a same-origin streaming endpoint otherwise:

<Steps>

1. The client requests an upload URL (`POST /api/media/upload-url`).
2. The client uploads to the returned target. S3-compatible adapters can return a signed URL that bypasses Worker body-size limits; native R2 bindings and local storage return an EmDash streaming endpoint.
3. The client confirms (`POST /api/media/:id/confirm`).
4. The server extracts metadata (dimensions, MIME type).

</Steps>

## Extending the content importer

The WordPress importer is built on a pluggable `ImportSource` interface. A custom source implements probe, analyze, and fetch:

```ts
interface ImportSource {
	probe(input: ImportInput): Promise;
	analyze(input: ImportInput): Promise<AnalysisResult>;
	fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}
```

`probe` validates the input and reports what it found, `analyze` maps source post types to EmDash collections and flags schema gaps, and `fetchContent` streams normalized entries the import pipeline writes through the same repositories the admin uses. Built-in sources cover WordPress WXR, WordPress.com, and the WordPress REST API; register a custom source to import from another system.

---

## File: docs/src/content/docs/contributing/docs-style-guide.mdx

---
title: Documentation Style Guide
---

This guide defines how EmDash documentation is written. Contributions are edited to match it. You do not need to memorize it — reviewers and editors will help — but following it makes a contribution merge faster.

Documentation exists to help someone do something, then get back to their project. Write for a reader who is tired, in a hurry, reading in a second language, or new to the stack. Serve that reader above all else.

## Readability

Prefer:

- Short sentences and short paragraphs.
- Plain vocabulary over jargon.
- Abbreviations and acronyms written out the first time.
- Headings and lists to break up long passages.
- Active voice.

Document **how to build with EmDash**, not **how EmDash is built**. Implementation detail belongs in docs only when it changes a decision the reader has to make (when to pick a non-default value, a caveat that affects their project). It never replaces a usage example.

For non-EmDash topics — TypeScript, the AT Protocol, web fonts, SQL — link to a reputable source instead of explaining them. Document what someone needs to know to use the feature **in EmDash**.

## What to emphasise

A page's emphasis must be set by what the reader needs to do their task, never by what was interesting or recent to the people who built EmDash. Three habits to actively resist:

- **Authoring-recency weight.** A decision being new, or fresh in the writer's mind, is not a reason to feature it. The most-changed thing is rarely the most important thing to a reader. Order sections, list items, and headlines by how often a reader needs them, not by when they were added. If you are documenting something because it just changed, you are probably writing a changelog entry, not a doc.

- **Builder-salience over reader-salience.** Internal architecture and design decisions that were significant to *make* are usually invisible and irrelevant to *use*. State the capability the reader gets, not the mechanism behind it. A reader defining a collection does not need to know where the schema is stored, any more than they need to know the parser's language. If the mechanism genuinely helps someone working on EmDash, it belongs in the internals doc, not in a user-facing page.

- **Straw-man self-definition.** Do not define EmDash by contrast with a caricature of other tools ("Unlike most CMSs…", "Traditional CMSs force you to…", "in many CMSs you declare X in code"). Describe what EmDash does, directly, and let it stand on its own. Comparison is allowed only when the comparison is *the reader's own question*: on the evaluation page and the "Coming from…" orientation pages. Even there it must be specific and fair — concrete behaviours and trade-offs, not a strawman the reader is invited to dislike.

- **Definition by negation.** Framing a capability as the work you _don't_ have to do — "no migration to write", "no rebuild", "without touching code", "no separate service" — is a straw-man in disguise: it only lands for a reader who is carrying the alternative you have invented for them. State what the reader _does_ and what _happens_. "Add a field in the admin panel; it takes effect immediately" — not "add a field with no migration, no rebuild, no code". The exception is a concrete, reader-relevant behaviour stated positively: "content is served at runtime, so edits appear immediately" is a fact about EmDash; "no rebuilds needed" is the same fact phrased as someone else's absent pain — prefer the first.

The test for any sentence: would a reader trying to finish their task be worse off if it were deleted? If not, delete it. If it only makes sense to a reader comparing EmDash to something else, it is in the wrong place or should be cut.

## Evergreen, not changelog

User-facing pages describe how EmDash works now, for a reader who has no prior version in their head. No "now", "no longer", "used to", "instead of the old", "this changed". Version-to-version differences live only in an [upgrade guide](#upgrade-and-migration-guides). A concept being recently introduced is never a reason to mention that it is recent.

## Voice and tone

Write neutral, factual sentences. State facts directly.

✅ Plugins run in an isolated runtime and can only reach the APIs they declare.

❌ Plugins live in a cosy little sandbox where nothing bad can ever happen!

- **Do not use _we_, _us_, _our_, or _let's_.** You are not sitting with the reader. Rephrase to address the reader directly or describe the system.
- **Never use _I_.** Documentation is not about the author.
- **Address the reader as _you_** when needed, especially to flag a step where something can go wrong.
- **Do not narrate or tell a story.** No "now that we've set up X, let's move on to Y". Start a section with the goal, then the steps.
- **Avoid whimsy, mascots, and cultural references.** They add reading effort and do not translate.
- **Exclamation points are rare.** Use one only for something genuinely encouraging or surprising. When unsure, use a period.

## Headings

- Page title is the `<h1>` (from frontmatter `title`). Sections start at `<h2>`.
- Keep headings short. `<h2>` and `<h3>` appear in the "On this page" sidebar; preview it and shorten anything that wraps.
- No trailing punctuation, including a colon.
- Format code as `<code>` in headings the same as in body text.

## Lists

- Use a bulleted list when order does not matter, such as a set of options or properties.
- Use a numbered list for steps that must be followed in sequence. Use the Starlight `<Steps>` component for procedures.
- When list items grow into multiple paragraphs or carry several code terms, switch to `<h3>` sections instead.

## Examples

- "for example" in full introduces a single example or hypothetical.
- "e.g." inside parentheses introduces a non-exhaustive list (`e.g. GitHub, GitLab`).
- A list that covers **every** option is not a list of examples — use parentheses without "e.g." (`the required properties (src, alt)`).

## Code samples

Code samples are as important as the prose around them.

**Introduce every code block with a full, standalone sentence** on its own line, telling the reader what the block does. Do not lead in with a sentence fragment ending in a colon, a bare heading, or "like so:".

✅ The following example registers a plugin in the `sandboxed` array:

❌ Add the plugin like so:

The introduction primes the reader for what the code does, so they only need to work out _how_. It also creates a fill-in-the-blank pattern for a reader doing something slightly different.

Within a `<Steps>` procedure, a direct imperative instruction is the introduction ("Add a `tsconfig.json`:" followed by the file is fine in a numbered step).

Other rules:

- **Use real, working code.** No `foo`/`bar`. Show one realistic configuration, not every possible value — the reader will only have one.
- **Add a `title=` filename** to any block that represents a file, so the reader knows where the code goes.

  ````md
  ```ts title="src/plugin.ts"
  ````

- **Use Expressive Code annotations**, not raw ` ```diff ` fences, for before/after changes. Mark changed lines with `del={n}` / `ins={n}` or changed text with `del="…"` / `ins="…"`. Keep diffs minimal and local to the lines that change.

  The following example shows a single line changing:

  ````md
  ```ts del={1} ins={2}
  import { definePlugin } from "emdash";
  import type { SandboxedPlugin } from "emdash/plugin";
  ```
  ````

- Preview rendered code locally before submitting. A typo can break the display.

## Upgrade and migration guides

A guide that helps a reader move an existing project to a new version follows a fixed structure. The "What should I do?" sections are the part readers value most — do not skimp on them.

Open with: how to upgrade, a note that things may "just work" but to read on if not, and a link to the changelog.

Then list each breaking change as its own entry:

```md
### [Renamed/Changed/Removed/Deprecated]: <feature>

In earlier versions, <one sentence, past tense, what it did>.

<One sentence, present tense, how it works now>.

#### What should I do?


```

Choose the verb by how the reader feels the impact. If a new default replaces their value, that is a "Changed: default value", not an "Added: option".

A breaking change is one that requires a change to the reader's project or it stops working. Give the action, not just the fact. Not "the minimum Node.js version is now X" but "check your Node.js version with the following command, and upgrade if it is below X".

## EmDash specifics

Conventions for recurring situations, ordered by how often a contributor hits them. This is a list of conventions, not a ranking of which features matter.

### Plugins: sandboxed vs native

Sandboxed and native plugins are different formats with different authoring shapes. A change to one rarely affects the other. State which format a page or example is about. When editing a sandboxed-plugin page, do not change native-plugin examples, and the reverse.

### Localization

Do not include `messages.po` changes in a docs PR. A workflow extracts catalogs on merge to `main`. Including them creates churn and merge conflicts.

### Experimental features

A feature behind an experimental flag, or an unstable wire format under an RFC, can change without notice. Keep its documentation lean, flag it with a caution `<Aside>`, and point to the RFC or discussion as the source of truth. Do not document an unstable surface in exhaustive detail.

### Atmosphere accounts

When the portable, user-owned identity behind Bluesky and the wider AT Protocol network comes up, call it an **Atmosphere account** and use that term consistently. Link the first mention to [the Atmosphere login guide](/guides/atmosphere-auth/) or [atmosphereaccount.com](https://atmosphereaccount.com). `did:plc:…` and handles are its concrete identifiers; use them where a literal value is needed.

## Docs are code

The documentation site is an EmDash-adjacent Astro project. Documentation changes go through the same pull request and review flow as code. Every text change waits for a review; a wording change can shift the meaning of a sentence or need matching edits elsewhere on the site. Small, reviewed, consistent changes keep the whole site coherent.

---

## File: docs/src/content/docs/contributing/index.mdx

---
title: Contributing to EmDash
---

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

EmDash is a pnpm monorepo. The main package is `packages/core` (published as `emdash`) — it contains the Astro integration, REST API, database layer, schema management, and plugin system. The admin UI lives in `packages/admin`.

For the full contributor reference — repo layout, architecture, code conventions, changeset policy — see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).

## Local Setup

<Steps>

1. **Clone and install**

   ```bash
   git clone https://github.com/emdash-cms/emdash.git
   cd emdash
   pnpm install
   pnpm build    # required before first run
   ```

2. **Start the demo**

   ```bash
   cd demos/simple
   pnpm dev
   ```

   The setup wizard runs automatically on first launch — it creates the database, runs migrations, and prompts you to create an admin account.

   To populate with sample content: `pnpm seed`

3. **Open the admin**

   Visit [http://localhost:4321/_emdash/admin](http://localhost:4321/_emdash/admin)

   In dev mode, you can skip passkey auth with the bypass endpoint:

   ```
   http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
   ```

</Steps>

## Development Workflow

### Watch Mode

For iterating on core packages alongside the demo, run two terminals:

```bash
# Terminal 1 — rebuild packages/core on change
cd packages/core && pnpm dev

# Terminal 2 — run the demo
cd demos/simple && pnpm dev
```

### Checks

Run these before committing (from the repo root):

```bash
pnpm typecheck    # TypeScript
pnpm lint         # full type-aware lint
pnpm format       # auto-format (oxfmt, tabs)
```

<Aside type="caution">
  Type checking and linting must both pass. Don't commit with known failures.
</Aside>

### Tests

<Tabs>
<TabItem label="All tests">
```bash
pnpm test
```
</TabItem>
<TabItem label="Core only">
```bash
cd packages/core && pnpm test
```
</TabItem>
<TabItem label="Watch mode">
```bash
cd packages/core && pnpm test --watch
```
</TabItem>
<TabItem label="E2E">
```bash
pnpm test:e2e    # starts its own server
```
</TabItem>
</Tabs>

Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.

## What Gets Accepted

| Type             | Process                                                                                       |
| ---------------- | --------------------------------------------------------------------------------------------- |
| **Bug fixes**    | Open a PR directly. Include a failing test.                                                   |
| **Docs / typos** | Open a PR directly.                                                                           |
| **Translations** | Open a PR directly. See [Translating EmDash](/contributing/translating/).                      |
| **Features**     | Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) and wait for a maintainer to approve it. |
| **Refactors**    | Open a Discussion first.                                                                      |

Feature PRs without prior maintainer approval will be closed.

For the full contribution policy, changeset guide, repo layout, and architecture overview, see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).