feat(web): integrate localized application catalogs

- compose app, UI, and independently imported block catalogs from JSON configuration\n- persist the selected locale on the server and activate it without hydration flicker\n- add a flag-based language switcher and localized navigation descriptors\n- mount the redesigned i18n devtool only in development\n- regenerate English and Simplified Chinese catalogs and add switcher coverage
This commit is contained in:
Maofeng
2026-07-30 14:48:25 +08:00
parent 6af4d7b798
commit 8cf3a1f03a
19 changed files with 2474 additions and 135 deletions
+21
View File
@@ -0,0 +1,21 @@
import { lazy, Suspense } from "react"
const LazyI18nDevtool = import.meta.env.DEV
? lazy(() =>
import("@workspace/i18n/devtool").then((module) => ({
default: module.I18nDevtool,
}))
)
: null
export function AppI18nDevtool() {
if (!LazyI18nDevtool) {
return null
}
return (
<Suspense fallback={null}>
<LazyI18nDevtool dark={"ui\\:dark"} />
</Suspense>
)
}
+91 -37
View File
@@ -1,51 +1,105 @@
import { lazy, Suspense, type PropsWithChildren } from "react"
import { I18nProvider, type LocaleDefinition } from "@workspace/i18n"
import {
createContext,
startTransition,
use,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from "react"
import {
I18nProvider,
useLocaleDefinition,
useTranslate,
} from "@workspace/i18n"
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
import {
I18nProvider as UiI18nProvider,
type CalendarLocale,
} from "@workspace/ui/components/i18n"
import { calendarLocale as englishCalendarLocale } from "@workspace/ui/locales/en"
import { calendarLocale as simplifiedChineseCalendarLocale } from "@workspace/ui/locales/zh-Hans"
import { messages as englishMessages } from "@/locales/en/messages"
import { messages as simplifiedChineseMessages } from "@/locales/zh-Hans/messages"
import { appLocales, DEFAULT_APP_LOCALE, type AppLocale } from "./app-locales"
import { AppI18nDevtool } from "./app-i18n-devtool"
import { setAppLocale as persistAppLocale } from "@/server/app-locale.functions"
const I18nDevtool = import.meta.env.DEV
? lazy(() =>
import("@workspace/i18n/devtool").then((module) => ({
default: module.I18nDevtool,
}))
)
: null
export const appLocales = [
{
label: "English",
locale: "en",
},
{
label: "简体中文",
locale: "zh-Hans",
},
] as const satisfies readonly LocaleDefinition[]
export type AppLocale = (typeof appLocales)[number]["locale"]
const appCatalogs = {
en: englishMessages,
"zh-Hans": simplifiedChineseMessages,
const appCalendarLocales: Partial<Record<AppLocale, CalendarLocale>> = {
en: englishCalendarLocale,
"zh-Hans": simplifiedChineseCalendarLocale,
}
export interface AppI18nProviderProps extends PropsWithChildren {
locale?: AppLocale
}
interface AppLocaleContextValue {
locale: AppLocale
setLocale: (locale: AppLocale) => void
}
const AppLocaleContext = createContext<AppLocaleContextValue | null>(null)
export function AppI18nProvider({
children,
locale = "zh-Hans",
locale: initialLocale = DEFAULT_APP_LOCALE,
}: AppI18nProviderProps) {
const [locale, setLocaleState] = useState(initialLocale)
const messages = use(loadMessageCatalog(locale))
const catalogs = useMemo(() => ({ [locale]: messages }), [locale, messages])
const setLocale = useCallback((nextLocale: AppLocale) => {
startTransition(() => {
setLocaleState(nextLocale)
})
void persistAppLocale({ data: nextLocale })
}, [])
const localeContext = useMemo(
() => ({ locale, setLocale }),
[locale, setLocale]
)
useEffect(() => {
const definition = appLocales.find(
(candidate) => candidate.locale === locale
)
document.documentElement.lang = definition?.languageTag ?? locale
document.documentElement.dir = definition?.direction ?? "ltr"
}, [locale])
return (
<I18nProvider catalogs={appCatalogs} locale={locale} locales={appLocales}>
{children}
{I18nDevtool && (
<Suspense fallback={null}>
<I18nDevtool dark={"ui\\:dark"} />
</Suspense>
)}
</I18nProvider>
<AppLocaleContext.Provider value={localeContext}>
<I18nProvider catalogs={catalogs} locale={locale} locales={appLocales}>
<AppUiI18nProvider>{children}</AppUiI18nProvider>
<AppI18nDevtool />
</I18nProvider>
</AppLocaleContext.Provider>
)
}
export function useAppLocale() {
const context = useContext(AppLocaleContext)
if (!context) {
throw new Error("useAppLocale must be used within AppI18nProvider")
}
return context
}
function AppUiI18nProvider({ children }: PropsWithChildren) {
const locale = useLocaleDefinition()
const translate = useTranslate()
return (
<UiI18nProvider
calendarLocale={appCalendarLocales[locale.locale as AppLocale]}
locale={locale.languageTag ?? locale.locale}
t={translate}
>
{children}
</UiI18nProvider>
)
}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { AppLanguageSwitcher } from "./app-language-switcher"
vi.mock("./app-i18n-provider", () => ({
useAppLocale: () => ({
locale: "en",
setLocale: vi.fn(),
}),
}))
vi.mock("country-flag-icons/react/1x1", () => ({
CN: () => <svg />,
US: () => <svg />,
}))
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
disconnect() {}
observe() {}
unobserve() {}
}
)
})
afterEach(cleanup)
describe("AppLanguageSwitcher", () => {
it("opens its grouped locale choices", async () => {
render(<AppLanguageSwitcher />)
fireEvent.click(
screen.getByRole("button", {
name: "Change language, currently English",
})
)
expect(await screen.findByText("简体中文")).not.toBeNull()
})
})
@@ -0,0 +1,94 @@
import { CN, US } from "country-flag-icons/react/1x1"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import { HeaderActionButton } from "@workspace/blocks/layout"
import { appLocales, isAppLocale, type AppLocaleCountry } from "./app-locales"
import { useAppLocale } from "./app-i18n-provider"
const localeFlags = {
CN,
US,
} satisfies Record<AppLocaleCountry, typeof CN>
function LocaleFlag({
country,
className,
}: {
className?: string
country: AppLocaleCountry
}) {
const Flag = localeFlags[country]
return (
<span
aria-hidden="true"
className={cn(
"inline-flex shrink-0 overflow-hidden rounded-full ring-1 ring-foreground/15",
className
)}
>
<Flag className="size-full" />
</span>
)
}
export function AppLanguageSwitcher() {
const { locale, setLocale } = useAppLocale()
const activeLocale =
appLocales.find((definition) => definition.locale === locale) ??
appLocales[0]
const menuLabel = locale === "zh-Hans" ? "语言" : "Language"
const triggerLabel =
locale === "zh-Hans"
? `切换语言,当前为${activeLocale.label}`
: `Change language, currently ${activeLocale.label}`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<HeaderActionButton
aria-label={triggerLabel}
className="rounded-full"
/>
}
>
<LocaleFlag country={activeLocale.country} className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44"
showArrow={{ cornerRadius: "lg", tailSize: "sm" }}
>
<DropdownMenuRadioGroup
value={locale}
onValueChange={(value) => {
if (isAppLocale(value)) {
setLocale(value)
}
}}
>
<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
{appLocales.map((definition) => (
<DropdownMenuRadioItem
key={definition.locale}
className="py-2"
value={definition.locale}
>
<LocaleFlag country={definition.country} className="size-5" />
<span lang={definition.languageTag}>{definition.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { LocaleDefinition } from "@workspace/i18n"
export type AppLocale = "en" | "zh-Hans"
export type AppLocaleCountry = "CN" | "US"
export interface AppLocaleDefinition extends LocaleDefinition {
country: AppLocaleCountry
locale: AppLocale
}
export const DEFAULT_APP_LOCALE: AppLocale = "zh-Hans"
export const appLocales = [
{
country: "US",
direction: "ltr",
languageTag: "en-US",
label: "English",
locale: "en",
},
{
country: "CN",
direction: "ltr",
languageTag: "zh-CN",
label: "简体中文",
locale: "zh-Hans",
},
] as const satisfies readonly AppLocaleDefinition[]
export function isAppLocale(value: unknown): value is AppLocale {
return appLocales.some((definition) => definition.locale === value)
}