fix(i18n): detect uk/fil browsers via aliases and base-language → regional locale match

This commit is contained in:
Markus Hartung
2026-09-02 02:34:43 -03:00
parent b2edd7c0bb
commit b7318e46c6
3 changed files with 74 additions and 8 deletions

View File

@@ -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<Record<string, readonly string[]>> = {}
): 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<string, string>();
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;

View File

@@ -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);

View File

@@ -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);
});
});