Contributing to EmDash
This guide covers how to set up a local development environment, understand the codebase architecture, and contribute to EmDash.
Repository Structure
Section titled “Repository Structure”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 orchestratorPrerequisites
Section titled “Prerequisites”- Node.js 20 or higher
- pnpm 9 or higher
- Git
# Install pnpm if you don't have itnpm install -g pnpmLocal Setup
Section titled “Local Setup”-
Clone the repository
Terminal window git clone <repository-url>cd emdash -
Install dependencies
Terminal window pnpm install -
Build packages (required before running the demo)
Terminal window pnpm build -
Seed the demo database (
demos/simple/)Terminal window pnpm --filter emdash-demo seed -
Start the development server
Terminal window pnpm --filter emdash-demo dev -
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
Development Workflow
Section titled “Development Workflow”Watch Mode
Section titled “Watch Mode”For package development, use watch mode alongside the demo:
# Terminal 1: Watch packages/core for changespnpm --filter emdash dev
# Terminal 2: Run the demo (demos/simple/)pnpm --filter emdash-demo devRunning Tests
Section titled “Running Tests”pnpm testpnpm --filter emdash testpnpm --filter emdash test --watchpnpm --filter emdash-demo test:e2eType Checking and Linting
Section titled “Type Checking and Linting”# Type check TypeScript packagespnpm typecheck
# Type check Astro demospnpm typecheck:demos
# Fast lint (< 1s) — run after every editpnpm lint:quick
# Full lint with type-aware rules (~10s) — run before commitspnpm lint:jsonFormatting
Section titled “Formatting”pnpm formatEmDash uses oxfmt (Oxc formatter). The config is in .prettierrc (oxfmt uses the same config file format). Tabs, not spaces.
Architecture Overview
Section titled “Architecture Overview”Core Concepts
Section titled “Core Concepts”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):
- Runtime init — creates database connection, initializes
EmDashRuntime - Setup check — redirects to setup wizard if not configured
- Auth — validates session, populates
locals.user - 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 } }.
Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
src/astro/integration/index.ts | Astro integration entry point; generates virtual modules |
src/emdash-runtime.ts | Central runtime; orchestrates DB, plugins, storage |
src/schema/registry.ts | Manages ec_* table creation/modification |
src/database/migrations/runner.ts | StaticMigrationProvider; register new migrations here |
src/plugins/manager.ts | Loads and orchestrates trusted plugins |
Database Patterns
Section titled “Database Patterns”EmDash uses Kysely for all queries. Key rules:
// CORRECT: parameterized valuesconst post = await db .selectFrom("ec_posts") .selectAll() .where("slug", "=", slug) // parameterized .executeTakeFirst();
// CORRECT: validated identifier in raw SQLvalidateIdentifier(tableName);const result = await sql.raw(`SELECT * FROM ${tableName}`).execute(db);
// WRONG: never interpolate unvalidated values into SQLconst 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.
Adding a Migration
Section titled “Adding a Migration”-
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();} -
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,
Adding an API Route
Section titled “Adding an API Route”Route files live in packages/core/src/astro/routes/api/. Follow these conventions:
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.
Plugin Development
Section titled “Plugin Development”Plugins are defined with definePlugin() and registered in the Astro config. See the Plugin System documentation for the full API.
For local plugin development:
# Create a local plugin in packages/pnpm --filter emdash dev # Watch modeLink your plugin in the demo’s astro.config.mjs:
import myPlugin from "../../packages/my-plugin/src/index.ts";
emdash({ plugins: [myPlugin()],});Testing Patterns
Section titled “Testing Patterns”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 testsDatabase 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");Code Conventions
Section titled “Code Conventions”Imports
Section titled “Imports”Always use .js extensions for internal imports (ESM requirement):
// Correctimport { ContentRepository } from "../../database/repositories/content.js";
// Wrongimport { ContentRepository } from "../../database/repositories/content";Use import type for type-only imports:
import type { Kysely } from "kysely";import type { User } from "@emdash-cms/auth";Error Handling
Section titled “Error Handling”Use the shared error utilities in API routes:
// Error responsesreturn apiError("NOT_FOUND", "Content not found", 404);
// Catch blockscatch (error) { return handleError(error, "Failed to update content", "CONTENT_UPDATE_ERROR");}Authorization
Section titled “Authorization”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;Commit and PR Process
Section titled “Commit and PR Process”- Create a feature branch from
main - Make changes, ensure
pnpm typecheckandpnpm lint:jsonpass - Run relevant tests
- Commit with a descriptive message
- Open a PR targeting
main
Commit messages should describe why, not just what:
# Goodfix: prevent media MIME sniffing with X-Content-Type-Options header
# Less goodfix: add header to media endpointGetting Help
Section titled “Getting Help”- Read
AGENTS.mdfor architecture decisions and code patterns - Check the documentation site for guides and API reference