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.
This commit is contained in:
@@ -32,6 +32,10 @@ See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||
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
|
||||
|
||||
+27
-2
@@ -8,8 +8,8 @@ migration files). No custom Go/JS hooks exist for either collection — schema a
|
||||
|
||||
| Collection | Fields |
|
||||
|---|---|
|
||||
| **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) |
|
||||
| **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](#english-content) below) |
|
||||
| **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern), `nameEn` (text, optional — see [English content](#english-content) below) |
|
||||
|
||||
Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete
|
||||
need — see `CLAUDE.md`:
|
||||
@@ -64,6 +64,31 @@ dimensions (see [frontend.md](./frontend.md)).
|
||||
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)).
|
||||
|
||||
## 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](./frontend.md#english-content).
|
||||
- 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).
|
||||
|
||||
+103
-19
@@ -2,16 +2,75 @@
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | File | Behavior |
|
||||
|---|---|---|
|
||||
| `/` | `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 `/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 PocketBase data. |
|
||||
| Route | English counterpart | File(s) | Behavior |
|
||||
|---|---|---|---|
|
||||
| `/` | `/en` | `app/pages/index.vue` + `app/pages/en/index.vue`, both thin wrappers around `app/components/views/HomeView.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
|
||||
| `/blog` | `/en/blog` | `app/pages/blog/index.vue` + `app/pages/en/blog/index.vue` → `BlogIndexView.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
||||
| `/blog/[slug]` | `/en/blog/[slug]` | `app/pages/blog/[slug].vue` + `app/pages/en/blog/[slug].vue` → `ArticleView.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]` | `/en/category/[slug]` | `app/pages/category/[slug].vue` + `app/pages/en/category/[slug].vue` → `CategoryView.vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
||||
| `/come-difendersi-dal-corralito` | *(none)* | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data, Italian only — see [English content](#english-content-1). |
|
||||
|
||||
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
||||
|
||||
### English content
|
||||
|
||||
Each dynamic route has a `/en/`-prefixed counterpart, implemented as a thin page file
|
||||
(`app/pages/en/**`) that renders the exact same view component as its Italian counterpart with
|
||||
`locale="en"` — no template is duplicated. `app/components/views/*.vue` hold the actual markup and
|
||||
logic; the two page files per route only differ in that one prop. This is deliberately hand-rolled
|
||||
(no `@nuxtjs/i18n` or similar): two locales, ~20 short UI strings, no plurals — a library's routing
|
||||
and message-catalog machinery would be more code than the plain approach below, and CLAUDE.md's
|
||||
existing minimalism rule ("no dependency without a concrete, current need") still applies once you
|
||||
account for what a library *wouldn't* solve for you here (translation-availability fallback logic,
|
||||
Nitro locale-awareness, and most of the SEO work are custom code either way).
|
||||
|
||||
- **Locale detection**: purely from the URL path (`useLocale()` composable — `/en` or `/en/...` is
|
||||
English, everything else Italian). No cookie, no `Accept-Language` negotiation.
|
||||
- **Static UI strings**: `frontend/shared/utils/i18n.ts` — a plain `{ it: {...}, en: {...} }`
|
||||
object (`STRINGS`), keyed by role, with an `interpolate()` helper for the handful of strings that
|
||||
take a parameter (e.g. `"Pagina {current} di {total}"`). This is the *only* place UI copy lives;
|
||||
don't hardcode a string in a component when adding a new one.
|
||||
- **Locale-aware paths**: `frontend/shared/utils/locale.ts` — `localePath(locale, path)` prefixes a
|
||||
bare path with `/en` when needed; `bareLocalePath(path)` does the inverse.
|
||||
- **Dates**: `formatDate`/`formatDateTime` (`shared/utils/format.ts`) take an optional second
|
||||
`Locale` argument (`'it' | 'en'`, default `'it'`) and format via `Intl.DateTimeFormat` with
|
||||
`it-IT`/`en-GB`. No new dependency, and every pre-existing call site keeps working unchanged.
|
||||
- **`/content/*` endpoints**: all four take an optional `?lang=en` query param
|
||||
(`langParam()` in `server/utils/queries.ts`, default `it`). See below for the fallback policy.
|
||||
- **The language-switch button** (`app/components/LanguageSwitch.vue`, rendered in the header by
|
||||
`default.vue`) needs to know, for the *current* page, whether an equivalent page exists in the
|
||||
other language — an article/category with no translation shouldn't link to a page that then
|
||||
404s. That information is resolved by `app/middleware/lang-switch.global.ts` **before** the page
|
||||
renders, into a shared `useLangSwitchState()` (`{ available: boolean; fallback: string }`).
|
||||
This has to be a route middleware, not a `ref` set from inside the page component itself: a
|
||||
layout renders its header (where the switch lives) before the page content in document order, so
|
||||
by the time a page component's own `<script setup>` would set that state, the header has already
|
||||
resolved and rendered with whatever the default was — middleware runs, and fully resolves,
|
||||
before the layout/page tree starts rendering at all, which is the only way to avoid that race.
|
||||
When unavailable, the switch falls back to the other language's blog index (articles/categories)
|
||||
or home page (the static corralito page, which has no English version at all).
|
||||
- **SEO**: `app/composables/useSeo.ts` centralises canonical URL, Open Graph, JSON-LD, and
|
||||
`hreflang` alternate links (`it`, `en`, `x-default`) for a given `{ locale, path, ... }` — `path`
|
||||
is always the bare, unprefixed path, so IT/EN pages for the same content always agree on each
|
||||
other's URL. `hasAlternate: false` omits the `hreflang="en"` link entirely for an untranslated
|
||||
article. Introduced now (rather than earlier) because doubling the pre-existing per-page
|
||||
canonical/OG/JSON-LD duplication across two locales would have made an already-duplicated
|
||||
pattern much worse.
|
||||
|
||||
#### `/content/*` fallback policy for missing translations
|
||||
|
||||
- **Detail endpoints** (`/content/articles/:slug`, `/content/categories/:slug`) with `?lang=en`:
|
||||
if the record has no translation, **404** — never silently fall back to Italian text on an
|
||||
`/en/...` URL. The language-switch button (above) avoids ever linking to this in the first
|
||||
place; the 404 is a safety net, e.g. for a bookmarked/shared link.
|
||||
- **Listing endpoints** (`/content/articles`, `/content/categories`) with `?lang=en`: **filter
|
||||
out** untranslated records (`titleEn != ''` / `nameEn != ''` in the PocketBase query) rather than
|
||||
404ing the whole list, so an `/en/blog` archive only ever links to articles that actually exist
|
||||
in English.
|
||||
- A category's English **name** shown inline within an article (nested `expand.category`) falls
|
||||
back to the Italian name if untranslated — that's secondary metadata alongside the primary
|
||||
content, not the resource being requested, so the stricter 404 policy doesn't apply there.
|
||||
|
||||
## Server (Nitro) endpoints — the only PocketBase client
|
||||
|
||||
`frontend/server/routes/content/` (not `server/api/` — `/api/*` is reserved for PocketBase itself,
|
||||
@@ -20,10 +79,12 @@ matching path with no added prefix, unlike `server/api/**`):
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter, optional `lang` (`it`\|`en`, default `it`). 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, unpublished, or (`lang=en`) untranslated. Returns `Article`, including `hasTranslation`. |
|
||||
| `GET /content/categories` | All categories (name + slug), sorted by name. Optional `lang`. |
|
||||
| `GET /content/categories/:slug` | One category by slug. 400/404 as above (including `lang=en` + untranslated). |
|
||||
|
||||
All four accept `?lang=en` — see [English content](#english-content) for the fallback policy.
|
||||
|
||||
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
|
||||
@@ -50,11 +111,20 @@ production — see [architecture.md](./architecture.md#path-routing-caddy).
|
||||
(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/app/composables/useMediaUrl.ts` — turns a `MediaImage` into an absolute browser URL by
|
||||
prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
|
||||
- `frontend/app/composables/useLocale.ts` — derives the current `Locale` from the URL path.
|
||||
- `frontend/app/composables/useLangSwitch.ts` — `useLangSwitchState()`, the shared
|
||||
`{ available, fallback }` state the language-switch button reads (set by
|
||||
`app/middleware/lang-switch.global.ts` — see [English content](#english-content)).
|
||||
- `frontend/app/composables/useSeo.ts` — canonical/OG/JSON-LD/hreflang, see
|
||||
[English content](#english-content).
|
||||
- `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`).
|
||||
- `frontend/shared/utils/format.ts` — date formatting (`formatDate`, `formatDateTime`, `isoDate`),
|
||||
locale-parametrized (`it-IT`/`en-GB`, default `it`).
|
||||
- `frontend/shared/utils/locale.ts` — `Locale` type, `localePath()`/`bareLocalePath()`.
|
||||
- `frontend/shared/utils/i18n.ts` — the `STRINGS` dictionary and `interpolate()` helper for static
|
||||
UI copy, see [English content](#english-content).
|
||||
|
||||
## Types (`frontend/shared/types/blog.ts`)
|
||||
|
||||
@@ -62,10 +132,14 @@ production — see [architecture.md](./architecture.md#path-routing-caddy).
|
||||
MediaImage { url, alt }
|
||||
Category { name, slug }
|
||||
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
|
||||
Article extends ArticleSummary { html, summary, author: string | null }
|
||||
Article extends ArticleSummary { html, summary, author: string | null, hasTranslation }
|
||||
Paginated<T> { items: T[], page, pageCount, total }
|
||||
```
|
||||
|
||||
`hasTranslation` is true when the article has a non-empty English title *and* content, regardless
|
||||
of which `lang` was requested — it's what the language-switch button uses to decide whether to
|
||||
link to this article's English version or fall back to the English blog index.
|
||||
|
||||
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
||||
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
|
||||
@@ -73,12 +147,22 @@ layout shift results.
|
||||
|
||||
## Layout & shared components
|
||||
|
||||
- `app/layouts/default.vue` — the only layout: header (logo, tagline, nav), `<main id="main">`
|
||||
slot, footer (contact email, nav, social links), with a skip-link for accessibility.
|
||||
- `app/layouts/default.vue` — the only layout: header (logo, tagline, nav, language switch),
|
||||
`<main id="main">` slot, footer (contact email, nav, social links), with a skip-link for
|
||||
accessibility. All copy comes from `STRINGS[locale]` (`shared/utils/i18n.ts`).
|
||||
- `app/components/ArticleCard.vue` — listing-grid card: cover (lazy, omitted if none), category
|
||||
kicker, title link, formatted date.
|
||||
kicker, title link, formatted date. Takes an optional `locale` prop (default `it`) for the link
|
||||
prefix and date formatting.
|
||||
- `app/components/SocialIcon.vue` — inlines SVG brand marks from `simple-icons` at build time
|
||||
(`?raw` imports) rather than bundling the whole icon set.
|
||||
- `app/components/LanguageSwitch.vue` — the header's language-switch link; reads
|
||||
`useLangSwitchState()` and `useLocale()`, computes the equivalent path in the other language.
|
||||
- `app/components/views/*.vue` (`HomeView`, `BlogIndexView`, `ArticleView`, `CategoryView`) — the
|
||||
actual page markup/logic, parametrized by a `locale` prop; the real `pages/**` and `pages/en/**`
|
||||
files are thin wrappers around these (see [English content](#english-content)). Registered
|
||||
without Nuxt's default nested-directory name prefix
|
||||
(`components: [{ path: '~/components', pathPrefix: false }]` in `nuxt.config.ts`), so pages
|
||||
reference them as `<HomeView>` etc., not `<ViewsHomeView>`.
|
||||
|
||||
## SEO & accessibility
|
||||
|
||||
|
||||
Reference in New Issue
Block a user