优化多语言处理
This commit is contained in:
@@ -3,7 +3,10 @@ import "./globals.css"
|
|||||||
import { JetBrains_Mono } from "next/font/google"
|
import { JetBrains_Mono } from "next/font/google"
|
||||||
import { NextIntlClientProvider } from "next-intl"
|
import { NextIntlClientProvider } from "next-intl"
|
||||||
import { AppI18nProvider } from "@/components/i18n-provider"
|
import { AppI18nProvider } from "@/components/i18n-provider"
|
||||||
|
import { getMessagesForLocale } from "@/i18n/messages"
|
||||||
|
import { resolveRequestLocale } from "@/i18n/resolve-request-locale"
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
|
import { toIntlLocale } from "@/lib/i18n"
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -15,16 +18,30 @@ export const metadata: Metadata = {
|
|||||||
description: "AI Coding Agent Conversation Manager",
|
description: "AI Coding Agent Conversation Manager",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const appLocale = await resolveRequestLocale()
|
||||||
|
const initialLocale = toIntlLocale(appLocale)
|
||||||
|
const initialMessages = await getMessagesForLocale(appLocale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={jetbrainsMono.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang={initialLocale}
|
||||||
|
className={jetbrainsMono.variable}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body>
|
<body>
|
||||||
<NextIntlClientProvider locale="en" messages={null}>
|
<NextIntlClientProvider
|
||||||
<AppI18nProvider>
|
locale={initialLocale}
|
||||||
|
messages={initialMessages}
|
||||||
|
>
|
||||||
|
<AppI18nProvider
|
||||||
|
initialLocale={initialLocale}
|
||||||
|
initialMessages={initialMessages}
|
||||||
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import {
|
|||||||
import { NextIntlClientProvider, type AbstractIntlMessages } from "next-intl"
|
import { NextIntlClientProvider, type AbstractIntlMessages } from "next-intl"
|
||||||
import { getFallbackMessages, getMessagesForLocale } from "@/i18n/messages"
|
import { getFallbackMessages, getMessagesForLocale } from "@/i18n/messages"
|
||||||
import {
|
import {
|
||||||
APP_LOCALE_TO_INTL_LOCALE,
|
fromIntlLocale,
|
||||||
DEFAULT_LANGUAGE_SETTINGS,
|
|
||||||
getSystemLocaleCandidates,
|
getSystemLocaleCandidates,
|
||||||
|
LANGUAGE_COOKIE_KEY,
|
||||||
|
LANGUAGE_MODE_COOKIE_KEY,
|
||||||
LANGUAGE_SETTINGS_STORAGE_KEY,
|
LANGUAGE_SETTINGS_STORAGE_KEY,
|
||||||
normalizeLanguageSettings,
|
normalizeLanguageSettings,
|
||||||
resolveAppLocale,
|
resolveAppLocale,
|
||||||
|
toIntlLocale,
|
||||||
|
type IntlLocale,
|
||||||
} from "@/lib/i18n"
|
} from "@/lib/i18n"
|
||||||
import { getSystemLanguageSettings } from "@/lib/tauri"
|
import { getSystemLanguageSettings } from "@/lib/tauri"
|
||||||
|
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
||||||
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
||||||
|
|
||||||
interface AppI18nContextValue {
|
interface AppI18nContextValue {
|
||||||
@@ -48,18 +52,6 @@ function getSystemLocaleServerSnapshot(): string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPersistedLanguageSettings(): SystemLanguageSettings | null {
|
|
||||||
if (typeof window === "undefined") return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(LANGUAGE_SETTINGS_STORAGE_KEY)
|
|
||||||
if (!raw) return null
|
|
||||||
return normalizeLanguageSettings(JSON.parse(raw) as SystemLanguageSettings)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistLanguageSettings(settings: SystemLanguageSettings) {
|
function persistLanguageSettings(settings: SystemLanguageSettings) {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
@@ -73,6 +65,17 @@ function persistLanguageSettings(settings: SystemLanguageSettings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistLanguageCookies(
|
||||||
|
settings: SystemLanguageSettings,
|
||||||
|
appLocale: AppLocale
|
||||||
|
) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
|
||||||
|
const maxAge = 60 * 60 * 24 * 365
|
||||||
|
document.cookie = `${LANGUAGE_MODE_COOKIE_KEY}=${settings.mode}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
|
||||||
|
document.cookie = `${LANGUAGE_COOKIE_KEY}=${toIntlLocale(appLocale)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
|
||||||
|
}
|
||||||
|
|
||||||
export function useAppI18n() {
|
export function useAppI18n() {
|
||||||
const context = useContext(AppI18nContext)
|
const context = useContext(AppI18nContext)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -81,14 +84,29 @@ export function useAppI18n() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppI18nProvider({ children }: { children: React.ReactNode }) {
|
interface AppI18nProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
initialLocale?: IntlLocale
|
||||||
|
initialMessages?: AbstractIntlMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppI18nProvider({
|
||||||
|
children,
|
||||||
|
initialLocale = "en",
|
||||||
|
initialMessages,
|
||||||
|
}: AppI18nProviderProps) {
|
||||||
|
const initialAppLocale = fromIntlLocale(initialLocale)
|
||||||
const [languageSettings, setLanguageSettingsState] =
|
const [languageSettings, setLanguageSettingsState] =
|
||||||
useState<SystemLanguageSettings>(
|
useState<SystemLanguageSettings>({
|
||||||
() => loadPersistedLanguageSettings() ?? DEFAULT_LANGUAGE_SETTINGS
|
mode: "manual",
|
||||||
)
|
language: initialAppLocale,
|
||||||
|
})
|
||||||
const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false)
|
const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false)
|
||||||
const [messages, setMessages] = useState<AbstractIntlMessages>(
|
const [messages, setMessages] = useState<AbstractIntlMessages>(
|
||||||
getFallbackMessages()
|
initialMessages ?? getFallbackMessages()
|
||||||
|
)
|
||||||
|
const [messagesLocale, setMessagesLocale] = useState<AppLocale>(
|
||||||
|
initialMessages ? initialAppLocale : "en"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemLocaleSnapshot = useSyncExternalStore(
|
const systemLocaleSnapshot = useSyncExternalStore(
|
||||||
@@ -137,15 +155,22 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
[languageSettings, systemLocaleCandidates]
|
[languageSettings, systemLocaleCandidates]
|
||||||
)
|
)
|
||||||
|
|
||||||
const intlLocale = APP_LOCALE_TO_INTL_LOCALE[appLocale]
|
useEffect(() => {
|
||||||
|
if (!languageSettingsLoaded) return
|
||||||
|
persistLanguageCookies(languageSettings, appLocale)
|
||||||
|
}, [appLocale, languageSettings, languageSettingsLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (appLocale === messagesLocale) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
getMessagesForLocale(appLocale)
|
getMessagesForLocale(appLocale)
|
||||||
.then((nextMessages) => {
|
.then((nextMessages) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMessages(nextMessages)
|
setMessages(nextMessages)
|
||||||
|
setMessagesLocale(appLocale)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -155,11 +180,15 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [appLocale])
|
}, [appLocale, messagesLocale])
|
||||||
|
|
||||||
|
const localeReady = appLocale === messagesLocale
|
||||||
|
const appReady = languageSettingsLoaded && localeReady
|
||||||
|
const activeIntlLocale = toIntlLocale(messagesLocale)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = intlLocale
|
document.documentElement.lang = activeIntlLocale
|
||||||
}, [intlLocale])
|
}, [activeIntlLocale])
|
||||||
|
|
||||||
const contextValue = useMemo<AppI18nContextValue>(
|
const contextValue = useMemo<AppI18nContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -173,8 +202,8 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppI18nContext.Provider value={contextValue}>
|
<AppI18nContext.Provider value={contextValue}>
|
||||||
<NextIntlClientProvider locale={intlLocale} messages={messages}>
|
<NextIntlClientProvider locale={activeIntlLocale} messages={messages}>
|
||||||
{children}
|
{appReady ? children : <AppBootLoading />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</AppI18nContext.Provider>
|
</AppI18nContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
14
src/components/layout/app-boot-loading.tsx
Normal file
14
src/components/layout/app-boot-loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
export function AppBootLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="flex items-center gap-3 px-2 py-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
<span className="text-sm font-medium tracking-tight">codeg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { getRequestConfig } from "next-intl/server"
|
import { getRequestConfig } from "next-intl/server"
|
||||||
import enMessages from "@/i18n/messages/en.json"
|
import { getMessagesForLocale } from "@/i18n/messages"
|
||||||
|
import { resolveRequestLocale } from "@/i18n/resolve-request-locale"
|
||||||
|
import { APP_LOCALE_TO_INTL_LOCALE } from "@/lib/i18n"
|
||||||
|
|
||||||
export default getRequestConfig(async () => ({
|
export default getRequestConfig(async () => {
|
||||||
locale: "en",
|
const appLocale = await resolveRequestLocale()
|
||||||
messages: enMessages,
|
|
||||||
}))
|
return {
|
||||||
|
locale: APP_LOCALE_TO_INTL_LOCALE[appLocale],
|
||||||
|
messages: await getMessagesForLocale(appLocale),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
51
src/i18n/resolve-request-locale.ts
Normal file
51
src/i18n/resolve-request-locale.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { cookies, headers } from "next/headers"
|
||||||
|
import {
|
||||||
|
LANGUAGE_COOKIE_KEY,
|
||||||
|
LANGUAGE_MODE_COOKIE_KEY,
|
||||||
|
parseAcceptLanguageHeader,
|
||||||
|
parseLocaleFromCookieValue,
|
||||||
|
resolveSystemLocale,
|
||||||
|
} from "@/lib/i18n"
|
||||||
|
import type { AppLocale, LanguageMode } from "@/lib/types"
|
||||||
|
|
||||||
|
const FALLBACK_LOCALE: AppLocale = "en"
|
||||||
|
|
||||||
|
function parseLanguageModeCookie(value: string | undefined): LanguageMode {
|
||||||
|
return value === "manual" ? "manual" : "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRequestLocale(): Promise<AppLocale> {
|
||||||
|
let configuredLocale: AppLocale | null = null
|
||||||
|
let languageMode: LanguageMode = "system"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
configuredLocale = parseLocaleFromCookieValue(
|
||||||
|
cookieStore.get(LANGUAGE_COOKIE_KEY)?.value
|
||||||
|
)
|
||||||
|
languageMode = parseLanguageModeCookie(
|
||||||
|
cookieStore.get(LANGUAGE_MODE_COOKIE_KEY)?.value
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ignore when request cookies are unavailable (e.g. static export build).
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredLocale && languageMode === "manual") {
|
||||||
|
return configuredLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headerStore = await headers()
|
||||||
|
const candidates = parseAcceptLanguageHeader(
|
||||||
|
headerStore.get("accept-language")
|
||||||
|
)
|
||||||
|
const fromHeader = resolveSystemLocale(candidates)
|
||||||
|
if (fromHeader) {
|
||||||
|
return fromHeader
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore when request headers are unavailable (e.g. static export build).
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredLocale ?? FALLBACK_LOCALE
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
|||||||
export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"]
|
export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"]
|
||||||
const FALLBACK_APP_LOCALE: AppLocale = "en"
|
const FALLBACK_APP_LOCALE: AppLocale = "en"
|
||||||
export const LANGUAGE_SETTINGS_STORAGE_KEY = "codeg.system_language_settings"
|
export const LANGUAGE_SETTINGS_STORAGE_KEY = "codeg.system_language_settings"
|
||||||
|
export const LANGUAGE_MODE_COOKIE_KEY = "codeg.language_mode"
|
||||||
|
export const LANGUAGE_COOKIE_KEY = "codeg.locale"
|
||||||
export type IntlLocale = "en" | "zh-CN" | "zh-TW"
|
export type IntlLocale = "en" | "zh-CN" | "zh-TW"
|
||||||
|
|
||||||
export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = {
|
export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = {
|
||||||
@@ -16,10 +18,28 @@ export const APP_LOCALE_TO_INTL_LOCALE: Record<AppLocale, IntlLocale> = {
|
|||||||
zh_tw: "zh-TW",
|
zh_tw: "zh-TW",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const INTL_LOCALE_TO_APP_LOCALE: Record<IntlLocale, AppLocale> = {
|
||||||
|
en: "en",
|
||||||
|
"zh-CN": "zh_cn",
|
||||||
|
"zh-TW": "zh_tw",
|
||||||
|
}
|
||||||
|
|
||||||
export function isAppLocale(value: unknown): value is AppLocale {
|
export function isAppLocale(value: unknown): value is AppLocale {
|
||||||
return APP_LOCALES.includes(value as AppLocale)
|
return APP_LOCALES.includes(value as AppLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isIntlLocale(value: unknown): value is IntlLocale {
|
||||||
|
return value === "en" || value === "zh-CN" || value === "zh-TW"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIntlLocale(locale: AppLocale): IntlLocale {
|
||||||
|
return APP_LOCALE_TO_INTL_LOCALE[locale]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromIntlLocale(locale: IntlLocale): AppLocale {
|
||||||
|
return INTL_LOCALE_TO_APP_LOCALE[locale]
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLanguageSettings(
|
export function normalizeLanguageSettings(
|
||||||
settings: Partial<SystemLanguageSettings> | null | undefined
|
settings: Partial<SystemLanguageSettings> | null | undefined
|
||||||
): SystemLanguageSettings {
|
): SystemLanguageSettings {
|
||||||
@@ -34,7 +54,7 @@ export function normalizeLanguageSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null {
|
export function mapLocaleTagToAppLocale(localeTag: string): AppLocale | null {
|
||||||
const normalized = localeTag.trim().toLowerCase().replace(/_/g, "-")
|
const normalized = localeTag.trim().toLowerCase().replace(/_/g, "-")
|
||||||
|
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -54,6 +74,26 @@ function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAcceptLanguageHeader(value: string | null): string[] {
|
||||||
|
if (!value) return []
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.split(";")[0]?.trim())
|
||||||
|
.filter((entry): entry is string => Boolean(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLocaleFromCookieValue(
|
||||||
|
value: string | undefined
|
||||||
|
): AppLocale | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (isAppLocale(value)) return value
|
||||||
|
if (isIntlLocale(value)) return fromIntlLocale(value)
|
||||||
|
|
||||||
|
return mapLocaleTagToAppLocale(value)
|
||||||
|
}
|
||||||
|
|
||||||
export function getSystemLocaleCandidates(): string[] {
|
export function getSystemLocaleCandidates(): string[] {
|
||||||
if (typeof navigator === "undefined") return []
|
if (typeof navigator === "undefined") return []
|
||||||
|
|
||||||
@@ -67,7 +107,7 @@ export function getSystemLocaleCandidates(): string[] {
|
|||||||
|
|
||||||
export function resolveSystemLocale(candidates: string[]): AppLocale | null {
|
export function resolveSystemLocale(candidates: string[]): AppLocale | null {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const resolved = mapSystemLocaleToAppLocale(candidate)
|
const resolved = mapLocaleTagToAppLocale(candidate)
|
||||||
if (resolved) return resolved
|
if (resolved) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user