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
+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); });