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,
}
})
@@ -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, '\\"')}"`