Rules
July 21, 2026 · View on GitHub
This file provides guidance to agentic coding tools working in this repository.
For human-facing contributor info (setup, repo layout, PR policy, changesets, i18n), see CONTRIBUTING.md. This file focuses on the patterns and gotchas an agent needs to write correct code.
CLAUDE.md is a symlink to this file. .opencode/skills and .claude/skills are symlinks to skills/. Don't try to sync between them.
Rules
Backwards compatibility matters. EmDash is published and in active use, pre-1.0. Prefer additive changes (new fields, new routes, new options with defaults). Breaking changes need an explicit decision, a package bump, and a changeset that calls the break out clearly. Database migrations are forward-only -- never write one that leaves existing content inaccessible. When in doubt, open a Discussion.
TDD for bugs. Failing test -> fix -> verify. 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 Localization and RTL.
Scope discipline. No drive-by refactors, no bulk lint/type cleanups, no "while I'm here" edits in unrelated files. If you see a systemic issue, open a Discussion. See CONTRIBUTING.md § Contribution Policy.
Never add queries to the logged-out hot path. Any change that increases the query count of a logged-out route needs a really good reason -- and that includes cold-start or first-request-only queries, not just steady state. See Performance.
Comments are for code readers, not reviewers. Write a comment only when the code can't be made clearer, or to give a non-obvious "why". Never justification, narrative, or issue/PR references. See Comments.
Workflow
Run pnpm lint:json | jq '.diagnostics | length' before starting and confirm it's clean -- if it's failing after your edits, your changes caused it.
During work:
pnpm lint:quickafter every edit (sub-second)pnpm typecheck(packages) orpnpm typecheck:demos(Astro demos) after each round of editspnpm formatregularly (oxfmt, tabs)
Before opening a PR: tests pass, lint clean, formatted, changeset added if a published package changed. See CONTRIBUTING.md § Changesets.
A changeset is release notes a user reads while upgrading -- not a commit message, PR description, or summary of your diff. Do not paste your PR prose into it. Write for someone who will run the new version and wants to know what changed for them: lead with a present-tense verb (Fixes, Adds, Updates, Removes), describe the observable effect, and leave out internal mechanics (file names, refactors, how you implemented it). For a breaking change, include the migration step. One sentence is often enough.
When opening a PR with gh/the API, copy .github/PULL_REQUEST_TEMPLATE.md into the body and fill every section -- the GitHub UI injects it automatically but the CLI does not, and PRs missing it are auto-closed. Check the AI-generated code disclosure box and name the model. Tick checklist items only for what you actually verified; for test-only/docs/CI PRs, note why changeset/i18n/Discussion items are n/a.
Architecture
EmDash is an Astro-native CMS on Cloudflare (D1 + R2 + Workers) or Node + SQLite.
- 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: runtime init -> setup check -> auth -> request context (ALS). Auth middleware checks authentication only; routes check authorization.
- Handler layer (
packages/core/src/api/handlers/*.ts) holds business logic and returnsApiResult<T>({ success: true, data } | { success: false, error: { code, message, details? } }). Route files are thin wrappers. - Storage abstraction:
Storageinterface withupload/download/delete/exists/list/getSignedUploadUrl.LocalStoragefor dev,S3Storagefor R2/AWS. Access viaemdash.storagefrom locals.
Key files:
| File | Purpose |
|---|---|
packages/core/src/emdash-runtime.ts | Central runtime; orchestrates DB, plugins, storage |
packages/core/src/schema/registry.ts | Manages ec_* table creation/modification |
packages/core/src/database/migrations/runner.ts | StaticMigrationProvider; register new migrations here |
packages/core/src/plugins/manager.ts | Loads and orchestrates plugins |
Code Patterns
Database: Never Interpolate Into SQL
Kysely is the query builder.
- Never use
sql.raw()with string interpolation or template literals containing variables. - For values, use Kysely's
sqltagged template -- interpolated values are automatically parameterized. - For identifiers (table/column names), use
sql.ref(). - If you must use
sql.raw()for dynamic identifiers, validate withvalidateIdentifier()fromdatabase/validate.tsfirst (asserts/^[a-z][a-z0-9_]*$/). - The
json_extract(data, '$.${field}')pattern is particularly dangerous -- always validatefield.
// WRONG -- SQL injection
const query = `SELECT * FROM ${table} WHERE name = '${name}'`;
await sql.raw(query).execute(db);
// RIGHT -- parameterized value, safe identifier
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}')`);
API Routes
Routes live in packages/core/src/astro/routes/api/. Conventions:
- Every route file starts with
export const prerender = false;. - Named exports:
export const GET: APIRoute, etc. Destructure from the Astro context. - Access runtime via
const { emdash } = locals;, user vialocals.user. - File structure mirrors URLs:
content/[collection]/index.tsfor list/create,[id].tsfor get/update/delete, sub-actions as siblings. - Never add GET handlers for state-changing operations.
Use the shared utilities -- don't roll your own:
| Need | Use |
|---|---|
| Error response | apiError(code, message, status) from #api/error.js |
| Catch block | handleError(error, message, code) -- never expose error.message to clients |
| Body validation | parseBody(request, zodSchema) from #api/parse.js -- never as cast request.json() |
| Unwrap handler | unwrapResult(result) -- maps error codes to HTTP statuses automatically |
| Init check | if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); |
The error helper is mapErrorStatus, not mapErrorToStatus.
Authorization
Every state-changing route must check authorization. Authorization is permission-based, not role-based -- the Permissions map in packages/auth/src/rbac.ts is authoritative. Never invent permission strings in route files; add them to rbac.ts with a sensible minimum role.
import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
// Any-actor capability (settings, schema)
const denied = requirePerm(user, "schema:manage");
if (denied) return denied;
// Ownership-aware (authors edit their own; editors edit anyone's)
const denied = requireOwnerPerm(user, post.authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;
Both helpers return null on success or a Response (401/403) to return directly.
CSRF
All state-changing endpoints require the X-EmDash-Request: 1 header, enforced by auth middleware. The admin UI and visual editing client send it automatically.
Pagination
List endpoints return { items, nextCursor? } -- never a bare array. Use encodeCursor(orderValue, id) / decodeCursor(cursor). Default limit 50, max 100, always clamp. The repository-level shape is FindManyResult<T>.
URL/Redirect handling
When accepting redirect URLs from query params or bodies: require leading /, reject //, HTML-escape before interpolation, prefer Response.redirect() over <meta http-equiv="refresh">.
Handler Layer
Handlers in api/handlers/*.ts are standalone async functions, not class methods.
- First parameter is always
db: Kysely<Database>, followed by route-specific params. - Return
ApiResult<T>. - Wrap the body in try/catch. Errors return
{ success: false, error: { code, message } }. - Error codes are
SCREAMING_SNAKE_CASE(NOT_FOUND,VALIDATION_ERROR,CONTENT_CREATE_ERROR).
Migrations
Migrations live in packages/core/src/database/migrations/.
- Naming:
NNN_descriptive_name.ts, zero-padded. - Exports:
up(db: Kysely<unknown>)anddown(db: Kysely<unknown>). - System tables: Kysely schema builder.
- Dynamic content tables (
ec_*):sqltagged templates withsql.ref()for identifiers. - Column types: SQLite --
text,integer,real,blob. Booleans areintegerdefaulting to 0. Timestamps aretextwithdefaultTo(sql`(datetime('now'))`). IDs aretextprimary keys (ULIDs fromulidx). - Registration: Migrations are statically imported in
runner.tsand added toStaticMigrationProvider. Not auto-discovered (Workers bundler compatibility). When adding: create the file, add a static import inrunner.ts, add it togetMigrations(). - Multi-table migrations: When altering all content tables, query
_emdash_collectionsand loop. See013_scheduled_publishing.ts.
Indexes
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.
Content Tables
Managed by SchemaRegistry in schema/registry.ts:
- Names:
ec_{collection_slug}. System tables:_emdash_{name}. - Slugs:
/^[a-z][a-z0-9_]*$/, max 63 chars, checked againstRESERVED_COLLECTION_SLUGS/RESERVED_FIELD_SLUGS. - Standard columns:
id,slug,status,author_id,created_at,updated_at,published_at,scheduled_at,deleted_at,version,live_revision_id,draft_revision_id. Field columns added viaALTER TABLE. - Field type -> column mapping:
FIELD_TYPE_TO_COLUMNinschema/types.ts. Most string-shaped types -> TEXT; number -> REAL; integer/boolean -> INTEGER; portableText/json/multiSelect -> JSON. - Orphan discovery:
discoverOrphanedTables()findsec_*tables without a matching_emdash_collectionsrow.
Content Localization
Content tables use a row-per-locale model (migration 019_i18n.ts):
- Every
ec_*table haslocale(defaults to'en') andtranslation_group(ULID shared across translations). - Slug uniqueness is
UNIQUE(slug, locale), not global. - Any new query against a content table must filter by
locale-- forgetting this is a correctness bug. - Fetch all translations via
GET /_emdash/api/content/{collection}/{id}/translations.
When adding content-table features, ask: per-locale (display fields) or per-translation-group (anything identifying "the same thing" across languages)?
Performance: Caching and Query Patterns
EmDash runs on D1 with the Sessions API. Anonymous reads go to the nearest replica; writes and authenticated reads route to the primary. Every round-trip matters.
The logged-out hot path only ratchets down. Anonymous page renders are what visitors actually hit; their query count is the product's performance envelope. Any change that adds a query to a logged-out route needs a really good reason -- including queries that only run on cold start or first request. Before accepting a new round-trip, look for a way to piggyback on an existing query, batch, defer with after(), or cache.
Wrap query helpers in requestCached. Per-request cache (src/request-cache.ts) dedupes identical calls within a render. If a helper takes stable args (slug, key, id) and may be called from multiple components, wrap it. The cache key must include every argument that changes the result. The promise is cached, so concurrent callers share the in-flight query.
export function getSiteSetting(key: string) {
return requestCached(`siteSetting:${key}`, async () => {
const db = await getDb();
return ...;
});
}
Module-scope singletons must live on globalThis. Vite duplicates modules across SSR chunks; a plain let cache = null becomes two variables. Use a Symbol.for key on globalThis. See packages/core/src/settings/index.ts (versioned) and packages/core/src/request-context.ts / request-cache.ts (per-request).
Prefer the batch query to a "has any" probe. Don't add a SELECT id FROM foo LIMIT 1 to skip work on empty sites -- on live sites you pay the extra query every request for no gain. Handle missing tables with isMissingTableError.
Defer bookkeeping with after(fn). Maintenance writes don't need to block TTFB. after() uses workerd's waitUntil when available, fire-and-forgets on Node. Wrap your function body in try/catch with a module-specific log prefix.
import { after } from "emdash";
after(async () => {
try {
await recoverStaleLocks();
} catch (error) {
console.error("[cron] recovery failed:", error);
}
});
One query beats two. Use LEFT JOIN for parent+children. Batch with WHERE id IN (...), chunked at SQL_BATCH_SIZE (from utils/chunks.ts) for D1's bind-parameter limit.
Query-count snapshots. pnpm query-counts (see scripts/query-counts.mjs) records per-route query counts in scripts/query-counts.snapshot.{sqlite,d1}.json. CI auto-updates on PRs -- review the diff. Fewer is always right; more needs a conversation. An increase on a logged-out route is presumed wrong: the snapshot diff makes it visible, it does not make it acceptable.
Admin UI
The admin (packages/admin) is a React SPA mounted under /_emdash/admin/*.
Kumo Components
Built on Kumo (Cloudflare's design system). Never roll your own buttons, inputs, dialogs, etc. -- use Kumo. Get consistent styling, dark mode, accessibility, RTL for free.
Look up docs from the CLI:
npx @cloudflare/kumo doc Button # specific component
npx @cloudflare/kumo ls # list all
Common imports: Button, LinkButton, Dialog, Input, InputArea, Select, Checkbox, Switch, Loader, Badge, Toast/Toasty, Popover, Dropdown, Tooltip, Label, CommandPalette.
Buttons and links
| Need | Component |
|---|---|
| In-place action | Button |
| External link styled as a button | LinkButton href="..." external |
| Internal router-aware link as a button | RouterLinkButton to="..." |
| Non-button element needing button classes | buttonVariants(...) |
RouterLinkButton wraps TanStack Router's <Link> with Kumo button classes. Never write <Link><Button>...</Button></Link> (invalid <a><button> HTML). Never hand-roll button styling on an <a>.
Styling rules
- Use semantic tokens (
bg-kumo-brand,text-kumo-subtle). Never raw Tailwind colors. - Never use
dark:prefixes. Kumo's tokens use CSSlight-dark(). - Never duplicate component styles. If you're writing
bg-kumo-brand text-white rounded-md px-3 py-2on a<button>, use Kumo'sButtoninstead.
Dialogs and errors
ConfirmDialog(incomponents/) for confirm/cancel modals. Passmutation.errordirectly -- don't manage error state manually.DialogError+getMutationError()for inline errors in form dialogs.- Admin API client functions use
throwResponseError()fromlib/api/client.tsto surface server messages -- neverthrow new Error("Failed to X")and lose the body.
Admin UI: Localization (Lingui)
Every user-facing string goes through Lingui. No hard-coded English in JSX, attributes, or strings that end up in the DOM.
- Catalogs:
packages/admin/src/locales/{locale}/messages.po. English is source. - Enabled locales:
packages/admin/src/locales/locales.ts. - Don't include
messages.pochanges in non-translation PRs. A workflow runspnpm locale:extracton merge tomain. Including extracted catalog updates in feature PRs creates merge churn -- revert before opening. - Set
EMDASH_PSEUDO_LOCALE=1in dev to render pseudo-localized text and spot untranslated leaks.
import { useLingui } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
function DeleteButton() {
const { t } = useLingui();
return <button aria-label={t`Delete post`}>{t`Delete`}</button>;
}
// JSX with nested components
<Trans>Published by <strong>{authorName}</strong> on {formattedDate}</Trans>
// Pluralization
import { plural } from "@lingui/core/macro";
const label = plural(count, { one: "# item", other: "# items" });
// Module-scope constants: msg`` descriptors, resolved with t() in the component
import { msg } from "@lingui/core/macro";
import type { MessageDescriptor } from "@lingui/core";
const transforms: { id: string; label: MessageDescriptor }[] = [
{ id: "paragraph", label: msg`Paragraph` },
];
// ...inside component: t(transforms[0].label)
Common mistakes:
- Bare string literals in JSX, unwrapped aria/title/placeholder/alt attributes.
- Concatenating translated pieces (
t`Hello ` + name) -- breaks word order. Uset`Hello ${name}`or<Trans>. - Calling
tat module scope -- locale isn't bound. Usemsg+t(descriptor)inside a component.
Server-side error messages are English-only for now. Keep error codes stable (SCREAMING_SNAKE_CASE); the admin maps codes to localized messages client-side.
Admin UI: RTL-safe Tailwind
The admin supports RTL locales. Use logical Tailwind classes, never physical:
| Use | Not |
|---|---|
ms-* / me-* | ml-* / mr-* |
ps-* / pe-* | pl-* / pr-* |
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 directional icons (chevrons, arrows), flip them with rtl:-scale-x-100 or use a bidi-aware icon.
LocaleDirectionProvider syncs document.documentElement.dir/lang automatically.
Test new admin UI in Arabic before declaring done. Broken directionality is the most common i18n regression.
Conventions
Comments
Comments are evergreen and addressed to a future reader of the code -- not to whoever reviews the PR. Most comments agents write should not exist. A comment earns its place only when:
- the code is unclear and genuinely can't be made clearer (first try making it clearer), or
- there's a non-obvious reason for something the reader would otherwise get wrong (an ordering constraint, a footgun, an invariant).
Comments are not:
- PR descriptions or summaries of the change
- messages to the reviewer
- justification for a decision ("intentionally", "for safety", "we accept", "unlike X")
- narrative about what you tried and rejected
- restatements of what the code plainly does
Never reference issues, PRs, or review threads in comments -- they're stale narrative the moment the change merges; that context belongs in the commit message and PR description. Never number comments.
Tool directives are exempt from all of the above: eslint-disable, oxlint-disable, @ts-expect-error, @ts-ignore, prettier-ignore, v8 ignore, and similar are machine instructions, not prose, and the short reason attached to one (// oxlint-disable-next-line no-await-in-loop -- sequential on purpose) states why the rule doesn't apply at that site. Keep the reason; don't flag it as justification.
Imports
- Internal imports use
.jsextensions (ESM):import { X } from "../foo.js". - Type-only imports use
import type(verbatimModuleSyntaxis on). - Package imports have no extension:
import { sql } from "kysely". - Virtual modules need a
// @ts-ignore:// @ts-ignore - virtual moduleaboveimport virtualConfig from "virtual:emdash/config". - Barrel files separate
export type { ... }from value exports.
Environment
- Use
import.meta.env.DEV/import.meta.env.PROD(Vite/Astro standard). Neverprocess.env.NODE_ENV. - Dev-only endpoints must check
import.meta.env.DEVand return 403 otherwise -- it's a compile-time constant, unspoofable at runtime. - Secrets pattern:
import.meta.env.EMDASH_X || import.meta.env.X || "".
Cloudflare Env
Import env directly from "cloudflare:workers" -- a virtual module that resolves to the right bindings for the current environment (Worker or local dev).
Don't manually type the Env object. In a Worker context, run pnpm wrangler types to generate worker-configuration.d.ts (includes wrangler.jsonc bindings and .env secrets). Reference it in tsconfig.json's include.
Local-dev secrets go in .env (read by Wrangler and the Cloudflare Vite plugin since Aug 2025), not .dev.vars. Note Wrangler loads either .dev.vars or .env but never both -- if a .dev.vars file exists it wins and .env is ignored entirely. Production secrets are set with wrangler secret put.
In libraries used in a Worker but not themselves Workers, install @cloudflare/workers-types and reference it in tsconfig.compilerOptions.types.
Testing
- Framework: vitest. Tests in
packages/core/tests/. - No mocks for the DB. SQLite (
better-sqlite3) by default. PostgreSQL parity tests via a realpgconnection with per-test schema isolation (setPG_CONNECTION_STRINGto opt in). - Utilities:
tests/utils/test-db.tsexposessetupTestDatabase(),setupTestDatabaseWithCollections(),teardownTestDatabase()for SQLite andsetupTestPostgresDatabase()etc. for Postgres. Dialect-agnostic:setupForDialect,setupForDialectWithCollections,teardownForDialect, plusdescribeEachDialect(name, fn). Use the dialect wrapper for query-builder code -- regressions tend to be dialect-specific. - Structure:
tests/unit/,tests/integration/,tests/e2e/(Playwright). Test files mirror source structure. Each test gets a fresh DB.
A test must be able to fail on a real regression. If it can't, it's not a test -- delete it. The common offenders:
- Config-pin tests: asserting a config literal, constant, or schema shape back at itself. It re-states the source file and fails only when someone makes an intentional change.
- Tautological tests: asserting the implementation detail you just wrote straight back -- add a CSS class to a component, then test the component has that class; call a function with arguments, then assert it was called with those arguments. This re-states the diff, not the behavior. Test what the change does for the user (the element is hidden, the query is filtered), not that the code you wrote is the code you wrote.
- Mocking the unit under test, or a mock that returns the very value the assertion checks. The test then verifies the mock, not the code.
- Tests that only exercise third-party code: Kysely, Zod, and Lingui have their own test suites. Test your behavior, not theirs.
Never change a test just to make it pass. Understand why it's failing first -- the test may be the only thing that's right. If you can't work out why, stop and say so.
Toolchain
- pnpm -- package manager
- tsdown -- TypeScript builds (ESM + DTS)
- vitest -- testing
- oxfmt -- formatting (tabs, configured in
.prettierrc). All source files use tabs.
TypeScript: target ES2023, module preserve, strict mode with noUncheckedIndexedAccess, noImplicitOverride, verbatimModuleSyntax.
Dev Bypass for Browser Testing
Passkey auth can't be automated in browser tests. Two dev-only endpoints (import.meta.env.DEV only, 403 in prod):
GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin-- runs migrations, creates a dev admin user (dev@emdash.local), establishes a session, redirects.GET /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin-- assumes setup is complete, just creates a session.
In agent-browser:
await page.goto("http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin");