react-autolocale 0.1.0 → 0.2.0
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/README.md +106 -5
- package/dist/cli.js +32 -5
- package/dist/index.d.ts +59 -0
- package/dist/index.js +149 -20
- package/dist/overrides-ZQ6meLck.js +557 -0
- package/dist/{redirect-CKNY4ru5.js → suggestion-UZQ8ngd0.js} +29 -3
- package/dist/vite.d.ts +30 -2
- package/dist/vite.js +165 -469
- package/package.json +5 -3
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
const byCode = new Map([
|
|
8
|
+
{
|
|
9
|
+
code: "en",
|
|
10
|
+
native: "English",
|
|
11
|
+
english: "English",
|
|
12
|
+
nllb: "eng_Latn"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
code: "fr",
|
|
16
|
+
native: "Français",
|
|
17
|
+
english: "French",
|
|
18
|
+
nllb: "fra_Latn",
|
|
19
|
+
opus: "fr"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
code: "es",
|
|
23
|
+
native: "Español",
|
|
24
|
+
english: "Spanish",
|
|
25
|
+
nllb: "spa_Latn",
|
|
26
|
+
opus: "es"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
code: "de",
|
|
30
|
+
native: "Deutsch",
|
|
31
|
+
english: "German",
|
|
32
|
+
nllb: "deu_Latn",
|
|
33
|
+
opus: "de"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
code: "it",
|
|
37
|
+
native: "Italiano",
|
|
38
|
+
english: "Italian",
|
|
39
|
+
nllb: "ita_Latn",
|
|
40
|
+
opus: "it"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
code: "pt",
|
|
44
|
+
native: "Português",
|
|
45
|
+
english: "Portuguese",
|
|
46
|
+
nllb: "por_Latn"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: "nl",
|
|
50
|
+
native: "Nederlands",
|
|
51
|
+
english: "Dutch",
|
|
52
|
+
nllb: "nld_Latn",
|
|
53
|
+
opus: "nl"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
code: "ru",
|
|
57
|
+
native: "Русский",
|
|
58
|
+
english: "Russian",
|
|
59
|
+
nllb: "rus_Cyrl",
|
|
60
|
+
opus: "ru"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: "zh",
|
|
64
|
+
native: "中文",
|
|
65
|
+
english: "Chinese (Simplified)",
|
|
66
|
+
nllb: "zho_Hans",
|
|
67
|
+
opus: "zh"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
code: "ja",
|
|
71
|
+
native: "日本語",
|
|
72
|
+
english: "Japanese",
|
|
73
|
+
nllb: "jpn_Jpan"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
code: "ko",
|
|
77
|
+
native: "한국어",
|
|
78
|
+
english: "Korean",
|
|
79
|
+
nllb: "kor_Hang"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
code: "ar",
|
|
83
|
+
native: "العربية",
|
|
84
|
+
english: "Arabic",
|
|
85
|
+
nllb: "arb_Arab",
|
|
86
|
+
opus: "ar",
|
|
87
|
+
rtl: true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
code: "hi",
|
|
91
|
+
native: "हिन्दी",
|
|
92
|
+
english: "Hindi",
|
|
93
|
+
nllb: "hin_Deva",
|
|
94
|
+
opus: "hi"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
code: "tr",
|
|
98
|
+
native: "Türkçe",
|
|
99
|
+
english: "Turkish",
|
|
100
|
+
nllb: "tur_Latn"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
code: "pl",
|
|
104
|
+
native: "Polski",
|
|
105
|
+
english: "Polish",
|
|
106
|
+
nllb: "pol_Latn"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
code: "sv",
|
|
110
|
+
native: "Svenska",
|
|
111
|
+
english: "Swedish",
|
|
112
|
+
nllb: "swe_Latn",
|
|
113
|
+
opus: "sv"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
code: "da",
|
|
117
|
+
native: "Dansk",
|
|
118
|
+
english: "Danish",
|
|
119
|
+
nllb: "dan_Latn",
|
|
120
|
+
opus: "da"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
code: "no",
|
|
124
|
+
native: "Norsk",
|
|
125
|
+
english: "Norwegian",
|
|
126
|
+
nllb: "nob_Latn"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
code: "fi",
|
|
130
|
+
native: "Suomi",
|
|
131
|
+
english: "Finnish",
|
|
132
|
+
nllb: "fin_Latn",
|
|
133
|
+
opus: "fi"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
code: "cs",
|
|
137
|
+
native: "Čeština",
|
|
138
|
+
english: "Czech",
|
|
139
|
+
nllb: "ces_Latn",
|
|
140
|
+
opus: "cs"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
code: "hu",
|
|
144
|
+
native: "Magyar",
|
|
145
|
+
english: "Hungarian",
|
|
146
|
+
nllb: "hun_Latn",
|
|
147
|
+
opus: "hu"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
code: "ro",
|
|
151
|
+
native: "Română",
|
|
152
|
+
english: "Romanian",
|
|
153
|
+
nllb: "ron_Latn",
|
|
154
|
+
opus: "ro"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
code: "uk",
|
|
158
|
+
native: "Українська",
|
|
159
|
+
english: "Ukrainian",
|
|
160
|
+
nllb: "ukr_Cyrl",
|
|
161
|
+
opus: "uk"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
code: "el",
|
|
165
|
+
native: "Ελληνικά",
|
|
166
|
+
english: "Greek",
|
|
167
|
+
nllb: "ell_Grek"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
code: "he",
|
|
171
|
+
native: "עברית",
|
|
172
|
+
english: "Hebrew",
|
|
173
|
+
nllb: "heb_Hebr",
|
|
174
|
+
rtl: true
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
code: "vi",
|
|
178
|
+
native: "Tiếng Việt",
|
|
179
|
+
english: "Vietnamese",
|
|
180
|
+
nllb: "vie_Latn",
|
|
181
|
+
opus: "vi"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
code: "id",
|
|
185
|
+
native: "Bahasa Indonesia",
|
|
186
|
+
english: "Indonesian",
|
|
187
|
+
nllb: "ind_Latn",
|
|
188
|
+
opus: "id"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
code: "th",
|
|
192
|
+
native: "ไทย",
|
|
193
|
+
english: "Thai",
|
|
194
|
+
nllb: "tha_Thai"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
code: "bn",
|
|
198
|
+
native: "বাংলা",
|
|
199
|
+
english: "Bengali",
|
|
200
|
+
nllb: "ben_Beng"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
code: "fa",
|
|
204
|
+
native: "فارسی",
|
|
205
|
+
english: "Persian",
|
|
206
|
+
nllb: "pes_Arab",
|
|
207
|
+
rtl: true
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
code: "ur",
|
|
211
|
+
native: "اردو",
|
|
212
|
+
english: "Urdu",
|
|
213
|
+
nllb: "urd_Arab",
|
|
214
|
+
rtl: true
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
code: "bg",
|
|
218
|
+
native: "Български",
|
|
219
|
+
english: "Bulgarian",
|
|
220
|
+
nllb: "bul_Cyrl"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
code: "hr",
|
|
224
|
+
native: "Hrvatski",
|
|
225
|
+
english: "Croatian",
|
|
226
|
+
nllb: "hrv_Latn"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
code: "sk",
|
|
230
|
+
native: "Slovenčina",
|
|
231
|
+
english: "Slovak",
|
|
232
|
+
nllb: "slk_Latn"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
code: "ca",
|
|
236
|
+
native: "Català",
|
|
237
|
+
english: "Catalan",
|
|
238
|
+
nllb: "cat_Latn"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
code: "ms",
|
|
242
|
+
native: "Bahasa Melayu",
|
|
243
|
+
english: "Malay",
|
|
244
|
+
nllb: "zsm_Latn"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
code: "sw",
|
|
248
|
+
native: "Kiswahili",
|
|
249
|
+
english: "Swahili",
|
|
250
|
+
nllb: "swh_Latn"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
code: "tl",
|
|
254
|
+
native: "Tagalog",
|
|
255
|
+
english: "Tagalog",
|
|
256
|
+
nllb: "tgl_Latn"
|
|
257
|
+
}
|
|
258
|
+
].map((l) => [l.code, l]));
|
|
259
|
+
function getLanguage(code) {
|
|
260
|
+
return byCode.get(code);
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/core/translate.ts
|
|
264
|
+
function toTranslator(engine) {
|
|
265
|
+
if (typeof engine !== "function") return engine;
|
|
266
|
+
return { translate: (texts, from, to, contexts = []) => engine({
|
|
267
|
+
texts,
|
|
268
|
+
contexts: texts.map((_, i) => contexts[i] ?? ""),
|
|
269
|
+
from,
|
|
270
|
+
to
|
|
271
|
+
}) };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Uses a local LLM through Ollama (free, runs on your machine). Unlike the default models it is told
|
|
275
|
+
* where each string appears, which fixes most one-word mistakes ("Home" in a nav bar is not "Sommaire").
|
|
276
|
+
*/
|
|
277
|
+
function ollama(options = { model: "llama3.1" }) {
|
|
278
|
+
const url = (options.url ?? "http://localhost:11434").replace(/\/+$/, "");
|
|
279
|
+
return { async translate(texts, from, to, contexts = []) {
|
|
280
|
+
const out = [];
|
|
281
|
+
for (let i = 0; i < texts.length; i++) {
|
|
282
|
+
const where = contexts[i] ? ` It appears in a website ${contexts[i]} element.` : "";
|
|
283
|
+
const res = await fetch(`${url}/api/chat`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "content-type": "application/json" },
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
model: options.model,
|
|
288
|
+
stream: false,
|
|
289
|
+
options: { temperature: 0 },
|
|
290
|
+
messages: [{
|
|
291
|
+
role: "system",
|
|
292
|
+
content: `You translate website interface text from ${from} to ${to}.${where} Keep tokens like X0X, X1X and {name} exactly as written. Reply with only the translation.`
|
|
293
|
+
}, {
|
|
294
|
+
role: "user",
|
|
295
|
+
content: texts[i]
|
|
296
|
+
}]
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) throw new Error(`Ollama request failed (${res.status}). Is it running at ${url}?`);
|
|
300
|
+
const body = await res.json();
|
|
301
|
+
out.push((body.message?.content ?? "").trim());
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
} };
|
|
305
|
+
}
|
|
306
|
+
const ENGINE_PACKAGE = "@huggingface/transformers@^4.3.1";
|
|
307
|
+
const ENGINE_DIR = path.join(os.homedir(), ".cache", "react-autolocale", "engine");
|
|
308
|
+
/**
|
|
309
|
+
* Finds the translation engine. Uses the project's own copy if it has one; otherwise installs it once
|
|
310
|
+
* into ~/.cache/react-autolocale/engine (shared by all projects, never touches the project's node_modules).
|
|
311
|
+
*/
|
|
312
|
+
async function loadEngine(log) {
|
|
313
|
+
try {
|
|
314
|
+
return await import("@huggingface/transformers");
|
|
315
|
+
} catch {}
|
|
316
|
+
const require = createRequire(path.join(ENGINE_DIR, "package.json"));
|
|
317
|
+
const find = () => require.resolve("@huggingface/transformers");
|
|
318
|
+
let entry;
|
|
319
|
+
try {
|
|
320
|
+
entry = find();
|
|
321
|
+
} catch {
|
|
322
|
+
log("[react-autolocale] Installing the translation engine (one time, about 440 MB)...");
|
|
323
|
+
fs.mkdirSync(ENGINE_DIR, { recursive: true });
|
|
324
|
+
const pkg = path.join(ENGINE_DIR, "package.json");
|
|
325
|
+
if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, JSON.stringify({
|
|
326
|
+
name: "react-autolocale-engine",
|
|
327
|
+
private: true
|
|
328
|
+
}));
|
|
329
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
330
|
+
const res = spawnSync(npm, [
|
|
331
|
+
"install",
|
|
332
|
+
"--prefix",
|
|
333
|
+
ENGINE_DIR,
|
|
334
|
+
"--no-audit",
|
|
335
|
+
"--no-fund",
|
|
336
|
+
"--loglevel=error",
|
|
337
|
+
ENGINE_PACKAGE
|
|
338
|
+
], {
|
|
339
|
+
stdio: "inherit",
|
|
340
|
+
shell: process.platform === "win32"
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
if (res.status !== 0) throw new Error("npm failed");
|
|
344
|
+
entry = find();
|
|
345
|
+
} catch {
|
|
346
|
+
throw new Error(`Could not install the translation engine automatically. Install it yourself: npm install -D @huggingface/transformers`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
350
|
+
return mod.pipeline ? mod : mod.default;
|
|
351
|
+
}
|
|
352
|
+
const NLLB_MODEL = "Xenova/nllb-200-distilled-600M";
|
|
353
|
+
const BATCH = 1;
|
|
354
|
+
/**
|
|
355
|
+
* Free, offline translation using Transformers.js (ONNX models run on the CPU).
|
|
356
|
+
* Models are downloaded once from Hugging Face and cached in ~/.cache/react-autolocale.
|
|
357
|
+
* English -> X uses small per-language OPUS-MT models where they exist; everything else uses NLLB-200.
|
|
358
|
+
*/
|
|
359
|
+
function createLocalTranslator(log = () => {}) {
|
|
360
|
+
const pipes = /* @__PURE__ */ new Map();
|
|
361
|
+
let hf = null;
|
|
362
|
+
const getPipe = (model) => {
|
|
363
|
+
let p = pipes.get(model);
|
|
364
|
+
if (!p) {
|
|
365
|
+
hf ??= loadEngine(log).then((m) => {
|
|
366
|
+
m.env.cacheDir = path.join(os.homedir(), ".cache", "react-autolocale", "models");
|
|
367
|
+
return m;
|
|
368
|
+
});
|
|
369
|
+
log(`Loading model ${model} (first run downloads it)...`);
|
|
370
|
+
let lastPct = -10;
|
|
371
|
+
p = hf.then((m) => m.pipeline("translation", model, {
|
|
372
|
+
dtype: "q8",
|
|
373
|
+
progress_callback: (e) => {
|
|
374
|
+
if (e.status === "progress" && e.progress - lastPct >= 10) {
|
|
375
|
+
lastPct = e.progress;
|
|
376
|
+
log(` downloading ${e.file}: ${Math.round(e.progress)}%`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}));
|
|
380
|
+
pipes.set(model, p);
|
|
381
|
+
}
|
|
382
|
+
return p;
|
|
383
|
+
};
|
|
384
|
+
const run = async (model, texts, options) => {
|
|
385
|
+
const pipe = await getPipe(model);
|
|
386
|
+
const out = [];
|
|
387
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
388
|
+
const res = await pipe(texts.slice(i, i + BATCH), {
|
|
389
|
+
max_new_tokens: 256,
|
|
390
|
+
...options
|
|
391
|
+
});
|
|
392
|
+
out.push(...res.map((r) => r.translation_text.trim()));
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
};
|
|
396
|
+
return { async translate(texts, from, to) {
|
|
397
|
+
const src = getLanguage(from);
|
|
398
|
+
const dst = getLanguage(to);
|
|
399
|
+
if (!src || !dst) throw new Error(`Unsupported language: ${!src ? from : to}`);
|
|
400
|
+
if (from === "en" && dst.opus) try {
|
|
401
|
+
return await run(`Xenova/opus-mt-en-${dst.opus}`, texts, {});
|
|
402
|
+
} catch (err) {
|
|
403
|
+
log(` OPUS-MT model for "${to}" unavailable (${err.message}); falling back to NLLB.`);
|
|
404
|
+
}
|
|
405
|
+
return run(NLLB_MODEL, texts, {
|
|
406
|
+
src_lang: src.nllb,
|
|
407
|
+
tgt_lang: dst.nllb
|
|
408
|
+
});
|
|
409
|
+
} };
|
|
410
|
+
}
|
|
411
|
+
const token = (i) => `X${i}X`;
|
|
412
|
+
/** Prices, phone numbers, versions, hours, percentages: kept exactly as written. */
|
|
413
|
+
const NUMBER = /[$€£¥]?\d+(?:[.,:\-/]\d+)*(?:[a-zA-Z]{1,2}\b)?%?/g;
|
|
414
|
+
/** Runtime values like {name} must survive translation untouched. */
|
|
415
|
+
const PLACEHOLDER = /\{\w+\}/g;
|
|
416
|
+
/**
|
|
417
|
+
* Swaps protected pieces for placeholders so the model leaves them alone: excluded phrases and glossary terms
|
|
418
|
+
* (restored as given), {placeholders}, and numbers. `phrases[i]` is what `X{i}X` turns back into.
|
|
419
|
+
*/
|
|
420
|
+
function mask(text, exclude, opts = {}) {
|
|
421
|
+
const phrases = [];
|
|
422
|
+
const mark = (restore) => {
|
|
423
|
+
phrases.push(restore);
|
|
424
|
+
return String.fromCharCode(57344) + String.fromCharCode(57600 + phrases.length - 1) + String.fromCharCode(57345);
|
|
425
|
+
};
|
|
426
|
+
let masked = text;
|
|
427
|
+
const entries = [...exclude.map((p) => [p, p]), ...Object.entries(opts.glossary ?? {})].sort((a, b) => b[0].length - a[0].length);
|
|
428
|
+
for (const [from, to] of entries) if (from && masked.includes(from)) masked = masked.split(from).join(mark(to));
|
|
429
|
+
masked = masked.replace(PLACEHOLDER, (m) => mark(m));
|
|
430
|
+
if (opts.numbers !== false) masked = masked.replace(NUMBER, (m) => mark(m));
|
|
431
|
+
return {
|
|
432
|
+
masked: masked.replace(/\uE000([\uE100-\uE9FF])\uE001/g, (_, c) => token(c.charCodeAt(0) - 57600)),
|
|
433
|
+
phrases
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/** Puts protected pieces back. Returns null if the model dropped or mangled a placeholder. */
|
|
437
|
+
function unmask(translated, phrases) {
|
|
438
|
+
let out = translated;
|
|
439
|
+
for (let i = 0; i < phrases.length; i++) {
|
|
440
|
+
if (!out.includes(token(i))) return null;
|
|
441
|
+
out = out.split(token(i)).join(phrases[i]);
|
|
442
|
+
}
|
|
443
|
+
return /X\d+X/.test(out) ? null : out;
|
|
444
|
+
}
|
|
445
|
+
/** True if every run of digits in the source still appears in the translation. */
|
|
446
|
+
function digitsPreserved(source, translated) {
|
|
447
|
+
const key = (s) => (s.match(/\d+/g) ?? []).sort().join(",");
|
|
448
|
+
return key(source) === key(translated);
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/core/cache.ts
|
|
452
|
+
/** Generated translations live here, outside the source tree. */
|
|
453
|
+
function cacheDir(root) {
|
|
454
|
+
return path.join(root, "node_modules", ".cache", "react-autolocale");
|
|
455
|
+
}
|
|
456
|
+
function cacheFile(root, lang) {
|
|
457
|
+
return path.join(cacheDir(root), `${lang}.json`);
|
|
458
|
+
}
|
|
459
|
+
function readDict(file) {
|
|
460
|
+
try {
|
|
461
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
462
|
+
} catch {
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** Strings that are entirely an excluded phrase need no translation. */
|
|
467
|
+
function needsTranslation(s, exclude) {
|
|
468
|
+
return !exclude.includes(s.trim());
|
|
469
|
+
}
|
|
470
|
+
/** Translates only strings missing from the cache, drops unused ones, and returns every language's dictionary. */
|
|
471
|
+
async function ensureTranslations(opts) {
|
|
472
|
+
const { root, site, strings } = opts;
|
|
473
|
+
const log = opts.log ?? (() => {});
|
|
474
|
+
const wanted = strings.filter((s) => needsTranslation(s, site.exclude));
|
|
475
|
+
const result = {};
|
|
476
|
+
fs.mkdirSync(cacheDir(root), { recursive: true });
|
|
477
|
+
for (const lang of site.languages) {
|
|
478
|
+
const file = cacheFile(root, lang);
|
|
479
|
+
const existing = readDict(file);
|
|
480
|
+
const fixed = opts.overrides?.[lang] ?? {};
|
|
481
|
+
const missing = wanted.filter((s) => !existing[s] && !fixed[s]);
|
|
482
|
+
const next = {};
|
|
483
|
+
for (const s of wanted) if (existing[s]) next[s] = existing[s];
|
|
484
|
+
for (const s of wanted) if (fixed[s]) next[s] = fixed[s];
|
|
485
|
+
const terms = opts.glossary?.[lang] ?? {};
|
|
486
|
+
for (const s2 of missing) if (terms[s2]) next[s2] = terms[s2];
|
|
487
|
+
const toTranslate = missing.filter((s2) => !terms[s2]);
|
|
488
|
+
if (toTranslate.length) {
|
|
489
|
+
log(`[react-autolocale] ${lang}: translating ${toTranslate.length} new string${toTranslate.length === 1 ? "" : "s"}...`);
|
|
490
|
+
const ctx = toTranslate.map((s2) => opts.contexts?.[s2] ?? "");
|
|
491
|
+
const run = async (items, contextList, numbers) => {
|
|
492
|
+
const jobs = items.map((s2) => ({
|
|
493
|
+
source: s2,
|
|
494
|
+
...mask(s2, site.exclude, {
|
|
495
|
+
glossary: terms,
|
|
496
|
+
numbers
|
|
497
|
+
})
|
|
498
|
+
}));
|
|
499
|
+
const out = await opts.getTranslator().translate(jobs.map((j) => j.masked), site.original, lang, contextList);
|
|
500
|
+
return jobs.map((job, i) => unmask(out[i] ?? "", job.phrases));
|
|
501
|
+
};
|
|
502
|
+
const first = await run(toTranslate, ctx, false);
|
|
503
|
+
const redo = toTranslate.map((source, i) => i).filter((i) => !first[i] || !digitsPreserved(toTranslate[i], first[i]));
|
|
504
|
+
const second = redo.length ? await run(redo.map((i) => toTranslate[i]), redo.map((i) => ctx[i]), true) : [];
|
|
505
|
+
toTranslate.forEach((source, i) => {
|
|
506
|
+
const result = redo.includes(i) ? second[redo.indexOf(i)] : first[i];
|
|
507
|
+
if (result) next[source] = result;
|
|
508
|
+
else log(`[react-autolocale] ${lang}: kept "${source}" untranslated (could not preserve its numbers or excluded text)`);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
if (!(JSON.stringify(existing) === JSON.stringify(next))) fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
|
|
512
|
+
result[lang] = next;
|
|
513
|
+
}
|
|
514
|
+
return result;
|
|
515
|
+
}
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/core/overrides.ts
|
|
518
|
+
/** A committed file the developer edits to correct translations. It always wins over machine translation. */
|
|
519
|
+
const OVERRIDES_FILE = "react-autolocale.overrides.json";
|
|
520
|
+
function readOverrides(root) {
|
|
521
|
+
const file = path.join(root, OVERRIDES_FILE);
|
|
522
|
+
if (!fs.existsSync(file)) return {};
|
|
523
|
+
try {
|
|
524
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
525
|
+
} catch (err) {
|
|
526
|
+
throw new Error(`${OVERRIDES_FILE} is not valid JSON: ${err.message}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Later arguments win. */
|
|
530
|
+
function mergeOverrides(...all) {
|
|
531
|
+
const out = {};
|
|
532
|
+
for (const o of all) for (const [lang, strings] of Object.entries(o ?? {})) out[lang] = {
|
|
533
|
+
...out[lang],
|
|
534
|
+
...strings
|
|
535
|
+
};
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
/** Writes every generated translation into the overrides file (keeping entries already there) so it can be edited. */
|
|
539
|
+
function exportTranslations(root) {
|
|
540
|
+
const dir = cacheDir(root);
|
|
541
|
+
if (!fs.existsSync(dir)) throw new Error("No generated translations found. Run your build first.");
|
|
542
|
+
const generated = {};
|
|
543
|
+
for (const name of fs.readdirSync(dir)) {
|
|
544
|
+
const m = /^([a-z]{2,3})\.json$/i.exec(name);
|
|
545
|
+
if (m) generated[m[1].toLowerCase()] = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
546
|
+
}
|
|
547
|
+
if (!Object.keys(generated).length) throw new Error("No generated translations found. Run your build first.");
|
|
548
|
+
const merged = mergeOverrides(generated, readOverrides(root));
|
|
549
|
+
const file = path.join(root, OVERRIDES_FILE);
|
|
550
|
+
fs.writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
|
|
551
|
+
return {
|
|
552
|
+
file,
|
|
553
|
+
languages: Object.fromEntries(Object.entries(merged).map(([l, s]) => [l, Object.keys(s).length]))
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
export { cacheDir as a, createLocalTranslator as c, toTranslator as d, unmask as f, readOverrides as i, mask as l, exportTranslations as n, cacheFile as o, getLanguage as p, mergeOverrides as r, ensureTranslations as s, OVERRIDES_FILE as t, ollama as u };
|
|
@@ -18,9 +18,35 @@ function redirectTarget(opts) {
|
|
|
18
18
|
return `/${target}${pathname}${opts.search ?? ""}${opts.hash ?? ""}`;
|
|
19
19
|
}
|
|
20
20
|
const STORAGE_KEY = "react-autolocale:language";
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The same logic as a tiny inline script, so the root page redirects before anything paints. When the visitor stays,
|
|
23
|
+
* the URL is rewritten to the original language's prefix (no reload) so client-side routers see a prefixed path.
|
|
24
|
+
*/
|
|
22
25
|
function redirectScript(codes, original) {
|
|
23
|
-
return `(function(){try{var C=${JSON.stringify(codes)},O=${JSON.stringify(original)},p=location.pathname,m=/^\\/([a-z]{2,3})(?=\\/|$)/i.exec(p);if(m&&C.indexOf(m[1].toLowerCase())>-1)return;var t=null,s=localStorage.getItem(${JSON.stringify(STORAGE_KEY)});if(s&&C.indexOf(s)>-1)t=s;var l=navigator.languages||[navigator.language];for(var i=0;i<l.length&&!t;i++){var c=String(l[i]).toLowerCase();if(C.indexOf(c)>-1)t=c;else if(C.indexOf(c.split("-")[0])>-1)t=c.split("-")[0]}if(t&&t!==O)location.replace("/"+t+p+location.search+location.hash)}catch(e){}})()`;
|
|
26
|
+
return `(function(){try{var C=${JSON.stringify(codes)},O=${JSON.stringify(original)},p=location.pathname,m=/^\\/([a-z]{2,3})(?=\\/|$)/i.exec(p);if(m&&C.indexOf(m[1].toLowerCase())>-1)return;var t=null,s=localStorage.getItem(${JSON.stringify(STORAGE_KEY)});if(s&&C.indexOf(s)>-1)t=s;var l=navigator.languages||[navigator.language];for(var i=0;i<l.length&&!t;i++){var c=String(l[i]).toLowerCase();if(C.indexOf(c)>-1)t=c;else if(C.indexOf(c.split("-")[0])>-1)t=c.split("-")[0]}if(t&&t!==O)location.replace("/"+t+p+location.search+location.hash);else history.replaceState(history.state,"","/"+O+p+location.search+location.hash)}catch(e){}})()`;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/core/suggestion.ts
|
|
30
|
+
/**
|
|
31
|
+
* Should we offer the visitor another language? Looks at the first browser language we support:
|
|
32
|
+
* if it is the page's own language, was dismissed, or the visitor already chose a language, offer nothing.
|
|
33
|
+
*/
|
|
34
|
+
function suggestLanguage(opts) {
|
|
35
|
+
if (opts.chosen) return null;
|
|
36
|
+
for (const tag of opts.browser) {
|
|
37
|
+
const lower = String(tag).toLowerCase();
|
|
38
|
+
const base = lower.split("-")[0];
|
|
39
|
+
const code = opts.codes.includes(lower) ? lower : opts.codes.includes(base) ? base : null;
|
|
40
|
+
if (!code) continue;
|
|
41
|
+
return code === opts.current || opts.dismissed.includes(code) ? null : code;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
24
44
|
}
|
|
45
|
+
const DISMISSED_KEY = "react-autolocale:banner-dismissed";
|
|
46
|
+
const BANNER_EN = {
|
|
47
|
+
prompt: "This page is also available in {language}.",
|
|
48
|
+
switch: "Read in {language}",
|
|
49
|
+
dismiss: "Not now"
|
|
50
|
+
};
|
|
25
51
|
//#endregion
|
|
26
|
-
export { redirectScript as n, redirectTarget as r,
|
|
52
|
+
export { redirectScript as a, STORAGE_KEY as i, DISMISSED_KEY as n, redirectTarget as o, suggestLanguage as r, BANNER_EN as t };
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
import { Plugin } from "vite";
|
|
2
|
+
//#region src/core/translate.d.ts
|
|
3
|
+
interface Translator {
|
|
4
|
+
/** `contexts[i]` says where `texts[i]` appears ("button", "h1", "placeholder", ...). Engines may ignore it. */
|
|
5
|
+
translate(texts: string[], from: string, to: string, contexts?: string[]): Promise<string[]>;
|
|
6
|
+
}
|
|
7
|
+
/** The simplest way to plug in your own engine (DeepL, an LLM, ...): one function. */
|
|
8
|
+
type TranslateFn = (request: {
|
|
9
|
+
texts: string[];
|
|
10
|
+
contexts: string[];
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
}) => Promise<string[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Uses a local LLM through Ollama (free, runs on your machine). Unlike the default models it is told
|
|
16
|
+
* where each string appears, which fixes most one-word mistakes ("Home" in a nav bar is not "Sommaire").
|
|
17
|
+
*/
|
|
18
|
+
export declare function ollama(options?: {
|
|
19
|
+
model: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
}): Translator;
|
|
22
|
+
//#endregion
|
|
2
23
|
//#region src/vite/index.d.ts
|
|
3
24
|
export interface ReactAutolocaleOptions {
|
|
4
25
|
/** Folders (relative to the project root) scanned for JSX. Defaults to "src". */
|
|
5
26
|
include?: string[];
|
|
6
|
-
/** Fix individual translations
|
|
27
|
+
/** Fix individual translations in code, e.g. { fr: { Contact: "Contact" } }. Adds to (and wins over) react-autolocale.overrides.json. */
|
|
7
28
|
overrides?: Record<string, Record<string, string>>;
|
|
8
29
|
/**
|
|
9
30
|
* Extra pages to build, mainly dynamic ones such as "/users/1" (a function can fetch them). Pages reached
|
|
10
31
|
* by links and fixed routes written in your router (<Route path="/thank-you">) are found automatically.
|
|
11
32
|
*/
|
|
12
33
|
routes?: string[] | (() => string[] | Promise<string[]>);
|
|
34
|
+
/**
|
|
35
|
+
* Replace the built-in free local model. Pass a function, or a ready-made engine such as `ollama({ model })`.
|
|
36
|
+
* Engines receive where each string appears (button, h1, ...) so they can translate short labels correctly.
|
|
37
|
+
*/
|
|
38
|
+
engine?: Translator | TranslateFn;
|
|
39
|
+
/** Terms that must always translate a given way, e.g. { fr: { Roadmap: "Feuille de route" } }. */
|
|
40
|
+
glossary?: Record<string, Record<string, string>>;
|
|
13
41
|
}
|
|
14
42
|
export default function reactAutolocale(options?: ReactAutolocaleOptions): Plugin;
|
|
15
43
|
//#endregion
|
|
16
|
-
export { reactAutolocale };
|
|
44
|
+
export { type TranslateFn, type Translator, reactAutolocale };
|