fix(i18n): overhaul i18n with [locale] routing, SSR translations, and hreflang SEO

Rewrites the i18n system introduced in PR #94 to use Next.js App Router
[locale] dynamic segments with SSR-rendered translations and proper
middleware locale routing.

- Add middleware locale detection: / rewrites to /zh-CN/ internally,
  /en and /ja pass through, /zh-CN/... redirects to bare path
- Move all 7 pages under app/[locale]/ with SSR translation injection
- Fix server→client serialization: pre-evaluate function-valued
  translations (makeSerializable) to eliminate hydration flash
- Fix language switch key flash: use hard navigation with localStorage-
  only persistence, avoiding React state update before page reload
- Add <link rel="alternate" hreflang> tags for multilingual SEO
- Fix Supabase setAll overwriting locale rewrite response
- Trim locales from 22 to 3 (zh-CN/en/ja), delete 19 incomplete files
- LLM-translate 240 firstact game preset JSONs (en + ja, landscape +
  portrait) and story titles via gemini-3.5-flash
- Delete 11 one-off migration scripts and outdated i18n docs
- Add useLocalePath hook and navigation utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-18 23:16:17 +08:00
parent 941b54c3f8
commit 0a7076d5b9
301 changed files with 2447 additions and 4358 deletions
-142
View File
@@ -1,142 +0,0 @@
#!/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`);
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
// Add home.errors.cardNotFound key to all locales
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');
const keyToAdd = ' cardNotFound: "找不到精选剧情:{cardName}",';
// Target locales including zh-CN
const targetLocales = [
'zh-CN', 'en', 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru',
'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs'
];
function addKeyToErrors(content) {
// Check if key already exists
if (content.includes('cardNotFound:')) {
return null;
}
// Find the errors section and add the key
const errorsPattern = /("errors": \{[^}]*)(\})/;
const match = content.match(errorsPattern);
if (match) {
// Add the new key before the closing brace
const before = match[1];
const after = match[2];
// Check if there's already content in errors
if (before.trim().endsWith('{')) {
// Empty errors object, add on new line
return content.replace(errorsPattern, `$1\n${keyToAdd}\n${after}`);
} else {
// Non-empty, add after last key
return content.replace(errorsPattern, `${before},\n${keyToAdd}\n${after}`);
}
}
// If errors section doesn't exist, we need to create it
// Find "ui" section and add errors after it
const uiPattern = /("ui": \{[^}]*\n[^}]*\})/;
const uiMatch = content.match(uiPattern);
if (uiMatch) {
const uiEnd = uiMatch.index + uiMatch[0].length;
return content.slice(0, uiEnd) + ',\n "errors": {\n' + keyToAdd + '\n }' + content.slice(uiEnd);
}
return null;
}
let successCount = 0;
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = addKeyToErrors(content);
if (newContent) {
writeFileSync(filePath, newContent);
console.log(`✓ Added cardNotFound to ${locale}.ts`);
successCount++;
} else {
console.log(`- Skipped ${locale}.ts (key already exists)`);
}
} catch (e) {
console.error(`✗ Error updating ${locale}:`, e.message);
}
}
console.log(`\nDone! Updated ${successCount} locale files with cardNotFound key`);
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env node
// Copy new translation keys from zh-CN to all other locales
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 content to extract new keys
const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8');
// New keys to add (manually extracted from zh-CN.ts)
const newKeysSection = `
// ========== Play Page (PlayCanvas.tsx & play/page.tsx) ==========
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: "剧情分享打包失败",
},
},
`;
// Find the line where to insert (before ' language: {' or at end)
function addKeysToFile(content, locale) {
// Check if file already has play section
if (content.includes('play: {')) {
console.log(`${locale} already has play section, skipping`);
return null;
}
// Find position to insert (before the last ' language:' or before '}')
const langIndex = content.lastIndexOf(' language:');
if (langIndex > 0) {
return content.slice(0, langIndex) + newKeysSection + content.slice(langIndex);
}
// If no language: found, find the end of the object
const lastBrace = content.lastIndexOf('}');
if (lastBrace > 0) {
return content.slice(0, lastBrace) + ',' + newKeysSection + '\n}' + content.slice(lastBrace + 1);
}
return null;
}
// 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'
];
let successCount = 0;
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = addKeysToFile(content, locale);
if (newContent) {
writeFileSync(filePath, newContent);
console.log(`✓ Updated ${locale}.ts`);
successCount++;
}
} catch (e) {
console.error(`✗ Error updating ${locale}:`, e.message);
}
}
console.log(`\nDone! Updated ${successCount} locale files`);
console.log('Note: New keys are in Chinese. Run translation script to translate them.');
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env node
// Simple script to copy missing translation keys from zh-CN to all other locales
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 as source (remove comments and export)
function parseLocaleFile(content) {
// Remove comments
let cleaned = content.replace(/\/\/.*$/gm, '');
// Remove export and type declarations
cleaned = cleaned.replace(/export const \w+ = /, '');
cleaned = cleaned.replace(/ as const;?.*$/, '');
cleaned = cleaned.replace(/export type [\s\S]*$/, '');
// Parse
return JSON.parse(cleaned);
}
function flattenKeys(obj, prefix = '') {
const keys = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(keys, flattenKeys(value, fullKey));
} else {
keys[fullKey] = value;
}
}
return keys;
}
function setNestedValue(obj, key, value) {
const keys = key.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!(keys[i] in current)) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
}
// Read zh-CN
let zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8');
const zhCN = parseLocaleFile(zhCNContent);
const zhCNKeys = flattenKeys(zhCN);
// 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'
];
// Process each locale
for (const locale of targetLocales) {
const filePath = resolve(localesDir, `${locale}.ts`);
try {
let content = readFileSync(filePath, 'utf-8');
const existing = parseLocaleFile(content);
const existingKeys = flattenKeys(existing);
// Add missing keys
let added = 0;
for (const [key, value] of Object.entries(zhCNKeys)) {
if (!(key in existingKeys)) {
setNestedValue(existing, key, value);
added++;
}
}
if (added > 0) {
console.log(`Added ${added} missing keys to ${locale}.ts`);
// Generate new content
const varName = locale.replace('-', '').replace('-', '');
const typeName = varName.charAt(0).toUpperCase() + varName.slice(1);
const newContent = `// ${locale} - Auto-copied missing keys from zh-CN (fallback)
// Run translation script to translate these keys
export const ${varName} = ${JSON.stringify(existing, null, 2)} as const;
export type ${typeName}Translations = typeof ${varName};
`;
writeFileSync(filePath, newContent);
}
} catch (e) {
console.error(`Error processing ${locale}:`, e.message);
}
}
console.log('Done copying missing keys to all locales');
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env node
// Fix ICU MessageFormat syntax in hint.text across all locales - v2
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');
// Function translations for each locale
const hintTranslations = {
'zh-TW': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'zh-HK': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'ja': {
text: (params) => {
const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : '';
return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、<em>InfiPlot</em>を素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`;
},
closeAriaLabel: "このヒントを再度表示しない",
},
'ko': {
text: (params) => {
const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : '';
return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 <em>InfiPlot</em>을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`;
},
closeAriaLabel: "이 힌트를 다시 표시하지 않음",
},
'es': {
text: (params) => {
const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : '';
return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente <em>InfiPlot</em>. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`;
},
closeAriaLabel: "No volver a mostrar este consejo",
},
'fr': {
text: (params) => {
const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : '';
return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement <em>InfiPlot</em>. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`;
},
closeAriaLabel: "Ne plus afficher cette astuce",
},
'de': {
text: (params) => {
const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : '';
return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um <em>InfiPlot</em> schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`;
},
closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen",
},
'pt-BR': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar mais este aviso",
},
'pt': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar esta dica novamente",
},
'ru': {
text: (params) => {
const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : '';
return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать <em>InfiPlot</em>. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`;
},
closeAriaLabel: "Больше не показывать эту подсказку",
},
'it': {
text: (params) => {
const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : '';
return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente <em>InfiPlot</em>. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`;
},
closeAriaLabel: "Non mostrare più questo suggerimento",
},
'vi': {
text: (params) => {
const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : '';
return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh <em>InfiPlot</em>. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`;
},
closeAriaLabel: "Không còn hiển thị gợi ý này",
},
'th': {
text: (params) => {
const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : '';
return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส <em>InfiPlot</em> ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`;
},
closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก",
},
'id': {
text: (params) => {
const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : '';
return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat <em>InfiPlot</em>. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`;
},
closeAriaLabel: "Jangan tampilkan petunjuk ini lagi",
},
'tr': {
text: (params) => {
const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : '';
return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek <em>InfiPlot</em>'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`;
},
closeAriaLabel: "Bu ipucunu bir daha gösterme",
},
'pl': {
text: (params) => {
const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : '';
return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć <em>InfiPlot</em>. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`;
},
closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi",
},
'nl': {
text: (params) => {
const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : '';
return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om <em>InfiPlot</em> snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`;
},
closeAriaLabel: "Deze hint niet meer weergeven",
},
'uk': {
text: (params) => {
const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : '';
return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати <em>InfiPlot</em>. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`;
},
closeAriaLabel: "Більше не показувати цю підказку",
},
'hi': {
text: (params) => {
const authHint = params.authEnabled ? ' (बीटा के दौरान लॉगिन आवश्यक, मुफ्त खेल)' : '';
return `अपने विचार दर्ज करें, शैलियों को कॉन्फ़िगर करें और खेलने के लिए "शुरू" क्लिक करें${authHint}। आप नीचे से एक क्यूरेटेड कहानी चुनकर <em>InfiPlot</em> का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और TTS कुंजियों को कॉन्फ़िगर करने के लिए—सब कुछ अधिक स्थिर अनुभव के लिए आपके ब्राउज़र में स्थानीय रूप से संग्रहीत है।`;
},
closeAriaLabel: "यह संकेत फिर न दिखाएं",
},
'cs': {
text: (params) => {
const authHint = params.authEnabled ? ' (během bety vyžadováno přihlášení, hra zdarma)' : '';
return `Zadejte své nápady, nakonfigurujte styly a klikněte na "Spustit" pro hraní${authHint}. Můžete si také vybrat kurátorskou příběh z níže pro rychlé zážitky <em>InfiPlot</em>. Klikněte na "Nastavení" pro zadání vašeho jména a konfiguraci vlastních klíčů pro text, obrázky, vizi a TTS—vše uloženo lokálně ve vašem prohlížeči pro stabilnější zážitek.`;
},
closeAriaLabel: "Znovu nezobrazovat tuto nápovědu",
},
};
// Target locales - the ones that still need fixing
const targetLocales = ['pt-BR', 'id', 'tr'];
function fixHintText(content, locale) {
const translation = hintTranslations[locale];
if (!translation) {
console.log(` No translation for ${locale}`);
return null;
}
// The replacement hint object
const replacement = `"hint": {
"text": ${translation.text.toString().replace(/\n/g, '\n ')},
"closeAriaLabel": "${translation.closeAriaLabel}"
}`;
// Try to find and replace the hint section
// Pattern: "hint": { "text": "...", "closeAriaLabel": "..." }
// This handles multi-line strings with escaped quotes
const hintPattern = /"hint":\s*\{\s*"text":\s*"[^]*",\s*"closeAriaLabel":\s*"[^"]*"\s*\}/;
if (hintPattern.test(content)) {
return content.replace(hintPattern, replacement);
}
console.log(` No matching hint pattern found in ${locale}.ts`);
return null;
}
let successCount = 0;
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = fixHintText(content, locale);
if (newContent && newContent !== content) {
writeFileSync(filePath, newContent);
console.log(`✓ Fixed ${locale}.ts`);
successCount++;
} else {
console.log(`- Skipped ${locale}.ts`);
}
} catch (e) {
console.error(`✗ Error updating ${locale}:`, e.message);
}
}
console.log(`\nDone! Fixed ${successCount} locale files`);
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env node
// Fix ICU MessageFormat syntax in hint.text across all locales
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');
// Function translations for each locale
const hintTranslations = {
'zh-TW': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'zh-HK': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'ja': {
text: (params) => {
const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : '';
return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、<em>InfiPlot</em>を素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`;
},
closeAriaLabel: "このヒントを再度表示しない",
},
'ko': {
text: (params) => {
const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : '';
return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 <em>InfiPlot</em>을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`;
},
closeAriaLabel: "이 힌트를 다시 표시하지 않음",
},
'es': {
text: (params) => {
const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : '';
return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente <em>InfiPlot</em>. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`;
},
closeAriaLabel: "No volver a mostrar este consejo",
},
'fr': {
text: (params) => {
const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : '';
return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement <em>InfiPlot</em>. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`;
},
closeAriaLabel: "Ne plus afficher cette astuce",
},
'de': {
text: (params) => {
const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : '';
return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um <em>InfiPlot</em> schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`;
},
closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen",
},
'pt-BR': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar esta dica novamente",
},
'pt': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar esta dica novamente",
},
'ru': {
text: (params) => {
const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : '';
return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать <em>InfiPlot</em>. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`;
},
closeAriaLabel: "Больше не показывать эту подсказку",
},
'it': {
text: (params) => {
const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : '';
return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente <em>InfiPlot</em>. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`;
},
closeAriaLabel: "Non mostrare più questo suggerimento",
},
'vi': {
text: (params) => {
const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : '';
return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh <em>InfiPlot</em>. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`;
},
closeAriaLabel: "Không còn hiển thị gợi ý này",
},
'th': {
text: (params) => {
const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : '';
return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส <em>InfiPlot</em> ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`;
},
closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก",
},
'id': {
text: (params) => {
const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : '';
return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat <em>InfiPlot</em>. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`;
},
closeAriaLabel: "Jangan tampilkan petunjuk ini lagi",
},
'tr': {
text: (params) => {
const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : '';
return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek <em>InfiPlot</em>'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`;
},
closeAriaLabel: "Bu ipucunu bir daha gösterme",
},
'pl': {
text: (params) => {
const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : '';
return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć <em>InfiPlot</em>. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`;
},
closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi",
},
'nl': {
text: (params) => {
const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : '';
return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om <em>InfiPlot</em> snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`;
},
closeAriaLabel: "Deze hint niet meer weergeven",
},
'uk': {
text: (params) => {
const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : '';
return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати <em>InfiPlot</em>. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`;
},
closeAriaLabel: "Більше не показувати цю підказку",
},
'hi': {
text: (params) => {
const authHint = params.authEnabled ? ' (बीटा के दौरान लॉगिन आवश्यक, मुफ्त खेल)' : '';
return `अपने विचार दर्ज करें, शैलियों को कॉन्फ़िगर करें और खेलने के लिए "शुरू" क्लिक करें${authHint}। आप नीचे से एक क्यूरेटेड कहानी चुनकर <em>InfiPlot</em> का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और TTS कुंजियों को कॉन्फ़िगर करने के लिए—सब कुछ अधिक स्थिर अनुभव के लिए आपके ब्राउज़र में स्थानीय रूप से संग्रहीत है।`;
},
closeAriaLabel: "यह संकेत फिर न दिखाएं",
},
'cs': {
text: (params) => {
const authHint = params.authEnabled ? ' (během bety vyžadováno přihlášení, hra zdarma)' : '';
return `Zadejte své nápady, nakonfigurujte styly a klikněte na "Spustit" pro hraní${authHint}. Můžete si také vybrat kurátorskou příběh z níže pro rychlé zážitky <em>InfiPlot</em>. Klikněte na "Nastavení" pro zadání vašeho jména a konfiguraci vlastních klíčů pro text, obrázky, vizi a TTS—vše uloženo lokálně ve vašem prohlížeči pro stabilnější zážitek.`;
},
closeAriaLabel: "Znovu nezobrazovat tuto nápovědu",
},
};
// 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 fixHintText(content, locale) {
const translation = hintTranslations[locale];
if (!translation) return null;
// Pattern handles both hint: { and "hint": { (quoted keys)
// The ICU syntax can be {authEnabled...} or {{authEnabled...}}
const textPattern = /"text":\s*"[^"]*\{?authEnabled/;
// Build the replacement - handle both quoted and unquoted keys
const usesQuotedKeys = content.includes('"hint":');
const hintKey = usesQuotedKeys ? '"hint"' : 'hint';
const textKey = usesQuotedKeys ? '"text"' : 'text';
const closeLabelKey = usesQuotedKeys ? '"closeAriaLabel"' : 'closeAriaLabel';
const replacement = `${hintKey}: {
${textKey}: ${translation.text.toString().replace(/\n/g, '\n ')},
${closeLabelKey}: "${translation.closeAriaLabel}"
}`;
// Check for ICU syntax first
if (textPattern.test(content)) {
// Replace the entire hint section with ICU syntax
const fullHintPattern = /"hint":\s*\{[^}]*"text":\s*"[^"]*"[^}]*"closeAriaLabel":\s*"[^"]*"\s*\}/;
return content.replace(fullHintPattern, replacement);
}
// Check for empty hint object
const emptyHintPattern = /"hint":\s*\{\s*\}/;
if (emptyHintPattern.test(content)) {
console.log(` Found empty hint object in ${locale}.ts, replacing`);
return content.replace(emptyHintPattern, replacement);
}
console.log(` No ICU syntax or empty hint found in ${locale}.ts`);
return null;
}
let successCount = 0;
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = fixHintText(content, locale);
if (newContent && newContent !== content) {
writeFileSync(filePath, newContent);
console.log(`✓ Fixed ${locale}.ts`);
successCount++;
} else if (!newContent) {
console.log(`- Skipped ${locale}.ts (no ICU syntax found)`);
}
} catch (e) {
console.error(`✗ Error updating ${locale}:`, e.message);
}
}
console.log(`\nDone! Fixed ${successCount} locale files`);
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env node
// Fix syntax errors in locale files (remove extra comma before play section)
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');
// Fix the pattern: }\n, // should be }\n\n
function fixLocaleFile(content) {
// Replace the pattern where language closing is followed by comma and then play section
return content.replace(
/}\s*,\s*\/\/ ======== Play Page ========/g,
'},\n // ========== Play Page =========='
);
}
// All locales with the issue
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'
];
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = fixLocaleFile(content);
if (newContent !== content) {
writeFileSync(filePath, newContent);
console.log(`✓ Fixed ${locale}.ts`);
}
} catch (e) {
console.error(`✗ Error fixing ${locale}:`, e.message);
}
}
console.log('Done! Fixed locale files');
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env node
// Fix type annotations for params parameter in 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');
// Target locales
const targetLocales = [
'de', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt-BR', 'pt',
'ru', 'th', 'tr', 'uk', 'zh-TW', 'zh-HK'
];
function fixParamsType(content) {
// Replace (params) => with (params: { authEnabled?: boolean }) =>
return content.replace(
/\(params\)\s*=>\s*\{/g,
'(params: { authEnabled?: boolean }) => {'
);
}
let successCount = 0;
for (const locale of targetLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = readFileSync(filePath, 'utf-8');
const newContent = fixParamsType(content);
if (newContent !== content) {
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`);
-299
View File
@@ -1,299 +0,0 @@
#!/usr/bin/env node
// Rebuild all locale files from zh-CN template
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 as template
const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8');
// Function translations for hint.text in each locale
const hintTranslations = {
'en': {
text: (params) => {
const authHint = params.authEnabled ? ' (login required during beta, free to play)' : '';
return `Enter your ideas, configure styles, and click "Start" to play${authHint}. You can also pick a curated story from below to quickly experience <em>InfiPlot</em>. Click "Settings" to enter your name and configure your own text, image, vision models and TTS keys—all stored locally in your browser for a more stable experience.`;
},
closeAriaLabel: "Don't show this hint again",
},
'zh-TW': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'zh-HK': {
text: (params) => {
const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : '';
return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`;
},
closeAriaLabel: "不再顯示此提示",
},
'ja': {
text: (params) => {
const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : '';
return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、<em>InfiPlot</em>を素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`;
},
closeAriaLabel: "このヒントを再度表示しない",
},
'ko': {
text: (params) => {
const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : '';
return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 <em>InfiPlot</em>을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`;
},
closeAriaLabel: "이 힌트를 다시 표시하지 않음",
},
'es': {
text: (params) => {
const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : '';
return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente <em>InfiPlot</em>. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`;
},
closeAriaLabel: "No volver a mostrar este consejo",
},
'fr': {
text: (params) => {
const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : '';
return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement <em>InfiPlot</em>. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`;
},
closeAriaLabel: "Ne plus afficher cette astuce",
},
'de': {
text: (params) => {
const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : '';
return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um <em>InfiPlot</em> schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`;
},
closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen",
},
'pt-BR': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar mais este aviso",
},
'pt': {
text: (params) => {
const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : '';
return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente <em>InfiPlot</em>. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`;
},
closeAriaLabel: "Não mostrar esta dica novamente",
},
'ru': {
text: (params) => {
const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : '';
return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать <em>InfiPlot</em>. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`;
},
closeAriaLabel: "Больше не показывать эту подсказку",
},
'it': {
text: (params) => {
const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : '';
return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente <em>InfiPlot</em>. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`;
},
closeAriaLabel: "Non mostrare più questo suggerimento",
},
'vi': {
text: (params) => {
const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : '';
return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh <em>InfiPlot</em>. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`;
},
closeAriaLabel: "Không còn hiển thị gợi ý này",
},
'th': {
text: (params) => {
const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : '';
return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส <em>InfiPlot</em> ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`;
},
closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก",
},
'id': {
text: (params) => {
const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : '';
return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat <em>InfiPlot</em>. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`;
},
closeAriaLabel: "Jangan tampilkan petunjuk ini lagi",
},
'tr': {
text: (params) => {
const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : '';
return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek <em>InfiPlot</em>'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`;
},
closeAriaLabel: "Bu ipucunu bir daha gösterme",
},
'pl': {
text: (params) => {
const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : '';
return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć <em>InfiPlot</em>. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`;
},
closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi",
},
'nl': {
text: (params) => {
const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : '';
return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om <em>InfiPlot</em> snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`;
},
closeAriaLabel: "Deze hint niet meer weergeven",
},
'uk': {
text: (params) => {
const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : '';
return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати <em>InfiPlot</em>. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`;
},
closeAriaLabel: "Більше не показувати цю підказку",
},
};
// Locale metadata
const localeMetadata = {
'en': { name: 'English (United States)', comment: '// English (United States)' },
'zh-TW': { name: 'Chinese (Taiwan)', comment: '// Traditional Chinese (Taiwan)' },
'zh-HK': { name: 'Chinese (Hong Kong)', comment: '// Traditional Chinese (Hong Kong)' },
'ja': { name: 'Japanese', comment: '// Japanese' },
'ko': { name: 'Korean', comment: '// Korean' },
'es': { name: 'Spanish', comment: '// Spanish' },
'fr': { name: 'French', comment: '// French' },
'de': { name: 'German', comment: '// German (Germany)' },
'pt-BR': { name: 'Portuguese (Brazil)', comment: '// Portuguese (Brazil)' },
'pt': { name: 'Portuguese', comment: '// Portuguese (Portugal)' },
'ru': { name: 'Russian', comment: '// Russian' },
'it': { name: 'Italian', comment: '// Italian' },
'vi': { name: 'Vietnamese', comment: '// Vietnamese' },
'th': { name: 'Thai', comment: '// Thai' },
'id': { name: 'Indonesian', comment: '// Indonesian' },
'tr': { name: 'Turkish', comment: '// Turkish' },
'pl': { name: 'Polish', comment: '// Polish' },
'nl': { name: 'Dutch', comment: '// Dutch' },
'uk': { name: 'Ukrainian', comment: '// Ukrainian' },
};
// Get the variable name for a locale
function getVarName(locale) {
if (locale === 'zh-CN') return 'zhCN';
if (locale === 'zh-TW') return 'zhTW';
if (locale === 'zh-HK') return 'zhHK';
return locale.replace(/-/g, '').toLowerCase();
}
// Rebuild a locale file
function rebuildLocale(locale) {
const varName = getVarName(locale);
const metadata = localeMetadata[locale] || { name: locale, comment: `// ${locale}` };
// Start with the template structure but replace the hint.text with function
let content = `${metadata.comment}
// Auto-generated by scripts/translate-i18n.mjs
export const ${varName} = {
"layout": {
"metadata": {
"title": "InfiPlot",
"description": "InfiPlot"
}
},
"home": {
"examples": {
"male": [],
"female": [],
"x": []
},
"options": {
"gender": "",
"artStyle": "",
"plotStyle": "",
"voice": "",
"pacing": ""
},
"genders": {
"male": "",
"female": "",
"x": ""
},
"artStyles": {},
"plotStyles": {
"straightforward": "",
"twist": ""
},
"voiceOptions": {
"off": "",
"on": ""
},
"pacings": {
"fast": "",
"relaxed": ""
},
"stories": {},
"ui": {
"start": "",
"loadStory": "",
"settings": "",
"searchPlaceholder": "",
"noMatchingStyle": "",
"close": "",
"back": "",
"save": "",
"cancel": "",
"saveAndSelect": ""
},
"styleModal": {},
"hero": {
"title": "",
"placeholder": " ",
"enterHint": ""
},
"hint": {
"text": ${hintTranslations[locale]?.text.toString().replace(/\n/g, '\n ') || '(params) => ""'},
"closeAriaLabel": "${hintTranslations[locale]?.closeAriaLabel || ''}"
},
"about": {},
"errors": {
"emptyFile": "",
"fileTooLarge": "",
"unpackFailed": "",
"parseFailed": "",
"cardNotFound": ""
}
},
"play": {},
"settings": {},
"auth": {},
"history": {},
"customForm": {},
"language": {
"title": "",
"current": "",
"select": ""
}
} as const;
export type ${varName.charAt(0).toUpperCase() + varName.slice(1)}Translations = typeof ${varName};
`;
return content;
}
// Rebuild all truncated locales
const truncatedLocales = [
'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru',
'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk'
];
let successCount = 0;
for (const locale of truncatedLocales) {
try {
const filePath = resolve(localesDir, `${locale}.ts`);
const content = rebuildLocale(locale);
writeFileSync(filePath, content);
console.log(`✓ Rebuilt ${locale}.ts`);
successCount++;
} catch (e) {
console.error(`✗ Error rebuilding ${locale}:`, e.message);
}
}
console.log(`\nDone! Rebuilt ${successCount} locale files`);
console.log('Note: Files now have placeholder structure. Run translation script to fill in actual translations.');
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env node
// Remove duplicate play sections and fix type annotations
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');
// 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) {
let modified = false;
// 1. Remove duplicate play section (the one after the main object closes)
// Pattern: anything from ",\n // ========== Play Page" to end of file
const duplicatePlayPattern = /,\n \/\/ ========== Play Page[\s\S]*$/;
if (duplicatePlayPattern.test(content)) {
content = content.replace(duplicatePlayPattern, '');
modified = true;
console.log(` Removed duplicate play section from ${locale}.ts`);
}
// 2. Fix type annotations for params in function translations
// Pattern: (params) => { should be (params: { authEnabled?: boolean }) => {
const functionPattern = /\(params\)\s*=>\s*\{/g;
let matchCount = 0;
content = content.replace(functionPattern, () => {
matchCount++;
return '(params: { authEnabled?: boolean }) => {';
});
if (matchCount > 0) {
modified = true;
console.log(` Fixed ${matchCount} type annotations in ${locale}.ts`);
}
// 3. Fix trailing syntax issues
// Replace }\n, with }\n,
content = content.replace(/\}\n,/g, '},\n');
return modified ? content : null;
}
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`);
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env node
/**
* Translate prebaked firstact JSONs to target locales using an LLM.
*
* Reads TEXT_BASE_URL + TEXT_API_KEY from .env.local.
* Default model: gemini-3.5-flash (override with --model or TRANSLATE_MODEL).
*
* Output: public/home/firstact-{locale}/ and firstact-portrait-{locale}/
*
* Usage:
* node scripts/translate-firstacts.mjs # en + ja
* node scripts/translate-firstacts.mjs --locale=en # en only
* node scripts/translate-firstacts.mjs --only=m0,f1 # specific files
* node scripts/translate-firstacts.mjs --force # overwrite existing
* node scripts/translate-firstacts.mjs --portrait # portrait set only
* node scripts/translate-firstacts.mjs --stories # also output story titles for locale files
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const ENV_FILE = resolve(rootDir, ".env.local");
// ── Load .env.local ──────────────────────────────────────────────────
function loadEnv() {
if (!existsSync(ENV_FILE)) {
console.error("Missing .env.local — need TEXT_BASE_URL + TEXT_API_KEY");
process.exit(1);
}
const lines = readFileSync(ENV_FILE, "utf8").split("\n");
const env = {};
for (const line of lines) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]] = m[2].trim();
}
return env;
}
const env = loadEnv();
const BASE_URL = env.TEXT_BASE_URL;
const API_KEY = env.TEXT_API_KEY;
const MODEL = process.argv.find(a => a.startsWith("--model="))?.split("=")[1]
|| env.TRANSLATE_MODEL || "gemini-3.5-flash";
if (!BASE_URL || !API_KEY) {
console.error("TEXT_BASE_URL and TEXT_API_KEY must be set in .env.local");
process.exit(1);
}
// ── CLI args ─────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const force = args.includes("--force");
const portraitOnly = args.includes("--portrait");
const storiesMode = args.includes("--stories");
const localeArg = args.find(a => a.startsWith("--locale="))?.split("=")[1];
const onlyArg = args.find(a => a.startsWith("--only="))?.split("=")[1];
const LOCALES = localeArg ? localeArg.split(",") : ["en", "ja"];
const ONLY = onlyArg ? new Set(onlyArg.split(",")) : null;
const LOCALE_LABELS = { en: "English", ja: "Japanese (日本語)" };
// ── LLM caller ───────────────────────────────────────────────────────
async function callLLM(system, user, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
temperature: 0.3,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
}),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
}
const data = await res.json();
return data.choices?.[0]?.message?.content ?? "";
} catch (e) {
console.warn(` Attempt ${attempt + 1} failed: ${e.message}`);
if (attempt < retries - 1) await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
else throw e;
}
}
}
// ── Extract translatable fields from a firstact JSON ─────────────────
function extractTranslatableTexts(data) {
const texts = {};
if (data.cardTitle) texts["cardTitle"] = data.cardTitle;
if (data.cardGender) texts["cardGender"] = data.cardGender;
if (data.worldSetting) texts["worldSetting"] = data.worldSetting;
// scene.beats
if (data.scene?.beats) {
for (let i = 0; i < data.scene.beats.length; i++) {
const b = data.scene.beats[i];
if (b.narration) texts[`beats[${i}].narration`] = b.narration;
if (b.speaker) texts[`beats[${i}].speaker`] = b.speaker;
if (b.line) texts[`beats[${i}].line`] = b.line;
if (b.lineDelivery) texts[`beats[${i}].lineDelivery`] = b.lineDelivery;
if (b.activeCharacters) {
for (let j = 0; j < b.activeCharacters.length; j++) {
const ac = b.activeCharacters[j];
if (ac.name) texts[`beats[${i}].ac[${j}].name`] = ac.name;
if (ac.pose) texts[`beats[${i}].ac[${j}].pose`] = ac.pose;
}
}
if (b.next?.choices) {
for (let j = 0; j < b.next.choices.length; j++) {
const c = b.next.choices[j];
if (c.label) texts[`beats[${i}].choice[${j}].label`] = c.label;
if (c.effect?.nextSceneSeed) texts[`beats[${i}].choice[${j}].seed`] = c.effect.nextSceneSeed;
}
}
}
}
// characters
if (data.characters) {
for (let i = 0; i < data.characters.length; i++) {
const c = data.characters[i];
if (c.name) texts[`char[${i}].name`] = c.name;
if (c.voiceDescription) texts[`char[${i}].voiceDescription`] = c.voiceDescription;
}
}
// storyState
if (data.storyState) {
const ss = data.storyState;
for (const key of ["logline", "genreTags", "protagonist", "castNotes", "synopsis", "nextHook"]) {
if (ss[key]) texts[`ss.${key}`] = ss[key];
}
if (Array.isArray(ss.openThreads)) {
for (let i = 0; i < ss.openThreads.length; i++) {
texts[`ss.openThreads[${i}]`] = ss.openThreads[i];
}
}
if (Array.isArray(ss.relationships)) {
for (let i = 0; i < ss.relationships.length; i++) {
texts[`ss.relationships[${i}]`] = ss.relationships[i];
}
}
}
return texts;
}
// ── Apply translated texts back to a deep-cloned firstact JSON ───────
function applyTranslations(original, translations) {
const data = JSON.parse(JSON.stringify(original));
if (translations["cardTitle"]) data.cardTitle = translations["cardTitle"];
if (translations["cardGender"]) data.cardGender = translations["cardGender"];
if (translations["worldSetting"]) data.worldSetting = translations["worldSetting"];
if (data.scene?.beats) {
for (let i = 0; i < data.scene.beats.length; i++) {
const b = data.scene.beats[i];
const p = `beats[${i}]`;
if (translations[`${p}.narration`]) b.narration = translations[`${p}.narration`];
if (translations[`${p}.speaker`]) b.speaker = translations[`${p}.speaker`];
if (translations[`${p}.line`]) b.line = translations[`${p}.line`];
if (translations[`${p}.lineDelivery`]) b.lineDelivery = translations[`${p}.lineDelivery`];
if (b.activeCharacters) {
for (let j = 0; j < b.activeCharacters.length; j++) {
if (translations[`${p}.ac[${j}].name`]) b.activeCharacters[j].name = translations[`${p}.ac[${j}].name`];
if (translations[`${p}.ac[${j}].pose`]) b.activeCharacters[j].pose = translations[`${p}.ac[${j}].pose`];
}
}
if (b.next?.choices) {
for (let j = 0; j < b.next.choices.length; j++) {
if (translations[`${p}.choice[${j}].label`]) b.next.choices[j].label = translations[`${p}.choice[${j}].label`];
if (translations[`${p}.choice[${j}].seed`] && b.next.choices[j].effect) {
b.next.choices[j].effect.nextSceneSeed = translations[`${p}.choice[${j}].seed`];
}
}
}
}
}
if (data.characters) {
for (let i = 0; i < data.characters.length; i++) {
if (translations[`char[${i}].name`]) data.characters[i].name = translations[`char[${i}].name`];
if (translations[`char[${i}].voiceDescription`]) data.characters[i].voiceDescription = translations[`char[${i}].voiceDescription`];
}
}
if (data.storyState) {
const ss = data.storyState;
for (const key of ["logline", "genreTags", "protagonist", "castNotes", "synopsis", "nextHook"]) {
if (translations[`ss.${key}`]) ss[key] = translations[`ss.${key}`];
}
if (Array.isArray(ss.openThreads)) {
for (let i = 0; i < ss.openThreads.length; i++) {
if (translations[`ss.openThreads[${i}]`]) ss.openThreads[i] = translations[`ss.openThreads[${i}]`];
}
}
if (Array.isArray(ss.relationships)) {
for (let i = 0; i < ss.relationships.length; i++) {
if (translations[`ss.relationships[${i}]`]) ss.relationships[i] = translations[`ss.relationships[${i}]`];
}
}
}
return data;
}
// ── Build LLM prompt ─────────────────────────────────────────────────
function buildPrompt(texts, targetLang) {
const system = `You are a professional literary translator for an interactive story game (visual novel / galgame). Translate the given Chinese text into ${targetLang}.
Rules:
- This is a second-person narrative game. The player character is always addressed as "you" (or the equivalent in the target language).
- Preserve the tone, mood, and literary style of each text segment.
- Character names: transliterate or adapt naturally for the target language. Keep consistency within the same story.
- Do NOT translate text that is already in English (e.g. style descriptions, technical terms).
- For "voiceDescription" fields, translate the description but keep the voice acting direction style.
- For "worldSetting", translate the content but preserve any bracketed meta-instructions like 【男性向】.
- For "cardGender": 男性向→"Male-oriented"(en)/"男性向け"(ja), 女性向→"Female-oriented"(en)/"女性向け"(ja)
- Return ONLY a valid JSON object with the same keys mapping to translated values. No explanation.`;
const user = `Translate these Chinese texts to ${targetLang}. Return a JSON object with the same keys:\n\n${JSON.stringify(texts, null, 2)}`;
return { system, user };
}
// ── Parse LLM response ───────────────────────────────────────────────
function parseResponse(raw) {
let cleaned = raw.trim();
// Strip markdown code fences
if (cleaned.startsWith("```")) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
}
return JSON.parse(cleaned);
}
// ── Concurrency pool ─────────────────────────────────────────────────
const CONCURRENCY = parseInt(process.env.TRANSLATE_CONCURRENCY || "10", 10);
async function runPool(tasks, concurrency) {
const results = [];
let idx = 0;
async function worker() {
while (idx < tasks.length) {
const i = idx++;
results[i] = await tasks[i]();
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()));
return results;
}
// ── Main ─────────────────────────────────────────────────────────────
async function main() {
const dirs = portraitOnly
? [{ src: "firstact-portrait", suffix: "-portrait" }]
: [
{ src: "firstact", suffix: "" },
{ src: "firstact-portrait", suffix: "-portrait" },
];
const storyTitles = {}; // locale → { m0: { title, outline }, ... }
for (const locale of LOCALES) {
storyTitles[locale] = {};
const langLabel = LOCALE_LABELS[locale] || locale;
// Collect all translation tasks across dirs, then run in parallel
const tasks = [];
for (const { src, suffix } of dirs) {
const srcDir = join(rootDir, "public/home", src);
const outDir = join(rootDir, `public/home/${src}-${locale}`);
mkdirSync(outDir, { recursive: true });
const files = readdirSync(srcDir).filter(f => f.endsWith(".json")).sort();
for (const file of files) {
const name = file.replace(".json", "");
if (ONLY && !ONLY.has(name)) continue;
const outPath = join(outDir, file);
if (!force && existsSync(outPath)) {
console.log(` [skip] ${src}-${locale}/${file} (exists)`);
if (suffix === "") {
try {
const existing = JSON.parse(readFileSync(outPath, "utf8"));
if (existing.cardTitle) {
storyTitles[locale][name] = { title: existing.cardTitle };
}
} catch { /* ignore */ }
}
continue;
}
const srcPath = join(srcDir, file);
const data = JSON.parse(readFileSync(srcPath, "utf8"));
const texts = extractTranslatableTexts(data);
const textCount = Object.keys(texts).length;
if (textCount === 0) {
console.log(` [skip] ${src}-${locale}/${file} (no Chinese text)`);
writeFileSync(outPath, JSON.stringify(data));
continue;
}
// Queue the translation as a task
tasks.push(async () => {
console.log(` [translate] ${src}-${locale}/${file} (${textCount} fields)...`);
try {
const { system, user } = buildPrompt(texts, langLabel);
const raw = await callLLM(system, user);
const translated = parseResponse(raw);
const returnedKeys = Object.keys(translated);
const coverage = returnedKeys.length / textCount;
if (coverage < 0.5) {
console.warn(`${file}: low coverage (${(coverage * 100).toFixed(0)}%), retrying...`);
const raw2 = await callLLM(system, user);
const translated2 = parseResponse(raw2);
Object.assign(translated, translated2);
}
const result = applyTranslations(data, translated);
writeFileSync(outPath, JSON.stringify(result));
console.log(`${file}: ${returnedKeys.length}/${textCount} fields`);
if (suffix === "" && result.cardTitle) {
storyTitles[locale][name] = { title: result.cardTitle };
}
} catch (e) {
console.error(`${file}: ${e.message}`);
}
});
}
}
if (tasks.length > 0) {
console.log(`\n[${locale}] Translating ${tasks.length} files (concurrency: ${CONCURRENCY})...`);
await runPool(tasks, CONCURRENCY);
}
}
// Output story titles summary
if (storiesMode || Object.values(storyTitles).some(v => Object.keys(v).length > 0)) {
console.log("\n=== Story Titles for Locale Files ===");
for (const [locale, titles] of Object.entries(storyTitles)) {
console.log(`\n${locale}:`);
for (const [name, data] of Object.entries(titles).sort()) {
console.log(` "${name}": "${data.title}",`);
}
}
}
console.log("\nDone!");
}
main().catch(e => {
console.error(e);
process.exit(1);
});
-364
View File
@@ -1,364 +0,0 @@
#!/usr/bin/env node
/**
* Translate lib/i18n/locales/zh-CN.ts to target locales using an LLM.
*
* Defaults to translating only `ja` (English is hand-curated in en.ts).
* Override with --locales=en,ja. Other locales remain stubs.
*
* Uses the existing OpenAI-compatible TEXT_BASE_URL + TEXT_API_KEY from
* .env.local. Default model is `gemini-3.5-flash` (the openai-next.com proxy
* supports it alongside gpt-4.1); override with --model or TRANSLATE_MODEL.
*
* Strategy:
* 1. Read zh-CN.ts as TEXT (so structure + function signatures stay intact).
* 2. Tokenize source, finding every string literal that contains Han chars.
* 3. Mask ${...} interpolations and HTML attributes/URLs, send the rest to
* the LLM with strict "preserve these tokens" instructions.
* 4. Replace each match in source (back-to-front to keep indices valid).
* 5. Rename `zhCN`/`ZhCNTranslations` → target locale var names, write file.
*
* Why source-as-text instead of import + serialize: the source contains two
* ICU-style functions (hint.text, about.legalNotice) whose control flow and
* parameter typing must survive unchanged. String-literal find-and-replace
* leaves them alone — only their Chinese substrings get translated.
*
* Usage:
* node scripts/translate-i18n.mjs # ja only, gemini-3.5-flash
* node scripts/translate-i18n.mjs --locales=en,ja # both
* node scripts/translate-i18n.mjs --model=gemini-2.5-flash
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { argv } from "node:process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const ENV_FILE = resolve(rootDir, ".env.local");
// ── Load .env.local (matches scripts/enrich-firstacts-stepfun.mjs) ────
function loadEnv(path) {
if (!existsSync(path)) return {};
const txt = readFileSync(path, "utf8");
const env = {};
for (const raw of txt.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
env[k] = v;
}
return env;
}
const env = loadEnv(ENV_FILE);
// ── CLI parsing ───────────────────────────────────────────────────────
let targets = ["ja"];
let model = env.TRANSLATE_MODEL || "gemini-3.5-flash";
let concurrency = 6;
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--locales" && argv[i + 1]) targets = argv[++i].split(",").map((s) => s.trim());
else if (a === "--model" && argv[i + 1]) model = argv[++i];
else if (a === "--concurrency" && argv[i + 1]) concurrency = Number(argv[++i]);
}
const baseUrl = (env.TEXT_BASE_URL || "").replace(/\/+$/, "");
const apiKey = env.TEXT_API_KEY || "";
if (!baseUrl || !apiKey) {
console.error(`❌ TEXT_BASE_URL and TEXT_API_KEY must be set in ${ENV_FILE}`);
process.exit(1);
}
const LOCALE_NAMES = {
en: "English",
"zh-CN": "Simplified Chinese",
"zh-TW": "Traditional Chinese (Taiwan)",
"zh-HK": "Traditional Chinese (Hong Kong)",
ja: "Japanese",
ko: "Korean",
es: "Spanish",
fr: "French",
de: "German",
"pt-BR": "Portuguese (Brazil)",
pt: "Portuguese",
ru: "Russian",
it: "Italian",
vi: "Vietnamese",
th: "Thai",
id: "Indonesian",
tr: "Turkish",
pl: "Polish",
nl: "Dutch",
uk: "Ukrainian",
hi: "Hindi",
cs: "Czech",
};
// ── LLM call ──────────────────────────────────────────────────────────
const cache = new Map();
async function translateText(text, targetLang) {
const cacheKey = `${targetLang}::${text}`;
if (cache.has(cacheKey)) return cache.get(cacheKey);
// Mask ${...} template interpolations so the model can't rewrite them.
const interps = [];
let masked = text.replace(/\$\{[^}]*\}/g, (m) => {
interps.push(m);
return `⟦I${interps.length - 1}`;
});
// Mask {placeholder} and {{placeholder}} style too — common in our strings.
// (Keep this conservative; only single-word curlies.)
const placeholders = [];
masked = masked.replace(/\{\{\w+\}\}|\{\w+\}/g, (m) => {
placeholders.push(m);
return `⟦P${placeholders.length - 1}`;
});
const prompt = `You are a professional UI translator for an interactive fiction game (galgame) called InfiPlot.
Target language: ${targetLang}.
CRITICAL RULES — violations break the build:
1. Translate ONLY the human-readable text into ${targetLang}.
2. PRESERVE EXACTLY (do not translate, do not move):
- Tokens shaped ⟦I0⟧, ⟦I1⟧ — these are code placeholders; copy them verbatim into the output.
- Tokens shaped ⟦P0⟧, ⟦P1⟧ — same.
- HTML tags: <em>, <a ...>, <span ...>, <br/> — keep tags exactly; translate only inner text.
- HTML attributes: class="...", href="...", target="..." — keep as-is.
- URLs (https://..., mailto:...).
3. KEEP PROPER NOUNS UNCHANGED: InfiPlot, GitHub, Google, Umami, QQ, API, Key, BASE URL, MiMo, StepFun.
4. DOT SEPARATOR RULE: the Chinese source uses " · " between characters as a stylistic effect. DO NOT use "·" in your translation. Output normal words. Example: "正 · 在 · 绘 · 制" → English: "Drawing", Japanese: "描画中".
5. Match tone: playful for loading/game UI, professional for technical labels.
6. Output ONLY the translated string. No wrapping quotes, no markdown fences, no commentary.
Source text:
${masked}`;
let out = "";
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
}),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`API ${res.status}: ${errText.slice(0, 200)}`);
}
const data = await res.json();
out = data.choices?.[0]?.message?.content?.trim() ?? "";
break;
} catch (err) {
if (attempt === 2) throw err;
const backoff = 800 * Math.pow(2, attempt);
console.log(` ⚠️ retry in ${backoff}ms: ${err.message.slice(0, 100)}`);
await new Promise((r) => setTimeout(r, backoff));
}
}
// Strip wrapping quotes / fences the model sometimes adds.
out = out.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
out = out.replace(/^["'`]+|["'`]+$/g, "");
// Restore placeholders in the right order.
out = out.replace(/⟦I(\d+)⟧/g, (_, i) => interps[Number(i)]);
out = out.replace(/⟦P(\d+)⟧/g, (_, i) => placeholders[Number(i)]);
cache.set(cacheKey, out);
return out;
}
// ── Tokenizer: find every string literal containing Han chars ─────────
function findChineseStrings(source) {
const results = [];
let i = 0;
let line = 1;
while (i < source.length) {
const ch = source[i];
if (ch === "\n") { line++; i++; continue; }
// Skip line comments
if (ch === "/" && source[i + 1] === "/") {
while (i < source.length && source[i] !== "\n") i++;
continue;
}
// Skip block comments
if (ch === "/" && source[i + 1] === "*") {
i += 2;
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) {
if (source[i] === "\n") line++;
i++;
}
i += 2;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
const start = i;
const startLine = line;
const quote = ch;
i++;
const parts = [];
while (i < source.length) {
const c = source[i];
if (c === "\\") {
parts.push(c, source[i + 1] ?? "");
i += 2;
continue;
}
if (c === "\n") line++;
if (c === quote) {
i++;
break;
}
// For backticks, treat ${...} as opaque (don't translate the expression body).
if (quote === "`" && c === "$" && source[i + 1] === "{") {
let depth = 1;
parts.push(c, source[i + 1]);
i += 2;
while (i < source.length && depth > 0) {
const cc = source[i];
if (cc === "{") depth++;
else if (cc === "}") depth--;
if (cc === "\n") line++;
parts.push(cc);
i++;
}
continue;
}
parts.push(c);
i++;
}
const content = parts.join("");
if (/[一-鿿]/.test(content)) {
results.push({
full: source.slice(start, i),
quote,
content,
start,
end: i,
line: startLine,
});
}
continue;
}
i++;
}
return results;
}
// ── Variable rename for target locale file ────────────────────────────
function transformForLocale(source, locale) {
const varName = locale.replace(/-./g, (c) => c[1].toUpperCase());
const typeName = varName[0].toUpperCase() + varName.slice(1) + "Translations";
const localeDisplay = LOCALE_NAMES[locale] || locale;
let out = source
.replace(/\bzhCN\b/g, varName)
.replace(/\bZhCNTranslations\b/g, typeName);
// Replace the leading comment line with locale info.
out = out.replace(
/^\/\/[^\n]*\n/,
`// ${localeDisplay} — auto-translated from zh-CN by scripts/translate-i18n.mjs (review for quality).\n`,
);
return out;
}
// ── Concurrency-limited map ───────────────────────────────────────────
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
let done = 0;
async function worker() {
while (next < items.length) {
const idx = next++;
try {
results[idx] = await fn(items[idx], idx);
} catch (err) {
results[idx] = { __error: err };
}
done++;
if (done % 5 === 0 || done === items.length) {
process.stdout.write(`\r translated ${done}/${items.length} `);
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
process.stdout.write("\n");
return results;
}
// ── Main per-locale ───────────────────────────────────────────────────
async function translateFile(locale) {
const localeName = LOCALE_NAMES[locale] || locale;
console.log(`\n🌐 zh-CN → ${locale} (${localeName})`);
const sourcePath = join(rootDir, "lib", "i18n", "locales", "zh-CN.ts");
let source = readFileSync(sourcePath, "utf-8");
const strings = findChineseStrings(source);
console.log(` Found ${strings.length} Chinese strings (concurrency=${concurrency})`);
const translated = await mapWithConcurrency(strings, concurrency, async (s, idx) => {
try {
const out = await translateText(s.content, localeName);
return { ok: true, value: out, idx: s };
} catch (err) {
console.error(`\n ⚠️ line ${s.line} failed: ${err.message.slice(0, 100)} — keeping source`);
return { ok: false, value: s.content, idx: s };
}
});
// Apply replacements back-to-front so indices stay valid.
for (let i = strings.length - 1; i >= 0; i--) {
const s = strings[i];
const newContent = translated[i].value;
if (newContent === s.content) continue;
const newFull = s.quote + newContent + s.quote;
source = source.slice(0, s.start) + newFull + source.slice(s.end);
}
source = transformForLocale(source, locale);
const outPath = join(rootDir, "lib", "i18n", "locales", `${locale}.ts`);
writeFileSync(outPath, source, "utf-8");
console.log(` ✅ Wrote ${outPath}`);
}
// ── Run ───────────────────────────────────────────────────────────────
console.log("🚀 InfiPlot i18n translation");
console.log(` Endpoint: ${baseUrl}`);
console.log(` Model: ${model}`);
console.log(` Targets: ${targets.join(", ")}`);
for (const locale of targets) {
if (!LOCALE_NAMES[locale]) {
console.error(`❌ Unknown locale: ${locale}`);
continue;
}
await translateFile(locale);
}
console.log("\n✨ Done. Review the generated files, then run `pnpm typecheck`.");
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* Translate STORIES_BASE card data (title, outline, style, tags) and write
* the translations into lib/i18n/locales/{en,ja}.ts as a `stories` section.
*
* Reads the same TEXT_* env vars as translate-firstacts.mjs.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const ENV_FILE = resolve(rootDir, ".env.local");
function loadEnv() {
if (!existsSync(ENV_FILE)) {
console.error("Missing .env.local");
process.exit(1);
}
const lines = readFileSync(ENV_FILE, "utf8").split("\n");
const env = {};
for (const line of lines) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]] = m[2].trim();
}
return env;
}
const env = loadEnv();
const BASE_URL = env.TEXT_BASE_URL;
const API_KEY = env.TEXT_API_KEY;
const MODEL = process.argv.find(a => a.startsWith("--model="))?.split("=")[1]
|| env.TRANSLATE_MODEL || "gemini-3.5-flash";
async function callLLM(system, user, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({
model: MODEL, temperature: 0.3,
messages: [{ role: "system", content: system }, { role: "user", content: user }],
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
const data = await res.json();
return data.choices?.[0]?.message?.content ?? "";
} catch (e) {
console.warn(` Attempt ${attempt + 1} failed: ${e.message}`);
if (attempt < retries - 1) await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
else throw e;
}
}
}
function parseResponse(raw) {
let cleaned = raw.trim();
if (cleaned.startsWith("```")) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
}
return JSON.parse(cleaned);
}
// Extract STORIES_BASE from page.tsx using a simple parser
function extractStories() {
const pagePath = join(rootDir, "app/[locale]/page.tsx");
const src = readFileSync(pagePath, "utf8");
// Find the STORIES_BASE definition and extract male/female arrays
const stories = { male: [], female: [] };
const genders = ["男性向", "女性向"];
for (const gender of genders) {
const key = gender === "男性向" ? "male" : "female";
// Find all story objects for this gender
const regex = /\{\s*"title":\s*"([^"]+)",\s*"outline":\s*"([^"]+)",\s*"style":\s*"([^"]+)",\s*"tags":\s*\[([\s\S]*?)\]\s*\}/g;
// Find the section start
const sectionStart = src.indexOf(`${gender}: [`);
if (sectionStart === -1) continue;
// Find the matching end bracket
let depth = 0;
let sectionEnd = sectionStart;
const startBracket = src.indexOf('[', sectionStart);
for (let i = startBracket; i < src.length; i++) {
if (src[i] === '[') depth++;
if (src[i] === ']') depth--;
if (depth === 0) { sectionEnd = i + 1; break; }
}
const section = src.slice(sectionStart, sectionEnd);
let match;
while ((match = regex.exec(section)) !== null) {
const tags = match[4].split(",").map(t => t.trim().replace(/^"|"$/g, "")).filter(Boolean);
stories[key].push({ title: match[1], outline: match[2], style: match[3], tags });
}
}
console.log(`Extracted ${stories.male.length} male + ${stories.female.length} female stories`);
return stories;
}
async function translateStories(stories, targetLocale, targetLang) {
// Flatten into a translatable map
const texts = {};
for (const [gender, items] of Object.entries(stories)) {
const prefix = gender === "male" ? "m" : "f";
for (let i = 0; i < items.length; i++) {
const s = items[i];
texts[`${prefix}${i}.title`] = s.title;
texts[`${prefix}${i}.outline`] = s.outline;
texts[`${prefix}${i}.style`] = s.style;
for (let j = 0; j < s.tags.length; j++) {
texts[`${prefix}${i}.tags[${j}]`] = s.tags[j];
}
}
}
const system = `You are a professional game translator. Translate the given Chinese text to ${targetLang}.
Rules:
- "title" fields are story titles — keep them evocative and concise (2-5 words).
- "outline" fields are story synopses — translate naturally, preserve dramatic tone.
- "style" fields are visual art style descriptions — translate descriptively, keep parenthetical English terms.
- "tags" fields are short genre/theme tags — use standard genre terminology in the target language.
- For "cardGender": 男性向→"Male-oriented"(en)/"男性向け"(ja), 女性向→"Female-oriented"(en)/"女性向け"(ja)
- Return ONLY a valid JSON object with the same keys. No explanation.`;
console.log(`Translating ${Object.keys(texts).length} story fields to ${targetLocale}...`);
// Split into batches of ~80 keys to stay within token limits
const keys = Object.keys(texts);
const batchSize = 80;
const allTranslated = {};
for (let i = 0; i < keys.length; i += batchSize) {
const batch = {};
for (const k of keys.slice(i, i + batchSize)) {
batch[k] = texts[k];
}
const user = `Translate to ${targetLang}:\n\n${JSON.stringify(batch, null, 2)}`;
const raw = await callLLM(system, user);
const result = parseResponse(raw);
Object.assign(allTranslated, result);
console.log(` Batch ${Math.floor(i / batchSize) + 1}: ${Object.keys(result).length} fields`);
if (i + batchSize < keys.length) await new Promise(r => setTimeout(r, 500));
}
return allTranslated;
}
function buildStoriesObject(translated, stories) {
const result = { male: [], female: [] };
for (const [gender, items] of Object.entries(stories)) {
const prefix = gender === "male" ? "m" : "f";
for (let i = 0; i < items.length; i++) {
const s = items[i];
const entry = {
title: translated[`${prefix}${i}.title`] || s.title,
outline: translated[`${prefix}${i}.outline`] || s.outline,
style: translated[`${prefix}${i}.style`] || s.style,
tags: s.tags.map((_, j) => translated[`${prefix}${i}.tags[${j}]`] || s.tags[j]),
};
result[gender].push(entry);
}
}
return result;
}
function injectIntoLocaleFile(localePath, storiesData) {
let content = readFileSync(localePath, "utf8");
// Build the stories object as a TS string
const lines = [` stories: {`];
for (const [gender, items] of Object.entries(storiesData)) {
lines.push(` ${gender}: [`);
for (const item of items) {
const tagsStr = item.tags.map(t => `"${t.replace(/"/g, '\\"')}"`).join(", ");
lines.push(` { title: "${item.title.replace(/"/g, '\\"')}", outline: "${item.outline.replace(/"/g, '\\"')}", style: "${item.style.replace(/"/g, '\\"')}", tags: [${tagsStr}] },`);
}
lines.push(` ],`);
}
lines.push(` genderLabels: { male: ${gender === "en" ? '"Male-oriented"' : '"男性向け"'}, female: ${gender === "en" ? '"Female-oriented"' : '"女性向け"'} },`);
lines.push(` },`);
// Find the closing of the main export and insert before it
// Look for the last `};` or `} as const;`
const insertPoint = content.lastIndexOf("};");
if (insertPoint === -1) {
console.error(`Could not find insertion point in ${localePath}`);
return;
}
content = content.slice(0, insertPoint) + lines.join("\n") + "\n" + content.slice(insertPoint);
writeFileSync(localePath, content);
console.log(`Injected stories into ${localePath}`);
}
async function main() {
const stories = extractStories();
if (stories.male.length === 0 && stories.female.length === 0) {
console.error("No stories extracted from page.tsx");
process.exit(1);
}
const locales = [
{ code: "en", lang: "English", file: "en.ts" },
{ code: "ja", lang: "Japanese (日本語)", file: "ja.ts" },
];
for (const { code, lang, file } of locales) {
const translated = await translateStories(stories, code, lang);
const storiesData = buildStoriesObject(translated, stories);
// Write as standalone JSON for reference
const outPath = join(rootDir, `lib/i18n/stories-${code}.json`);
writeFileSync(outPath, JSON.stringify(storiesData, null, 2));
console.log(`Wrote ${outPath}`);
}
console.log("\nDone! Stories JSON files written to lib/i18n/stories-{en,ja}.json");
console.log("These will be loaded dynamically in the page component.");
}
main().catch(e => { console.error(e); process.exit(1); });