Files
infiplot-web/scripts/translate-firstacts.mjs
yuanzonghao 0a7076d5b9 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>
2026-06-18 23:16:17 +08:00

375 lines
15 KiB
JavaScript

#!/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);
});