This file provides guidance to agentic coding tools when working with code in this repository.
Beta. EmDash is published to npm. All development happens inside this monorepo using workspace:* links. See CONTRIBUTING.md for the human-readable contributor guide (setup, repo layout, "build your own site" workflow).
This is a monorepo using pnpm workspaces.
CLAUDE.md is a symlink to AGENTS.md. .opencode/skills and .claude/skills are symlinks to skills/. Don't try to sync between them.
- Root: Workspace configuration and shared tooling
- packages/core: Main
emdashpackage - Astro integration and core APIs - demos/: Demo applications and examples (
demos/simple/is the primary dev target) - templates/: Starter templates (blog, marketing, portfolio, starter, blank) -- contributors copy these into
demos/to build their own sites - docs/: Public documentation site (Starlight)
This is a pre-release project. Do not add backwards compatibility or legacy patterns. Do not deprecate -- remove instead. Do not add migration paths.
Build for the known future. If we know we'll need something, build it now. Only defer things where there's genuine uncertainty about whether or how we'll need them. "We'll need it later" is a reason to do it now, not a reason to punt.
TDD for bugs. Write a failing test -> fix the bug -> verify the test passes. A bug without a reproducing test is not fixed.
- Run
pnpm --silent lint:json | jq '.diagnostics | length'and fix any issues. Non-negotiable.
- Run
pnpm --silent lint:quickafter every edit -- takes less than a second. Returns JSON with stderr redirected to /dev/null, so it won't break parsers. Fix any issues immediately. - Run
pnpm typecheck(packages) orpnpm typecheck:demos(Astro demos) after each round of edits. - Format regularly. pnpm format in the root uses oxfmt with tabs for indentation and is very fast. Don't let formatting pile up.
- Commit regularly, and always format and quick lint beforehand.
- Update tasks.md when completing tasks. Write a journal entry when starting or finishing significant work, or if you learn anything interesting or useful that you'd like to remember.
You verified linting and types were clean before starting. If they're failing now, your changes caused it -- even if the errors are in files you didn't touch. Don't dismiss failures as "unrelated". Don't assign blame. Just fix them.
- All tests pass:
pnpm test - Full lint suite clean:
pnpm --silent lint:json | jq '.diagnostics | length'. Returns JSON with stderr piped to /dev/null, so it won't break parsers. Fix any issues. - Format with
pnpm format(oxfmt with tabs for indentation, configured in.prettierrc). - Open the PR with the
prskill
Use bgproc (not raw process management):
bgproc start -n devserver -w -- pnpm dev # start and wait for port
bgproc stop devserver # stop
bgproc logs devserver # view logsEmDash is an Astro-native CMS that stores its schema in the database, not in code.
- Schema in the database.
_emdash_collectionsand_emdash_fieldsare the source of truth. Each collection gets a real SQL table (ec_posts,ec_products) with typed columns -- not EAV. - Middleware chain (in order): runtime init -> setup check -> auth -> request context (ALS). Auth middleware handles authentication; individual routes handle authorization.
- Handler layer (
api/handlers/*.ts) -- Business logic returnsApiResponse<T>({ success, data?, error? }). Route files are thin wrappers that parse input, call handlers, and format responses. - Storage abstraction --
Storageinterface withupload/download/delete/exists/list/getSignedUploadUrl. Implementations:LocalStorage(dev),S3Storage(R2/AWS). Access viaemdash.storagefrom locals.
Index discipline. Every content table gets indexes on: status, slug, created_at, deleted_at, scheduled_at (partial -- WHERE scheduled_at IS NOT NULL), live_revision_id, draft_revision_id, author_id, primary_byline_id, updated_at, locale, translation_group. Foreign key columns always get an index. Naming: idx_{table}_{column} for single-column, idx_{table}_{purpose} for multi-column.
API envelope consistency. Handlers return ApiResponse<T> wrapping data in { success, data }. List endpoints return { items, nextCursor? } inside data. The admin client's parseApiResponse unwraps body.data. Be aware of this layering when adding new endpoints.
pnpm build- Build all packagespnpm test- Run tests for all packagespnpm check- Run type checking and linting for all packagespnpm format- Format code using oxfmt
pnpm build- Build the package using tsdown (ESM + DTS output)pnpm dev- Watch mode for developmentpnpm test- Run vitest testspnpm check- Run publint and @arethetypeswrong/cli checks
| File | Purpose |
|---|---|
src/live.config.ts |
Collection schemas + admin config (user's site) |
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 |
Kysely is the query builder. Use it properly:
- Never use
sql.raw()with string interpolation or template literals containing variables. - Never build SQL strings with
+or backtick interpolation and pass them tosql.raw(). - For values, use Kysely's
sqltagged template:sql`SELECT * FROM t WHERE id = ${id}`-- interpolated values are automatically parameterized. - For identifiers (table/column names), use
sql.ref()which quotes them safely. - If you absolutely must use
sql.raw()for dynamic identifiers, validate them first withvalidateIdentifier()fromdatabase/validate.tswhich asserts/^[a-z][a-z0-9_]*$/. - The
json_extract(data, '$.${field}')pattern is particularly dangerous -- always validatefieldbefore interpolation.
// WRONG -- SQL injection via string interpolation
const query = `SELECT * FROM ${table} WHERE name = '${name}'`;
await sql.raw(query).execute(db);
// WRONG -- field name interpolated into sql.raw()
return sql.raw(`json_extract(data, '$.${field}')`);
// RIGHT -- parameterized value
await sql`SELECT * FROM ${sql.ref(table)} WHERE name = ${name}`.execute(db);
// RIGHT -- validated identifier in raw SQL
validateIdentifier(field);
return sql.raw(`json_extract(data, '$.${field}')`);All API routes under astro/routes/api/ must follow these patterns:
Error responses -- use apiError() from api/error.ts:
// WRONG -- inline JSON.stringify with ad-hoc shape
return new Response(JSON.stringify({ error: "Not found" }), { status: 404 });
// RIGHT -- consistent shape: { error: { code, message } }
return apiError("NOT_FOUND", "Content not found", 404);Catch blocks -- use handleError(), never expose error.message to clients:
// WRONG -- leaks internal error details
catch (error) {
return new Response(JSON.stringify({
error: error instanceof Error ? error.message : "Unknown error"
}), { status: 500 });
}
// RIGHT -- logs internally, returns generic message
catch (error) {
return handleError(error, "Failed to update content", "CONTENT_UPDATE_ERROR");
}Input validation -- use parseBody() / parseQuery() from api/parse.ts, never use as casts on request.json():
// WRONG -- no runtime validation, malformed input reaches the database
const body = (await request.json()) as CreateContentInput;
// RIGHT -- Zod validation, returns 400 on failure
const body = await parseBody(request, createContentSchema);Initialization checks -- use a consistent message:
if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);Handler results -- when using the handler layer (api/handlers/*.ts), always unwrap consistently:
const result = await handler.handleContentGet(collection, id);
if (!result.success) {
return apiError(result.error.code, result.error.message, mapErrorToStatus(result.error.code));
}
return Response.json(result.data);Every route that modifies state must check authorization. The auth middleware only checks authentication (is the user logged in); individual routes must check roles:
import { requireRole, Role } from "../../auth/permissions.js";
// At the top of any state-changing handler:
const roleError = requireRole(user, Role.EDITOR);
if (roleError) return roleError;Minimum roles:
- ADMIN: settings, schema, plugins, user management, imports, search rebuild
- EDITOR: all content CRUD, media, taxonomies, menus, widgets, publish/unpublish
- AUTHOR: own content CRUD, media upload
- CONTRIBUTOR: own content create/edit (no publish), media upload
All state-changing endpoints (POST/PUT/DELETE) require the X-EmDash-Request: 1 header, enforced by auth middleware. The admin UI and visual editing client send this header automatically. Do not add GET handlers for state-changing operations.
All list endpoints must use cursor-based pagination with a consistent shape:
// Return type for all list queries
interface FindManyResult<T> {
items: T[];
nextCursor?: string;
}- Use
encodeCursor(orderValue, id)/decodeCursor(cursor)utilities. - Default limit: 50. Maximum limit: 100. Always clamp.
- The response array key is always
items(notresults, not a bare array). - Never return a bare array from a list endpoint -- always wrap in
{ items, nextCursor? }.
When creating tables or adding columns queried in WHERE or ORDER BY clauses, add indexes. Check existing patterns in database/migrations/ and schema/registry.ts. Foreign key columns should always have an index.
Index naming: idx_{table}_{column} for single-column, idx_{table}_{purpose} for multi-column. Content tables get standard indexes on status, slug, created_at, deleted_at, author_id, and all foreign key columns.
Migrations live in packages/core/src/database/migrations/. Conventions:
- Naming:
NNN_descriptive_name.ts-- zero-padded 3-digit sequential number. - Exports: Each migration exports
up(db: Kysely<unknown>)anddown(db: Kysely<unknown>). - System tables use Kysely's schema builder (
db.schema.createTable(...)). - Dynamic content tables (
ec_*) usesqltagged templates withsql.ref()for identifiers. - Column types: SQLite types --
"text","integer","real","blob". Booleans are"integer"withdefaultTo(0). Timestamps are"text"withdefaultTo(sql`(datetime('now'))`). IDs are"text"primary keys (ULIDs fromulidx). - Index naming:
idx_{table}_{column}for single-column,idx_{table}_{purpose}for multi-column. - Foreign keys must always have an accompanying index.
- Registration: Migrations are statically imported in
database/runner.tsand added to theStaticMigrationProvider. They are NOT auto-discovered -- this is required for Workers bundler compatibility. When adding a migration: (1) create the file, (2) add a static import inrunner.ts, (3) add it togetMigrations(). - Multi-table migrations: When altering all content tables, query
_emdash_collectionsto discoverec_*tables and loop. See013_scheduled_publishing.tsfor the pattern.
Route files live in packages/core/src/astro/routes/api/. Conventions:
- Every route file starts with
export const prerender = false;. - Handlers are named exports:
export const GET: APIRoute,export const POST: APIRoute, etc. - Handlers destructure from the Astro context:
({ params, request, url, locals }). - Access the CMS runtime via
const { emdash } = locals;. - Access the user via
const user = (locals as { user?: User }).user;. - URL structure mirrors file structure:
content/[collection]/index.tsfor list/create,content/[collection]/[id].tsfor get/update/delete, with sub-actions as siblings:[id]/publish.ts,[id]/schedule.ts. - Never add GET handlers for state-changing operations.
Handlers in api/handlers/*.ts contain business logic. Routes should be thin wrappers.
- Handlers are standalone async functions (not class methods).
- First parameter is always
db: Kysely<Database>, followed by route-specific params. - Always return
ApiResponse<T>-- the{ success, data?, error? }discriminated union fromapi/types.ts. - Entire body wrapped in try/catch. Errors return
{ success: false, error: { code, message } }. - Error codes are
SCREAMING_SNAKE_CASE:NOT_FOUND,VALIDATION_ERROR,CONTENT_CREATE_ERROR, etc.
All admin API functions use throwResponseError() from lib/api/client.ts to surface server error messages to the user. Never throw a generic error when the response body contains a message.
import { apiFetch, throwResponseError } from "./client.js";
// WRONG -- loses the server's error message
if (!response.ok) throw new Error("Failed to create term");
// WRONG -- manually parsing what throwResponseError already does
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || "Failed to create term");
}
// RIGHT -- parses { error: { message } } body, falls back to generic message
if (!response.ok) await throwResponseError(response, "Failed to create term");Use ConfirmDialog from components/ConfirmDialog.tsx for all confirmation modals (delete, disable, demote, etc.). Pass mutation.error directly -- don't manage error state manually.
import { ConfirmDialog } from "./ConfirmDialog.js";
<ConfirmDialog
open={!!deleteSlug}
onClose={() => { setDeleteSlug(null); deleteMutation.reset(); }}
title="Delete Section?"
description="This will permanently delete the section."
confirmLabel="Delete"
pendingLabel="Deleting..."
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteMutation.mutate(deleteSlug)}
/>For form dialogs and other cases where ConfirmDialog doesn't fit, use DialogError and getMutationError() from components/DialogError.tsx:
import { DialogError, getMutationError } from "./DialogError.js";
// In JSX -- renders nothing when message is null
<DialogError message={getMutationError(createMutation.error)} />
// With local error state fallback (e.g. client-side validation)
<DialogError message={localError || getMutationError(mutation.error)} />Don't duplicate the error banner styling inline -- always use DialogError.
- Internal imports always use
.jsextensions (ESM requirement):import { ContentRepository } from "../../database/repositories/content.js";
- Type-only imports must use
import type(enforced byverbatimModuleSyntax: true):import type { Kysely } from "kysely";
- Package imports do not use extensions:
import { sql } from "kysely". - Virtual modules use
// @ts-ignorecomment:// @ts-ignore - virtual module import virtualConfig from "virtual:emdash/config";
- Barrel files (
index.ts) re-export from sub-modules. Separateexport type { ... }from value exports.
- Dev-only endpoints must check
import.meta.env.DEVand return 403 if false. This is a compile-time constant -- it cannot be spoofed at runtime. - Never use
process.env.NODE_ENV-- always useimport.meta.env.DEVorimport.meta.env.PROD(Vite/Astro standard). - Secrets follow the pattern:
import.meta.env.EMDASH_X || import.meta.env.X || ""-- check prefixed name first, then generic, then fallback.
To access the Cloudflare env object, import it directly from "cloudflare:workers" -- no need to access it from the context in a handler. This is a virtual module that resolves to the correct bindings for the current environment, whether that's a Worker or a local dev environment.
Do not manually type the Cloudflare Env object. When in a Worker context, run pnpm wrangler types to generate worker-configuration.d.ts with the correct bindings for the current environment. This includes types for bindings in wrangler.jsonc as well as secrets in .dev.vars. Regenerate it if you edit the bindings. Ensure it is referenced in tsconfig.json under include and then the types will be available globally.
If not working in a Worker context, but in a library that will be used in a Worker, install @cloudflare/workers-types and reference it in tsconfig.json under compilerOptions.types. This will allow you to use Cloudflare-specific types like R2Bucket and D1Database in your code.
Dynamic content tables are managed by SchemaRegistry in schema/registry.ts:
- Table names:
ec_{collection_slug}(e.g.,ec_posts). System tables:_emdash_{name}. - Slug validation:
/^[a-z][a-z0-9_]*$/, max 63 chars. Checked againstRESERVED_COLLECTION_SLUGSandRESERVED_FIELD_SLUGS. - Standard columns: Every content table gets
id,slug,status,author_id,created_at,updated_at,published_at,scheduled_at,deleted_at,version,live_revision_id,draft_revision_id. User-defined field columns are added viaALTER TABLE. - Field type mapping:
FIELD_TYPE_TO_COLUMNmaps: string/text/datetime/image/reference -> TEXT, number -> REAL, integer/boolean -> INTEGER, portableText/json -> JSON. - Orphan discovery:
discoverOrphanedTables()findsec_*tables without matching_emdash_collectionsentries. This is used for recovering from crashes during schema changes.
- Framework: vitest. Tests in
packages/core/tests/. - Database: Tests use real in-memory SQLite via
better-sqlite3+ Kysely. No DB mocking. - Utilities:
tests/utils/test-db.tsprovidescreateTestDatabase(),setupTestDatabase()(with migrations), andsetupTestDatabaseWithCollections()(with standard post/page collections). - Structure:
tests/unit/for unit,tests/integration/for integration (real DB),tests/e2e/for Playwright. Test files mirror source structure. - Lifecycle: Each test gets a fresh in-memory DB in
beforeEach, destroyed inafterEach.
When accepting redirect URLs from query params or request bodies:
- Validate the URL starts with
/(relative path only). - Reject URLs starting with
//(protocol-relative -- would redirect to external hosts). - HTML-escape any URL values before interpolating into HTML responses.
- Prefer server-side
Response.redirect()over HTML<meta http-equiv="refresh">.
- pnpm -- package manager
- tsdown -- TypeScript builds (ESM + DTS)
- vitest -- testing
- oxfmt -- code formatting (tabs for indentation, configured in
.prettierrc). All source files use tabs, not spaces.
- Target: ES2022
- Module: preserve (for bundler compatibility)
- Strict mode with
noUncheckedIndexedAccess,noImplicitOverride
EmDash uses passkey authentication which cannot be automated in browser tests. Two dev-only endpoints are available to bypass authentication:
Skips the setup wizard, runs migrations, creates a dev admin user, and establishes a session:
GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
Creates a session for the dev admin user (assumes setup is already complete):
GET /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin
When testing the admin UI with agent-browser, navigate to the setup bypass URL first:
await page.goto("http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin");This will:
- Run database migrations
- Create a dev admin user (
dev@emdash.local) - Set up a session cookie
- Redirect to the admin dashboard
Note: These endpoints only work when import.meta.env.DEV is true. They return 403 in production.