It was deliberately left Italian-only when /en support was first added; now translated and following the same thin-page + shared-view pattern as every other route, with the nav link and language switch no longer special-casing it.
16 KiB
Frontend (Nuxt)
Pages
| 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 |
/en/come-difendersi-dal-corralito |
app/pages/come-difendersi-dal-corralito.vue + app/pages/en/come-difendersi-dal-corralito.vue → CorralitoView.vue |
Fully static marketing page, no PocketBase data — copy lives in STRINGS[locale].corralito (shared/utils/i18n.ts), same pattern as every other view. |
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 —/enor/en/...is English, everything else Italian). No cookie, noAccept-Languagenegotiation. - Static UI strings:
frontend/shared/utils/i18n.ts— a plain{ it: {...}, en: {...} }object (STRINGS), keyed by role, with aninterpolate()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/enwhen needed;bareLocalePath(path)does the inverse. - Dates:
formatDate/formatDateTime(shared/utils/format.ts) take an optional secondLocaleargument ('it' | 'en', default'it') and format viaIntl.DateTimeFormatwithit-IT/en-GB. No new dependency, and every pre-existing call site keeps working unchanged. /content/*endpoints: all four take an optional?lang=enquery param (langParam()inserver/utils/queries.ts, defaultit). See below for the fallback policy.- The language-switch button (
app/components/LanguageSwitch.vue, rendered in the header bydefault.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 byapp/middleware/lang-switch.global.tsbefore the page renders, into a shareduseLangSwitchState()({ available: boolean; fallback: string }). This has to be a route middleware, not arefset 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/category list; every other route (including the static corralito page) exists in both languages unconditionally, so the mechanical/enprefix swap is always valid there. - SEO:
app/composables/useSeo.tscentralises canonical URL, Open Graph, JSON-LD, andhreflangalternate links (it,en,x-default) for a given{ locale, path, ... }—pathis always the bare, unprefixed path, so IT/EN pages for the same content always agree on each other's URL.hasAlternate: falseomits thehreflang="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/blogarchive 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,
see architecture.md; Nitro maps server/routes/** to the
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, 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 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
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.
Supporting utilities
frontend/server/utils/pocketbase.tspbFetch<T>(path)— server-only fetch againstruntimeConfig.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; returnsnullwhen 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— 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'sfilterDSL).frontend/app/composables/useMediaUrl.ts— turns aMediaImageinto an absolute browser URL by prefixingruntimeConfig.public.pocketbaseUrlunless already absolute.frontend/app/composables/useLocale.ts— derives the currentLocalefrom the URL path.frontend/app/composables/useLangSwitch.ts—useLangSwitchState(), the shared{ available, fallback }state the language-switch button reads (set byapp/middleware/lang-switch.global.ts— see English content).frontend/app/composables/useSeo.ts— canonical/OG/JSON-LD/hreflang, see English content.frontend/shared/utils/site.ts— site constants (SITE_NAME,SITE_EMAIL,SOCIAL_LINKS).frontend/shared/utils/format.ts— date formatting (formatDate,formatDateTime,isoDate), locale-parametrized (it-IT/en-GB, defaultit).frontend/shared/utils/locale.ts—Localetype,localePath()/bareLocalePath().frontend/shared/utils/i18n.ts— theSTRINGSdictionary andinterpolate()helper for static UI copy, see English content.
Types (frontend/shared/types/blog.ts)
MediaImage { url, alt }
Category { name, slug }
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | 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
layout shift results.
Layout & shared components
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 fromSTRINGS[locale](shared/utils/i18n.ts).app/components/ArticleCard.vue— listing-grid card: cover (lazy, omitted if none), category kicker, title link, formatted date. Takes an optionallocaleprop (defaultit) for the link prefix and date formatting.app/components/SocialIcon.vue— inlines SVG brand marks fromsimple-iconsat build time (?rawimports) rather than bundling the whole icon set.app/components/LanguageSwitch.vue— the header's language toggle: both language names are always shown (Italiano / English), the current one as plain non-interactive text (aria-current="true"), the other as a link — readsuseLangSwitchState()anduseLocale()to compute the target path, per the fallback rules in English content.app/components/PaginationNav.vue— numbered pagination (not just prev/next): first, last, the current page and its immediate neighbors, collapsing gaps into a single…viashared/utils/pagination.ts'spaginationRange(). Used byBlogIndexView/CategoryView. Renders page links viaNuxtLink'scustom/v-slotAPI rather than letting the component render its own<a>—RouterLink's built-in active-class/aria-currentdetection compares only the route'spath, not its query string, so with plainNuxtLinks every?page=Nlink would end up (wrongly) marked as the current page.app/components/views/*.vue(HomeView,BlogIndexView,ArticleView,CategoryView,CorralitoView) — the actual page markup/logic, parametrized by alocaleprop; the realpages/**andpages/en/**files are thin wrappers around these (see English content). Registered without Nuxt's default nested-directory name prefix (components: [{ path: '~/components', pathPrefix: false }]innuxt.config.ts), so pages reference them as<HomeView>etc., not<ViewsHomeView>.
SEO & accessibility
Every article page ships: unique <title>, meta description, canonical URL, Open Graph tags, and
JSON-LD BlogPosting structured data, with the article body already present in server-rendered
HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
— see CLAUDE.md.
Tests (tests/, repo root)
Kept at the repo root, not under frontend/, since e2e exercises the frontend and PocketBase
together — but the tooling (Vitest/Playwright configs, node_modules) still lives in frontend/,
the only npm project in the repo; the configs there just point include/testDir at ../tests/.
Three layers, all against real code (no mocked PocketBase):
tests/unit/— Vitest,environment: 'nuxt'(frontend/vitest.unit.config.ts). Pure functions infrontend/server/utils/*andfrontend/shared/utils/*(imported directly, not via auto-import) plus components and composables via@nuxt/test-utils/runtime'smountSuspended.tests/integration/— Vitest, node environment (frontend/vitest.integration.config.ts).@nuxt/test-utils/e2e'ssetup()builds and runs the real Nitro server, pointed (vianuxtConfig.runtimeConfigoverrides) at a real ephemeral PocketBase instance fromtests/support/pocketbase.ts. Exercises the actual/content/*and/adminroutes.tests/e2e/— Playwright (frontend/playwright.config.ts), a real browser against the built app (node .output/server/index.mjs) and another ephemeral PocketBase on a fixed port (needed so the Nuxt server's env and Playwright'sglobalSetupcan reference each other without an async hand-off — see the comments inplaywright.config.ts/tests/e2e/global-setup.ts).
tests/support/pocketbase.ts is the shared piece: it downloads the same pinned PocketBase binary
pocketbase/Dockerfile uses (cached in .cache/pocketbase/ at the repo root, gitignored), starts
it against the real pocketbase/pb_migrations/, and seeds it from tests/support/fixtures.ts —
so integration and e2e tests run against the exact schema/rules that ship to production, not a
hand-maintained approximation of them.