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,142 @@
|
||||
#!/usr/bin/env node
|
||||
// Add all missing sections to locale files
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const localesDir = resolve(__dirname, '../lib/i18n/locales');
|
||||
|
||||
// Read zh-CN to get the complete structure
|
||||
const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8');
|
||||
|
||||
// Extract the play section from zh-CN (we need to add this)
|
||||
const playSection = ` "play": {
|
||||
"loading": {
|
||||
"firstFrame": "正 · 在 · 绘 · 制 · 第 · 一 · 幕",
|
||||
"transitioning": "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕",
|
||||
"visionThinking": "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么",
|
||||
"loadingFirst": "正 · 在 · 唤 · 起 · 第 · 一 · 幕",
|
||||
"awakening": "载入中",
|
||||
},
|
||||
|
||||
"freeform": {
|
||||
"placeholder": "输入你想说的或想做的...",
|
||||
"title": "自由输入",
|
||||
"ariaLabel": "自由输入",
|
||||
},
|
||||
|
||||
"choiceDisabled": "分享剧情未包含这条分支",
|
||||
|
||||
"tooltips": {
|
||||
"openSettings": "打开设置",
|
||||
"openHistory": "剧情回溯",
|
||||
"fullscreen": "全屏 (F)",
|
||||
"enterFullscreen": "进入全屏",
|
||||
"exportGallery": "导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)",
|
||||
"exportGalleryLabel": "导出可交互图集",
|
||||
"shareStory": "导出本局为可继续游玩的剧情 .infiplot(含配音)",
|
||||
"shareStoryLabel": "分享当前剧情",
|
||||
"mute": "静音",
|
||||
"unmute": "取消静音",
|
||||
"closeNudge": "关闭提示",
|
||||
"silenceNudge": "效果不满意/经常没声音?填入自己的 API Key 试试",
|
||||
"back": "返回",
|
||||
},
|
||||
|
||||
"imageAlt": "Generated scene",
|
||||
|
||||
"counter": {
|
||||
"scene": "第 · {n} · 幕",
|
||||
"beat": "{n} · 拍",
|
||||
"middle": "·",
|
||||
},
|
||||
|
||||
"buttons": {
|
||||
"fullscreen": "F · 键 · 全 · 屏",
|
||||
"exportGallery": "导 · 出 · 图 · 集",
|
||||
"shareStory": "分 · 享 · 剧 · 情",
|
||||
"muted": "静 · 音",
|
||||
"sound": "有 · 声",
|
||||
},
|
||||
|
||||
"error": {
|
||||
"title": "出 · 了 · 点 · 状 · 况",
|
||||
"back": "返 · 回",
|
||||
},
|
||||
|
||||
"previousStep": "上 · 一 · 步 ·",
|
||||
|
||||
"settingsFooter": "保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。",
|
||||
|
||||
"shareErrors": {
|
||||
"notFound": "没有找到要载入的剧情文件。",
|
||||
"invalid": "剧情分享文件没有可载入的剧情。",
|
||||
"noImage": "剧情分享文件缺少第一幕图片。",
|
||||
"noNextImage": "剧情分享文件缺少下一幕图片。",
|
||||
"noMemory": "剧情分享文件缺少初始剧情记忆,无法载入。",
|
||||
"packFailed": "剧情分享打包失败",
|
||||
},
|
||||
}`;
|
||||
|
||||
// Extract other sections from zh-CN
|
||||
const settingsMatch = zhCNContent.match(/ "settings": \{[^}]*"actions": \{[^}]*\}\s*\},/s);
|
||||
const authMatch = zhCNContent.match(/ "auth": \{[^}]*"ariaLabel": "[^"]*"\s*\},/s);
|
||||
const historyMatch = zhCNContent.match(/ "history": \{[^}]*"ariaLabel": "[^"]*"\s*\},/s);
|
||||
const customFormMatch = zhCNContent.match(/ "customForm": \{[^}]*"start": "[^"]*"\s*\},/s);
|
||||
|
||||
// Target locales
|
||||
const targetLocales = [
|
||||
'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru',
|
||||
'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs'
|
||||
];
|
||||
|
||||
function fixLocaleFile(content, locale) {
|
||||
// Check if file already ends properly with "as const;"
|
||||
if (content.trim().endsWith('as const;')) {
|
||||
console.log(` ${locale}.ts already properly formatted`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find where the language section ends (around line 280)
|
||||
const languageEndPattern = / "language": \{\s*"title": "[^"]*",\s*"current": "[^"]*",\s*"select": "[^"]*"\s*\}\s*/;
|
||||
|
||||
// Build the replacement
|
||||
const replacement = `$&\n${playSection},`;
|
||||
|
||||
if (!languageEndPattern.test(content)) {
|
||||
console.log(` No language section found in ${locale}.ts`);
|
||||
return null;
|
||||
}
|
||||
|
||||
content = content.replace(languageEndPattern, replacement);
|
||||
|
||||
// Add the closing "as const;" and type export
|
||||
const exportType = `export type ${locale.replace('-', '')}Translations = typeof ${locale.replace('-', '')};`;
|
||||
|
||||
// Remove any trailing content and add proper ending
|
||||
const trailingContentPattern = /\s*\}[\s\S]*$/;
|
||||
content = content.replace(trailingContentPattern, `\n} as const;\n\n${exportType}`);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
for (const locale of targetLocales) {
|
||||
try {
|
||||
const filePath = resolve(localesDir, `${locale}.ts`);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const newContent = fixLocaleFile(content, locale);
|
||||
|
||||
if (newContent) {
|
||||
writeFileSync(filePath, newContent);
|
||||
console.log(`✓ Fixed ${locale}.ts`);
|
||||
successCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`✗ Error updating ${locale}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone! Fixed ${successCount} locale files`);
|
||||
Reference in New Issue
Block a user