Skip to content

Latest commit

 

History

History
435 lines (302 loc) · 20.9 KB

File metadata and controls

435 lines (302 loc) · 20.9 KB

This file provides guidance to agentic coding tools when working with code in this repository.

Project Status

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).

Repository Structure

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 emdash package - 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)

Rules

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.

Workflow

Before Starting

  1. Run pnpm --silent lint:json | jq '.diagnostics | length' and fix any issues. Non-negotiable.

During Work

  • Run pnpm --silent lint:quick after 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) or pnpm 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.

Before Committing

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.

PR Flow

  1. All tests pass: pnpm test
  2. 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.
  3. Format with pnpm format (oxfmt with tabs for indentation, configured in .prettierrc).
  4. Open the PR with the pr skill

Dev Servers

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 logs

Architecture Overview

EmDash is an Astro-native CMS that stores its schema in the database, not in code.

Core Architecture

  • Schema in the database. _emdash_collections and _emdash_fields are 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 returns ApiResponse<T> ({ success, data?, error? }). Route files are thin wrappers that parse input, call handlers, and format responses.
  • Storage abstraction -- Storage interface with upload/download/delete/exists/list/getSignedUploadUrl. Implementations: LocalStorage (dev), S3Storage (R2/AWS). Access via emdash.storage from locals.

Known Quality Patterns

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.

Commands

Root-level commands (run from repository root):

  • pnpm build - Build all packages
  • pnpm test - Run tests for all packages
  • pnpm check - Run type checking and linting for all packages
  • pnpm format - Format code using oxfmt

Package-level commands (run within individual packages):

  • pnpm build - Build the package using tsdown (ESM + DTS output)
  • pnpm dev - Watch mode for development
  • pnpm test - Run vitest tests
  • pnpm check - Run publint and @arethetypeswrong/cli checks

Key Files

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

Code Patterns

Database: Never Interpolate Into SQL

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 to sql.raw().
  • For values, use Kysely's sql tagged 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 with validateIdentifier() from database/validate.ts which asserts /^[a-z][a-z0-9_]*$/.
  • The json_extract(data, '$.${field}') pattern is particularly dangerous -- always validate field before 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}')`);

API Routes: Use Shared Utilities

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);

API Routes: Authorization

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

API Routes: CSRF Protection

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.

Pagination

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 (not results, not a bare array).
  • Never return a bare array from a list endpoint -- always wrap in { items, nextCursor? }.

Adding Database Tables or Columns

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

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>) and down(db: Kysely<unknown>).
  • System tables use Kysely's schema builder (db.schema.createTable(...)).
  • Dynamic content tables (ec_*) use sql tagged templates with sql.ref() for identifiers.
  • Column types: SQLite types -- "text", "integer", "real", "blob". Booleans are "integer" with defaultTo(0). Timestamps are "text" with defaultTo(sql`(datetime('now'))`). IDs are "text" primary keys (ULIDs from ulidx).
  • 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.ts and added to the StaticMigrationProvider. 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 in runner.ts, (3) add it to getMigrations().
  • Multi-table migrations: When altering all content tables, query _emdash_collections to discover ec_* tables and loop. See 013_scheduled_publishing.ts for the pattern.

API Route Structure

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.ts for list/create, content/[collection]/[id].ts for get/update/delete, with sub-actions as siblings: [id]/publish.ts, [id]/schedule.ts.
  • Never add GET handlers for state-changing operations.

Handler Layer

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 from api/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.

Admin UI: API Error Handling

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");

Admin UI: Confirmation Dialogs

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)}
/>

Admin UI: Inline Dialog Errors

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.

Import Conventions

  • Internal imports always use .js extensions (ESM requirement):
    import { ContentRepository } from "../../database/repositories/content.js";
  • Type-only imports must use import type (enforced by verbatimModuleSyntax: true):
    import type { Kysely } from "kysely";
  • Package imports do not use extensions: import { sql } from "kysely".
  • Virtual modules use // @ts-ignore comment:
    // @ts-ignore - virtual module
    import virtualConfig from "virtual:emdash/config";
  • Barrel files (index.ts) re-export from sub-modules. Separate export type { ... } from value exports.

Environment Gating

  • Dev-only endpoints must check import.meta.env.DEV and return 403 if false. This is a compile-time constant -- it cannot be spoofed at runtime.
  • Never use process.env.NODE_ENV -- always use import.meta.env.DEV or import.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.

Cloudflare Env

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.

Content Table Lifecycle

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 against RESERVED_COLLECTION_SLUGS and RESERVED_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 via ALTER TABLE.
  • Field type mapping: FIELD_TYPE_TO_COLUMN maps: string/text/datetime/image/reference -> TEXT, number -> REAL, integer/boolean -> INTEGER, portableText/json -> JSON.
  • Orphan discovery: discoverOrphanedTables() finds ec_* tables without matching _emdash_collections entries. This is used for recovering from crashes during schema changes.

Testing

  • 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.ts provides createTestDatabase(), setupTestDatabase() (with migrations), and setupTestDatabaseWithCollections() (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 in afterEach.

URL and Redirect Handling

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">.

Toolchain

  • 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.

TypeScript Configuration

  • Target: ES2022
  • Module: preserve (for bundler compatibility)
  • Strict mode with noUncheckedIndexedAccess, noImplicitOverride

Dev Bypass for Browser Testing

EmDash uses passkey authentication which cannot be automated in browser tests. Two dev-only endpoints are available to bypass authentication:

Setup Bypass

Skips the setup wizard, runs migrations, creates a dev admin user, and establishes a session:

GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin

Auth Bypass

Creates a session for the dev admin user (assumes setup is already complete):

GET /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin

Usage in Agent Browser

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:

  1. Run database migrations
  2. Create a dev admin user (dev@emdash.local)
  3. Set up a session cookie
  4. Redirect to the admin dashboard

Note: These endpoints only work when import.meta.env.DEV is true. They return 403 in production.