fix(i18n): normalize locale and prevent undefined translations (#914)

* fix(i18n): guard locale input and add safe translation fallback

* refactor(i18n): isolate locale utils and normalize server cookie decode

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Xinmin Zeng
2026-02-27 08:10:38 +08:00
committed by GitHub
parent 902ff3b9f3
commit e9adaab7a6
5 changed files with 81 additions and 32 deletions

View File

@@ -12,7 +12,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { enUS, zhCN, type Locale } from "@/core/i18n";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
@@ -89,7 +89,11 @@ export function AppearanceSettingsPage() {
>
<Select
value={locale}
onValueChange={(value) => changeLocale(value as Locale)}
onValueChange={(value) => {
if (isLocale(value)) {
changeLocale(value);
}
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue />

View File

@@ -7,7 +7,13 @@ import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
import { enUS } from "./locales/en-US";
import { zhCN } from "./locales/zh-CN";
import { detectLocale, type Locale, type Translations } from "./index";
import {
DEFAULT_LOCALE,
detectLocale,
normalizeLocale,
type Locale,
type Translations,
} from "./index";
const translations: Record<Locale, Translations> = {
"en-US": enUS,
@@ -17,7 +23,7 @@ const translations: Record<Locale, Translations> = {
export function useI18n() {
const { locale, setLocale } = useI18nContext();
const t = translations[locale];
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);
@@ -26,12 +32,19 @@ export function useI18n() {
// Initialize locale on mount
useEffect(() => {
const saved = getLocaleFromCookie() as Locale | null;
if (!saved) {
const detected = detectLocale();
setLocale(detected);
setLocaleInCookie(detected);
const saved = getLocaleFromCookie();
if (saved) {
const normalizedSaved = normalizeLocale(saved);
setLocale(normalizedSaved);
if (saved !== normalizedSaved) {
setLocaleInCookie(normalizedSaved);
}
return;
}
const detected = detectLocale();
setLocale(detected);
setLocaleInCookie(detected);
}, [setLocale]);
return {

View File

@@ -1,23 +1,11 @@
export { enUS } from "./locales/en-US";
export { zhCN } from "./locales/zh-CN";
export type { Translations } from "./locales/types";
export type Locale = "en-US" | "zh-CN";
// Helper function to detect browser locale
export function detectLocale(): Locale {
if (typeof window === "undefined") {
return "en-US";
}
const browserLang =
navigator.language ||
(navigator as unknown as { userLanguage: string }).userLanguage;
// Check if browser language is Chinese (zh, zh-CN, zh-TW, etc.)
if (browserLang.toLowerCase().startsWith("zh")) {
return "zh-CN";
}
return "en-US";
}
export {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
detectLocale,
isLocale,
normalizeLocale,
} from "./locale";
export type { Locale } from "./locale";

View File

@@ -0,0 +1,36 @@
export const SUPPORTED_LOCALES = ["en-US", "zh-CN"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en-US";
export function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export function normalizeLocale(locale: string | null | undefined): Locale {
if (!locale) {
return DEFAULT_LOCALE;
}
if (isLocale(locale)) {
return locale;
}
if (locale.toLowerCase().startsWith("zh")) {
return "zh-CN";
}
return DEFAULT_LOCALE;
}
// Helper function to detect browser locale
export function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const browserLang =
navigator.language ||
(navigator as unknown as { userLanguage: string }).userLanguage;
return normalizeLocale(browserLang);
}

View File

@@ -1,9 +1,17 @@
import { cookies } from "next/headers";
export type Locale = "en-US" | "zh-CN";
import { normalizeLocale, type Locale } from "./locale";
export async function detectLocaleServer(): Promise<Locale> {
const cookieStore = await cookies();
const locale = cookieStore.get("locale")?.value ?? "en-US";
return locale as Locale;
let locale = cookieStore.get("locale")?.value;
if (locale !== undefined) {
try {
locale = decodeURIComponent(locale);
} catch {
// Keep raw cookie value when decoding fails.
}
}
return normalizeLocale(locale);
}