diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index fc5d3a71..02275849 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,24 +2,11 @@ import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { env } from 'process'; -import { cookieName, cookieMaxAge, locales } from '$lib/paraglide/runtime'; +import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; +import { detectLocale } from '$lib/server/locale'; const PUBLIC_PATHS = ['/login', '/logout']; -function detectLocale(acceptLanguage: string): string | null { - const preferred = acceptLanguage - .split(',') - .map((part) => { - const [lang, q] = part.trim().split(';q='); - return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 }; - }) - .sort((a, b) => b.q - a.q); - for (const { lang } of preferred) { - if ((locales as readonly string[]).includes(lang)) return lang; - } - return null; -} - const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); diff --git a/frontend/src/lib/server/locale.spec.ts b/frontend/src/lib/server/locale.spec.ts new file mode 100644 index 00000000..f01ee1c8 --- /dev/null +++ b/frontend/src/lib/server/locale.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { detectLocale } from './locale'; + +describe('detectLocale', () => { + it('returns de for a German browser', () => { + expect(detectLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de'); + }); + + it('returns en for an English browser', () => { + expect(detectLocale('en-US,en;q=0.9')).toBe('en'); + }); + + it('returns es for a Spanish browser', () => { + expect(detectLocale('es-MX,es;q=0.9,en-US;q=0.8')).toBe('es'); + }); + + it('falls back to a supported language when the primary is unsupported', () => { + expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8')).toBe('en'); + }); + + it('respects quality values — picks the highest-priority supported locale', () => { + expect(detectLocale('en-US;q=0.7,de-DE;q=0.9')).toBe('de'); + }); + + it('returns null for a completely unsupported language', () => { + expect(detectLocale('ja-JP,ja;q=0.9,zh-CN;q=0.8')).toBeNull(); + }); + + it('returns null for an empty header', () => { + expect(detectLocale('')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/server/locale.ts b/frontend/src/lib/server/locale.ts new file mode 100644 index 00000000..079825f6 --- /dev/null +++ b/frontend/src/lib/server/locale.ts @@ -0,0 +1,20 @@ +import { locales } from '$lib/paraglide/runtime'; + +/** + * Picks the best supported locale from an Accept-Language header value. + * Returns null when no supported locale is found. + */ +export function detectLocale(acceptLanguage: string): string | null { + const preferred = acceptLanguage + .split(',') + .map((part) => { + const [lang, q] = part.trim().split(';q='); + return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 }; + }) + .sort((a, b) => b.q - a.q); + + for (const { lang } of preferred) { + if ((locales as readonly string[]).includes(lang)) return lang; + } + return null; +}