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
@@ -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,
}
})