Beta. EmDash is published to npm. During development you work inside the monorepo — packages use
workspace:*links, so everything "just works" without publishing.
- Node.js 22+
- pnpm 10+ (
corepack enableif you don't have it) - Git
git clone https://github.com/emdash-cms/emdash.git && cd emdash
pnpm install
pnpm build # build all packages (required before first run)The demos/simple/ app is the primary development target. It uses Node.js + SQLite — no Cloudflare account needed.
cd demos/simple
pnpm dev # http://localhost:4321Open the admin at http://localhost:4321/_emdash/admin. The setup wizard runs automatically on first launch — it creates the database, runs migrations, and prompts you to create an admin account.
In dev mode, you can skip passkey auth with the dev bypass:
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
To populate the demo with sample content:
pnpm seeddemos/cloudflare/ runs on the real workerd runtime with D1. See its README for setup.
Templates in templates/ are workspace members and can be run directly:
cd templates/portfolio
pnpm bootstrap # first time — set up database and seed content
pnpm dev # run dev serverAvailable templates: blog, portfolio, marketing.
To start fresh, delete the database and re-bootstrap:
rm templates/portfolio/data.db
cd templates/portfolio && pnpm bootstrapThis is a pnpm monorepo. Here's what each directory is for:
| Directory | What it is | When you'd work here |
|---|---|---|
packages/core/ |
The main emdash package — Astro integration, REST API, database, schema management, plugins |
Most core development |
packages/admin/ |
React SPA for the admin UI (@emdash-cms/admin) |
Admin UI changes, translations |
packages/auth/ |
Authentication — passkeys, OAuth, magic links (@emdash-cms/auth) |
Auth flow changes |
packages/cloudflare/ |
Cloudflare Workers adapter + plugin sandbox (@emdash-cms/cloudflare) |
Cloudflare-specific features |
packages/blocks/ |
Portable Text block definitions (@emdash-cms/blocks) |
Content block types |
packages/create-emdash/ |
create-emdash CLI scaffolder |
Project scaffolding |
packages/plugins/ |
First-party plugins (each subdirectory is a package) | Plugin development |
demos/simple/ |
Primary dev/test app (Node.js + SQLite) | Running and testing locally |
demos/cloudflare/ |
Cloudflare Workers demo (D1) | Testing on CF runtime |
templates/ |
Starter templates (blog, portfolio, marketing + CF variants) | Template development |
docs/ |
Documentation site (Starlight) | Docs changes |
e2e/ |
Playwright test fixtures | E2E test infrastructure |
i18n/ |
Translation status dashboard (Lunaria) | Translation tracking |
For iterating on core packages alongside the demo, run two terminals:
# Terminal 1 — rebuild packages/core on change
cd packages/core && pnpm dev
# Terminal 2 — run the demo
cd demos/simple && pnpm devChanges to packages/core/src/ will be picked up by the demo's dev server automatically.
Run these from the repo root before committing:
pnpm typecheck # TypeScript (packages)
pnpm lint # full type-aware lint
pnpm format # auto-format with oxfmt (tabs, not spaces)Type checking must pass. Lint must pass. Don't commit with known failures.
pnpm test # all packages
cd packages/core && pnpm test # core only
cd packages/core && pnpm test --watch # watch mode
pnpm test:e2e # Playwright (starts its own server)Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.
Copy a template into demos/, give it a unique name in package.json, run pnpm install, and start developing:
cp -r templates/blog demos/my-site
# edit demos/my-site/package.json to set a unique name
pnpm install
cd demos/my-site && pnpm devYour site uses workspace:* links to the local packages, so core changes are reflected immediately (with watch mode).
- Schema lives in the database, not in code.
_emdash_collectionsand_emdash_fieldsare the source of truth. - Real SQL tables per collection (
ec_posts,ec_products), not EAV. - Kysely for all queries. Never interpolate into SQL — see
AGENTS.mdfor the full rules. - Handler layer (
api/handlers/*.ts) holds business logic. Route files are thin wrappers. - Middleware chain: runtime init → setup check → auth → request context.
- Create
packages/core/src/database/migrations/NNN_description.ts(zero-padded sequence number). - Export
up(db)anddown(db)functions. - Register it in
packages/core/src/database/migrations/runner.ts— migrations are statically imported, not auto-discovered (Workers bundler compatibility).
- Create the file in
packages/core/src/astro/routes/api/. - Start with
export const prerender = false;. - Use
apiError(),handleError(),parseBody()from#api/. - Check authorization with
requirePerm()on all state-changing routes. - Register the route in
packages/core/src/astro/integration/routes.ts.
The admin UI is translatable using Lingui. All user-visible strings in packages/admin/src/ should be wrapped for translation.
Use the t tagged template for plain strings and <Trans> for strings containing JSX:
import { Trans, useLingui } from "@lingui/react/macro";
function MyComponent() {
const { t } = useLingui();
return (
<div>
{/* Plain strings */}
<h1>{t`Settings`}</h1>
<label>{t`Email address`}</label>
{/* Strings with interpolation */}
<p>{t`Authentication error: ${error}`}</p>
{/* Strings containing JSX elements */}
<p>
<Trans>
Don't have an account? <a href="/signup">Sign up</a>
</Trans>
</p>
</div>
);
}After adding or changing translatable strings, run extraction to update the PO catalogs:
pnpm run locale:extractThis updates packages/admin/src/locales/*/messages.po with any new or changed strings. Commit the updated PO files alongside your code changes.
- Button labels, headings, descriptions, error messages, placeholder text — anything a user reads.
- Don't wrap: log messages, developer-facing errors, HTML attributes that aren't user-visible, or strings that are the same in every language (brand names, URLs). Do wrap
aria-labelwhen it labels an interactive control, because screen readers announce it to users. For decorative elements, avoidaria-labeland usearia-hidden="true"instead.
For the full translation contributor guide, see Translating EmDash.
| Type | Process |
|---|---|
| Bug fixes | Open a PR directly. Include a failing test that reproduces the bug. |
| Docs / typos | Open a PR directly. |
| Translations | Open a PR directly. See Translating EmDash. |
| Features | Open a Discussion and wait for a maintainer to approve it. |
| Refactors | Open a Discussion first. Refactors are opinionated and need alignment. |
| Performance | Open a Discussion first with benchmarks showing the improvement. |
Feature PRs without prior maintainer approval will be closed. This isn't about gatekeeping — it's about not wasting your time on work that might not align with the project's direction. Open a Discussion, let us talk it through, and wait for a maintainer to give the go-ahead before writing code.
We welcome AI-assisted contributions. They are held to the same quality bar as any other PR:
- The submitter is responsible for the code's correctness, not the AI tool.
- AI-generated PRs must pass all CI checks, follow the project's code patterns, and include tests.
- The PR template has an AI disclosure checkbox — please check it. This isn't punitive; it helps reviewers know to pay extra attention to edge cases that AI tools commonly miss.
- Bulk/spray PRs across the repo (e.g., "fix all lint warnings", "add types everywhere") will be closed. If you see a pattern worth fixing, open a Discussion first.
- Drive-by feature additions. If there's no Discussion, there's no PR.
- Speculative refactors that don't solve a concrete problem.
- Dependency upgrades outside of Renovate/Dependabot. We manage these centrally.
- "Improvements" to code you haven't been asked to change (added logging, extra error handling, style changes in unrelated files).
Every PR that changes the behavior of a published package needs a changeset — a small Markdown file that describes the change for the CHANGELOG and determines the version bump. Without a changeset, the change won't trigger a package release.
- Bug fixes, features, refactors, or any other change that affects a published package's behavior or API.
- Changes that span multiple packages need one changeset listing all affected packages.
- If a PR makes more than one distinct change, add a separate changeset for each. Each one becomes its own CHANGELOG entry.
- Docs-only changes, test-only changes, CI/tooling changes, or changes to demo apps and templates (these are in the changeset ignore list).
Run from the repo root:
pnpm changesetThis walks you through selecting the affected package(s), the semver bump type, and a description. It creates a randomly-named .md file in .changeset/.
You can also create one manually — see the existing files in .changeset/ for the format.
Start with a present-tense verb describing what the change does, as if completing "This PR...":
- Adds — a new feature or capability
- Fixes — a bug fix
- Updates — an enhancement to existing behavior
- Removes — removed functionality
- Refactors — internal restructuring with no behavior change
Focus on how the change affects someone using the package, not implementation details. The description ends up in the CHANGELOG, which people read once during upgrades.
Patch (bug fixes, refactors, small improvements):
---
"emdash": patch
---
Fixes CLI `--json` flag so JSON output is clean. Log messages now go to stderr when `--json` is set.Minor (new features, non-breaking additions):
---
"emdash": minor
---
Adds `scheduled_at` field to content entries, enabling scheduled publishing via the admin UI.Major (breaking changes) — include migration guidance:
---
"emdash": major
---
Removes the `legacyAuth` option from the integration config. All sites must use passkey authentication.
To migrate, remove `legacyAuth: true` from your `emdash()` config in `astro.config.mjs`.Only published packages need changesets. Demos, templates, docs, and test fixtures are excluded. The main packages are:
emdash(core)@emdash-cms/admin,@emdash-cms/auth,@emdash-cms/cloudflare,@emdash-cms/blockscreate-emdash- First-party plugins (
@emdash-cms/plugin-*)
When in doubt, run pnpm changeset and it will only show packages that aren't ignored.
- Branch from
main. - Commit messages: describe why, not just what.
- Fill out the PR template completely. PRs with an empty template will be closed.
- Ensure
pnpm typecheckandpnpm lintpass before pushing. - Run relevant tests.
- Read
AGENTS.mdfor architecture and code patterns - Check the documentation site for guides and API reference
- Open an issue or ask in the chat