Files
blog/docs/frontend.md
T

200 lines
14 KiB
Markdown
Raw Normal View History

# 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` | *(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.
2026-09-11 11:25:14 +02:00
## Server (Nitro) endpoints — the only PocketBase client
2026-09-11 11:25:14 +02:00
`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 /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.
2026-09-11 11:25:14 +02:00
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
2026-09-11 11:25:14 +02:00
- `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.
2026-09-11 11:25:14 +02:00
- `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` — 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` — 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`)
```
2026-09-11 11:25:14 +02:00
MediaImage { url, alt }
Category { name, slug }
2026-09-11 11:25:14 +02:00
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
2026-09-11 11:25:14 +02:00
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 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. 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
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`.
2026-09-11 11:41:52 +02:00
## 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
in `frontend/server/utils/*` and `frontend/shared/utils/*` (imported directly, not via
auto-import) plus components and composables via `@nuxt/test-utils/runtime`'s `mountSuspended`.
- `tests/integration/` — Vitest, node environment (`frontend/vitest.integration.config.ts`).
`@nuxt/test-utils/e2e`'s `setup()` builds and runs the real Nitro server, pointed (via
`nuxtConfig.runtimeConfig` overrides) at a real ephemeral PocketBase instance from
`tests/support/pocketbase.ts`. Exercises the actual `/content/*` and `/admin` routes.
- `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's `globalSetup` can reference each other without an
async hand-off — see the comments in `playwright.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.