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

View File

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

View File

@@ -1,23 +1,11 @@
export { enUS } from "./locales/en-US"; export { enUS } from "./locales/en-US";
export { zhCN } from "./locales/zh-CN"; export { zhCN } from "./locales/zh-CN";
export type { Translations } from "./locales/types"; export type { Translations } from "./locales/types";
export {
export type Locale = "en-US" | "zh-CN"; DEFAULT_LOCALE,
SUPPORTED_LOCALES,
// Helper function to detect browser locale detectLocale,
export function detectLocale(): Locale { isLocale,
if (typeof window === "undefined") { normalizeLocale,
return "en-US"; } from "./locale";
} export type { Locale } from "./locale";
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";
}

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