Development
Running the board on your own machine — to read the code, write a theme, or send
a patch. Not to run a board anybody else can reach: that is the
Quickstart, and localhost:3000 is not something people can
post on.
You need: Node 22 or newer, pnpm 10, and Docker if you want a real database.
Getting it running
git clone https://github.com/meith-dev/meith.git
cd meith
pnpm install
pnpm devThat is already a working board on http://localhost:3000, with no database at all — see fixture mode below. Enough to click through every reading surface, try a theme, and see what the software is.
For anything that writes — posting, moderation, the installer — you need Postgres:
docker compose -f docker-compose.dev.yml up -d # Postgres on port 55432
cp .env.example .envThen set two lines in .env:
DATA_SOURCE=postgres
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/community_testpnpm community migrate
pnpm devOpen http://localhost:3000/install and run the installer, which is the same
one a real deployment runs. It seals itself when it finishes; on a scratch
database that is fine, and docker compose -f docker-compose.dev.yml down -v
gives you a clean one.
The dev compose file uses a named volume, so the board survives the
container being recreated. It is the -v that throws it away.
Fixture mode, and why it exists
With no DATABASE_URL, DATA_SOURCE falls back to fixture: deterministic
in-memory repositories with a sample board in them. It is not a mock layer bolted
on for tests — it is a driver behind the same interfaces as Postgres, and three
things depend on it.
- A fresh checkout runs.
pnpm install && pnpm devneeds nothing else, which is the difference between somebody trying this project and closing the tab. - The production build needs no database.
next buildprerenders, and a build that opened a connection would fail wherever the build runs before the database is reachable. CI builds in fixture mode; so does the Docker image. - The test suite is fast, because most of it never touches a socket.
What it deliberately does not do is fake a write. Fixture mode has no installer, no presence store and no statistics store, and each says so rather than returning a convincing zero.
The workspace
A pnpm workspace. Applications in apps/, everything else in packages/,
themes/ and plugins/.
| Directory | Package | What it is |
|---|---|---|
apps/community |
@meith/web |
The board itself. pnpm dev, on port 3000. |
apps/web |
@meith/site |
meith.dev — the landing page and these documents. pnpm site:dev, on port 3100. |
apps/worker |
@meith/worker |
The tick, as a long-running process. |
apps/cli |
@meith/cli |
The operator CLI. pnpm community …. |
packages/* |
@meith/* |
The domain: accounts, forums, posts, authorization, search, drivers, and the rest. |
themes/*, plugins/* |
The default theme, a second worked theme, and the reference plugin. | |
examples/* |
Reference code to copy, not installed: the worked example plugin and theme. See examples/README.md. |
Every @meith/* import resolves through tsconfig path aliases straight to
src/index.ts. There is no build step between packages, which is why a
typecheck is fast and why pnpm workspace:check exists — see
the invariant scripts.
How those packages relate — the layers, what may import what, and why — is Architecture.
The commands
pnpm dev |
The board, on 3000. |
pnpm site:dev |
meith.dev, on 3100. |
pnpm community <command> |
The operator CLI against your .env. --help lists it. |
pnpm test |
The whole suite. pnpm test:watch while you work. |
pnpm typecheck |
The workspace. :app and :site are the two Next projects. |
pnpm lint |
ESLint. |
pnpm verify |
Everything CI's static job runs. Run it before opening a pull request; CI's other jobs build the image and drive a browser. |
pnpm test:e2e |
Playwright: the no-JS paths, the staff panels, and the accessibility checks. It starts its own Postgres and two dev servers — nothing to install. |
pnpm verify is the one that matters: invariant guards, the generated-document
checks, lint, dependency rules, all three typecheck projects and the full test
suite. If it passes locally, CI's static job will too.
The database in tests
pnpm test needs no database at all. Repository tests, migrations, anything
asserting on real SQL — all of it runs against PGlite, a real Postgres compiled
to WebAssembly, booted in-process per suite with the checked-in migration SQL
applied.
One suite is the exception: packages/db/src/client.pg.test.ts needs a real
Postgres server, because PGlite bypasses the client driver and has accepted a
write every real server rejected. It skips unless TEST_DATABASE_URL is set:
docker compose -f docker-compose.dev.yml up -d
TEST_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/community_test pnpm testCI's migrations job sets it, so "it passed locally" covers everything except
that one seam — and CI covers the seam.
The browser suite, and who it can be
pnpm test:e2e starts everything it needs: a PGlite behind the Postgres wire
protocol, a next dev against it, and a second empty database and server for
/install. There is nothing to install and nothing to leave running.
Almost every spec runs with JavaScript disabled. That is the point rather
than a flourish: this board's claim is that a native <form> does the work and
the islands are optional, so a suite that tested the enhanced path would prove
the opposite of what is claimed.
A spec that needs a member registers through the form — the seeded accounts
carry a hash nothing can match, so the only way in is the way a member takes.
A spec that needs staff cannot do that, because a registration always lands in
the Registered group. Two accounts are therefore seeded with a real password,
both named in e2e/support/config.ts:
| Account | Group | For |
|---|---|---|
admin |
Administrators | The control panel. Bypasses forum permissions (R4.2). |
e2e_moderator |
Super Moderators | Moderation. Deliberately not an administrator, so the specs prove the moderator's own path rather than the bypass — and prove the panel is shut to them. |
Use signUp, signInAsModerator and enterAdminPanel from
e2e/support/session.ts rather than repeating the forms; signUp also asserts
the username fits the board's 30-character maximum, because the registration
input silently truncates a longer one and the sign-in that follows then fails
with "Incorrect username or password".
One spec runs with scripting on, admin-panel-live.spec.ts, and it is the
exception that the rule needs. With scripting off a form post is a full
navigation, so the page is re-rendered whatever the action did about caching —
which makes the whole suite blind to a panel screen that does not refresh its own
list. That blindness was hiding four of them.
The suite shares one database across every spec, in file order. A spec that changes something every page shows — a board-wide announcement, a board setting, a pinned thread — puts it back, or a later file fails for a reason nothing in it can explain.
The specs are typechecked by pnpm typecheck with everything else. Playwright
transpiles TypeScript without checking it, so until e2e/ was added to the root
project a spec that did not compile failed only when it ran — and a support file
that did not compile never failed at all.
Passing is not enough — the run also fails on what the board logged.
e2e/support/server-errors.ts is a reporter that reads the dev server's output
and fails the run on an unhandled server error however many tests passed. It
exists because a green run was hiding fifty-six: every control-panel page threw
a ForbiddenError on a visit its layout had already answered with the sign-in
form, and every spec asserting on that form passed over the top of it.
The scripts that fail on purpose
Several gates in pnpm verify exist because something once passed every other
check and broke on a clean install. Each is a fact about the repository that
nothing else reads:
| Script | What it catches |
|---|---|
workspace:check |
A package directory with sources and no package.json, or a manifest the lockfile has not seen. Both pass every other gate and fail pnpm install --frozen-lockfile, which is CI's first step. |
guards |
Textual invariants — the things a grep can prove and a type cannot. |
slots:check |
The server/client boundary in theme slots. |
hooks:wired |
A hook fired by name that the registry does not declare — the typo that would otherwise be a call nothing listens to. It also derives the wired/unwired list that pnpm plugin:docs publishes. |
theme:docs:check, plugin:docs:check, api:docs:check, perf:docs:check |
A generated reference that has drifted from the code it describes. |
docs:index:check, site:docs:check |
A document in docs/ that no index links to, or that is neither published nor explicitly repository-only. |
The generated documents
Four documents here are written from the code they describe and must not be edited by hand:
pnpm theme:docs # docs/theme-slots.md, from the theme registry
pnpm plugin:docs # docs/plugin-hooks.md, from the hook registry
pnpm api:docs # docs/rest-api.md, from the route registry
pnpm perf:docs # docs/performance.md, from the last load runpnpm verify fails when one is stale, deliberately: a reference read by somebody
who cannot see the source is worse than no reference when it is wrong.
The documentation itself
docs/*.md is the one editable copy. The site at
www.meith.dev/docs renders those same files at build time
and holds no copy of any of them, so a correction is one edit in one place.
Adding a document means putting it in docs/, naming it in
apps/web/content/docs.manifest.json — under documents to publish it, or
internal to keep it in the repository — and running:
pnpm site:docs # rewrites the documentation table in README.md and checks the setBoth index checks fail on a file that is in neither list, so a new document cannot quietly go unlinked.
Before opening a pull request
pnpm verifypasses.- New behaviour has a test that fails without it.
- Next.js conventions — the decisions that would otherwise be re-litigated in every review.
Where to read next
| You want to | Read |
|---|---|
| How the system fits together | Architecture |
| The conventions this codebase holds to | Next.js conventions |
| To write a theme | The theme API |
| To write a plugin | The plugin API |