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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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, '\\"')}"`
|
||||
|
||||
Reference in New Issue
Block a user