diff --git a/src/i18n/detectLocale.test.ts b/src/i18n/detectLocale.test.ts new file mode 100644 index 000000000..ba0ae047b --- /dev/null +++ b/src/i18n/detectLocale.test.ts @@ -0,0 +1,74 @@ +import { assertEquals } from "@test/assert"; +import { detectLocale } from "./detectLocale"; + +function runTests() { + assertEquals( + detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"), + "zh-TW", + "An exact regional match should take precedence over a language fallback" + ); + assertEquals( + detectLocale("ZH-tw"), + "zh-TW", + "Locale matching should be case-insensitive" + ); + assertEquals( + detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "), + "zh-TW", + "Whitespace and quality parameters should not prevent an exact match" + ); + assertEquals( + detectLocale("zh-CN,zh-TW;q=0.9"), + "zh-CN", + "Simplified Chinese should still match exactly" + ); + assertEquals( + detectLocale("zh"), + "zh-CN", + "A generic Chinese preference should retain the existing fallback" + ); + assertEquals( + detectLocale("en-GB,en;q=0.9"), + "en-US", + "An unsupported region should fall back to a supported locale for the language" + ); + assertEquals( + detectLocale("ja-JP,zh-TW;q=0.9"), + "zh-TW", + "The next preference should be used when a language is unsupported" + ); + assertEquals( + detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"), + "zh-TW", + "Preferences should be evaluated by quality" + ); + assertEquals( + detectLocale("zh-TW;q=0,fr-FR;q=0.8"), + "fr-FR", + "Locales with zero quality should be excluded" + ); + assertEquals( + detectLocale("*,de-DE;q=0.8"), + "de-DE", + "A wildcard should not obscure a supported preference" + ); + assertEquals( + detectLocale("ja-JP"), + undefined, + "An unsupported language should not match" + ); + assertEquals( + detectLocale(""), + undefined, + "An empty Accept-Language header should not match" + ); + + console.log("All locale detection tests passed!"); +} + +try { + runTests(); +} catch (error) { + console.error("Locale detection test failed:", error); + process.exit(1); +} diff --git a/src/i18n/detectLocale.ts b/src/i18n/detectLocale.ts new file mode 100644 index 000000000..1588db61d --- /dev/null +++ b/src/i18n/detectLocale.ts @@ -0,0 +1,48 @@ +import { Locale, locales } from "./config"; + +export function detectLocale(acceptLanguage: string): Locale | undefined { + const browserLocales = acceptLanguage + .split(",") + .map((entry, index) => { + const [locale, ...parameters] = entry.trim().split(";"); + const qualityParameter = parameters.find((parameter) => + parameter.trim().toLowerCase().startsWith("q=") + ); + const quality = qualityParameter + ? Number(qualityParameter.trim().slice(2)) + : 1; + + return { + locale: locale.trim().toLowerCase(), + quality, + index + }; + }) + .filter( + ({ locale, quality }) => + locale && locale !== "*" && quality > 0 && quality <= 1 + ) + .sort( + (left, right) => + right.quality - left.quality || left.index - right.index + ); + + for (const { locale: browserLocale } of browserLocales) { + const exactMatch = locales.find( + (locale) => locale.toLowerCase() === browserLocale + ); + if (exactMatch) { + return exactMatch; + } + + const browserLanguage = browserLocale.split("-")[0]; + const languageMatch = locales.find( + (locale) => locale.split("-")[0].toLowerCase() === browserLanguage + ); + if (languageMatch) { + return languageMatch; + } + } + + return undefined; +} diff --git a/src/services/locale.ts b/src/services/locale.ts index 2d3e1d21f..ebea903cd 100644 --- a/src/services/locale.ts +++ b/src/services/locale.ts @@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers"; import { Locale, defaultLocale, locales } from "@/i18n/config"; +import { detectLocale } from "@/i18n/detectLocale"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -47,12 +48,7 @@ export async function getUserLocale(): Promise { const acceptLang = headerList.get("accept-language"); if (acceptLang) { - const browserLang = acceptLang.split(",")[0]; - const matched = locales.find((locale) => - browserLang - .toLowerCase() - .startsWith(locale.split("-")[0].toLowerCase()) - ); + const matched = detectLocale(acceptLang); if (matched) { return matched; }