255 lines
8.6 KiB
JavaScript
255 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Auto-translate the React app's i18n bundles. Reads
|
|
* `src/i18n/locales/en/<namespace>.json` and writes the equivalent
|
|
* `src/i18n/locales/<locale>/<namespace>.json` for every non-default
|
|
* locale, calling DeepSeek (primary) with a Gemini fallback.
|
|
*
|
|
* One LLM call per (locale, namespace) pair. The whole bundle is sent
|
|
* in a single request — works as long as a single namespace stays
|
|
* roughly under a few thousand tokens. If a namespace grows past that,
|
|
* split it before the request balloons.
|
|
*
|
|
* Usage:
|
|
* DEEPSEEK_API_KEY=sk-... node scripts/translate-i18n.mjs
|
|
* DEEPSEEK_API_KEY=sk-... node scripts/translate-i18n.mjs --locale es
|
|
* DEEPSEEK_API_KEY=sk-... node scripts/translate-i18n.mjs --force
|
|
*
|
|
* Flags:
|
|
* --locale <code> Process only one target locale. Otherwise all 8.
|
|
* --namespace <ns> Process only one namespace. Otherwise auto-detect.
|
|
* --force Overwrite existing target files. Default skips them.
|
|
*/
|
|
|
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = resolve(fileURLToPath(import.meta.url), "../..");
|
|
const LOCALES_DIR = resolve(ROOT, "src/i18n/locales");
|
|
const SOURCE_LOCALE = "en";
|
|
const TARGET_LOCALES = [
|
|
"es",
|
|
"pt-br",
|
|
"it",
|
|
"fr",
|
|
"zh-cn",
|
|
"de",
|
|
"ja",
|
|
"ru",
|
|
];
|
|
const LOCALE_NAMES = {
|
|
en: "English",
|
|
es: "Spanish (Spain)",
|
|
"pt-br": "Brazilian Portuguese",
|
|
it: "Italian",
|
|
fr: "French",
|
|
"zh-cn": "Simplified Chinese",
|
|
de: "German",
|
|
ja: "Japanese",
|
|
ru: "Russian",
|
|
};
|
|
|
|
// ── Provider helpers ────────────────────────────────────────────────────
|
|
async function callDeepSeek(prompt) {
|
|
const apiKey = process.env.DEEPSEEK_API_KEY;
|
|
if (!apiKey) throw new Error("DEEPSEEK_API_KEY not set");
|
|
const res = await fetch(
|
|
(process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com") +
|
|
"/chat/completions",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.DEEPSEEK_MODEL || "deepseek-chat",
|
|
temperature: 0,
|
|
messages: [{ role: "user", content: prompt }],
|
|
stream: false,
|
|
max_tokens: 8192,
|
|
response_format: { type: "json_object" },
|
|
}),
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error(`DeepSeek HTTP ${res.status}: ${await res.text()}`);
|
|
}
|
|
const data = await res.json();
|
|
const content = data?.choices?.[0]?.message?.content;
|
|
if (typeof content !== "string" || content.length === 0) {
|
|
throw new Error("DeepSeek returned empty content");
|
|
}
|
|
return content;
|
|
}
|
|
|
|
async function callGemini(prompt) {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
if (!apiKey) throw new Error("GEMINI_API_KEY not set");
|
|
const { GoogleGenAI } = await import("@google/genai");
|
|
const ai = new GoogleGenAI({ apiKey });
|
|
const res = await ai.models.generateContent({
|
|
model: process.env.GEMINI_MODEL || "gemini-3-flash-preview",
|
|
contents: prompt,
|
|
});
|
|
const content = res?.text;
|
|
if (typeof content !== "string" || content.length === 0) {
|
|
throw new Error("Gemini returned empty content");
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// ── Prompt + parsing ────────────────────────────────────────────────────
|
|
function buildPrompt(targetLocale, sourceJson) {
|
|
const targetName = LOCALE_NAMES[targetLocale] ?? targetLocale;
|
|
return `You are a professional UI translator working on a software product.
|
|
|
|
Translate the JSON file below from English to ${targetName}.
|
|
|
|
CRITICAL RULES — follow ALL of them:
|
|
1. Preserve the JSON structure exactly: same keys, same nesting, same array order.
|
|
2. Translate ONLY the string VALUES, never the keys.
|
|
3. Brand names and proper nouns stay UNCHANGED in the original case:
|
|
"Velxio", "Arduino", "ESP32", "ESP32-S3", "ESP32-C3", "RP2040", "ATtiny85",
|
|
"Raspberry Pi", "Raspberry Pi Pico", "GitHub", "Discord", "AGPLv3", "MIT",
|
|
"SPICE", "Monaco", "wokwi", "wokwi-elements", "avr8js".
|
|
4. Acronyms / standards stay in their original form: "USART", "GPIO", "I2C",
|
|
"SPI", "BLE", "WiFi", "PWM", "DAC", "ADC".
|
|
5. Do not translate technical product nouns that act as proper names in
|
|
context (e.g. "Documentation" → translate; but "Editor" used as a
|
|
product page name may stay literal in some languages — use the natural
|
|
localised noun for "code editor").
|
|
6. Keep lengths reasonable: UI labels are short, don't pad them.
|
|
7. Output ONLY the translated JSON. No markdown fence, no commentary,
|
|
no leading/trailing whitespace beyond the JSON body itself.
|
|
|
|
Source JSON (English):
|
|
${JSON.stringify(sourceJson, null, 2)}`;
|
|
}
|
|
|
|
function extractJson(raw) {
|
|
let t = raw.trim();
|
|
// Strip code fence if present.
|
|
const fence = /^```(?:json)?\r?\n([\s\S]*?)\r?\n```$/;
|
|
const m = fence.exec(t);
|
|
if (m) t = m[1].trim();
|
|
return JSON.parse(t);
|
|
}
|
|
|
|
function sameShape(a, b) {
|
|
if (typeof a !== typeof b) return false;
|
|
if (a === null || b === null) return a === b;
|
|
if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length;
|
|
if (typeof a === "object") {
|
|
const aKeys = Object.keys(a).sort();
|
|
const bKeys = Object.keys(b).sort();
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
for (let i = 0; i < aKeys.length; i++) {
|
|
if (aKeys[i] !== bKeys[i]) return false;
|
|
if (!sameShape(a[aKeys[i]], b[bKeys[i]])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
return true; // primitives — values differ but type matches
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
function parseArgs(argv) {
|
|
const out = { locale: null, namespace: null, force: false };
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--locale") out.locale = argv[++i];
|
|
else if (a === "--namespace") out.namespace = argv[++i];
|
|
else if (a === "--force") out.force = true;
|
|
else throw new Error(`Unknown arg: ${a}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function listNamespaces() {
|
|
const sourceDir = resolve(LOCALES_DIR, SOURCE_LOCALE);
|
|
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
return entries
|
|
.filter(e => e.isFile() && e.name.endsWith(".json"))
|
|
.map(e => e.name.replace(/\.json$/, ""));
|
|
}
|
|
|
|
async function translateOne(targetLocale, namespace, sourceJson, opts) {
|
|
const targetPath = resolve(
|
|
LOCALES_DIR,
|
|
targetLocale,
|
|
`${namespace}.json`
|
|
);
|
|
if (!opts.force) {
|
|
try {
|
|
await readFile(targetPath, "utf8");
|
|
console.log(` - ${targetLocale}/${namespace}.json (exists, skip)`);
|
|
return;
|
|
} catch {
|
|
/* not there yet, proceed */
|
|
}
|
|
}
|
|
|
|
const prompt = buildPrompt(targetLocale, sourceJson);
|
|
|
|
let translated;
|
|
let provider;
|
|
try {
|
|
const raw = await callDeepSeek(prompt);
|
|
translated = extractJson(raw);
|
|
provider = "deepseek";
|
|
} catch (err) {
|
|
console.warn(` ! deepseek failed for ${targetLocale}: ${err.message}`);
|
|
if (!process.env.GEMINI_API_KEY) throw err;
|
|
const raw = await callGemini(prompt);
|
|
translated = extractJson(raw);
|
|
provider = "gemini";
|
|
}
|
|
|
|
if (!sameShape(sourceJson, translated)) {
|
|
throw new Error(
|
|
`${targetLocale}/${namespace}: shape mismatch — model added/removed keys`
|
|
);
|
|
}
|
|
|
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
await writeFile(
|
|
targetPath,
|
|
JSON.stringify(translated, null, 2) + "\n",
|
|
"utf8"
|
|
);
|
|
console.log(` v ${targetLocale}/${namespace}.json (via ${provider})`);
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv);
|
|
const targetLocales = opts.locale ? [opts.locale] : TARGET_LOCALES;
|
|
const namespaces = opts.namespace ? [opts.namespace] : await listNamespaces();
|
|
|
|
if (!process.env.DEEPSEEK_API_KEY && !process.env.GEMINI_API_KEY) {
|
|
console.error(
|
|
"Set DEEPSEEK_API_KEY (and optionally GEMINI_API_KEY) before running."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const ns of namespaces) {
|
|
const sourcePath = resolve(LOCALES_DIR, SOURCE_LOCALE, `${ns}.json`);
|
|
const sourceJson = JSON.parse(await readFile(sourcePath, "utf8"));
|
|
console.log(`namespace: ${ns}`);
|
|
for (const target of targetLocales) {
|
|
try {
|
|
await translateOne(target, ns, sourceJson, opts);
|
|
} catch (err) {
|
|
console.error(` x ${target}/${ns}: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|