Skip to content

Contributing to EmDash

This guide covers how to set up a local development environment, understand the codebase architecture, and contribute to EmDash.

EmDash is a pnpm monorepo with multiple packages:

emdash/
├── packages/
│ ├── core/ # emdash — Astro integration, APIs, admin (main package)
│ ├── auth/ # @emdash-cms/auth — Authentication (passkeys, OAuth, magic links)
│ ├── cloudflare/ # @emdash-cms/cloudflare — Cloudflare adapter + sandbox runner
│ ├── admin/ # @emdash-cms/admin — Admin React SPA
│ ├── create-emdash/ # create-emdash — project scaffolder
│ ├── gutenberg-to-portable-text/ # WordPress block → Portable Text converter
│ └── plugins/ # First-party plugins (each subdirectory is its own package)
├── demos/
│ ├── simple/ # emdash-demo — primary dev/test demo (Node.js)
│ ├── cloudflare/ # Cloudflare Workers demo
│ └── ... # plugins-demo, showcase, wordpress-import
└── docs/ # Documentation site (Starlight)

The main package is packages/core. It contains:

packages/core/src/
├── astro/
│ ├── integration/ # Astro integration entry point + virtual module generation
│ ├── middleware/ # Auth, setup check, request context (ALS)
│ └── routes/
│ ├── api/ # REST API route handlers
│ └── admin-shell.astro # Admin SPA shell
├── database/
│ ├── migrations/ # Numbered migration files (001_initial.ts, ...)
│ │ └── runner.ts # StaticMigrationProvider — register migrations here
│ ├── repositories/ # Data access layer (content, media, settings, ...)
│ └── types.ts # Kysely Database type
├── plugins/
│ ├── types.ts # Plugin API types
│ ├── define-plugin.ts # definePlugin()
│ ├── context.ts # PluginContext factory
│ ├── hooks.ts # HookPipeline
│ ├── manager.ts # PluginManager (trusted plugins)
│ └── sandbox/ # Sandbox interface + no-op runner
├── schema/
│ └── registry.ts # SchemaRegistry — manages ec_* tables
├── media/ # Media providers (local, types)
├── auth/ # Challenge store, OAuth state store
├── query.ts # getEmDashCollection, getEmDashEntry
├── loader.ts # Astro LiveLoader implementation
└── emdash-runtime.ts # EmDashRuntime — central orchestrator
  • Node.js 20 or higher
  • pnpm 9 or higher
  • Git
Terminal window
# Install pnpm if you don't have it
npm install -g pnpm
  1. Clone the repository

    Terminal window
    git clone <repository-url>
    cd emdash
  2. Install dependencies

    Terminal window
    pnpm install
  3. Build packages (required before running the demo)

    Terminal window
    pnpm build
  4. Seed the demo database (demos/simple/)

    Terminal window
    pnpm --filter emdash-demo seed
  5. Start the development server

    Terminal window
    pnpm --filter emdash-demo dev
  6. Open the admin

    Visit http://localhost:4321/_emdash/admin

    In development mode, use the dev bypass endpoint to skip passkey authentication:

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

For package development, use watch mode alongside the demo:

Terminal window
# Terminal 1: Watch packages/core for changes
pnpm --filter emdash dev
# Terminal 2: Run the demo (demos/simple/)
pnpm --filter emdash-demo dev
Terminal window
pnpm test
Terminal window
# Type check TypeScript packages
pnpm typecheck
# Type check Astro demos
pnpm typecheck:demos
# Fast lint (< 1s) — run after every edit
pnpm lint:quick
# Full lint with type-aware rules (~10s) — run before commits
pnpm lint:json
Terminal window
pnpm format

EmDash uses oxfmt (Oxc formatter). The config is in .prettierrc (oxfmt uses the same config file format). Tabs, not spaces.

D1 is the source of truth. Schema lives in two system tables:

  • _emdash_collections — collection metadata
  • _emdash_fields — field definitions

When you create a collection, EmDash runs ALTER TABLE to create a real ec_* table with typed columns. There’s no EAV (Entity-Attribute-Value) approach.

Middleware chain (in order for every request):

  1. Runtime init — creates database connection, initializes EmDashRuntime
  2. Setup check — redirects to setup wizard if not configured
  3. Auth — validates session, populates locals.user
  4. Request context — sets up AsyncLocalStorage for preview/edit mode

