EmDash is published to npm and in active use. During development you work inside the monorepo -- packages use workspace:* links, so everything works without publishing.
This guide covers setup, policy, and the rules around opening a PR. For code patterns (SQL, API routes, authorization, performance, Lingui, RTL, etc.), see AGENTS.md.
- 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 # required before first rundemos/simple/ is the primary development target. 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 on first launch.
In dev, skip passkey auth with the dev bypass:
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
Demo sites apply their seed/seed.json automatically on the first request when the database is empty -- there's no separate seed command.
demos/cloudflare/ runs on the real workerd runtime with D1. See its README.
Templates in templates/ are workspace members and runnable directly:
cd templates/portfolio
pnpm devAvailable templates: blank, starter, blog, portfolio, marketing, plus a -cloudflare variant of each runnable template. Seed content is applied automatically on first request. To start fresh, delete the local database (data.db or the D1 binding) and restart the dev server.
When iterating on packages/core alongside a demo, run two terminals:
# Terminal 1
cd packages/core && pnpm dev
# Terminal 2
cd demos/simple && pnpm devCore changes propagate to the demo automatically.
| Directory | What it is |
|---|---|
packages/core/ |
Main emdash package -- Astro integration, REST API, database, schema, plugins |
packages/admin/ |
React admin UI SPA (@emdash-cms/admin) |
packages/auth/ |
Auth -- passkeys, OAuth, magic links (@emdash-cms/auth) |
packages/cloudflare/ |
Cloudflare Workers adapter + plugin sandbox |
packages/blocks/ |
Portable Text block definitions |
packages/create-emdash/ |
create-emdash CLI scaffolder |
packages/plugins/ |
First-party plugins |
demos/ |
Dev/test apps (simple, cloudflare, postgres, ...) |
templates/ |
Starter templates |
docs/ |
Documentation site (Starlight) |
e2e/ |
Playwright test infrastructure |
i18n/ |
Translation status dashboard (Lunaria) |
Run before pushing:
pnpm typecheck # TypeScript (packages)
pnpm lint # full type-aware lint
pnpm format # auto-format with oxfmt (tabs)
pnpm test # all packages
pnpm test:e2e # PlaywrightTests use real in-memory SQLite -- no mocking. Each test gets a fresh database. Typecheck and lint must pass.
Copy a template into demos/, give it a unique name in package.json, install, and run:
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, so core changes are reflected immediately.
| 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 maintainer approval. |
| Refactors | Open a Discussion first. |
| Performance | Open a Discussion first with benchmarks. |
Feature PRs without prior maintainer approval will be closed. Not gatekeeping -- it's about not wasting your time on work that might not align with the project's direction.
- Drive-by feature additions. No Discussion, no PR.
- Speculative refactors that don't solve a concrete problem.
- Dependency upgrades outside Renovate/Dependabot.
- Drive-by "improvements" in code unrelated to your change.
- Bulk/spray PRs ("fix all lint warnings", "add types everywhere"). Open a Discussion first.
AI-assisted contributions are welcome and held to the same quality bar as any other PR:
- The submitter is responsible for correctness, not the tool.
- AI-generated PRs must pass CI, follow project patterns, and include tests.
- Check the PR template's AI disclosure box and name the model/tool (e.g. Claude Opus 4.7, GPT-5.5, Cursor + Sonnet 4.6). This isn't punitive -- it helps reviewers focus on edge cases that AI tools tend to miss and run the review pass with a different model family.
- Branch from
main. - Fill out the PR template completely. PRs with an empty or missing template will be closed automatically. The template is loaded by the GitHub UI; if you create a PR via API/CLI, copy
.github/PULL_REQUEST_TEMPLATE.mdinto the body. pnpm typecheckandpnpm lintmust pass before pushing.- Run relevant tests.
- Commit messages describe why, not just what.
Every PR that changes a published package's behavior needs a changeset -- a small Markdown file that describes the change for the CHANGELOG and determines the version bump. Without one, the change won't trigger a release.
- Bug fixes, features, refactors, or anything that affects a published package's behavior or API.
- Multi-package changes need one changeset listing all affected packages.
- A PR making multiple distinct changes can include a changeset per change -- each becomes its own CHANGELOG entry.
- Docs-only, test-only, CI/tooling changes, or changes to demos and templates (these are in the ignore list -- see
.changeset/config.json).
pnpm changesetThe CLI walks you through affected packages, bump type, and description. Edit the resulting .md file in .changeset/ if needed.
A changeset is the release note a user reads while upgrading -- it lands verbatim in the CHANGELOG. It is not a commit message, a PR description, or a summary of your diff. Don't paste your PR text into it: those explain the change to a reviewer reading the code, the changeset explains the effect to someone who will run the new version.
Write for that reader:
- Start with a present-tense verb -- Fixes (bug), Adds (feature), Updates (enhancement), Removes (removed functionality), Refactors (no behavior change).
- Describe the observable effect -- what's different for someone using the package.
- Leave out internal mechanics -- file names, function names, which catalog entry you bumped, how you implemented it. If a sentence only makes sense to someone who has read the diff, it doesn't belong here.
- For a breaking change, include the migration step.
One sentence is often enough.
- # too low-level -- reads like a commit message
- Align the catalog so identity-resolver's lexicons peer resolves; migrates parseCanonicalResourceUri off the result-object API in backfill.ts.
+ # right altitude -- the effect on the user
+ Fixes peer dependency warnings on install caused by mismatched `@atcute` package versions.Patch (bug fix or small improvement):
---
"emdash": patch
---
Fixes CLI `--json` flag so JSON output is clean. Log messages now go to stderr when `--json` is set.Minor (new non-breaking feature):
---
"emdash": minor
---
Adds `scheduled_at` field to content entries, enabling scheduled publishing via the admin UI.Major (breaking change) -- 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`.The admin UI is translatable using Lingui. All user-visible strings in packages/admin/src/ should be wrapped.
import { Trans, useLingui } from "@lingui/react/macro";
function MyComponent() {
const { t } = useLingui();
return (
<div>
<h1>{t`Settings`}</h1>
<p>{t`Authentication error: ${error}`}</p>
<p>
<Trans>
Don't have an account? <a href="/signup">Sign up</a>
</Trans>
</p>
</div>
);
}Wrap button labels, headings, descriptions, error messages, placeholders, and aria-label on interactive controls. Don't wrap log messages, developer-facing errors, brand names, or URLs. For decorative elements, prefer aria-hidden="true" over a translated aria-label.
Don't include messages.po changes in feature or bugfix PRs. A workflow runs pnpm locale:extract on merge to main and commits catalog updates automatically. Including extracted PO changes in non-translation PRs creates churn and merge conflicts because line-number references shift on every edit. If you ran extraction locally and ended up with .po changes, revert them before opening the PR.
Translation PRs are the exception -- see Translating EmDash for the full contributor guide.
For RTL rules and the full Lingui pattern reference, see AGENTS.md § Admin UI: Localization.
- AGENTS.md -- architecture and code patterns
- docs.emdashcms.com -- user guides and API reference
- Discussions -- ask questions, propose features
- Issues -- bug reports