Files
blog/docs/architecture.md
T
davide 33b49c5dbb Add hand-rolled English support (/en/*) alongside the Italian site
PocketBase gains optional, manually-authored English fields on articles
and categories (same record, same slug), and the frontend serves an /en
counterpart of every dynamic route via thin page wrappers around shared
view components — no i18n library, consistent with the project's existing
minimalism stance for a two-locale site with ~20 UI strings.

An article/category with no translation 404s cleanly on its own /en
detail page and is filtered out of /en listings, and the header's
language-switch link falls back to the English blog index rather than a
dead link; resolving that requires a global route middleware, since the
layout's header renders before the page content in document order and so
can't react to state a page component sets during its own async setup.
2026-09-11 13:53:17 +02:00

109 lines
6.6 KiB
Markdown

# Architecture
## Services
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
```text
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
("/admin" redirects to /_/, handled by Nitro)
```
- **pocketbase** — a single PocketBase binary, the sole source of editorial truth. Embedded
SQLite, no separate database service. No other backend framework exists in this repo; any
server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new
service.
- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
under `/content/*` (Nitro), which are the only code in the repo allowed to call PocketBase.
- **caddy** — single reverse proxy, single TLS certificate, single public domain
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain or port.
`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml`
it's not an auto-merged `override.yml`, precisely so it can't be picked up by accident in
production.
## Path routing (Caddy)
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
- `@pocketbase path /_/* /api/*``pocketbase:8090`, **unprefixed** (100MB body limit, for
uploads).
- everything else → `frontend:3000`.
This means the English routes under `/en/*` ([frontend.md](./frontend.md#english-content))
need **no Caddy changes** at all: they're plain Nuxt pages like any other, so they already fall
under "everything else."
`/admin` is **not** a Caddy rule: it's a Nitro route
(`frontend/server/routes/admin.get.ts`) that redirects to `${pocketbaseUrl}/_/` (PocketBase's own
fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather
than Caddy means it works identically in dev, where Caddy isn't part of the stack — `/admin`
redirects to `http://localhost:8090/_/` there — and in production, where it redirects to the same
origin's `/_/`, which Caddy then proxies to PocketBase.
**Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
`/admin/foo``/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
API call the dashboard's own JS makes afterwards targets `/_/...`/`/api/...` directly — those
requests would then miss the `/admin` prefix and never reach the rewrite rule, landing on Nuxt
instead and breaking the dashboard. Routing `/_/*` and `/api/*` at the domain root, unprefixed, is
the only configuration PocketBase's own code is written to expect (confirmed against a live
container: dashboard HTML, its JS/CSS assets under `/_/assets/...`, and REST calls under
`/api/...` all resolve correctly this way). This is also PocketBase's own documented recommendation
for reverse-proxy deployments.
**`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
Nitro endpoints live under `/content/*` instead (`frontend/server/routes/content/`, not
`frontend/server/api/`, since Nitro auto-prefixes anything under `server/api/` with `/api`).
PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro
server, over `POCKETBASE_URL=http://pocketbase:8090`; and directly by the browser for the two
things that don't go through Nitro — the admin UI and cover images.
## Request flow: reading an article
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/articles/my-slug')` — a
same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip
over the network in production).
3. `frontend/server/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
`frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
with a PocketBase filter/fields query built by `frontend/server/utils/queries.ts`. PocketBase's
`listRule`/`viewRule` on the `articles` collection already exclude unpublished entries — the
endpoint doesn't need to check that itself.
4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
`Article` from `frontend/shared/types/blog.ts`.
5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
the browser fetches straight from PocketBase.
## Request flow: publishing content
1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
authenticated superuser only, no public sign-up, see [content-model.md](./content-model.md)).
2. Editor writes/edits an Article (Markdown body) or Category, and sets `publishedAt` to publish
it.
3. PocketBase writes to its embedded SQLite database (on the `pocketbase-data` volume). No cache
to invalidate: the next public request for that slug hits PocketBase live through the Nitro
endpoint.
## Environment variables
Defined in [.env.example](../.env.example) (root, drives docker-compose).
| Variable | Consumed by | Purpose |
|---|---|---|
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
| `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
| `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
| `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
| `PUBLIC_POCKETBASE_URL` | frontend, browser | Public-facing PocketBase origin (same domain as `PUBLIC_SITE_URL`; Caddy proxies `/_/*` and `/api/*` there) for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_POCKETBASE_URL`. |
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
only).