Handler layer: business logic lives in api/handlers/*.ts. Route files are thin wrappers that parse input, call handlers, and format responses. Handlers return ApiResponse<T> = { success: boolean; data?: T; error?: { code, message } }.

FilePurpose
src/astro/integration/index.tsAstro integration entry point; generates virtual modules
src/emdash-runtime.tsCentral runtime; orchestrates DB, plugins, storage
src/schema/registry.tsManages ec_* table creation/modification
src/database/migrations/runner.tsStaticMigrationProvider; register new migrations here
src/plugins/manager.tsLoads and orchestrates trusted plugins

EmDash uses Kysely for all queries. Key rules:

// CORRECT: parameterized values
const post = await db
.selectFrom("ec_posts")
.selectAll()
.where("slug", "=", slug) // parameterized
.executeTakeFirst();
// CORRECT: validated identifier in raw SQL
validateIdentifier(tableName);
const result = await sql.raw(`SELECT * FROM ${tableName}`).execute(db);
// WRONG: never interpolate unvalidated values into SQL
const result = await sql.raw(`SELECT * FROM ${userInput}`).execute(db);

Never use sql.raw() with string interpolation for values. Use sql.ref() for identifiers, and the Kysely fluent API for everything else.

  1. Create packages/core/src/database/migrations/NNN_description.ts:

    import type { Kysely } from "kysely";
    export async function up(db: Kysely<unknown>): Promise<void> {
    await db.schema
    .createTable("my_table")
    .addColumn("id", "text", (col) => col.primaryKey())
    .addColumn("name", "text", (col) => col.notNull())
    .execute();
    }
    export async function down(db: Kysely<unknown>): Promise<void> {
    await db.schema.dropTable("my_table").execute();
    }
  2. Register it in packages/core/src/database/migrations/runner.ts:

    import * as m018 from "./018_my_migration.js";
    // Add to getMigrations() return value:
    "018_my_migration": m018,

Route files live in packages/core/src/astro/routes/api/. Follow these conventions:

packages/core/src/astro/routes/api/my-resource.ts
import type { APIRoute } from "astro";
import type { User } from "@emdash-cms/auth";
import { apiError, handleError } from "#api/error.js";
import { requirePerm } from "#api/authorize.js";
import { parseBody } from "#api/parse.js";
import { z } from "zod";
export const prerender = false;
const createInput = z.object({
name: z.string().min(1),
});
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash } = locals;
const user = (locals as { user?: User }).user;
if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
// requirePerm returns a 403 Response if denied, or null if authorized
const denied = requirePerm(user, "content:edit_any");
if (denied) return denied;
const body = await parseBody(request, createInput);
if (body instanceof Response) return body;
try {
// business logic here
return Response.json({ success: true });
} catch (error) {
return handleError(error, "Failed to create resource", "CREATE_ERROR");
}
};

Then register the route in packages/core/src/astro/integration/routes.ts.

Plugins are defined with definePlugin() and registered in the Astro config. See the Plugin System documentation for the full API.

For local plugin development:

Terminal window
# Create a local plugin in packages/
pnpm --filter emdash dev # Watch mode

Link your plugin in the demo’s astro.config.mjs:

import myPlugin from "../../packages/my-plugin/src/index.ts";
emdash({
plugins: [myPlugin()],
});

Tests live in packages/core/tests/. The structure mirrors source:

tests/
├── unit/ # Pure function tests
├── integration/ # Real DB tests (in-memory SQLite)
└── e2e/ # Playwright browser tests

Database tests use real SQLite, not mocks:

import { describe, it, beforeEach, afterEach } from "vitest";
import { setupTestDatabase } from "../utils/test-db.js";
import type { Kysely } from "kysely";
import type { Database } from "../../src/database/types.js";
describe("ContentRepository", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await db.destroy();
});
it("creates a content entry", async () => {
// test with real DB
});
});

E2E tests use Playwright with the dev bypass for authentication:

await page.goto(
"http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin"
);

Always use .js extensions for internal imports (ESM requirement):

// Correct
import { ContentRepository } from "../../database/repositories/content.js";
// Wrong
import { ContentRepository } from "../../database/repositories/content";

Use import type for type-only imports:

import type { Kysely } from "kysely";
import type { User } from "@emdash-cms/auth";

Use the shared error utilities in API routes:

// Error responses
return apiError("NOT_FOUND", "Content not found", 404);
// Catch blocks
catch (error) {
return handleError(error, "Failed to update content", "CONTENT_UPDATE_ERROR");
}

Every state-changing route must check authorization. Use requirePerm() from #api/authorize.js — it returns a Response (403) if denied, or null if authorized:

import { requirePerm } from "#api/authorize.js";
const denied = requirePerm(user, "content:edit_any");
if (denied) return denied;

For ownership-scoped actions, use requireOwnerPerm():

import { requireOwnerPerm } from "#api/authorize.js";
const denied = requireOwnerPerm(user, item.authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;
  1. Create a feature branch from main
  2. Make changes, ensure pnpm typecheck and pnpm lint:json pass
  3. Run relevant tests
  4. Commit with a descriptive message
  5. Open a PR targeting main

Commit messages should describe why, not just what:

# Good
fix: prevent media MIME sniffing with X-Content-Type-Options header
# Less good
fix: add header to media endpoint
  • Read AGENTS.md for architecture decisions and code patterns
  • Check the documentation site for guides and API reference