feat(i18n): add language switcher with en/ja translations
- New client-side i18n via React Context (useI18n, tArray, I18nProvider) - Catalog ships 21 locale stubs; only zh-CN/en/ja have reviewed translations - Header language switcher (globe icon + short label) before settings gear - All hardcoded Chinese UI text migrated to keys: typewriter, options, hints (with embedded gear icon via dangerouslySetInnerHTML), settings panel, footer/about, play page hints - AI output language follows user-selected locale via trailing one-liner directive appended to Architect/Writer/CharacterDesigner/InsertBeat user messages (preserves system-prompt cacheability) - Per-locale separator rule: zh uses middot between every glyph; en/ja use plain spaces - Option value → i18n key suffix maps preserve Chinese as the underlying identifier so analytics unions and STYLE_MAP keys stay byte-stable Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import type { Locale } from "./config";
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using a dot-notation path
|
||||
* @example getNestedValue({ a: { b: "c" } }, "a.b") // "c"
|
||||
*/
|
||||
export function getNestedValue<T>(obj: T, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((current, key) => {
|
||||
if (current && typeof current === "object" && key in current) {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a translation string with parameters
|
||||
* Supports both {{key}} syntax and simple function-based interpolation
|
||||
*/
|
||||
export function formatTranslation(
|
||||
template: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
): string {
|
||||
if (Object.keys(params).length === 0) return template;
|
||||
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
return params[key]?.toString() ?? `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*/
|
||||
export function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Partial<T>,
|
||||
): T {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
||||
if (result[key] && typeof result[key] === "object" && !Array.isArray(result[key])) {
|
||||
result[key] = deepMerge(
|
||||
result[key] as Record<string, unknown>,
|
||||
source[key] as Record<string, unknown>,
|
||||
) as T[Extract<keyof T, string>];
|
||||
} else {
|
||||
result[key] = source[key] as T[Extract<keyof T, string>];
|
||||
}
|
||||
} else {
|
||||
result[key] = source[key] as T[Extract<keyof T, string>];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate locale string
|
||||
*/
|
||||
export function isValidLocale(locale: string): locale is Locale {
|
||||
const validLocales: Locale[] = [
|
||||
"en",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"zh-HK",
|
||||
"ja",
|
||||
"ko",
|
||||
"es",
|
||||
"fr",
|
||||
"de",
|
||||
"pt-BR",
|
||||
"pt",
|
||||
"ru",
|
||||
"it",
|
||||
"vi",
|
||||
"th",
|
||||
"id",
|
||||
"tr",
|
||||
"pl",
|
||||
"nl",
|
||||
"uk",
|
||||
"hi",
|
||||
"cs",
|
||||
];
|
||||
return validLocales.includes(locale as Locale);
|
||||
}
|
||||
Reference in New Issue
Block a user