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
+6 -6
View File
@@ -6,7 +6,7 @@ the root [README.md](../README.md); for coding conventions and constraints, see
depth than either of those.
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
- [content-model.md](./content-model.md) — Strapi content types, admin panel, permissions, editorial workflow.
- [content-model.md](./content-model.md) — PocketBase collections, admin panel, permissions, editorial workflow.
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
## What this repository is
@@ -15,9 +15,9 @@ A blog website with two faces:
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
no login, no client-side calls to the CMS.
- **Admin panel** — the site owner logs into Strapi's admin UI (`/admin`) to write, edit and
publish articles and categories. This is the only way content changes; there is no other CMS
and no public user accounts.
- **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
to write, edit and publish articles and categories. This is the only way content changes; there
is no other CMS and no public user accounts.
One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
single Caddy reverse proxy on one domain.
One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
process), fronted by a single Caddy reverse proxy on one domain.
+58 -53
View File
@@ -2,22 +2,22 @@
## Services
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
```text
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
("/admin" redirects to /_/, handled by Nitro)
```
- **database** — `postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the
Docker network. Data on the `postgres-data` volume.
- **cms** — Strapi 5, the sole source of editorial truth. 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.
- **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 `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
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.
(`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`
@@ -26,74 +26,79 @@ production.
## Path routing (Caddy)
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
```
/admin* /content-manager* /content-type-builder* /upload* /i18n*
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
```
- `@pocketbase path /_/* /api/*``pocketbase:8090`, **unprefixed** (100MB body limit, for
uploads).
- everything else → `frontend:3000`.
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
else — including `/api/*` — goes to `frontend:3000`.
`/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.
**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
**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.
If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the
Caddyfile — this is the one place that list is maintained.
**`/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('/api/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/api/articles/[slug].get.ts` calls `strapiFetch()` (in
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
Strapi-specific query built by `frontend/server/utils/queries.ts`.
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 directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
asset the browser fetches straight from Strapi.
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 logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public
sign-up, see [content-model.md](./content-model.md)).
2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
slug hits Strapi live through the Nitro endpoint.
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) and
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
Docker).
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. |
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
| `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_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. |
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
| `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).
+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.
+37 -25
View File
@@ -4,44 +4,54 @@
| Route | File | Behavior |
|---|---|---|
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. |
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. |
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data. |
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
## Server (Nitro) endpoints — the only Strapi client
## Server (Nitro) endpoints — the only PocketBase client
`frontend/server/api/`:
`frontend/server/routes/content/` (not `server/api/``/api/*` is reserved for PocketBase itself,
see [architecture.md](./architecture.md#path-routing-caddy); Nitro maps `server/routes/**` to the
matching path with no added prefix, unlike `server/api/**`):
| Endpoint | Purpose |
|---|---|
| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated<ArticleSummary>`. |
| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. |
| `GET /api/categories` | All categories (name + slug only), sorted by name. |
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
| `GET /content/articles/:slug` | One article: queries PocketBase with `ARTICLE_DETAIL_FIELDS` (includes `content` + `authorName`), converts Markdown to HTML, builds the meta description. 400 without a slug, 404 if not found or unpublished. Returns `Article`. |
| `GET /content/categories` | All categories (name + slug only), sorted by name. |
| `GET /content/categories/:slug` | One category by slug. 400/404 as above. |
This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
If you add a view that needs new data, add the endpoint here and type its return in
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
`$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
add a view that needs new data, add the endpoint here (under `/content/*`) and type its return in
`frontend/shared/types/blog.ts` — don't scatter PocketBase calls into components.
`GET /admin` (`frontend/server/routes/admin.get.ts`) is the one other top-level server route: a
redirect to `${runtimeConfig.public.pocketbaseUrl}/_/`, PocketBase's own fixed dashboard path. It
lives in Nitro rather than Caddy so it works the same in dev (no Caddy in that stack) and
production — see [architecture.md](./architecture.md#path-routing-caddy).
## Supporting utilities
- `frontend/server/utils/strapi.ts`
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
Bearer token; wraps failures as a 502 so internal details never leak to the client.
- `frontend/server/utils/pocketbase.ts`
- `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
as a 502 so internal details never leak to the client.
- `toMediaImage(collection, id, filename, alt)` — builds a PocketBase file URL
(`/api/files/{collection}/{id}/{filename}`) from a record; returns `null` when there's no
file.
- `renderMarkdown(source)``marked.parse()`, no sanitization (trusted, admin-only content).
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
description, word-boundary clipped.
- `frontend/server/utils/queries.ts`Strapi query-string builders kept intentionally minimal
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
`PAGE_SIZE`, `pagination()`, `pageParam()`.
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
absolute.
- `frontend/server/utils/queries.ts`PocketBase query-string builders kept intentionally minimal
(only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
`PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
DSL).
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a `MediaImage` into an
absolute browser URL by prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
- `frontend/shared/utils/format.ts``it-IT` date formatting (`formatDate`, `formatDateTime`,
`isoDate`).
@@ -49,15 +59,17 @@ If you add a view that needs new data, add the endpoint here and type its return
## Types (`frontend/shared/types/blog.ts`)
```
StrapiImage { url, alternativeText, width, height }
MediaImage { url, alt }
Category { name, slug }
ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null }
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
Article extends ArticleSummary { html, summary, author: string | null }
Paginated<T> { items: T[], page, pageCount, total }
```
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
listing pages use.
listing pages use. `MediaImage` carries no width/height — PocketBase file fields don't store
dimensions, and the covers already reserve their aspect ratio via CSS (`aspect-ratio`), so no
layout shift results.
## Layout & shared components