Files
blog/docs/content-model.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

6.2 KiB

Content model & admin panel

Collections

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.

Collection Fields
articles title (text, required, max 160), slug (text, required, unique, kebab-case pattern), content (text, max 10000 — 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), titleEn/contentEn/coverAltEn (text, optional — see English content below)
categories name (text, required, unique), slug (text, required, unique, kebab-case pattern), nameEn (text, optional — see English content below)

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/pocketbase.ts).
  • Publish date is publishedAt.
  • Social preview image is the cover.
  • 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.

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).

Admin login & permissions

  • The admin panel lives at /_/ (also reachable via /admin, which just redirects there), routed by Caddy straight to the pocketbase container (architecture.md). 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.
  • 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 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).

English content

The site is now bilingual (Italian, the source language, and English), but PocketBase has no localization feature and no separate translation workflow was introduced for it: titleEn, contentEn, coverAltEn on articles and nameEn on categories are plain, optional text fields living on the same record as their Italian counterparts — same slug, same cover, same category relation, same publishedAt. Added by migration pocketbase/pb_migrations/1757500002_add_english_fields.js, which alters the two existing collections (app.findCollectionByNameOrId + collection.fields.add(...) / .removeByName(...) on the down-migration) rather than recreating them — the first example of that pattern in this repo, since until now every migration only ever created a collection.

Translations are written manually by the editor in the admin UI, at their own pace — there is no auto-translation step. Consequently:

  • An article/category with empty titleEn/contentEn/nameEn simply has no English version yet. This is normal, not an error state.
  • listRule/viewRule are unchanged: whether a record is published is still governed solely by publishedAt, independent of translation completeness. Whether it's offered in English is decided by the Nitro layer (frontend/server/routes/content/*), not by a PocketBase rule — see frontend.md.
  • There's no publishedAtEn or similar: translation is all-or-nothing at the field level, not a separate publish gate. If partial/staged English publishing is ever needed, that's the natural next field to add — don't build it speculatively now.

Why Markdown, not a rich-text/WYSIWYG field

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.