Contributing guide
This is the long version of CONTRIBUTING.md. It covers the repository layout, the dev loop and how to add the three things people add most: an API route, an admin screen and a block.
Monorepo layout
pnpm workspace, Node 22 or newer, TypeScript everywhere, ESM only.
packages/
shared/ @spillway-cms/shared MIT Types, permissions, API contract, pure helpers. No runtime deps.
blocks/ @spillway-cms/blocks MIT Puck block library: config, CSS, sample layouts.
core/ @spillway-cms/core AGPL Hono server: API, auth, SQLite and Postgres, page rendering, admin host, CLI.
admin/ @spillway-cms/admin AGPL React + Vite + react-router + Puck admin SPA, served at /admin.
sdk/ @spillway-cms/sdk MIT Module author SDK and scaffolder.
fleet/ @spillway-cms/fleet AGPL Provisioning on Fly.io, Docker and Kubernetes.
kits/ MIT Installable content kits (JSON).
client/ @spillway-cms/client MIT Typed fetch client for the public API.
astro/ @spillway-cms/astro MIT Astro integration.
next/ @spillway-cms/next MIT Next.js App Router adapter.
docs-tools/ @spillway-cms/docs-tools MIT Generators for docs/reference and the docs kit (private).
examples/ astro-site, next-site, modules.
sites/ spillwaycms.com deployment files.
e2e/ Browser smoke tests.
docs/ User and developer documentation (docs/reference is generated).
Dependency direction: shared <- blocks <- core and admin. core and admin never import each other; they share only the contract in shared. The adapters depend on shared, blocks and client. Build order is shared, blocks, admin, core, fleet, sdk, client, astro, next, docs-tools (the root pnpm build lists them in that order).
Scripts
Root:
| Script | Does |
|---|---|
pnpm build | Builds every package in dependency order. |
pnpm dev | Runs core (tsx watch) and admin (Vite) in parallel. |
pnpm start | scripts/start.mjs: loads .env when present, then runs packages/core/dist/server.js. Needs a build first. |
pnpm test | Runs every package's tests (Vitest in core, fleet, client, astro and next; node --test in sdk and docs-tools). |
pnpm typecheck | tsc --noEmit in every package. |
pnpm preflight | scripts/doctor.mjs: checks Node, pnpm, the native modules, the data folder and the port. |
pnpm docs:build | Regenerates docs/reference/ and the docs kit's content from the code and docs/**.md; pnpm docs:check fails when they are stale, pnpm docs:links checks every relative link and anchor in the docs. |
pnpm release | scripts/release.mjs: bump, changelog, docs, commit, tag (see releases.md). |
Per package, use pnpm --filter @spillway-cms/<name> <script>:
shared:build,dev(tsc watch),typecheck.blocks:build(tsc, thenscripts/emit-css.mjswritessrc/styles.cssanddist/styles.cssfromblockCss),dev,typecheck.admin:build(typecheck, thenvite buildintopackages/admin/dist),dev(Vite on 5173),typecheck.core:build(tsc withtsconfig.build.json, which excludessrc/test),dev(tsx watch src/server.ts),start,typecheck,test(vitest run). ThespillwayCLI isdist/cli.js.
Dev loop
pnpm install
pnpm --filter @spillway-cms/shared build
pnpm --filter @spillway-cms/blocks build
pnpm dev
- Core runs on http://localhost:3000 with
tsx watch, restarting on every change underpackages/core/src. Data goes to./data(override withDATA_DIR). - Admin runs on http://localhost:5173/admin with Vite hot reload. Vite proxies
/api,/media,/_previewand/healthztohttp://localhost:3000(setSPILLWAY_API_URLto point elsewhere), so cookies and CSRF behave as in production. - The public site is served by core on port 3000. The admin's "View site" link opens it there.
sharedandblocksare consumed from theirdistfolders. After changing them, rebuild (pnpm --filter @spillway-cms/blocks build) or run theirdevscript in another terminal.
To run the production layout locally, pnpm build && pnpm start and open http://localhost:3000/admin; core serves the built admin from packages/admin/dist.
Tests
- Core tests live in
packages/core/src/test/*.test.tsand run withpnpm --filter @spillway-cms/core test(Vitest).src/test/helpers.tsexportstestApp(), which callscreateAppwith an in-memory SQLite database and a temporary data directory, and aClientthat keeps the session cookie and sends the CSRF header. Tests call the Hono app directly withapp.request(), so no port is opened. The same suite runs on Postgres whenTEST_DATABASE_URL=postgres://user:pass@host:5432/dbis set:testApp()then creates atest_<random>schema per app (throughsearch_pathon the connection) and drops it on close. Locally,docker compose --profile postgres up -d postgresgives you one; CI runs this lane as thepostgresjob. - Add a test per route group (
setup.test.ts,auth.test.ts,types.test.tsare the pattern). Test behavior through the API (status codes, response shapes, permissions), not implementation details. - CI (
.github/workflows/ci.yml) runspnpm install --frozen-lockfile,pnpm run build,pnpm run typecheck,pnpm run test,pnpm run docs:check,pnpm run docs:linksand the dependency audit on every push tomainand every pull request, runs the core tests again on Postgres and Redis, then builds the Docker image (without pushing it) and runs the upgrade check (scripts/upgrade-check.mjs: a site from the previous release, migrated and booted on this build; it skips until the first tag exists). Keep them green.
Adding an API route
- Add a handler in
packages/core/src/routes/next to the related ones (one file per resource). Routes are Hono sub-apps mounted under/apiinpackages/core/src/app.ts. - Validate the body with zod. Throw
HttpError(or the helpers inpackages/core/src/errors.ts:badRequest,validation,unauthorized,forbidden,notFound,conflict) for client-facing failures;app.onErrorturns them into the{ error: { code, message, details } }shape. - Check auth with the guards in
packages/core/src/auth/guards.ts:requireUser(c),requirePermission(c, 'content.publish'),requireEditAccess(c, authorId). Mutating routes under/apialso need the CSRF header; the middleware inapp.tschecks it for every non-GET request, you do not need to. - Keep database access in
packages/core/src/repos/(one module per table, Kysely only, no SQLite-specific SQL) and logic inpackages/core/src/content/or the relevant service. - If the route is part of the public contract, document it in
packages/shared/src/index.ts(the comment block above the request/response types) and indocs/api.md, and add the client method inpackages/admin/src/api.ts. - Add a test.
Adding an admin screen
- Create
packages/admin/src/screens/<area>/<Name>.tsx. Screens are plain function components; data loading goes throughuseApi(packages/admin/src/hooks/useApi.ts) and the typed client inpackages/admin/src/api.ts. - Register the route in
packages/admin/src/App.tsx. Screens inside<Shell />get the sidebar and top bar;RequireAuthredirects anonymous users to/login. - Add a navigation entry in
packages/admin/src/shell/Shell.tsxif the screen is top-level, with thepermissionit needs so the link is hidden from users who cannot use it. Also check the permission inside the screen withuseMe().can(...). - Use the shared components in
packages/admin/src/components/(Button,Field,Table,Modal,ConfirmDialog,EmptyState,Badge,PageHeader) and the existing CSS classes inpackages/admin/src/styles.css. Show errors withuseToast()anderrorMessage(err). - Forms that can lose work call
useUnsaved().setDirty(true)so navigation asks before leaving.
Adding a block
See docs/blocks.md, section "Adding a block". Short version: props type, config.components entry with labeled fields and defaultProps, a category, blockMeta, CSS in src/css.ts, rebuild. Render functions must be server-safe.
Coding conventions
- TypeScript
strict,verbatimModuleSyntax(useimport type), ESM with.jsextensions on relative imports incore,sharedandblocks. - Two-space indent, single quotes, trailing commas, semicolons. Keep lines under 140 characters.
- Names: camelCase in TypeScript, snake_case in SQL columns, kebab-case for files in docs, PascalCase for React components and block names.
- Prefer small modules with one job. A repo module talks to one table. A route module handles one resource.
- No new runtime dependencies in
shared. Think twice before adding one anywhere else; the Docker image should stay small. - Comments explain why, not what. Keep them short.
- User-visible strings and documentation: plain sentences, no exclamation marks, American spelling, headings in sentence case, no em dashes. One term per concept, the one the admin uses: entity (a record of a content type), site and instance (one running Spillway hosts one or more sites), workstream, kit, module, theme (a kit with
kind: theme), collection (a saved query; the Collection block lists it), reviewer (a person who can publish), publisher (an account that publishes to the registry).
Commit style
Conventional Commits, scoped by package:
feat(core): add sitemap route
fix(admin): keep unsaved changes dialog on logout
docs: describe cache headers
chore(blocks): regenerate styles.css
One logical change per commit. Every commit needs a DCO sign-off (git commit -s). Pull requests describe the change, how you tested it, and link the issue if there is one.
Changelog and releases
Every pull request that changes behavior adds a line to CHANGELOG.md under "Unreleased" (Added, Changed, Deprecated, Removed, Fixed or Security). Releases are cut with pnpm release <patch|minor|major> and the tag starts the release workflow; what a version number promises, what counts as a breaking change, the support policy and the deprecation rules are in releases.md.
Browser smoke test
e2e/smoke.mjs boots the built server on a scratch data directory, runs the setup wizard in headless Chromium, renders the home page, creates and publishes a page through the page builder, creates a content type, and takes the screenshots in docs/screenshots/. smoke-phase2.mjs and smoke-phase3.mjs cover the later admin areas, smoke-registry.mjs the registry's public pages and admin screens against a REGISTRY_HOST=1 server, and smoke-import.mjs the importer wizard against a fixture site. They need Playwright with Chromium installed (npm i -g playwright && npx playwright install chromium), then:
pnpm build
node e2e/smoke.mjs && node e2e/smoke-phase2.mjs && node e2e/smoke-phase3.mjs
node e2e/smoke-registry.mjs
node e2e/smoke-import.mjs
Dragging in the page builder: Puck starts a mouse drag only after a 200 ms hold within 10 px, so smoke.mjs and smoke-modules.mjs scroll the panel item into view, press, hold, then move to the drop zone. Playwright reports a frame element's box in page coordinates, so aim at the drop zone's box directly. Every script reports every check passed.
Spillway 0.1.0 · Edit this page on GitHub