From b7318e46c6267f3264f86ec975754a49af243798 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 02:34:43 -0300 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20detect=20uk/fil=20browsers=20via?= =?UTF-8?q?=20aliases=20and=20base-language=20=E2=86=92=20regional=20local?= =?UTF-8?q?e=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/detectBrowserLocale.ts | 43 ++++++++++++++++--- src/shared/components/LocaleAutoDetect.tsx | 8 +++- tests/unit/i18n-detect-browser-locale.test.ts | 31 +++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/i18n/detectBrowserLocale.ts b/src/i18n/detectBrowserLocale.ts index 00f8128750..ca23971b7a 100644 --- a/src/i18n/detectBrowserLocale.ts +++ b/src/i18n/detectBrowserLocale.ts @@ -6,15 +6,22 @@ * 1. Exact match against `navigator.languages` entries (case-insensitive). * 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since * OmniRoute does not ship a dedicated Hong-Kong/Macau locale. - * 3. Language-prefix match — e.g. `en-US` matches a supported `en` locale. - * 4. No match → `null` (caller should keep the existing default). + * 3. Declared alias — `aliases` has the shape of `LOCALE_ALIASES` from + * `@/i18n/config` (locale code → lower-case BCP-47 tags) and is matched on + * the full tag or on its base language, e.g. `fil`, `fil-PH`, `tl` → `phi` + * and `uk` → `uk-UA`. Aliases of locales not in `locales` are ignored. + * 4. Language-prefix match — e.g. `en-US` matches a supported `en` locale. + * 5. Bare base language → first supported regional locale of that language, + * in `locales` (config) order — e.g. `uk` → `uk-UA`, `zh` → `zh-CN`. + * 6. No match → `null` (caller should keep the existing default). * * Kept dependency-free (no DOM/`navigator` access) so it is trivially unit * testable and reusable from both client components and future server code. */ export function detectBrowserLocale( languages: readonly string[], - locales: readonly string[] + locales: readonly string[], + aliases: Readonly> = {} ): string | null { if (!languages || languages.length === 0 || !locales || locales.length === 0) { return null; @@ -22,9 +29,18 @@ export function detectBrowserLocale( const normalizedLocales = locales.map((locale) => locale.toLowerCase()); + // alias tag (lower-case) → supported locale, only for locales actually offered. + const aliasIndex = new Map(); + for (const [code, tags] of Object.entries(aliases)) { + const index = normalizedLocales.indexOf(code.toLowerCase()); + if (index === -1) continue; + for (const tag of tags) aliasIndex.set(tag.toLowerCase(), locales[index]); + } + for (const rawLanguage of languages) { if (!rawLanguage) continue; const language = rawLanguage.toLowerCase(); + const prefix = language.split("-")[0]; // 1. Exact match. const exactIndex = normalizedLocales.indexOf(language); @@ -32,7 +48,8 @@ export function detectBrowserLocale( return locales[exactIndex]; } - // 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported. + // 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported (kept for callers + // that do not pass aliases). if (language === "zh-hk" || language === "zh-mo") { const zhTwIndex = normalizedLocales.indexOf("zh-tw"); if (zhTwIndex !== -1) { @@ -40,12 +57,26 @@ export function detectBrowserLocale( } } - // 3. Language-prefix match (e.g. "en-US" -> "en"). - const prefix = language.split("-")[0]; + // 3. Declared alias, on the full tag or on its base language (fil-PH → phi). + const aliased = aliasIndex.get(language) ?? aliasIndex.get(prefix); + if (aliased) { + return aliased; + } + + // 4. Language-prefix match (e.g. "en-US" -> "en"). const prefixIndex = normalizedLocales.indexOf(prefix); if (prefixIndex !== -1) { return locales[prefixIndex]; } + + // 5. Bare base language → first supported regional locale of that language + // ("uk" -> "uk-UA", "zh" -> "zh-CN"). Config order decides the tie. + const regionalIndex = normalizedLocales.findIndex( + (locale) => locale.includes("-") && locale.split("-")[0] === prefix + ); + if (regionalIndex !== -1) { + return locales[regionalIndex]; + } } return null; diff --git a/src/shared/components/LocaleAutoDetect.tsx b/src/shared/components/LocaleAutoDetect.tsx index 6cba7d3e04..d0936a6b7c 100644 --- a/src/shared/components/LocaleAutoDetect.tsx +++ b/src/shared/components/LocaleAutoDetect.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { LOCALES, LOCALE_COOKIE } from "@/i18n/config"; +import { LOCALES, LOCALE_ALIASES, LOCALE_COOKIE } from "@/i18n/config"; import type { Locale } from "@/i18n/config"; import { detectBrowserLocale } from "@/i18n/detectBrowserLocale"; import { persistLocale } from "@/shared/lib/persistLocale"; @@ -23,7 +23,11 @@ export function LocaleAutoDetect() { useEffect(() => { if (typeof navigator === "undefined" || hasLocaleCookie()) return; - const detected = detectBrowserLocale(navigator.languages ?? [navigator.language], LOCALES); + const detected = detectBrowserLocale( + navigator.languages ?? [navigator.language], + LOCALES, + LOCALE_ALIASES + ); if (!detected) return; persistLocale(detected as Locale); diff --git a/tests/unit/i18n-detect-browser-locale.test.ts b/tests/unit/i18n-detect-browser-locale.test.ts index d55748fb96..eed1b27483 100644 --- a/tests/unit/i18n-detect-browser-locale.test.ts +++ b/tests/unit/i18n-detect-browser-locale.test.ts @@ -40,4 +40,35 @@ describe("detectBrowserLocale", () => { it("is case-insensitive", () => { assert.equal(detectBrowserLocale(["PT-br"], SUPPORTED_LOCALES), "pt-BR"); }); + + // Regional-code locales (`uk-UA`, `phi`) plus the alias map shape exported by + // `@/i18n/config` (`LOCALE_ALIASES`) — browsers send `uk`, `fil`/`tl`. + const REGIONAL_LOCALES = ["en", "uk-UA", "phi", "pt", "pt-BR", "zh-CN", "zh-TW"] as const; + const ALIASES = { "uk-UA": ["uk"], phi: ["fil", "tl"] } as const; + + it("resolves a declared alias (fil → phi)", () => { + assert.equal(detectBrowserLocale(["fil"], REGIONAL_LOCALES, ALIASES), "phi"); + }); + + it("resolves an alias carried by a regional tag (fil-PH → phi, TL → phi)", () => { + assert.equal(detectBrowserLocale(["fil-PH"], REGIONAL_LOCALES, ALIASES), "phi"); + assert.equal(detectBrowserLocale(["TL"], REGIONAL_LOCALES, ALIASES), "phi"); + }); + + it("matches a bare base language to a regional locale of that language (uk → uk-UA) without aliases", () => { + assert.equal(detectBrowserLocale(["uk"], REGIONAL_LOCALES), "uk-UA"); + }); + + it("picks the first regional locale in config order for a bare base language (zh → zh-CN)", () => { + assert.equal(detectBrowserLocale(["zh"], REGIONAL_LOCALES), "zh-CN"); + }); + + it("keeps the exact/prefix precedence: pt-PT → pt, pt-BR → pt-BR", () => { + assert.equal(detectBrowserLocale(["pt-PT"], REGIONAL_LOCALES, ALIASES), "pt"); + assert.equal(detectBrowserLocale(["pt-BR"], REGIONAL_LOCALES, ALIASES), "pt-BR"); + }); + + it("still returns null when nothing matches, even with aliases", () => { + assert.equal(detectBrowserLocale(["ja-JP"], REGIONAL_LOCALES, ALIASES), null); + }); });