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:
2026-09-11 13:53:17 +02:00
parent 847edc7397
commit 33b49c5dbb
40 changed files with 1285 additions and 576 deletions
+8 -4
View File
@@ -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))
}
+119
View File
@@ -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
)
}
+16
View File
@@ -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
}