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
+9 -4
View File
@@ -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>
+200
View File
@@ -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>
+16
View File
@@ -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' }))
}
+7
View File
@@ -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'))
}
+61
View File
@@ -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,
})
}
+33 -17
View File
@@ -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">
&copy; {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
&copy; {{ 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 -155
View File
@@ -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 -64
View File
@@ -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 -69
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<template>
<ArticleView locale="en" />
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<BlogIndexView locale="en" />
</template>
@@ -0,0 +1,3 @@
<template>
<CategoryView locale="en" />
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<HomeView locale="en" />
</template>
+1 -208
View File
@@ -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>
+5
View File
@@ -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,
}))
})
+8 -2
View File
@@ -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, '\\"')}"`
+2
View File
@@ -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> {
+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
}