Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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:

ScriptDoes
pnpm buildBuilds every package in dependency order.
pnpm devRuns core (tsx watch) and admin (Vite) in parallel.
pnpm startscripts/start.mjs: loads .env when present, then runs packages/core/dist/server.js. Needs a build first.
pnpm testRuns every package's tests (Vitest in core, fleet, client, astro and next; node --test in sdk and docs-tools).
pnpm typechecktsc --noEmit in every package.
pnpm preflightscripts/doctor.mjs: checks Node, pnpm, the native modules, the data folder and the port.
pnpm docs:buildRegenerates 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 releasescripts/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, then scripts/emit-css.mjs writes src/styles.css and dist/styles.css from blockCss), dev, typecheck.
  • admin: build (typecheck, then vite build into packages/admin/dist), dev (Vite on 5173), typecheck.
  • core: build (tsc with tsconfig.build.json, which excludes src/test), dev (tsx watch src/server.ts), start, typecheck, test (vitest run). The spillway CLI is dist/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 under packages/core/src. Data goes to ./data (override with DATA_DIR).
  • Admin runs on http://localhost:5173/admin with Vite hot reload. Vite proxies /api, /media, /_preview and /healthz to http://localhost:3000 (set SPILLWAY_API_URL to 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.
  • shared and blocks are consumed from their dist folders. After changing them, rebuild (pnpm --filter @spillway-cms/blocks build) or run their dev script 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.ts and run with pnpm --filter @spillway-cms/core test (Vitest). src/test/helpers.ts exports testApp(), which calls createApp with an in-memory SQLite database and a temporary data directory, and a Client that keeps the session cookie and sends the CSRF header. Tests call the Hono app directly with app.request(), so no port is opened. The same suite runs on Postgres when TEST_DATABASE_URL=postgres://user:pass@host:5432/db is set: testApp() then creates a test_<random> schema per app (through search_path on the connection) and drops it on close. Locally, docker compose --profile postgres up -d postgres gives you one; CI runs this lane as the postgres job.
  • Add a test per route group (setup.test.ts, auth.test.ts, types.test.ts are the pattern). Test behavior through the API (status codes, response shapes, permissions), not implementation details.
  • CI (.github/workflows/ci.yml) runs pnpm install --frozen-lockfile, pnpm run build, pnpm run typecheck, pnpm run test, pnpm run docs:check, pnpm run docs:links and the dependency audit on every push to main and 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

  1. Add a handler in packages/core/src/routes/ next to the related ones (one file per resource). Routes are Hono sub-apps mounted under /api in packages/core/src/app.ts.
  2. Validate the body with zod. Throw HttpError (or the helpers in packages/core/src/errors.ts: badRequest, validation, unauthorized, forbidden, notFound, conflict) for client-facing failures; app.onError turns them into the { error: { code, message, details } } shape.
  3. Check auth with the guards in packages/core/src/auth/guards.ts: requireUser(c), requirePermission(c, 'content.publish'), requireEditAccess(c, authorId). Mutating routes under /api also need the CSRF header; the middleware in app.ts checks it for every non-GET request, you do not need to.
  4. Keep database access in packages/core/src/repos/ (one module per table, Kysely only, no SQLite-specific SQL) and logic in packages/core/src/content/ or the relevant service.
  5. 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 in docs/api.md, and add the client method in packages/admin/src/api.ts.
  6. Add a test.

Adding an admin screen

  1. Create packages/admin/src/screens/<area>/<Name>.tsx. Screens are plain function components; data loading goes through useApi (packages/admin/src/hooks/useApi.ts) and the typed client in packages/admin/src/api.ts.
  2. Register the route in packages/admin/src/App.tsx. Screens inside <Shell /> get the sidebar and top bar; RequireAuth redirects anonymous users to /login.
  3. Add a navigation entry in packages/admin/src/shell/Shell.tsx if the screen is top-level, with the permission it needs so the link is hidden from users who cannot use it. Also check the permission inside the screen with useMe().can(...).
  4. Use the shared components in packages/admin/src/components/ (Button, Field, Table, Modal, ConfirmDialog, EmptyState, Badge, PageHeader) and the existing CSS classes in packages/admin/src/styles.css. Show errors with useToast() and errorMessage(err).
  5. 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 (use import type), ESM with .js extensions on relative imports in core, shared and blocks.
  • 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

Was this page helpful?