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:
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('English home page renders in English', async ({ page }) => {
|
||||
await page.goto('/en')
|
||||
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en')
|
||||
await expect(page.getByRole('link', { name: 'Read the latest article' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('English blog archive lists only translated articles', async ({ page }) => {
|
||||
await page.goto('/en/blog')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Published article' })).toBeVisible()
|
||||
await expect(page.getByText('Secondo articolo pubblicato')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('translated article renders in English at the /en prefix', async ({ page }) => {
|
||||
await page.goto('/en/blog/articolo-pubblicato')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Published article' })).toBeVisible()
|
||||
await expect(page.locator('.prose strong')).toHaveText('markdown')
|
||||
})
|
||||
|
||||
test('an untranslated article 404s under /en', async ({ page }) => {
|
||||
const response = await page.goto('/en/blog/secondo-articolo')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('language switch links to the English version of a translated article', async ({ page }) => {
|
||||
await page.goto('/blog/articolo-pubblicato')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en/blog/articolo-pubblicato')
|
||||
})
|
||||
|
||||
test('language switch falls back to the English blog index for an untranslated article', async ({ page }) => {
|
||||
await page.goto('/blog/secondo-articolo')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en/blog')
|
||||
})
|
||||
|
||||
test('language switch on the Italian-only corralito page falls back to the English home page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/come-difendersi-dal-corralito')
|
||||
|
||||
const languageSwitch = page.getByRole('link', { name: 'English' })
|
||||
await expect(languageSwitch).toHaveAttribute('href', '/en')
|
||||
})
|
||||
@@ -53,6 +53,7 @@ describe('GET /content/articles/:slug', () => {
|
||||
expect(article.summary.length).toBeGreaterThan(0)
|
||||
expect(article.author).toBe('Redazione')
|
||||
expect(article.category?.slug).toBe('economia')
|
||||
expect(article.hasTranslation).toBe(true)
|
||||
})
|
||||
|
||||
it('404s for an unknown slug', async () => {
|
||||
@@ -62,6 +63,36 @@ describe('GET /content/articles/:slug', () => {
|
||||
it('404s for a draft (unpublished) slug', async () => {
|
||||
await expectStatus($fetch('/content/articles/articolo-bozza'), 404)
|
||||
})
|
||||
|
||||
it('reports hasTranslation false for an article with no English content', async () => {
|
||||
const article = await $fetch<Article>('/content/articles/secondo-articolo')
|
||||
expect(article.hasTranslation).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the English fields when lang=en and a translation exists', async () => {
|
||||
const article = await $fetch<Article>('/content/articles/articolo-pubblicato', {
|
||||
query: { lang: 'en' },
|
||||
})
|
||||
expect(article.title).toBe('Published article')
|
||||
expect(article.html).toContain('<strong>markdown</strong>')
|
||||
})
|
||||
|
||||
it('404s for lang=en when no translation exists', async () => {
|
||||
await expectStatus(
|
||||
$fetch('/content/articles/secondo-articolo', { query: { lang: 'en' } }),
|
||||
404
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/articles with lang=en', () => {
|
||||
it('only lists articles that have an English translation', async () => {
|
||||
const response = await $fetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { lang: 'en' },
|
||||
})
|
||||
expect(response.items.map((a) => a.slug)).toEqual(['articolo-pubblicato'])
|
||||
expect(response.items[0]?.title).toBe('Published article')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories', () => {
|
||||
@@ -69,6 +100,11 @@ describe('GET /content/categories', () => {
|
||||
const categories = await $fetch<Category[]>('/content/categories')
|
||||
expect(categories.map((c) => c.name)).toEqual(['Attualità', 'Economia'])
|
||||
})
|
||||
|
||||
it('only lists translated categories when lang=en', async () => {
|
||||
const categories = await $fetch<Category[]>('/content/categories', { query: { lang: 'en' } })
|
||||
expect(categories.map((c) => c.name)).toEqual(['Economy'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories/:slug', () => {
|
||||
@@ -80,6 +116,15 @@ describe('GET /content/categories/:slug', () => {
|
||||
it('404s for an unknown slug', async () => {
|
||||
await expectStatus($fetch('/content/categories/does-not-exist'), 404)
|
||||
})
|
||||
|
||||
it('returns the English name when lang=en and a translation exists', async () => {
|
||||
const category = await $fetch<Category>('/content/categories/economia', { query: { lang: 'en' } })
|
||||
expect(category.name).toBe('Economy')
|
||||
})
|
||||
|
||||
it('404s for lang=en when no translation exists', async () => {
|
||||
await expectStatus($fetch('/content/categories/attualita', { query: { lang: 'en' } }), 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /admin', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export interface FixtureCategory {
|
||||
name: string
|
||||
slug: string
|
||||
nameEn?: string
|
||||
}
|
||||
|
||||
export interface FixtureArticle {
|
||||
@@ -13,10 +14,12 @@ export interface FixtureArticle {
|
||||
/** `null` = draft (never returned by the public API). */
|
||||
publishedAt: string | null
|
||||
authorName?: string
|
||||
titleEn?: string
|
||||
contentEn?: string
|
||||
}
|
||||
|
||||
export const FIXTURE_CATEGORIES: FixtureCategory[] = [
|
||||
{ name: 'Economia', slug: 'economia' },
|
||||
{ name: 'Economia', slug: 'economia', nameEn: 'Economy' },
|
||||
{ name: 'Attualità', slug: 'attualita' },
|
||||
]
|
||||
|
||||
@@ -28,6 +31,8 @@ export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
category: 'economia',
|
||||
publishedAt: '2025-01-15 10:00:00',
|
||||
authorName: 'Redazione',
|
||||
titleEn: 'Published article',
|
||||
contentEn: '# Title\n\nTest **markdown** body with [a link](https://example.com).',
|
||||
},
|
||||
{
|
||||
title: 'Articolo in bozza',
|
||||
@@ -43,5 +48,6 @@ export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
category: 'attualita',
|
||||
publishedAt: '2025-02-01 08:00:00',
|
||||
authorName: 'Redazione',
|
||||
// Deliberately no English translation: exercises the untranslated path.
|
||||
},
|
||||
]
|
||||
|
||||
@@ -150,6 +150,8 @@ export async function startTestPocketbase(options: StartOptions = {}): Promise<T
|
||||
category: category?.id,
|
||||
publishedAt: article.publishedAt ?? '',
|
||||
authorName: article.authorName ?? '',
|
||||
titleEn: article.titleEn ?? '',
|
||||
contentEn: article.contentEn ?? '',
|
||||
}),
|
||||
})
|
||||
articles.push((await response.json()) as TestArticle)
|
||||
|
||||
@@ -4,10 +4,14 @@ import { formatDate, formatDateTime, isoDate } from '../../frontend/shared/utils
|
||||
const SAMPLE = '2025-01-15T10:30:00.000Z'
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a date in long it-IT style', () => {
|
||||
it('formats a date in long it-IT style by default', () => {
|
||||
expect(formatDate(SAMPLE)).toBe('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('formats a date in long en-GB style when locale is en', () => {
|
||||
expect(formatDate(SAMPLE, 'en')).toBe('15 January 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDate(null)).toBe('')
|
||||
expect(formatDate(undefined)).toBe('')
|
||||
@@ -20,6 +24,11 @@ describe('formatDateTime', () => {
|
||||
expect(result).toContain('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('includes both date and time in English', () => {
|
||||
const result = formatDateTime(SAMPLE, 'en')
|
||||
expect(result).toContain('15 January 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDateTime(null)).toBe('')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAGE_SIZE, pageParam, pagination, quote } from '../../frontend/server/utils/queries'
|
||||
import { PAGE_SIZE, langParam, pageParam, pagination, quote } from '../../frontend/server/utils/queries'
|
||||
|
||||
describe('pagination', () => {
|
||||
it('defaults to PAGE_SIZE', () => {
|
||||
@@ -25,6 +25,16 @@ describe('pageParam', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('langParam', () => {
|
||||
it('accepts "en"', () => {
|
||||
expect(langParam('en')).toBe('en')
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'it', 'fr', 123])('falls back to "it" for %p', (value) => {
|
||||
expect(langParam(value)).toBe('it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('quote', () => {
|
||||
it('wraps a plain value in double quotes', () => {
|
||||
expect(quote('economia')).toBe('"economia"')
|
||||
|
||||
Reference in New Issue
Block a user