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:
@@ -46,10 +46,17 @@ dominio, un solo certificato TLS. Se cambi questa scelta di routing, spiega il t
|
||||
(`/api/files/...`, sola lettura, nessun'autenticazione richiesta né concessa).
|
||||
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
|
||||
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
||||
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
||||
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
||||
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano monolingua.
|
||||
- Il sito è **bilingue** (italiano, sorgente, e inglese), fatto in casa senza `@nuxtjs/i18n` né
|
||||
file di traduzione runtime: due lingue, ~20 stringhe statiche, nessun plurale — la libreria
|
||||
costerebbe più codice di quanto risolva (il fallback su contenuti non tradotti, la
|
||||
localizzazione degli endpoint Nitro e gran parte della SEO restano comunque da scrivere a mano).
|
||||
`<html lang>` è dinamico (per pagina, via `useSeo`), non più fisso. Le pagine inglesi vivono sotto
|
||||
prefisso `/en/*`, come wrapper sottili attorno agli stessi componenti "view" delle pagine
|
||||
italiane — vedi `docs/frontend.md#english-content`. Le stringhe UI statiche vivono **solo** in
|
||||
`frontend/shared/utils/i18n.ts`. Gli **articoli** hanno campi paralleli opzionali
|
||||
(`titleEn`/`contentEn`/`coverAltEn`, `nameEn` su categorie), tradotti **a mano** dall'editor in
|
||||
admin — nessuna traduzione automatica. Vedi `docs/content-model.md#english-content` per lo
|
||||
schema e la politica di fallback (404 pulito sul dettaglio non tradotto, filtro nelle liste).
|
||||
- `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici
|
||||
solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/
|
||||
`updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato.
|
||||
@@ -107,8 +114,8 @@ richiede `sudo`; se mancano librerie di sistema per il browser, installarle a pa
|
||||
|
||||
| Type | Campi |
|
||||
|---|---|
|
||||
| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName |
|
||||
| Category | name, slug |
|
||||
| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName, titleEn, contentEn, coverAltEn |
|
||||
| Category | name, slug, nameEn |
|
||||
|
||||
Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
|
||||
dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
|
||||
@@ -120,7 +127,7 @@ codice applicativo. Le URL pubbliche usano lo **slug**, mai l'id del record.
|
||||
|
||||
## Frontend
|
||||
|
||||
Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
|
||||
Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`, ciascuna con equivalente `/en/...`.
|
||||
|
||||
- SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto.
|
||||
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ article: ArticleSummary }>()
|
||||
const props = withDefaults(defineProps<{ article: ArticleSummary; locale?: Locale }>(), {
|
||||
locale: 'it',
|
||||
})
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(props.article.cover))
|
||||
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -14,7 +19,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<NuxtLink
|
||||
v-if="cover"
|
||||
class="cover-link"
|
||||
:to="`/blog/${article.slug}`"
|
||||
:to="href"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -29,12 +34,12 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
|
||||
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
|
||||
<NuxtLink :to="href">{{ article.title }}</NuxtLink>
|
||||
</h2>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDate(article.publishedAt) }}
|
||||
{{ formatDate(article.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { bareLocalePath, localePath } from '#shared/utils/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const locale = useLocale()
|
||||
const langSwitch = useLangSwitchState()
|
||||
|
||||
const target = computed(() => {
|
||||
if (!langSwitch.value.available) return langSwitch.value.fallback
|
||||
const otherLocale = locale.value === 'en' ? 'it' : 'en'
|
||||
return localePath(otherLocale, bareLocalePath(route.path))
|
||||
})
|
||||
|
||||
const label = computed(() => STRINGS[locale.value].langSwitchLabel)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink class="lang-switch" :to="target">{{ label }}</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lang-switch {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
padding-block: 0.6rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.lang-switch:hover {
|
||||
border-bottom-color: var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
||||
const blogHref = computed(() => localePath(props.locale, '/blog'))
|
||||
const categoryHref = computed(() =>
|
||||
article.value?.category ? localePath(props.locale, `/category/${article.value.category.slug}`) : ''
|
||||
)
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/blog/${slug}`,
|
||||
title: article.value.title,
|
||||
description: article.value.summary,
|
||||
type: 'article',
|
||||
image: cover.value,
|
||||
hasAlternate: props.locale === 'en' ? true : article.value.hasTranslation,
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.value.title,
|
||||
description: article.value.summary,
|
||||
datePublished: isoDate(article.value.publishedAt),
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${useRuntimeConfig().public.siteUrl}${localePath(props.locale, `/blog/${slug}`)}` },
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" :aria-label="strings.article.breadcrumbAriaLabel">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.articlesCrumb }}</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="categoryHref">
|
||||
{{ article.category.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt, locale) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>{{ interpolate(strings.article.byAuthor, { author: article.author }) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.backToArticles }}</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `blog-${props.locale}-${page.value}`,
|
||||
query: { page, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/blog',
|
||||
title: page.value > 1 ? interpolate(strings.value.blog.titlePage, { page: page.value }) : strings.value.blog.title,
|
||||
description: strings.value.blog.metaDescription,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.blog.archiveKicker }}</p>
|
||||
<h1>{{ strings.blog.title }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">{{ strings.blog.loading }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ strings.blog.prevPage }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ strings.blog.nextPage }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`, {
|
||||
key: `category-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `category-articles-${slug}-${props.locale}-${page.value}`,
|
||||
query: { page, category: slug, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/category/${slug}`,
|
||||
title: category.value.name,
|
||||
description: interpolate(strings.value.category.metaDescription, { name: category.value.name }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.category.kicker }}</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.category.loadError }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.category.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ strings.blog.prevPage }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ strings.blog.nextPage }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: `home-articles-${props.locale}`,
|
||||
query: { page: 1, lang: props.locale },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
const featuredHref = computed(() => (featured.value ? localePath(props.locale, `/blog/${featured.value.slug}`) : '/'))
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/',
|
||||
title: SITE_NAME,
|
||||
description: strings.value.home.metaDescription,
|
||||
image: `${useRuntimeConfig().public.siteUrl}/logo.png`,
|
||||
})
|
||||
useHead({ titleTemplate: '%s' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
:alt="strings.home.img1Alt"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>{{ strings.home.heading }}</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
:alt="strings.home.img2Alt"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>{{ strings.home.p1 }}</p>
|
||||
<p>{{ strings.home.p2 }}</p>
|
||||
<p>{{ strings.home.p3 }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="!featured">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="featuredHref"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : strings.home.fallbackKicker }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="featuredHref">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="featuredHref">
|
||||
{{ strings.home.readLatest }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared per-navigation state for the language-switch button in the layout.
|
||||
* Each page sets this on setup: `available` is false when there is no
|
||||
* equivalent page in the other language for the current content (e.g. an
|
||||
* untranslated article, or the Italian-only corralito page) — in that case
|
||||
* `fallback` is where the switch should go instead (typically the other
|
||||
* language's blog index).
|
||||
*/
|
||||
interface LangSwitchState {
|
||||
available: boolean
|
||||
fallback: string
|
||||
}
|
||||
|
||||
export function useLangSwitchState() {
|
||||
return useState<LangSwitchState>('lang-switch', () => ({ available: true, fallback: '/en' }))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
/** Derives the current locale from the URL path — `/en` or `/en/...` is English, everything else Italian. */
|
||||
export function useLocale() {
|
||||
const route = useRoute()
|
||||
return computed<Locale>(() => (route.path === '/en' || route.path.startsWith('/en/') ? 'en' : 'it'))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
interface SeoOptions {
|
||||
locale: Locale
|
||||
/** The bare, unprefixed path, e.g. `/blog/mio-slug` — never `/en/...`. */
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
type?: 'website' | 'article'
|
||||
image?: string
|
||||
/** False when no translation exists at all for this content — omits the alternate link. */
|
||||
hasAlternate?: boolean
|
||||
jsonLd?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralises canonical/hreflang/OG/JSON-LD head tags so IT and EN pages for
|
||||
* the same content always agree on each other's URL — duplicating this per
|
||||
* page, doubled for two locales, is exactly how hreflang correctness drifts.
|
||||
*/
|
||||
export function useSeo(options: SeoOptions) {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const bare = options.path === '/' ? '' : options.path
|
||||
const itUrl = `${config.siteUrl}${bare}`
|
||||
const enUrl = `${config.siteUrl}/en${bare}`
|
||||
const canonical = options.locale === 'en' ? enUrl : itUrl
|
||||
|
||||
useSeoMeta({
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
ogTitle: options.title,
|
||||
ogDescription: options.description,
|
||||
ogType: options.type ?? 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: options.image,
|
||||
twitterCard: options.image ? 'summary_large_image' : undefined,
|
||||
})
|
||||
|
||||
const link: Array<{ rel: 'canonical' | 'alternate'; href: string; hreflang?: string }> = [
|
||||
{ rel: 'canonical', href: canonical },
|
||||
]
|
||||
if (options.hasAlternate !== false) {
|
||||
link.push(
|
||||
{ rel: 'alternate', hreflang: 'it', href: itUrl },
|
||||
{ rel: 'alternate', hreflang: 'en', href: enUrl },
|
||||
{ rel: 'alternate', hreflang: 'x-default', href: itUrl }
|
||||
)
|
||||
}
|
||||
|
||||
useHead({
|
||||
htmlAttrs: { lang: options.locale },
|
||||
// unhead's Link type narrows required keys per literal `rel` value in a
|
||||
// way a plain canonical/alternate/hreflang link doesn't fit — the shape
|
||||
// itself is valid HTML, so this is a type-only escape hatch.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
link: link as any,
|
||||
script: options.jsonLd
|
||||
? [{ type: 'application/ld+json', innerHTML: JSON.stringify(options.jsonLd) }]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const locale = useLocale()
|
||||
const strings = computed(() => STRINGS[locale.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<a class="skip-link" href="#main">Vai al contenuto</a>
|
||||
<a class="skip-link" href="#main">{{ strings.skipLink }}</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<p class="wordmark">
|
||||
<NuxtLink to="/">
|
||||
<NuxtLink :to="localePath(locale, '/')">
|
||||
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="tagline kicker">Articoli, guide e analisi.</p>
|
||||
<p class="tagline kicker">{{ strings.tagline }}</p>
|
||||
<p class="lang-switch-header">
|
||||
<LanguageSwitch />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="site-nav" aria-label="Navigazione principale">
|
||||
<nav class="site-nav" :aria-label="strings.navAriaLabel">
|
||||
<div class="container nav-inner">
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -34,24 +44,26 @@
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-block">
|
||||
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
<p class="muted">Articoli, guide e analisi.</p>
|
||||
<p class="muted">{{ strings.tagline }}</p>
|
||||
<address class="contact">
|
||||
Contatti:
|
||||
{{ strings.contactLabel }}
|
||||
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<nav class="footer-block" aria-label="Naviga">
|
||||
<p class="kicker">Naviga</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerNavAriaLabel">
|
||||
<p class="kicker">{{ strings.footerNavigaKicker }}</p>
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-block" aria-label="Seguici">
|
||||
<p class="kicker">Seguici</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerFollowAriaLabel">
|
||||
<p class="kicker">{{ strings.footerSeguiciKicker }}</p>
|
||||
<ul>
|
||||
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
||||
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
||||
@@ -64,7 +76,7 @@
|
||||
</div>
|
||||
|
||||
<p class="container copyright kicker">
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ strings.copyright }}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -109,6 +121,10 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.lang-switch-header {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Resolves the language-switch target *before* the layout (and its header,
|
||||
* rendered ahead of the page in document order) mounts — setting this from
|
||||
* inside the page component itself is too late: a parent renders its
|
||||
* children in document order, so the header's LanguageSwitch would already
|
||||
* be resolved before the page's own setup ever runs, translated content or
|
||||
* not. Running as global route middleware guarantees this resolves first.
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const langSwitch = useLangSwitchState()
|
||||
const locale = to.path === '/en' || to.path.startsWith('/en/') ? 'en' : 'it'
|
||||
const bare = locale === 'en' ? to.path.slice(3) || '/' : to.path
|
||||
|
||||
const articleMatch = bare.match(/^\/blog\/([^/]+)$/)
|
||||
const categoryMatch = bare.match(/^\/category\/([^/]+)$/)
|
||||
|
||||
if (articleMatch) {
|
||||
const slug = articleMatch[1]
|
||||
const { data } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: Boolean(data.value?.hasTranslation), fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryMatch) {
|
||||
// A category's own translation only gates that specific label — the
|
||||
// switch always lands somewhere useful (a filtered, possibly empty,
|
||||
// article list), so it's always shown.
|
||||
void useFetch<Category>(`/content/categories/${categoryMatch[1]}`, {
|
||||
key: `category-${categoryMatch[1]}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: true, fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (bare === '/come-difendersi-dal-corralito') {
|
||||
langSwitch.value = { available: false, fallback: '/en' }
|
||||
return
|
||||
}
|
||||
|
||||
langSwitch.value = { available: true, fallback: '/en' }
|
||||
})
|
||||
@@ -1,157 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`)
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const mediaUrl = useMediaUrl()
|
||||
|
||||
const canonical = `${config.siteUrl}${route.path}`
|
||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
||||
|
||||
useSeoMeta({
|
||||
title: article.value.title,
|
||||
description: article.value.summary,
|
||||
ogTitle: article.value.title,
|
||||
ogDescription: article.value.summary,
|
||||
ogType: 'article',
|
||||
ogUrl: canonical,
|
||||
ogImage: cover,
|
||||
twitterCard: 'summary_large_image',
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: canonical }],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.value.title,
|
||||
description: article.value.summary,
|
||||
datePublished: isoDate(article.value.publishedAt),
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
||||
<NuxtLink to="/blog">Articoli</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="`/category/${article.category.slug}`">
|
||||
{{ article.category.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>Di {{ article.author }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink to="/blog">Torna a tutti gli articoli</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
<ArticleView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { page },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => (page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli'),
|
||||
description: 'Tutti gli articoli pubblicati sul blog.',
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">L'archivio</p>
|
||||
<h1>Articoli</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">Caricamento…</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">Non è stato ancora pubblicato nulla.</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
Pagina precedente
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
Pagina successiva
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<BlogIndexView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -1,71 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated, Category } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`)
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { page, category: slug },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: category.value.name,
|
||||
description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">Categoria</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
Pagina precedente
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
Pagina successiva
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<CategoryView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<ArticleView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<BlogIndexView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<CategoryView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<HomeView locale="en" />
|
||||
</template>
|
||||
@@ -1,210 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: 'home-articles',
|
||||
query: { page: 1 },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
||||
|
||||
// The home page is the site itself, so it carries the bare name as its title.
|
||||
useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
||||
|
||||
useSeoMeta({
|
||||
title: SITE_NAME,
|
||||
description: 'Articoli, guide e analisi.',
|
||||
ogTitle: SITE_NAME,
|
||||
ogDescription: 'Articoli, guide e analisi.',
|
||||
ogType: 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: `${config.siteUrl}/logo.png`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
alt="Manifesti e scritte contro le banche durante una crisi bancaria"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>La fine dell'Unione Europea per come l'abbiamo conosciuta.</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
alt="Bandiera dell'Unione Europea incrinata"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>
|
||||
La fine della nostra moneta come moneta forte: la fine di quella moneta che ci ha
|
||||
dato la vera libertà di viaggiare, consumare e fare.
|
||||
</p>
|
||||
<p>
|
||||
Una fine che avverrà a seguito di un "incidente" nucleare, al quale farà seguito un
|
||||
corralito bancario, ossia l'impossibilità, da parte dei cittadini europei, di
|
||||
disporre liberamente dei propri depositi.
|
||||
</p>
|
||||
<p>
|
||||
Le monete fiduciarie sono per loro natura cannibalizzanti. Oggi tocca all'euro.
|
||||
Anche perché le altre hanno praticamente perso tutte tutto.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
|
||||
<p v-else-if="!featured">Non è stato ancora pubblicato nulla.</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="`/blog/${featured.slug}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : 'Ultimo' }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
||||
Leggi l'ultimo articolo
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<HomeView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,11 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
// Default auto-import prefixes nested component dirs with their path
|
||||
// (e.g. `components/views/HomeView.vue` -> `<ViewsHomeView>`) — disabled so
|
||||
// the it/en thin page wrappers can reference `<HomeView>` etc. directly.
|
||||
components: [{ path: '~/components', pathPrefix: false }],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'it' },
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn?: string | null
|
||||
}
|
||||
|
||||
interface RawArticle {
|
||||
id: string
|
||||
title: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
publishedAt: string
|
||||
content: string | null
|
||||
contentEn: string | null
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
coverAltEn: string | null
|
||||
authorName: string | null
|
||||
expand?: { category?: Category }
|
||||
expand?: { category?: RawCategory }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
@@ -19,6 +26,8 @@ export default defineEventHandler(async (event): Promise<Article> => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
|
||||
const response = await pbFetch<PbList<RawArticle>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_DETAIL_FIELDS}&expand=category&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&${pagination(1, 1)}`
|
||||
)
|
||||
@@ -29,14 +38,37 @@ export default defineEventHandler(async (event): Promise<Article> => {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const hasTranslation = Boolean(article.titleEn && article.contentEn)
|
||||
|
||||
// An /en/ request for an article with no translation yet 404s cleanly,
|
||||
// rather than silently falling back to Italian text on an English URL —
|
||||
// see docs/content-model.md.
|
||||
if (lang === 'en' && !hasTranslation) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const title = lang === 'en' ? article.titleEn! : article.title
|
||||
const content = lang === 'en' ? article.contentEn : article.content
|
||||
const coverAlt = lang === 'en' ? article.coverAltEn || article.coverAlt : article.coverAlt
|
||||
const category = article.expand?.category
|
||||
? {
|
||||
name:
|
||||
lang === 'en'
|
||||
? article.expand.category.nameEn || article.expand.category.name
|
||||
: article.expand.category.name,
|
||||
slug: article.expand.category.slug,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
title,
|
||||
slug: article.slug,
|
||||
publishedAt: article.publishedAt,
|
||||
cover: toMediaImage('articles', article.id, article.cover, article.coverAlt),
|
||||
category: article.expand?.category ?? null,
|
||||
html: renderMarkdown(article.content),
|
||||
summary: summarise(article.content),
|
||||
cover: toMediaImage('articles', article.id, article.cover, coverAlt),
|
||||
category,
|
||||
html: renderMarkdown(content),
|
||||
summary: summarise(content),
|
||||
author: article.authorName || null,
|
||||
hasTranslation,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +1,60 @@
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn?: string | null
|
||||
}
|
||||
|
||||
interface RawArticleSummary {
|
||||
id: string
|
||||
title: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
expand?: { category?: Category }
|
||||
coverAltEn: string | null
|
||||
expand?: { category?: RawCategory }
|
||||
}
|
||||
|
||||
function toSummary(raw: RawArticleSummary): ArticleSummary {
|
||||
function toSummary(raw: RawArticleSummary, lang: Locale): ArticleSummary {
|
||||
const title = lang === 'en' ? raw.titleEn || raw.title : raw.title
|
||||
const coverAlt = lang === 'en' ? raw.coverAltEn || raw.coverAlt : raw.coverAlt
|
||||
const category = raw.expand?.category
|
||||
? {
|
||||
name: lang === 'en' ? raw.expand.category.nameEn || raw.expand.category.name : raw.expand.category.name,
|
||||
slug: raw.expand.category.slug,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
title: raw.title,
|
||||
title,
|
||||
slug: raw.slug,
|
||||
publishedAt: raw.publishedAt,
|
||||
cover: toMediaImage('articles', raw.id, raw.cover, raw.coverAlt),
|
||||
category: raw.expand?.category ?? null,
|
||||
cover: toMediaImage('articles', raw.id, raw.cover, coverAlt),
|
||||
category,
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const lang = langParam(query.lang)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filter = category
|
||||
? `&filter=${encodeURIComponent(`category.slug = ${quote(category)}`)}`
|
||||
: ''
|
||||
const filters = []
|
||||
if (category) filters.push(`category.slug = ${quote(category)}`)
|
||||
// An English listing only ever shows articles that have actually been
|
||||
// translated — never a partial/fallback entry, see docs/content-model.md.
|
||||
if (lang === 'en') filters.push(`titleEn != ''`)
|
||||
const filter = filters.length ? `&filter=${encodeURIComponent(filters.join(' && '))}` : ''
|
||||
|
||||
const response = await pbFetch<PbList<RawArticleSummary>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_SUMMARY_FIELDS}&expand=category&sort=-publishedAt&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.items.map(toSummary),
|
||||
items: response.items.map((raw) => toSummary(raw, lang)),
|
||||
page: response.page,
|
||||
pageCount: response.totalPages,
|
||||
total: response.totalItems,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
@@ -7,8 +11,10 @@ export default defineEventHandler(async (event): Promise<Category> => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const response = await pbFetch<PbList<Category>>(
|
||||
`/api/collections/categories/records?fields=name,slug&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
|
||||
const response = await pbFetch<PbList<RawCategory>>(
|
||||
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||
)
|
||||
|
||||
const category = response.items[0]
|
||||
@@ -17,5 +23,12 @@ export default defineEventHandler(async (event): Promise<Category> => {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
return category
|
||||
if (lang === 'en' && !category.nameEn) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
name: lang === 'en' ? category.nameEn! : category.name,
|
||||
slug: category.slug,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||
const response = await pbFetch<PbList<Category>>(
|
||||
'/api/collections/categories/records?fields=name,slug&sort=name&perPage=100'
|
||||
interface RawCategory extends Category {
|
||||
nameEn: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category[]> => {
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
// English listing only ever shows categories that have actually been
|
||||
// translated — mirrors the articles listing policy.
|
||||
const filter = lang === 'en' ? `&filter=${encodeURIComponent(`nameEn != ''`)}` : ''
|
||||
|
||||
const response = await pbFetch<PbList<RawCategory>>(
|
||||
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&sort=name&perPage=100${filter}`
|
||||
)
|
||||
|
||||
return response.items
|
||||
return response.items.map((category) => ({
|
||||
name: lang === 'en' ? category.nameEn || category.name : category.name,
|
||||
slug: category.slug,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** PocketBase query fragments. Only the fields the pages actually render are requested. */
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
export const ARTICLE_SUMMARY_FIELDS =
|
||||
'id,title,slug,publishedAt,cover,coverAlt,expand.category.name,expand.category.slug'
|
||||
'id,title,titleEn,slug,publishedAt,cover,coverAlt,coverAltEn,expand.category.name,expand.category.nameEn,expand.category.slug'
|
||||
|
||||
export const ARTICLE_DETAIL_FIELDS =
|
||||
'id,title,slug,content,publishedAt,cover,coverAlt,authorName,expand.category.name,expand.category.slug'
|
||||
'id,title,titleEn,slug,content,contentEn,publishedAt,cover,coverAlt,coverAltEn,authorName,expand.category.name,expand.category.nameEn,expand.category.slug'
|
||||
|
||||
export const CATEGORY_FIELDS = 'name,nameEn,slug'
|
||||
|
||||
export const PAGE_SIZE = 12
|
||||
|
||||
@@ -16,5 +19,8 @@ export const pageParam = (value: unknown): number => {
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
/** Reads a `lang` query param, falling back to Italian for anything but `en`. */
|
||||
export const langParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
||||
|
||||
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
|
||||
export const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Article extends ArticleSummary {
|
||||
summary: string
|
||||
/** Byline: the article's authorName field, filled in manually by the editor. */
|
||||
author: string | null
|
||||
/** Whether this article has a (manually authored) English translation. */
|
||||
hasTranslation: boolean
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Locale } from './locale'
|
||||
|
||||
const INTL_LOCALE: Record<Locale, string> = { it: 'it-IT', en: 'en-GB' }
|
||||
|
||||
/** Human-readable date for display; returns an empty string when unset. */
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
export function formatDate(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long' }).format(new Date(value))
|
||||
}
|
||||
|
||||
/** Date and time of publication, shown in the article byline. */
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
export function formatDateTime(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long', timeStyle: 'short' })
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' })
|
||||
.format(new Date(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Static UI copy, in both languages. Deliberately a plain object, not a
|
||||
* library: two locales, ~20 short strings, no plurals — see docs/frontend.md.
|
||||
* Keyed by role (not by the Italian text), so a wording tweak never touches
|
||||
* the key.
|
||||
*/
|
||||
import type { Locale } from './locale'
|
||||
|
||||
export const STRINGS = {
|
||||
it: {
|
||||
skipLink: 'Vai al contenuto',
|
||||
tagline: 'Articoli, guide e analisi.',
|
||||
navAriaLabel: 'Navigazione principale',
|
||||
navHome: 'Home',
|
||||
navBlog: 'Tutti gli articoli',
|
||||
navCorralito: 'Come difendersi dal corralito',
|
||||
footerNavAriaLabel: 'Naviga',
|
||||
footerNavigaKicker: 'Naviga',
|
||||
footerSeguiciKicker: 'Seguici',
|
||||
footerFollowAriaLabel: 'Seguici',
|
||||
contactLabel: 'Contatti:',
|
||||
copyright: 'Tutti i diritti riservati.',
|
||||
langSwitchLabel: 'English',
|
||||
home: {
|
||||
heading: "La fine dell'Unione Europea per come l'abbiamo conosciuta.",
|
||||
img1Alt: 'Manifesti e scritte contro le banche durante una crisi bancaria',
|
||||
img2Alt: "Bandiera dell'Unione Europea incrinata",
|
||||
p1: "La fine della nostra moneta come moneta forte: la fine di quella moneta che ci ha dato la vera libertà di viaggiare, consumare e fare.",
|
||||
p2: "Una fine che avverrà a seguito di un \"incidente\" nucleare, al quale farà seguito un corralito bancario, ossia l'impossibilità, da parte dei cittadini europei, di disporre liberamente dei propri depositi.",
|
||||
p3: "Le monete fiduciarie sono per loro natura cannibalizzanti. Oggi tocca all'euro. Anche perché le altre hanno praticamente perso tutte tutto.",
|
||||
fallbackKicker: 'Ultimo',
|
||||
readLatest: "Leggi l'ultimo articolo",
|
||||
metaDescription: 'Articoli, guide e analisi.',
|
||||
},
|
||||
blog: {
|
||||
archiveKicker: "L'archivio",
|
||||
title: 'Articoli',
|
||||
titlePage: 'Articoli — pagina {page}',
|
||||
metaDescription: 'Tutti gli articoli pubblicati sul blog.',
|
||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
||||
loading: 'Caricamento…',
|
||||
empty: 'Non è stato ancora pubblicato nulla.',
|
||||
prevPage: 'Pagina precedente',
|
||||
nextPage: 'Pagina successiva',
|
||||
pageOf: 'Pagina {current} di {total}',
|
||||
paginationAriaLabel: 'Paginazione',
|
||||
},
|
||||
category: {
|
||||
kicker: 'Categoria',
|
||||
metaDescription: 'Tutti gli articoli nella categoria {name}.',
|
||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
||||
empty: 'Non ci sono articoli in questa categoria.',
|
||||
},
|
||||
article: {
|
||||
breadcrumbAriaLabel: 'Percorso di navigazione',
|
||||
articlesCrumb: 'Articoli',
|
||||
byAuthor: 'Di {author}',
|
||||
backToArticles: 'Torna a tutti gli articoli',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
skipLink: 'Skip to content',
|
||||
tagline: 'Articles, guides and analysis.',
|
||||
navAriaLabel: 'Main navigation',
|
||||
navHome: 'Home',
|
||||
navBlog: 'All articles',
|
||||
navCorralito: '',
|
||||
footerNavAriaLabel: 'Navigate',
|
||||
footerNavigaKicker: 'Navigate',
|
||||
footerSeguiciKicker: 'Follow us',
|
||||
footerFollowAriaLabel: 'Follow us',
|
||||
contactLabel: 'Contact:',
|
||||
copyright: 'All rights reserved.',
|
||||
langSwitchLabel: 'Italiano',
|
||||
home: {
|
||||
heading: "The end of the European Union as we've known it.",
|
||||
img1Alt: 'Banners and graffiti against banks during a banking crisis',
|
||||
img2Alt: 'Cracked European Union flag',
|
||||
p1: 'The end of our currency as a strong currency: the end of the currency that gave us the real freedom to travel, spend and act.',
|
||||
p2: 'An end that will follow a nuclear "incident", followed by a banking corralito — the impossibility, for European citizens, of freely accessing their own deposits.',
|
||||
p3: "Fiat currencies are cannibalizing by nature. Today it's the euro's turn. Especially since the others have already lost almost everything.",
|
||||
fallbackKicker: 'Latest',
|
||||
readLatest: 'Read the latest article',
|
||||
metaDescription: 'Articles, guides and analysis.',
|
||||
},
|
||||
blog: {
|
||||
archiveKicker: 'The archive',
|
||||
title: 'Articles',
|
||||
titlePage: 'Articles — page {page}',
|
||||
metaDescription: 'All the articles published on the blog.',
|
||||
loadError: 'Could not load the articles. Please try again later.',
|
||||
loading: 'Loading…',
|
||||
empty: 'Nothing has been published yet.',
|
||||
prevPage: 'Previous page',
|
||||
nextPage: 'Next page',
|
||||
pageOf: 'Page {current} of {total}',
|
||||
paginationAriaLabel: 'Pagination',
|
||||
},
|
||||
category: {
|
||||
kicker: 'Category',
|
||||
metaDescription: 'All the articles in the {name} category.',
|
||||
loadError: 'Could not load the articles. Please try again later.',
|
||||
empty: 'There are no articles in this category.',
|
||||
},
|
||||
article: {
|
||||
breadcrumbAriaLabel: 'Breadcrumb',
|
||||
articlesCrumb: 'Articles',
|
||||
byAuthor: 'By {author}',
|
||||
backToArticles: 'Back to all articles',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<Locale, unknown>
|
||||
|
||||
/** Fills `{placeholder}` tokens in a translated string. */
|
||||
export function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||
key in params ? String(params[key]) : match
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type Locale = 'it' | 'en'
|
||||
|
||||
export const LOCALES: Locale[] = ['it', 'en']
|
||||
|
||||
/** Prefixes a bare, unprefixed path (e.g. `/blog/mio-slug`) for the given locale. */
|
||||
export function localePath(locale: Locale, path: string): string {
|
||||
if (locale !== 'en') return path
|
||||
return path === '/' ? '/en' : `/en${path}`
|
||||
}
|
||||
|
||||
/** Strips the `/en` prefix, if any, returning the bare Italian-equivalent path. */
|
||||
export function bareLocalePath(path: string): string {
|
||||
if (path === '/en') return '/'
|
||||
if (path.startsWith('/en/')) return path.slice(3)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Adds optional English counterparts to the manually-authored Italian fields,
|
||||
// on the same record (same slug, same cover, same category, same
|
||||
// publishedAt) — see docs/content-model.md. An article/category with no
|
||||
// translation yet simply leaves these blank; nothing else changes.
|
||||
migrate((app) => {
|
||||
const articles = app.findCollectionByNameOrId('articles')
|
||||
articles.fields.add(
|
||||
new TextField({ name: 'titleEn', max: 160 }),
|
||||
new TextField({ name: 'contentEn', max: 10000 }),
|
||||
new TextField({ name: 'coverAltEn', max: 200 })
|
||||
)
|
||||
app.save(articles)
|
||||
|
||||
const categories = app.findCollectionByNameOrId('categories')
|
||||
categories.fields.add(new TextField({ name: 'nameEn', max: 160 }))
|
||||
app.save(categories)
|
||||
}, (app) => {
|
||||
const articles = app.findCollectionByNameOrId('articles')
|
||||
articles.fields.removeByName('titleEn')
|
||||
articles.fields.removeByName('contentEn')
|
||||
articles.fields.removeByName('coverAltEn')
|
||||
app.save(articles)
|
||||
|
||||
const categories = app.findCollectionByNameOrId('categories')
|
||||
categories.fields.removeByName('nameEn')
|
||||
app.save(categories)
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('English home page renders in English', async ({ page }) => {
|
||||
await page.goto('/en')
|
||||
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en')
|
||||
await expect(page.getByRole('link', { name: 'Read the latest article' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('English blog archive lists only translated articles', async ({ page }) => {
|
||||
await page.goto('/en/blog')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Published article' })).toBeVisible()
|
||||
await expect(page.getByText('Secondo articolo pubblicato')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('translated article renders in English at the /en prefix', async ({ page }) => {
|
||||
await page.goto('/en/blog/articolo-pubblicato')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Published article' })).toBeVisible()
|
||||
await expect(page.locator('.prose strong')).toHaveText('markdown')
|
||||
})
|
||||
|
||||
test('an untranslated article 404s under /en', async ({ page }) => {
|
||||
const response = await page.goto('/en/blog/secondo-articolo')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('language switch links to the English version of a translated article', async ({ page }) => {
|
||||
await page.goto('/blog/articolo-pubblicato')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en/blog/articolo-pubblicato')
|
||||
})
|
||||
|
||||
test('language switch falls back to the English blog index for an untranslated article', async ({ page }) => {
|
||||
await page.goto('/blog/secondo-articolo')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en/blog')
|
||||
})
|
||||
|
||||
test('language switch on the Italian-only corralito page falls back to the English home page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/come-difendersi-dal-corralito')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en')
|
||||
})
|
||||
@@ -53,6 +53,7 @@ describe('GET /content/articles/:slug', () => {
|
||||
expect(article.summary.length).toBeGreaterThan(0)
|
||||
expect(article.author).toBe('Redazione')
|
||||
expect(article.category?.slug).toBe('economia')
|
||||
expect(article.hasTranslation).toBe(true)
|
||||
})
|
||||
|
||||
it('404s for an unknown slug', async () => {
|
||||
@@ -62,6 +63,36 @@ describe('GET /content/articles/:slug', () => {
|
||||
it('404s for a draft (unpublished) slug', async () => {
|
||||
await expectStatus($fetch('/content/articles/articolo-bozza'), 404)
|
||||
})
|
||||
|
||||
it('reports hasTranslation false for an article with no English content', async () => {
|
||||
const article = await $fetch<Article>('/content/articles/secondo-articolo')
|
||||
expect(article.hasTranslation).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the English fields when lang=en and a translation exists', async () => {
|
||||
const article = await $fetch<Article>('/content/articles/articolo-pubblicato', {
|
||||
query: { lang: 'en' },
|
||||
})
|
||||
expect(article.title).toBe('Published article')
|
||||
expect(article.html).toContain('<strong>markdown</strong>')
|
||||
})
|
||||
|
||||
it('404s for lang=en when no translation exists', async () => {
|
||||
await expectStatus(
|
||||
$fetch('/content/articles/secondo-articolo', { query: { lang: 'en' } }),
|
||||
404
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/articles with lang=en', () => {
|
||||
it('only lists articles that have an English translation', async () => {
|
||||
const response = await $fetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { lang: 'en' },
|
||||
})
|
||||
expect(response.items.map((a) => a.slug)).toEqual(['articolo-pubblicato'])
|
||||
expect(response.items[0]?.title).toBe('Published article')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories', () => {
|
||||
@@ -69,6 +100,11 @@ describe('GET /content/categories', () => {
|
||||
const categories = await $fetch<Category[]>('/content/categories')
|
||||
expect(categories.map((c) => c.name)).toEqual(['Attualità', 'Economia'])
|
||||
})
|
||||
|
||||
it('only lists translated categories when lang=en', async () => {
|
||||
const categories = await $fetch<Category[]>('/content/categories', { query: { lang: 'en' } })
|
||||
expect(categories.map((c) => c.name)).toEqual(['Economy'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories/:slug', () => {
|
||||
@@ -80,6 +116,15 @@ describe('GET /content/categories/:slug', () => {
|
||||
it('404s for an unknown slug', async () => {
|
||||
await expectStatus($fetch('/content/categories/does-not-exist'), 404)
|
||||
})
|
||||
|
||||
it('returns the English name when lang=en and a translation exists', async () => {
|
||||
const category = await $fetch<Category>('/content/categories/economia', { query: { lang: 'en' } })
|
||||
expect(category.name).toBe('Economy')
|
||||
})
|
||||
|
||||
it('404s for lang=en when no translation exists', async () => {
|
||||
await expectStatus($fetch('/content/categories/attualita', { query: { lang: 'en' } }), 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /admin', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export interface FixtureCategory {
|
||||
name: string
|
||||
slug: string
|
||||
nameEn?: string
|
||||
}
|
||||
|
||||
export interface FixtureArticle {
|
||||
@@ -13,10 +14,12 @@ export interface FixtureArticle {
|
||||
/** `null` = draft (never returned by the public API). */
|
||||
publishedAt: string | null
|
||||
authorName?: string
|
||||
titleEn?: string
|
||||
contentEn?: string
|
||||
}
|
||||
|
||||
export const FIXTURE_CATEGORIES: FixtureCategory[] = [
|
||||
{ name: 'Economia', slug: 'economia' },
|
||||
{ name: 'Economia', slug: 'economia', nameEn: 'Economy' },
|
||||
{ name: 'Attualità', slug: 'attualita' },
|
||||
]
|
||||
|
||||
@@ -28,6 +31,8 @@ export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
category: 'economia',
|
||||
publishedAt: '2025-01-15 10:00:00',
|
||||
authorName: 'Redazione',
|
||||
titleEn: 'Published article',
|
||||
contentEn: '# Title\n\nTest **markdown** body with [a link](https://example.com).',
|
||||
},
|
||||
{
|
||||
title: 'Articolo in bozza',
|
||||
@@ -43,5 +48,6 @@ export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
category: 'attualita',
|
||||
publishedAt: '2025-02-01 08:00:00',
|
||||
authorName: 'Redazione',
|
||||
// Deliberately no English translation: exercises the untranslated path.
|
||||
},
|
||||
]
|
||||
|
||||
@@ -150,6 +150,8 @@ export async function startTestPocketbase(options: StartOptions = {}): Promise<T
|
||||
category: category?.id,
|
||||
publishedAt: article.publishedAt ?? '',
|
||||
authorName: article.authorName ?? '',
|
||||
titleEn: article.titleEn ?? '',
|
||||
contentEn: article.contentEn ?? '',
|
||||
}),
|
||||
})
|
||||
articles.push((await response.json()) as TestArticle)
|
||||
|
||||
@@ -4,10 +4,14 @@ import { formatDate, formatDateTime, isoDate } from '../../frontend/shared/utils
|
||||
const SAMPLE = '2025-01-15T10:30:00.000Z'
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a date in long it-IT style', () => {
|
||||
it('formats a date in long it-IT style by default', () => {
|
||||
expect(formatDate(SAMPLE)).toBe('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('formats a date in long en-GB style when locale is en', () => {
|
||||
expect(formatDate(SAMPLE, 'en')).toBe('15 January 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDate(null)).toBe('')
|
||||
expect(formatDate(undefined)).toBe('')
|
||||
@@ -20,6 +24,11 @@ describe('formatDateTime', () => {
|
||||
expect(result).toContain('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('includes both date and time in English', () => {
|
||||
const result = formatDateTime(SAMPLE, 'en')
|
||||
expect(result).toContain('15 January 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDateTime(null)).toBe('')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAGE_SIZE, pageParam, pagination, quote } from '../../frontend/server/utils/queries'
|
||||
import { PAGE_SIZE, langParam, pageParam, pagination, quote } from '../../frontend/server/utils/queries'
|
||||
|
||||
describe('pagination', () => {
|
||||
it('defaults to PAGE_SIZE', () => {
|
||||
@@ -25,6 +25,16 @@ describe('pageParam', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('langParam', () => {
|
||||
it('accepts "en"', () => {
|
||||
expect(langParam('en')).toBe('en')
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'it', 'fr', 123])('falls back to "it" for %p', (value) => {
|
||||
expect(langParam(value)).toBe('it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('quote', () => {
|
||||
it('wraps a plain value in double quotes', () => {
|
||||
expect(quote('economia')).toBe('"economia"')
|
||||
|
||||
Reference in New Issue
Block a user