This file provides guidance to agentic coding tools when working with code in this repository.
Beta, post pre-release. EmDash is published to npm and in active use, with i18n, RTL, and the plugin system shipped. We're no longer in the scorched-earth pre-release phase -- real users depend on current behavior, so backwards compatibility now matters (see Rules below). 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, runtime, schema, API routes, CLI - packages/admin:
@emdash-cms/admin-- React admin UI shipped as a single mounted app under/_emdash/admin/* - packages/auth:
@emdash-cms/auth-- RBAC primitives (Permissions,hasPermission,canActOnOwn), passkey + magic link, RoleLevel ladder - packages/blocks:
@emdash-cms/blocks-- shared Portable Text block defs and renderers - packages/cloudflare:
@emdash-cms/cloudflare-- D1/R2/Workers integration helpers - packages/marketplace:
@emdash-cms/marketplace-- plugin/theme marketplace client - packages/x402:
@emdash-cms/x402-- HTTP 402 payment middleware - packages/create-emdash:
create-emdash-- scaffolding CLI for new sites - packages/gutenberg-to-portable-text: WordPress import helper
- 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)
Backwards compatibility matters now. We're out of pre-release, but pre-1.0. Real installs depend on current behavior, schemas, and API shapes. Breaking changes are allowed in minors, but need an explicit decision, a bump on the affected package, and a changeset that calls the break out clearly. Prefer additive changes: new fields, new routes, new options with sensible defaults. If an old API is obsolete, mark the replacement as preferred and keep the old path working unless there's a reason it can't. Database migrations are forward-only -- never write one that leaves existing content inaccessible. When in doubt, open a Discussion before coding.
TDD for bugs. Write a failing test -> fix the bug -> verify the test passes. A bug without a reproducing test is not fixed.
Localize everything user-facing. All admin UI strings, aria labels, and toast messages go through Lingui. All admin layout uses RTL-safe logical Tailwind classes. See the Localization and RTL sections below.
Read CONTRIBUTING.md before opening a PR. Key rules:
- You MUST use the PR template. Every PR must include the PR template with all sections filled out. The template is loaded automatically when you create a PR via the GitHub UI. If you create a PR via the API or CLI, copy the template from
.github/PULL_REQUEST_TEMPLATE.mdinto the PR body. PRs that do not use the template will be closed automatically by CI. - Features require a prior approved Discussion. Do not open a feature PR without one. It will be closed. Open a Discussion in the Ideas category first.
- Bug fixes and docs can be PRed directly.
- Check every applicable checkbox in the PR template, including the "I have read CONTRIBUTING.md" box and the AI disclosure box if any part of the code was AI-generated.
- Do not make bulk/spray changes (e.g., "fix all lint warnings", "add types everywhere", "improve error handling across codebase"). If you see a systemic issue, open a Discussion.
- Do not touch code outside the scope of your change. No drive-by refactors, no "while I'm here" improvements, no added comments or logging in unrelated files.
- All CI checks must pass. Typecheck, lint, format, and tests. No exceptions.
- All non-trivial code changes should have an adversarial review. Before opening the PR, perform cycles of adversarial review in a sub-agent, then fix, then re-review until no issues remain.
- 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.
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.
If your change affects a published package's behavior, add a changeset. Without one, the change won't trigger a package release.
pnpm changeset --emptyThis creates a blank changeset file in .changeset/. Edit it to add the affected package(s), bump type, and description:
---
"emdash": patch
---
Fixes CLI `--json` flag so JSON output is clean.Start descriptions with a present-tense verb (Adds, Fixes, Updates, Removes, Refactors). Focus on what changes for the user, not implementation details.
Skip changesets for docs-only, test-only, CI, or demo/template changes.
See CONTRIBUTING.md § Changesets for full guidance and examples.
- 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). - Add a changeset if the change affects a published package:
pnpm changeset. - Open the PR (via
gh pr createor the GitHub UI). Fill out every section of the PR template -- copy.github/PULL_REQUEST_TEMPLATE.mdinto the body if using the API/CLI. Check the AI disclosure box if any code was AI-generated.
EmDash is an Astro-native CMS
- 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 -- prefer the unwrapResult() helper over manual unwrapping:
import { unwrapResult } from "#api/error.js";
// RIGHT -- one-liner; returns the right status from the error code automatically
const result = await handleContentGet(db, collection, id);
return unwrapResult(result);
// Manual unwrap is only needed when you want to do something between the
// success check and the response (e.g. set a custom header):
import { apiError, mapErrorStatus } from "#api/error.js";
if (!result.success) {
return apiError(result.error.code, result.error.message, mapErrorStatus(result.error.code));
}
return Response.json(result.data);Note the function is named mapErrorStatus, not mapErrorToStatus.
Every route that modifies state must check authorization. The auth middleware only checks authentication (is the user logged in); individual routes must check permissions.
Authorization is permission-based, not role-based. The Permissions map in @emdash-cms/auth (see packages/auth/src/rbac.ts) lists every gate -- "content:read_drafts", "content:edit_own", "content:edit_any", "schema:manage", "media:upload", etc. -- and binds each to a minimum RoleLevel. Roles still exist as the underlying ladder (SUBSCRIBER < CONTRIBUTOR < AUTHOR < EDITOR < ADMIN), but route code never references them directly.
Use the helpers from #api/authorize.js:
import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
// Simple permission check -- use for any-actor capabilities (settings, schema, etc.)
const denied = requirePerm(user, "schema:manage");
if (denied) return denied;
// Ownership-aware check -- use for resources where authors can act on their own
// but only editors can act on anyone else's. Pass the resource owner's id and
// both the "own" and "any" permissions.
const denied = requireOwnerPerm(user, post.authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;requirePerm returns null on success, or a Response (401 if unauthenticated, 403 if authorized but missing the permission) that you should return directly. Same shape for requireOwnerPerm.
To find the right permission string for a new endpoint, scan packages/auth/src/rbac.ts. If no existing permission fits, add one there with a sensible minimum role -- this is the authoritative list. Never invent a permission string in a route file.
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.
EmDash runs on D1 with the Sessions API. Anonymous reads go to the nearest replica; writes and authenticated reads route to the primary. The primary is thousands of miles from some CF colos -- every round-trip matters, especially on cold isolates.
A few rules and patterns cover 90% of the footguns.
Always add requestCached to query helpers called from templates. Page-level template code runs inside the ALS request context, so the per-request cache (src/request-cache.ts) deduplicates identical calls within a single render. A single un-cached helper called from three widgets turns into three primary-routed reads on a page that should have made one. Rule of thumb: if a helper takes stable arguments (slug, key, entry ID) and can be called from multiple components, wrap it.
// WRONG — every caller re-queries
export async function getSiteSetting(key: string) {
const db = await getDb();
return db.selectFrom("options").where("name", "=", key)...
}
// RIGHT — shared within one render
export function getSiteSetting(key: string) {
return requestCached(`siteSetting:${key}`, async () => {
const db = await getDb();
return ...;
});
}The cache key must include every argument that changes the result. Missing an argument means wrong values get served; including too much just means more cache misses.
requestCached caches the promise, so concurrent callers share the in-flight query. On error the entry is deleted so the next call retries.
Module-scope singletons must live on globalThis. Vite duplicates modules across chunks during SSR bundling. A plain let cache: X | null = null in a module becomes two variables if two chunks inline the module -- defeating the singleton. Use a Symbol.for key on globalThis, as request-context.ts does. See also packages/core/src/bylines/index.ts (bylinesHolder) for the pattern applied to a boolean cache. The fix cut ~2 cold-start queries per D1 isolate.
Prefer the batch query to a "has any" probe. Adding a SELECT id FROM foo LIMIT 1 before a batch query to skip it on empty sites trades one extra query on every real request for saving one query on sites that almost never exist. On live sites the batch query returns empty at the same cost; handle missing tables with an isMissingTableError catch.
Defer bookkeeping past the response with after(fn). Maintenance writes (cron recovery, audit log flushes) don't need to block TTFB. after(fn) hands the promise to workerd's waitUntil when available, or fire-and-forgets on Node. Errors are caught and logged with the [emdash] prefix -- add your own try/catch inside fn with a module-specific prefix (e.g. [cron]) for better grep-ability. Deferred writes still happen; they just don't gate the response.
import { after } from "emdash";
after(async () => {
try {
await recoverStaleLocks();
} catch (error) {
console.error("[cron] recovery failed:", error);
}
});One query beats two whenever possible. Every query pays a round-trip to the replica (and the primary for writes). If you're fetching parent + children, use a LEFT JOIN. If you're fetching related records by a list of IDs, batch with WHERE id IN (...) -- but chunk at SQL_BATCH_SIZE (from utils/chunks.ts) to stay below D1's bind-parameter limit.
Every new helper gets a query-count impact check. The fixture harness (pnpm query-counts, see scripts/query-counts.mjs) builds fixtures/perf-site/ and records per-route query counts in scripts/query-counts.snapshot.{sqlite,d1}.json. CI auto-updates the snapshots on PRs; review the diff. Fewer queries on a route is always the right direction. More requires a conversation.
The admin UI is built on Kumo (Cloudflare's design system). Use Kumo components for all standard UI elements -- never roll your own buttons, inputs, dialogs, selects, etc. This gives us consistent styling, dark mode, accessibility, and RTL support for free.
Look up component docs from the CLI -- don't guess at props:
npx @cloudflare/kumo doc Button # docs for a specific component
npx @cloudflare/kumo ls # list all available components
npx @cloudflare/kumo docs # docs for everythingKey components (all from @cloudflare/kumo):
Button-- all clickable actions. Supportsvariant,size,icon, andloading.LinkButton-- anchor styled as a button. Use for navigation, never<a>with manual styling. Supportsexternalprop for new-tab links.Dialog-- all modals. UseConfirmDialog(ours) for simple confirm/cancel flows.Input,InputArea,Select,Checkbox,Switch-- form controls.Toast/Toasty-- notifications.Loader-- loading spinners.Badge-- status labels, counts.Popover,Dropdown,Tooltip-- overlays.CommandPalette-- the admin command palette.Label-- form labels (pairs with inputs).
import { Button, LinkButton, Loader } from "@cloudflare/kumo";
// loading prop -- shows spinner and disables interaction automatically
<Button variant="primary" loading={mutation.isPending}>
{t`Save`}
</Button>
// icon prop with conditional Loader -- use when the icon itself changes per state
// (e.g. different icons for idle/pending/done -- see SaveButton.tsx for the full pattern)
<Button
variant={isSaved ? "secondary" : "primary"}
icon={isSaving ? <Loader size="sm" /> : isSaved ? <Check /> : <FloppyDisk />}
disabled={isSaving || isSaved}
aria-busy={isSaving}
>
{isSaving ? t`Saving...` : isSaved ? t`Saved` : t`Save`}
</Button>
// icon prop -- pass a Phosphor icon component or React element
<Button variant="secondary" icon={PlusIcon}>{t`Add item`}</Button>
// icon-only buttons require shape + aria-label
<Button shape="square" icon={TrashIcon} aria-label={t`Delete`} variant="ghost" />
// LinkButton for navigation -- never use <a> with manual button classes
<LinkButton href="/_emdash/admin" variant="ghost" icon={HouseIcon}>
{t`Dashboard`}
</LinkButton>
// external links open in new tab with rel="noopener noreferrer"
<LinkButton href="https://docs.example.com" external>{t`Docs`}</LinkButton>Styling rules:
- Use semantic color tokens, not raw Tailwind colors.
bg-kumo-brandnotbg-blue-500.text-kumo-subtlenottext-gray-500. The full token list is in the Kumo styles. - Never use
dark:prefixes. Kumo's semantic tokens use CSSlight-dark()to handle dark mode automatically. If you're writingdark:bg-something, you're bypassing the design system. - Don't reach for raw HTML elements or Tailwind-only solutions when a Kumo component exists. If you need a button, use
Button. If you need a link that looks like a button, useLinkButton. If you needbuttonVariants()classes on a non-button element, importbuttonVariantsfrom@cloudflare/kumo.
Every user-facing string in the admin UI goes through Lingui. No hard-coded English literals in JSX, attributes, or TypeScript strings that end up in the DOM.
- Catalogs live in
packages/admin/src/locales/{locale}/messages.po. English is the source. - Enabled locales are defined in
packages/admin/src/locales/locales.ts. - After adding or changing strings, run
pnpm locale:extractthenpnpm locale:compile. CI fails if catalogs are stale. - Set
EMDASH_PSEUDO_LOCALE=1in dev to render pseudo-localized text -- any untranslated English leaking through is immediately visible.
import { useLingui } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
// Simple strings -- tagged template
function DeleteButton() {
const { t } = useLingui();
return (
<button type="button" aria-label={t`Delete post`}>
{t`Delete`}
</button>
);
}
// JSX with interpolation or nested components -- <Trans> macro
<Trans>
Published by <strong>{authorName}</strong> on {formattedDate}
</Trans>;
// Pluralization -- use plural macro
import { plural } from "@lingui/core/macro";
const label = plural(count, { one: "# item", other: "# items" });
// Module-scope constants -- use msg`` descriptors, resolve with t() inside component
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
interface BlockTransform {
id: string;
label: MessageDescriptor;
}
const blockTransforms: BlockTransform[] = [
{ id: "paragraph", label: msg`Paragraph` },
{ id: "heading1", label: msg`Heading 1` },
];
function BlockMenu() {
const { t } = useLingui();
return blockTransforms.map((b) => <span key={b.id}>{t(b.label)}</span>);
}Common mistakes to avoid:
- Bare string literals in JSX:
<button>Save</button>-- must be<button>{t\Save`}orSave`. - Unwrapped aria labels, titles, placeholders, alt text: these are user-facing too.
aria-label="Close"->aria-label={t`Close`}. - Concatenating translated pieces:
t`Hello ` + namebreaks word order in other languages. Uset`Hello ${name}`or<Trans>. - Calling
tat module scope: the locale isn't bound yet. Usemsgto create aMessageDescriptor, then resolve it witht(descriptor)inside the component. Type the constant asMessageDescriptor(from@lingui/core). - Reusing the same key for different meanings: give them distinct messages or use context.
Server-side (API error messages): still English-only for now. Keep error codes stable (SCREAMING_SNAKE_CASE) -- the admin UI maps codes to localized messages client-side.
The admin supports RTL locales. Use logical Tailwind classes, never physical ones. An LTR-only class that slips in will misplace UI in Arabic.
| Use | Not |
|---|---|
ms-* / me-* (margin-inline-start/end) |
ml-* / mr-* |
ps-* / pe-* (padding-inline-start/end) |
pl-* / pr-* |
start-* / end-* (inset-inline-start/end) |
left-* / right-* |
text-start / text-end |
text-left / text-right |
border-s / border-e |
border-l / border-r |
rounded-s-* / rounded-e-* |
rounded-l-* / rounded-r-* |
float-start / float-end |
float-left / float-right |
For icons that indicate direction (chevrons, arrows, back/forward), flip them in RTL. Use rtl:-scale-x-100 on the icon, or pick a bidi-aware icon. Don't rely on the icon being visually neutral.
LocaleDirectionProvider (packages/admin/src/locales/LocaleDirectionProvider.tsx) syncs document.documentElement.dir and lang with the active locale. You don't need to set these manually.
Always test new admin UI in Arabic before considering it done. Switch the locale in the admin and walk through the feature. Broken directionality is the single most common i18n regression.
Content tables use a row-per-locale model (see migration 019_i18n.ts):
- Every
ec_*table has alocalecolumn (defaults to'en') and atranslation_groupULID shared across translations of the same piece of content. - Slug uniqueness is
UNIQUE(slug, locale)-- not global. Two posts can share a slug across locales. - Any new query against a content table must filter by
localeor deliberately operate across locales (e.g. the translations endpoint). Forgetting the filter is a correctness bug, not a style issue. - Indexes exist on both
localeandtranslation_group. Use them. - Fetch all translations of a single piece via
GET /_emdash/api/content/{collection}/{id}/translations.
When adding new content-table features (new columns, new filters, new list endpoints), ask: does this need to be per-locale or per-translation-group? Per-locale is usually correct for display fields; per-group is correct for anything identifying "the same thing" across languages (e.g. comment counts, view counts might aggregate across the group).
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 databases, never mocks. SQLite (
better-sqlite3) for the default in-memory case; PostgreSQL via a realpgconnection with per-test schema isolation for parity tests of dialect-sensitive code (setPG_CONNECTION_STRINGto opt in). - Utilities:
tests/utils/test-db.tsprovides:- SQLite:
createTestDatabase(),setupTestDatabase()(runs migrations),setupTestDatabaseWithCollections()(migrations + standard post/page collections),teardownTestDatabase() - Postgres:
setupTestPostgresDatabase(),setupTestPostgresDatabaseWithCollections(),teardownTestPostgresDatabase() - Dialect-agnostic:
setupForDialect(dialect),setupForDialectWithCollections(dialect),teardownForDialect(ctx), plus adescribeEachDialect(name, fn)wrapper that runs the same test suite against each dialect. Use this for any code that builds queries -- regressions tend to be SQLite-only or Postgres-only.
- SQLite:
- 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 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). 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.