refactor: support i18n
This commit is contained in:
@@ -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"
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user