Blog and category archives now show a numbered page list (first, last, current +/-1, collapsing gaps into an ellipsis) instead of only previous/next — see shared/utils/pagination.ts's paginationRange(). Renders page links via NuxtLink's custom/v-slot API rather than letting it render its own <a>: RouterLink's built-in active-class/aria-current detection compares only the route's path, not its query string, so every ?page=N link was incorrectly marked as the current page. The language switch now shows both "Italiano / English" at all times, with the current one as plain non-interactive text and the other as a link, moved to the header's top-right corner instead of stacked under the logo.
209 lines
15 KiB
Markdown
209 lines
15 KiB
Markdown
# 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.
|
|
|
|
## Server (Nitro) endpoints — the only PocketBase client
|
|
|
|
`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.
|
|
|
|
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/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` — 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`)
|
|
|
|
```
|
|
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 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 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 — reads `useLangSwitchState()` and `useLocale()` to
|
|
compute the target path, per the fallback rules in [English content](#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 `…` via
|
|
`shared/utils/pagination.ts`'s `paginationRange()`. Used by `BlogIndexView`/`CategoryView`.
|
|
Renders page links via `NuxtLink`'s `custom`/`v-slot` API rather than letting the component
|
|
render its own `<a>` — `RouterLink`'s built-in active-class/`aria-current` detection compares
|
|
only the route's `path`, not its query string, so with plain `NuxtLink`s every `?page=N` link
|
|
would end up (wrongly) marked as the current page.
|
|
- `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`.
|
|
|
|
## 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.
|