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:
@@ -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); });
|
||||
Reference in New Issue
Block a user