refactor: support i18n

This commit is contained in:
mlogclub
2026-05-25 12:06:15 +08:00
parent 309ac1fe9e
commit 988f55c80d
179 changed files with 10968 additions and 3763 deletions
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadConfig() {
const source = await readFile(new URL("./config.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "config.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("normalizes supported locale aliases", async () => {
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
assert.equal(normalizeLocale("zh"), "zh-CN")
assert.equal(normalizeLocale("en-US"), "en-US")
assert.equal(normalizeLocale("en_US"), "en-US")
assert.equal(normalizeLocale("en"), "en-US")
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
})
test("resolves browser locale from stored value before navigator languages", async () => {
const { resolveBrowserLocale } = await loadConfig()
assert.equal(
resolveBrowserLocale({
storedLocale: "en-US",
navigatorLanguages: ["zh-CN"],
}),
"en-US"
)
})
test("falls back through navigator languages", async () => {
const { resolveBrowserLocale } = await loadConfig()
assert.equal(
resolveBrowserLocale({
storedLocale: "",
navigatorLanguages: ["fr-FR", "en"],
}),
"en-US"
)
})
+76
View File
@@ -0,0 +1,76 @@
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
export const DEFAULT_LOCALE: AppLocale = "zh-CN"
export const LOCALE_STORAGE_KEY = "cs_ai_agent_locale"
const LOCALE_ALIASES: Record<string, AppLocale> = {
zh: "zh-CN",
"zh-cn": "zh-CN",
zh_cn: "zh-CN",
"zh-hans": "zh-CN",
en: "en-US",
"en-us": "en-US",
en_us: "en-US",
}
export function normalizeLocale(value: string | null | undefined): AppLocale {
return normalizeSupportedLocale(value) ?? DEFAULT_LOCALE
}
function normalizeSupportedLocale(
value: string | null | undefined
): AppLocale | null {
if (!value) {
return null
}
const key = value.trim().toLowerCase()
return LOCALE_ALIASES[key] ?? null
}
export function isSupportedLocale(
value: string | null | undefined
): value is AppLocale {
if (!value) {
return false
}
return SUPPORTED_LOCALES.includes(value as AppLocale)
}
export function resolveBrowserLocale({
storedLocale,
navigatorLanguages,
}: {
storedLocale?: string | null
navigatorLanguages?: readonly string[] | null
}): AppLocale {
if (isSupportedLocale(storedLocale)) {
return storedLocale
}
for (const locale of navigatorLanguages ?? []) {
const normalized = normalizeSupportedLocale(locale)
if (normalized) {
return normalized
}
}
return DEFAULT_LOCALE
}
export function readStoredLocale(): AppLocale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE
}
return resolveBrowserLocale({
storedLocale: window.localStorage.getItem(LOCALE_STORAGE_KEY),
navigatorLanguages: window.navigator.languages,
})
}
export function writeStoredLocale(locale: AppLocale) {
if (typeof window === "undefined") {
return
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
}
+51
View File
@@ -0,0 +1,51 @@
import { DEFAULT_LOCALE, type AppLocale, readStoredLocale } from "@/i18n/config"
import enUSMessages from "@/messages/en-US.json"
import zhCNMessages from "@/messages/zh-CN.json"
const messages = {
"zh-CN": zhCNMessages,
"en-US": enUSMessages,
} satisfies Record<AppLocale, typeof zhCNMessages>
export function translateMessage(
locale: AppLocale,
key: string,
values?: Record<string, string | number>
): string {
const value = getMessageValue(messages[locale], key)
if (typeof value === "string") {
return formatMessage(value, values)
}
const fallback = getMessageValue(messages[DEFAULT_LOCALE], key)
return typeof fallback === "string" ? formatMessage(fallback, values) : key
}
export function translateCurrentMessage(
key: string,
values?: Record<string, string | number>
): string {
return translateMessage(readStoredLocale(), key, values)
}
function getMessageValue(source: unknown, key: string): unknown {
let current = source
for (const part of key.split(".")) {
if (!current || typeof current !== "object" || !(part in current)) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
return current
}
function formatMessage(
message: string,
values?: Record<string, string | number>
): string {
if (!values) {
return message
}
return message.replace(/\{(\w+)\}/g, (match, key) =>
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
)
}
+73
View File
@@ -0,0 +1,73 @@
"use client"
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
DEFAULT_LOCALE,
type AppLocale,
readStoredLocale,
writeStoredLocale,
} from "@/i18n/config"
import { translateMessage } from "@/i18n/messages"
type LocaleContextValue = {
locale: AppLocale
setLocale: (locale: AppLocale) => void
t: (key: string, values?: Record<string, string | number>) => string
}
const LocaleContext = createContext<LocaleContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {},
t: (key) => key,
})
export function AppI18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<AppLocale>(DEFAULT_LOCALE)
useEffect(() => {
const storedLocale = readStoredLocale()
setLocaleState(storedLocale)
document.documentElement.lang = storedLocale
document.title = translateMessage(storedLocale, "app.metadataTitle")
}, [])
useEffect(() => {
document.title = translateMessage(locale, "app.metadataTitle")
}, [locale])
const value = useMemo<LocaleContextValue>(
() => ({
locale,
t: (key, values) => translateMessage(locale, key, values),
setLocale: (nextLocale) => {
setLocaleState(nextLocale)
writeStoredLocale(nextLocale)
document.documentElement.lang = nextLocale
document.title = translateMessage(nextLocale, "app.metadataTitle")
},
}),
[locale]
)
return (
<LocaleContext.Provider value={value}>
{children}
</LocaleContext.Provider>
)
}
export function useAppLocale() {
return useContext(LocaleContext)
}
export function useI18n() {
return useContext(LocaleContext).t
}