@liberfi.io/i18n 0.1.231 → 0.1.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +1 -1
- package/dist/locale.csv +1066 -934
- package/dist/locales/de.json +937 -0
- package/dist/locales/en.json +2 -2
- package/dist/locales/es.json +937 -0
- package/dist/locales/fr.json +937 -0
- package/dist/locales/it.json +937 -0
- package/dist/locales/ja.json +937 -0
- package/dist/locales/ko.json +937 -0
- package/dist/locales/pt.json +937 -0
- package/dist/locales/ru.json +937 -0
- package/dist/locales/th.json +937 -0
- package/dist/locales/vi.json +937 -0
- package/dist/locales/zh-Hans.json +937 -0
- package/dist/locales/zh-Hant.json +937 -0
- package/dist/server.d.mts +18 -3
- package/dist/server.d.ts +18 -3
- package/dist/server.js +3 -3
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +3 -3
- package/dist/server.mjs.map +1 -1
- package/package.json +2 -2
- package/script/translateLocales.mjs +239 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// One-shot locale translation script for @liberfi.io/i18n.
|
|
2
|
+
//
|
|
3
|
+
// Translates the canonical English bundle (locales/en.json) into the target
|
|
4
|
+
// languages using an OpenAI-compatible chat endpoint (default: arouter.ai /
|
|
5
|
+
// gpt-4o, matching the backend i18n pipeline choice in the worldcup i18n plan).
|
|
6
|
+
//
|
|
7
|
+
// Design notes:
|
|
8
|
+
// - The API key is read from env (I18N_LLM_API_KEY / OPENAI_API_KEY); it is
|
|
9
|
+
// NEVER hardcoded. The key is injected at run time, same as the backend.
|
|
10
|
+
// - Resumable: an existing locales/<lang>.json is loaded and only MISSING keys
|
|
11
|
+
// are translated, so re-runs are cheap and partial progress is preserved.
|
|
12
|
+
// - Batched: strings are translated in chunks (JSON array in -> array out).
|
|
13
|
+
// On a length mismatch the batch is split in half and retried, so a single
|
|
14
|
+
// bad response never corrupts the whole file.
|
|
15
|
+
// - Placeholders ({{x}}, {x}, <...>, %s, \n, numbers) must be preserved; the
|
|
16
|
+
// prompt enforces this and we keep the English key order.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// I18N_LLM_API_KEY=... node script/translateLocales.mjs
|
|
20
|
+
// I18N_LLM_API_KEY=... LANGS=ja,ko node script/translateLocales.mjs
|
|
21
|
+
// I18N_LLM_API_KEY=... PREFIXES=predict,scaffold,common node script/translateLocales.mjs
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const localesDir = join(__dirname, "..", "locales");
|
|
29
|
+
|
|
30
|
+
const API_KEY = process.env.I18N_LLM_API_KEY || process.env.OPENAI_API_KEY;
|
|
31
|
+
const BASE_URL = process.env.I18N_LLM_BASE_URL || "https://api.arouter.ai/v1";
|
|
32
|
+
const MODEL = process.env.I18N_LLM_MODEL || "gpt-4o";
|
|
33
|
+
const BATCH_SIZE = Number(process.env.BATCH_SIZE || 30);
|
|
34
|
+
const CONCURRENCY = Number(process.env.CONCURRENCY || 4);
|
|
35
|
+
const MAX_RETRIES = Number(process.env.MAX_RETRIES || 4);
|
|
36
|
+
|
|
37
|
+
// Target languages (product set minus en; zh-Hans is a copy of zh.json and is
|
|
38
|
+
// not regenerated here). Display names guide the model's dialect/script.
|
|
39
|
+
const DEFAULT_LANGS = {
|
|
40
|
+
"zh-Hant": "Traditional Chinese (Taiwan/Hong Kong conventions, 繁體中文)",
|
|
41
|
+
ja: "Japanese",
|
|
42
|
+
ko: "Korean",
|
|
43
|
+
th: "Thai",
|
|
44
|
+
vi: "Vietnamese",
|
|
45
|
+
fr: "French",
|
|
46
|
+
de: "German",
|
|
47
|
+
it: "Italian",
|
|
48
|
+
es: "Spanish",
|
|
49
|
+
pt: "Portuguese (Brazil)",
|
|
50
|
+
ru: "Russian",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!API_KEY) {
|
|
54
|
+
console.error(
|
|
55
|
+
"[translateLocales] Missing I18N_LLM_API_KEY (or OPENAI_API_KEY). Aborting.",
|
|
56
|
+
);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const langFilter = (process.env.LANGS || "")
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
const prefixFilter = (process.env.PREFIXES || "")
|
|
65
|
+
.split(",")
|
|
66
|
+
.map((s) => s.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
|
|
69
|
+
const langs = langFilter.length
|
|
70
|
+
? Object.fromEntries(
|
|
71
|
+
Object.entries(DEFAULT_LANGS).filter(([code]) =>
|
|
72
|
+
langFilter.includes(code),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
: DEFAULT_LANGS;
|
|
76
|
+
|
|
77
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
|
|
79
|
+
function loadJson(path) {
|
|
80
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stripFences(text) {
|
|
84
|
+
let t = text.trim();
|
|
85
|
+
if (t.startsWith("```")) {
|
|
86
|
+
t = t.replace(/^```[a-zA-Z]*\n?/, "").replace(/```$/, "").trim();
|
|
87
|
+
}
|
|
88
|
+
return t;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function chat(messages) {
|
|
92
|
+
const res = await fetch(`${BASE_URL}/chat/completions`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ model: MODEL, temperature: 0, messages }),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const body = await res.text().catch(() => "");
|
|
102
|
+
throw new Error(`HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
103
|
+
}
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildPrompt(languageName, values) {
|
|
109
|
+
const system = [
|
|
110
|
+
"You are a professional UI string localizer for a crypto trading and",
|
|
111
|
+
"prediction-market web app. Translate from English to",
|
|
112
|
+
`${languageName}.`,
|
|
113
|
+
"Rules:",
|
|
114
|
+
"- Return ONLY a JSON array of strings, same length and order as the input.",
|
|
115
|
+
"- Do not add, drop, merge, reorder, or explain anything.",
|
|
116
|
+
"- Preserve ALL placeholders verbatim: {{var}}, {var}, %s, %d, <tag>...</tag>,",
|
|
117
|
+
" HTML entities, line breaks (\\n), URLs, and leading/trailing whitespace.",
|
|
118
|
+
"- Do not translate brand names, ticker symbols, or numbers.",
|
|
119
|
+
"- Keep the tone concise and idiomatic for app UI labels.",
|
|
120
|
+
].join(" ");
|
|
121
|
+
const user = JSON.stringify(values);
|
|
122
|
+
return [
|
|
123
|
+
{ role: "system", content: system },
|
|
124
|
+
{ role: "user", content: user },
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function translateBatch(languageName, values, depth = 0) {
|
|
129
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
130
|
+
try {
|
|
131
|
+
const content = await chat(buildPrompt(languageName, values));
|
|
132
|
+
const parsed = JSON.parse(stripFences(content));
|
|
133
|
+
if (Array.isArray(parsed) && parsed.length === values.length) {
|
|
134
|
+
return parsed.map((v, i) => (typeof v === "string" ? v : values[i]));
|
|
135
|
+
}
|
|
136
|
+
throw new Error(
|
|
137
|
+
`length mismatch: got ${Array.isArray(parsed) ? parsed.length : "non-array"}, want ${values.length}`,
|
|
138
|
+
);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const backoff = 500 * Math.pow(2, attempt);
|
|
141
|
+
console.warn(
|
|
142
|
+
`[translateLocales] retry ${attempt + 1}/${MAX_RETRIES} (${languageName}, n=${values.length}): ${err.message}`,
|
|
143
|
+
);
|
|
144
|
+
await sleep(backoff);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Split-and-retry to isolate the bad chunk; single items fall back to English.
|
|
148
|
+
if (values.length > 1 && depth < 6) {
|
|
149
|
+
const mid = Math.floor(values.length / 2);
|
|
150
|
+
const left = await translateBatch(languageName, values.slice(0, mid), depth + 1);
|
|
151
|
+
const right = await translateBatch(languageName, values.slice(mid), depth + 1);
|
|
152
|
+
return [...left, ...right];
|
|
153
|
+
}
|
|
154
|
+
console.error(
|
|
155
|
+
`[translateLocales] giving up on ${values.length} item(s) for ${languageName}; keeping English.`,
|
|
156
|
+
);
|
|
157
|
+
return values;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runPool(tasks, concurrency) {
|
|
161
|
+
const results = new Array(tasks.length);
|
|
162
|
+
let next = 0;
|
|
163
|
+
async function worker() {
|
|
164
|
+
while (next < tasks.length) {
|
|
165
|
+
const i = next++;
|
|
166
|
+
results[i] = await tasks[i]();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await Promise.all(
|
|
170
|
+
Array.from({ length: Math.min(concurrency, tasks.length) }, worker),
|
|
171
|
+
);
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function translateLang(code, languageName, en) {
|
|
176
|
+
const outPath = join(localesDir, `${code}.json`);
|
|
177
|
+
const existing = existsSync(outPath) ? loadJson(outPath) : {};
|
|
178
|
+
|
|
179
|
+
let keys = Object.keys(en);
|
|
180
|
+
if (prefixFilter.length) {
|
|
181
|
+
keys = keys.filter((k) => prefixFilter.some((p) => k.startsWith(p)));
|
|
182
|
+
}
|
|
183
|
+
const missing = keys.filter(
|
|
184
|
+
(k) => typeof existing[k] !== "string" || existing[k] === "",
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
console.log(
|
|
188
|
+
`[translateLocales] ${code} (${languageName}): ${missing.length} missing / ${keys.length} keys`,
|
|
189
|
+
);
|
|
190
|
+
if (!missing.length) {
|
|
191
|
+
return { code, translated: 0, total: keys.length };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const batches = [];
|
|
195
|
+
for (let i = 0; i < missing.length; i += BATCH_SIZE) {
|
|
196
|
+
batches.push(missing.slice(i, i + BATCH_SIZE));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let done = 0;
|
|
200
|
+
const tasks = batches.map((batchKeys) => async () => {
|
|
201
|
+
const values = batchKeys.map((k) => en[k]);
|
|
202
|
+
const out = await translateBatch(languageName, values);
|
|
203
|
+
batchKeys.forEach((k, i) => {
|
|
204
|
+
existing[k] = out[i];
|
|
205
|
+
});
|
|
206
|
+
done += batchKeys.length;
|
|
207
|
+
process.stdout.write(
|
|
208
|
+
`\r[translateLocales] ${code}: ${done}/${missing.length} `,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await runPool(tasks, CONCURRENCY);
|
|
213
|
+
process.stdout.write("\n");
|
|
214
|
+
|
|
215
|
+
// Write keys in the English file's order for stable diffs.
|
|
216
|
+
const ordered = {};
|
|
217
|
+
for (const k of Object.keys(en)) {
|
|
218
|
+
if (typeof existing[k] === "string") ordered[k] = existing[k];
|
|
219
|
+
}
|
|
220
|
+
writeFileSync(outPath, JSON.stringify(ordered, null, 2) + "\n", "utf8");
|
|
221
|
+
console.log(`[translateLocales] wrote ${outPath}`);
|
|
222
|
+
return { code, translated: missing.length, total: keys.length };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function main() {
|
|
226
|
+
const en = loadJson(join(localesDir, "en.json"));
|
|
227
|
+
console.log(
|
|
228
|
+
`[translateLocales] model=${MODEL} base=${BASE_URL} batch=${BATCH_SIZE} concurrency=${CONCURRENCY}`,
|
|
229
|
+
);
|
|
230
|
+
for (const [code, name] of Object.entries(langs)) {
|
|
231
|
+
await translateLang(code, name, en);
|
|
232
|
+
}
|
|
233
|
+
console.log("[translateLocales] done.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().catch((err) => {
|
|
237
|
+
console.error("[translateLocales] fatal:", err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|