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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user