diff --git a/i18n/__tests__/locale-resolution.test.ts b/i18n/__tests__/locale-resolution.test.ts new file mode 100644 index 000000000..d44257bc3 --- /dev/null +++ b/i18n/__tests__/locale-resolution.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { detectBrowserLocale } from '../detect-locale'; +import { localeFromAcceptLanguage, normalizeLocaleTag } from '../locale-resolution'; + +const originalLanguages = Object.getOwnPropertyDescriptor(navigator, 'languages'); + +function setBrowserLanguages(languages: string[]) { + Object.defineProperty(navigator, 'languages', { + configurable: true, + value: languages, + }); +} + +afterEach(() => { + if (originalLanguages) { + Object.defineProperty(navigator, 'languages', originalLanguages); + } else { + Reflect.deleteProperty(navigator, 'languages'); + } +}); + +describe('Norwegian locale aliases', () => { + it.each(['no', 'no-NO'])('normalizes %s to Bokmål', (tag) => { + expect(normalizeLocaleTag(tag)).toBe('nb'); + }); + + it('keeps native Bokmål tags on nb', () => { + expect(normalizeLocaleTag('nb-NO')).toBe('nb'); + }); + + it('resolves no-NO from Accept-Language using quality ranking', () => { + expect(localeFromAcceptLanguage('da;q=0.5, no-NO;q=0.9, en;q=0.8')).toBe('nb'); + }); + + it.each(['no', 'no-NO'])('detects browser language %s as Bokmål', (tag) => { + setBrowserLanguages([tag]); + expect(detectBrowserLocale('en')).toBe('nb'); + }); +}); diff --git a/i18n/detect-locale.ts b/i18n/detect-locale.ts index a20335847..5ee759a03 100644 --- a/i18n/detect-locale.ts +++ b/i18n/detect-locale.ts @@ -1,5 +1,6 @@ import { routing } from './routing'; import { useLocaleStore } from '@/stores/locale-store'; +import { normalizeLocaleTag } from './locale-resolution'; /** * Pick the best supported locale for a first-time visitor from their browser @@ -15,7 +16,7 @@ export function detectBrowserLocale(fallback: string): string { const prefs = navigator.languages?.length ? navigator.languages : [navigator.language]; for (const tag of prefs) { if (!tag) continue; - const base = tag.toLowerCase().split('-')[0]; + const base = normalizeLocaleTag(tag); if (base === 'en') return fallback; // English is the top preference -> keep default if (supported.has(base)) return base; // first supported non-English preference wins } diff --git a/i18n/locale-resolution.ts b/i18n/locale-resolution.ts new file mode 100644 index 000000000..4a53dfeb9 --- /dev/null +++ b/i18n/locale-resolution.ts @@ -0,0 +1,25 @@ +import { routing } from './routing'; + +/** Map a BCP 47 language tag to the locale code Bulwark supports. */ +export function normalizeLocaleTag(tag: string): string { + const base = tag.trim().toLowerCase().split('-')[0]; + return base === 'no' ? 'nb' : base; +} + +/** Pick the highest-ranked supported locale from an Accept-Language header. */ +export function localeFromAcceptLanguage(header: string | null): string | null { + if (!header) return null; + const supported = new Set(routing.locales as readonly string[]); + const ranked = header + .split(',') + .map((part) => { + const [tag, q] = part.trim().split(';q='); + return { locale: normalizeLocaleTag(tag), q: q ? parseFloat(q) : 1 }; + }) + .sort((a, b) => b.q - a.q); + + for (const { locale } of ranked) { + if (supported.has(locale)) return locale; + } + return null; +} diff --git a/i18n/request.ts b/i18n/request.ts index 59f3b3258..2986fa30c 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -1,29 +1,12 @@ import { getRequestConfig } from 'next-intl/server'; import { headers } from 'next/headers'; +import { localeFromAcceptLanguage, normalizeLocaleTag } from './locale-resolution'; import { mergeMessages } from './merge-messages'; import { routing, type Locale } from './routing'; -// Default a first-time visitor (no explicit stored choice) to the best supported -// language from their browser's Accept-Language header, so "Auto" is the real -// default and the first server render already matches the browser language. -function localeFromAcceptLanguage(header: string | null): string | null { - if (!header) return null; - const supported = new Set(routing.locales as readonly string[]); - const ranked = header - .split(',') - .map((part) => { - const [tag, q] = part.trim().split(';q='); - return { base: tag.toLowerCase().split('-')[0], q: q ? parseFloat(q) : 1 }; - }) - .sort((a, b) => b.q - a.q); - for (const { base } of ranked) { - if (supported.has(base)) return base; - } - return null; -} - export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale; + if (locale) locale = normalizeLocaleTag(locale); if (!locale || !routing.locales.includes(locale as Locale)) { const accept = (await headers()).get('accept-language');