Replace Strapi CMS with PocketBase

Strapi + Postgres are gone in favor of PocketBase: a single Go binary
with embedded SQLite, built-in admin UI and per-collection API rules.
No content existed yet, so this is a clean swap with no data migration.

Collections and rules are defined as code in pocketbase/pb_migrations/
and applied automatically on first boot. Draft & Publish has no native
PocketBase equivalent, so it's reproduced with a nullable `publishedAt`
field enforced by listRule/viewRule, matching the old Strapi semantics.

Routing flips: PocketBase's admin UI and REST/file API are hardwired to
`/_/` and `/api/*` at the domain root (its own dashboard assets and API
calls reference those paths directly, so a stripped path prefix like
`/admin/*` would break them). `/api` is therefore reserved for
PocketBase now, and the frontend's Nitro endpoints move to `/content/*`
(frontend/server/routes/content/, not server/api/). A `/admin` vanity
route in Nitro (not Caddy) redirects to `/_/`, so it works the same in
dev, where Caddy isn't part of the stack, and in production.

frontend/server/utils/strapi.ts becomes pocketbase.ts; queries.ts is
rewritten for PocketBase's filter/sort/fields/expand query syntax.
StrapiImage becomes MediaImage (no width/height — PocketBase file
fields don't store dimensions, and the cover images already reserve
their aspect ratio via CSS, so this is not a regression).

docs/*.md, CLAUDE.md and README.md are updated in the same commit.
This commit is contained in:
2026-09-11 11:25:14 +02:00
parent 91540224a9
commit 49c42ecc96
64 changed files with 546 additions and 22706 deletions
+51 -40
View File
@@ -1,63 +1,74 @@
# Content model & admin panel
## Content types
## Collections
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
routes, services, or policies exist for either type.
Defined as code in `pocketbase/pb_migrations/*.js`, applied automatically the first time
PocketBase boots against an empty data directory (and on every subsequent deploy that adds new
migration files). No custom Go/JS hooks exist for either collection — schema and API rules only.
| Type | Fields |
| Collection | Fields |
|---|---|
| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) |
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
| **articles** | `title` (text, required, max 160), `slug` (text, required, unique, kebab-case pattern), `content` (text — Markdown source, not the WYSIWYG `editor` field type), `cover` (single file, images only), `coverAlt` (text, alt text), `category` (relation to `categories`), `publishedAt` (date, nullable), `authorName` (text) |
| **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern) |
Deliberately minimal: no Author (the only author is the admin account), no tags, no separate SEO
fields. Don't add these back without a concrete need — see `CLAUDE.md`:
Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete
need — see `CLAUDE.md`:
- Meta description is derived at request time from the article body (`summarise()` in
`frontend/server/utils/strapi.ts`).
- Publish date is Draft & Publish's `publishedAt`.
`frontend/server/utils/pocketbase.ts`).
- Publish date is `publishedAt`.
- Social preview image is the cover.
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
not a content field.
- The article byline is `authorName`, a plain field the editor fills in — PocketBase has no
built-in "creator" metadata to auto-populate it from, unlike Strapi's `createdBy`.
Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
numeric id.
There is no native Draft & Publish in PocketBase. `publishedAt` reproduces its semantics
explicitly: empty = draft (never returned by the public API), set and not in the future =
published. This is enforced by the collection's `listRule`/`viewRule`
(`publishedAt != '' && publishedAt <= @now`), not by application code — an unpublished or
future-dated article is invisible to `GET /api/collections/articles/records` regardless of what
Nitro asks for. Categories have no draft state (`slug` is unique so a category always exists or
doesn't). Public URLs always use the slug, never the PocketBase record id.
PocketBase file fields store only a filename, no width/height/alt metadata — that's why `coverAlt`
is a real field rather than upload metadata, and why the frontend's `MediaImage` type carries no
dimensions (see [frontend.md](./frontend.md)).
## Admin login & permissions
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
email/password admin authentication — no custom auth code in this repo.
- The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there),
routed by Caddy straight to the `pocketbase` container
([architecture.md](./architecture.md#path-routing-caddy)). It's PocketBase's standard
superuser email/password auth — no custom auth code in this repo.
- Public visitors **never** authenticate. There is no visitor account system, no comments, no
public write access of any kind.
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
and Category, and nothing else (no create/update/delete, no other content type). This is
what lets the Nitro endpoints read published content without a token.
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
(`allow_register: false`), since no front-end user accounts should ever exist.
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
deliberate least-privilege boundary, not an oversight.
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
- Each collection's `createRule`/`updateRule`/`deleteRule` is `null` — writes are superuser-only,
full stop. There is no separate "editor" role: the site owner is the only superuser, matching
"no front-end user accounts should ever exist."
- `listRule`/`viewRule` on `articles` are `publishedAt != '' && publishedAt <= @now` (public read
of published content only); on `categories` they're `''` (public read, unconditional — categories
aren't drafted).
- `cover`'s field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the
equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of
globally.
- Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and
needs to be justified explicitly.
## Editorial workflow
1. Log into `/admin`.
2. Create/edit a Category if needed (name → slug is generated automatically).
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
permissions above — Strapi's default behavior already excludes non-published entries from the
public API.
1. Log into `PUBLIC_SITE_URL/admin` (redirects to `/_/`).
2. Create/edit a Category if needed.
3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category,
author name.
4. Set `publishedAt` (to now, or a past/future date) to publish it. Leaving it empty keeps the
article a draft, invisible to the public API.
5. The change is live immediately: the public site has no cache layer to invalidate (see
[architecture.md](./architecture.md#request-flow-reading-an-article)).
## Why Markdown, not a rich-text/WYSIWYG field
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
in Strapi and never in the browser. This keeps `marked` out of the client bundle and keeps HTML
generation in one place. There is no sanitization step: this is intentional, since the only author
is the trusted admin, not arbitrary users.
`content` is a plain PocketBase `text` field, not the `editor` field type (which stores HTML).
PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt
Nitro endpoint (`renderMarkdown()`, using `marked`) — never in PocketBase and never in the browser.
This keeps `marked` out of the client bundle and keeps HTML generation in one place. There is no
sanitization step: this is intentional, since the only author is the trusted superuser, not
arbitrary users.