emdash Documentation

EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress

11,557 stars TypeScript TypeScript 1 file ยท ~25,653 tokens #astro#cms#emdash#typescript
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy emdash on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name emdash -p 9000:9000 --restart always minio/minio:latest
Configured Services & Ports
โš™๏ธ minio
image: minio/minio:latest
๐Ÿ”Œ Ports: 9000:9000, 9001:9001
โš™๏ธ minio-setup
image: minio/mc:latest
โš™๏ธ workspace-remote
image: build locally
๐Ÿ”Œ Ports: 2223:22
docker-compose.yml
yaml (docker-compose.yml)
services:
  minio:
    image: minio/minio:latest
    container_name: emdash-workspace-minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - minio-data:/data
    restart: unless-stopped

  minio-setup:
    image: minio/mc:latest
    container_name: emdash-workspace-minio-setup
    depends_on:
      - minio
    entrypoint: /bin/sh
    command:
      - -c
      - |
        set -eu
        until mc alias set local http://minio:9000 minioadmin minioadmin >/dev/null 2>&1; do
          sleep 1
        done
        mc mb --ignore-existing local/emdash-releases
        mc anonymous set download local/emdash-releases
    restart: "no"

  workspace-remote:
    platform: ${WORKSPACE_REMOTE_PLATFORM:-}
    build:
      context: ./tooling/docker
      dockerfile: remote-machine.dockerfile
    container_name: emdash-workspace-remote
    depends_on:
      minio-setup:
        condition: service_completed_successfully
    ports:
      - "2223:22"
    volumes:
      - workspace-remote-home:/home/devuser
    restart: unless-stopped

volumes:
  minio-data:
    name: emdash-workspace-minio-data
    driver: local
  workspace-remote-home:
    name: emdash-workspace-remote-home
    driver: local

File: README.md

EmDash

A full-stack TypeScript CMS built on Astro and Cloudflare. 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 (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:

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

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

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

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

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. 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 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: "[email protected]",
					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, 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 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 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.

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

See CONTRIBUTING.md for the full contributor guide.

Repository Structure

text
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.

  1. 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.

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.

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 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.

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.

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.

See Database Options โ€” 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 for KV setup, options, and invalidation behavior.

Workers Cache

Cloudflare's 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,
	},
}
  1. 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.

  1. 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"
}),

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 โ€” 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.

Cloudflare bills these transforms as Images transformations. 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 for full configuration options.

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

1. Register the plugin in the `plugins` array passed to EmDash:
text
```js title="astro.config.mjs"
import { aiSearch } from "@emdash-cms/cloudflare/plugins";

// ...
plugins: [
	formsPlugin(),
	aiSearch(),
],
```
  1. Add the AI Search namespace binding to your Worker configuration:

    jsonc title="wrangler.jsonc"
    {
        "ai_search_namespaces": [
            {
                "binding": "AI_SEARCH",
                "namespace": "default",
            },
        ],
    }
  2. 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";
  3. 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>
  4. Deploy the site:

    bash
    pnpm exec wrangler deploy
  5. 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.

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
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: "[email protected]", name: "My Site CMS" },
			replyTo: "[email protected]", // 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.

Environment Variables

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

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.

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

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

Read Replicas

D1 supports 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.

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.

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.

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 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 },
});

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 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. 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
```jsonc { "hyperdrive": [ { "binding": "HYPERDRIVE", "id": "" } ] } ``` ```toml [[hyperdrive]] binding = "HYPERDRIVE" id = "" ```

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 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.

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.

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}` });

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 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.

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
			}),
		}),
	],
});

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.

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

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

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

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:

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

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",
});

S3-Compatible Storage

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

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" }),
		}),
	],
});

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.

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 and the 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:

  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.

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 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. 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:

  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.

An admin request:

  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.

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:

  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).

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. 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 or 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.

Local Setup

  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

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

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

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)

Tests

```bash pnpm test ``` ```bash cd packages/core && pnpm test ``` ```bash cd packages/core && pnpm test --watch ``` ```bash pnpm test:e2e # starts its own server ```

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.
Features Open a Discussion 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.