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
package/dist/vite.js
CHANGED
|
@@ -1,456 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as redirectScript, t as BANNER_EN } from "./suggestion-UZQ8ngd0.js";
|
|
2
|
+
import { a as cacheDir, c as createLocalTranslator, d as toTranslator, f as unmask, i as readOverrides, l as mask, o as cacheFile, p as getLanguage, r as mergeOverrides, s as ensureTranslations, t as OVERRIDES_FILE, u as ollama } from "./overrides-ZQ6meLck.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
import { pathToFileURL } from "node:url";
|
|
8
5
|
import { parse } from "@babel/parser";
|
|
9
6
|
import _traverse from "@babel/traverse";
|
|
10
7
|
import _generate from "@babel/generator";
|
|
11
8
|
import * as t from "@babel/types";
|
|
12
9
|
import { JSDOM, VirtualConsole } from "jsdom";
|
|
13
10
|
import { createServer } from "vite";
|
|
14
|
-
const byCode = new Map([
|
|
15
|
-
{
|
|
16
|
-
code: "en",
|
|
17
|
-
native: "English",
|
|
18
|
-
english: "English",
|
|
19
|
-
nllb: "eng_Latn"
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
code: "fr",
|
|
23
|
-
native: "Français",
|
|
24
|
-
english: "French",
|
|
25
|
-
nllb: "fra_Latn",
|
|
26
|
-
opus: "fr"
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
code: "es",
|
|
30
|
-
native: "Español",
|
|
31
|
-
english: "Spanish",
|
|
32
|
-
nllb: "spa_Latn",
|
|
33
|
-
opus: "es"
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
code: "de",
|
|
37
|
-
native: "Deutsch",
|
|
38
|
-
english: "German",
|
|
39
|
-
nllb: "deu_Latn",
|
|
40
|
-
opus: "de"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
code: "it",
|
|
44
|
-
native: "Italiano",
|
|
45
|
-
english: "Italian",
|
|
46
|
-
nllb: "ita_Latn",
|
|
47
|
-
opus: "it"
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
code: "pt",
|
|
51
|
-
native: "Português",
|
|
52
|
-
english: "Portuguese",
|
|
53
|
-
nllb: "por_Latn"
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
code: "nl",
|
|
57
|
-
native: "Nederlands",
|
|
58
|
-
english: "Dutch",
|
|
59
|
-
nllb: "nld_Latn",
|
|
60
|
-
opus: "nl"
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
code: "ru",
|
|
64
|
-
native: "Русский",
|
|
65
|
-
english: "Russian",
|
|
66
|
-
nllb: "rus_Cyrl",
|
|
67
|
-
opus: "ru"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
code: "zh",
|
|
71
|
-
native: "中文",
|
|
72
|
-
english: "Chinese (Simplified)",
|
|
73
|
-
nllb: "zho_Hans",
|
|
74
|
-
opus: "zh"
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
code: "ja",
|
|
78
|
-
native: "日本語",
|
|
79
|
-
english: "Japanese",
|
|
80
|
-
nllb: "jpn_Jpan"
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
code: "ko",
|
|
84
|
-
native: "한국어",
|
|
85
|
-
english: "Korean",
|
|
86
|
-
nllb: "kor_Hang"
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
code: "ar",
|
|
90
|
-
native: "العربية",
|
|
91
|
-
english: "Arabic",
|
|
92
|
-
nllb: "arb_Arab",
|
|
93
|
-
opus: "ar",
|
|
94
|
-
rtl: true
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
code: "hi",
|
|
98
|
-
native: "हिन्दी",
|
|
99
|
-
english: "Hindi",
|
|
100
|
-
nllb: "hin_Deva",
|
|
101
|
-
opus: "hi"
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
code: "tr",
|
|
105
|
-
native: "Türkçe",
|
|
106
|
-
english: "Turkish",
|
|
107
|
-
nllb: "tur_Latn"
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
code: "pl",
|
|
111
|
-
native: "Polski",
|
|
112
|
-
english: "Polish",
|
|
113
|
-
nllb: "pol_Latn"
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
code: "sv",
|
|
117
|
-
native: "Svenska",
|
|
118
|
-
english: "Swedish",
|
|
119
|
-
nllb: "swe_Latn",
|
|
120
|
-
opus: "sv"
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
code: "da",
|
|
124
|
-
native: "Dansk",
|
|
125
|
-
english: "Danish",
|
|
126
|
-
nllb: "dan_Latn",
|
|
127
|
-
opus: "da"
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
code: "no",
|
|
131
|
-
native: "Norsk",
|
|
132
|
-
english: "Norwegian",
|
|
133
|
-
nllb: "nob_Latn"
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
code: "fi",
|
|
137
|
-
native: "Suomi",
|
|
138
|
-
english: "Finnish",
|
|
139
|
-
nllb: "fin_Latn",
|
|
140
|
-
opus: "fi"
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
code: "cs",
|
|
144
|
-
native: "Čeština",
|
|
145
|
-
english: "Czech",
|
|
146
|
-
nllb: "ces_Latn",
|
|
147
|
-
opus: "cs"
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
code: "hu",
|
|
151
|
-
native: "Magyar",
|
|
152
|
-
english: "Hungarian",
|
|
153
|
-
nllb: "hun_Latn",
|
|
154
|
-
opus: "hu"
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
code: "ro",
|
|
158
|
-
native: "Română",
|
|
159
|
-
english: "Romanian",
|
|
160
|
-
nllb: "ron_Latn",
|
|
161
|
-
opus: "ro"
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
code: "uk",
|
|
165
|
-
native: "Українська",
|
|
166
|
-
english: "Ukrainian",
|
|
167
|
-
nllb: "ukr_Cyrl",
|
|
168
|
-
opus: "uk"
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
code: "el",
|
|
172
|
-
native: "Ελληνικά",
|
|
173
|
-
english: "Greek",
|
|
174
|
-
nllb: "ell_Grek"
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
code: "he",
|
|
178
|
-
native: "עברית",
|
|
179
|
-
english: "Hebrew",
|
|
180
|
-
nllb: "heb_Hebr",
|
|
181
|
-
rtl: true
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
code: "vi",
|
|
185
|
-
native: "Tiếng Việt",
|
|
186
|
-
english: "Vietnamese",
|
|
187
|
-
nllb: "vie_Latn",
|
|
188
|
-
opus: "vi"
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
code: "id",
|
|
192
|
-
native: "Bahasa Indonesia",
|
|
193
|
-
english: "Indonesian",
|
|
194
|
-
nllb: "ind_Latn",
|
|
195
|
-
opus: "id"
|
|
196
|
-
},
|
|
197
|
-
{
|
|
198
|
-
code: "th",
|
|
199
|
-
native: "ไทย",
|
|
200
|
-
english: "Thai",
|
|
201
|
-
nllb: "tha_Thai"
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
code: "bn",
|
|
205
|
-
native: "বাংলা",
|
|
206
|
-
english: "Bengali",
|
|
207
|
-
nllb: "ben_Beng"
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
code: "fa",
|
|
211
|
-
native: "فارسی",
|
|
212
|
-
english: "Persian",
|
|
213
|
-
nllb: "pes_Arab",
|
|
214
|
-
rtl: true
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
code: "ur",
|
|
218
|
-
native: "اردو",
|
|
219
|
-
english: "Urdu",
|
|
220
|
-
nllb: "urd_Arab",
|
|
221
|
-
rtl: true
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
code: "bg",
|
|
225
|
-
native: "Български",
|
|
226
|
-
english: "Bulgarian",
|
|
227
|
-
nllb: "bul_Cyrl"
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
code: "hr",
|
|
231
|
-
native: "Hrvatski",
|
|
232
|
-
english: "Croatian",
|
|
233
|
-
nllb: "hrv_Latn"
|
|
234
|
-
},
|
|
235
|
-
{
|
|
236
|
-
code: "sk",
|
|
237
|
-
native: "Slovenčina",
|
|
238
|
-
english: "Slovak",
|
|
239
|
-
nllb: "slk_Latn"
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
code: "ca",
|
|
243
|
-
native: "Català",
|
|
244
|
-
english: "Catalan",
|
|
245
|
-
nllb: "cat_Latn"
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
code: "ms",
|
|
249
|
-
native: "Bahasa Melayu",
|
|
250
|
-
english: "Malay",
|
|
251
|
-
nllb: "zsm_Latn"
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
code: "sw",
|
|
255
|
-
native: "Kiswahili",
|
|
256
|
-
english: "Swahili",
|
|
257
|
-
nllb: "swh_Latn"
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
code: "tl",
|
|
261
|
-
native: "Tagalog",
|
|
262
|
-
english: "Tagalog",
|
|
263
|
-
nllb: "tgl_Latn"
|
|
264
|
-
}
|
|
265
|
-
].map((l) => [l.code, l]));
|
|
266
|
-
function getLanguage(code) {
|
|
267
|
-
return byCode.get(code);
|
|
268
|
-
}
|
|
269
|
-
//#endregion
|
|
270
|
-
//#region src/core/translate.ts
|
|
271
|
-
const ENGINE_PACKAGE = "@huggingface/transformers@^4.3.1";
|
|
272
|
-
const ENGINE_DIR = path.join(os.homedir(), ".cache", "react-autolocale", "engine");
|
|
273
|
-
/**
|
|
274
|
-
* Finds the translation engine. Uses the project's own copy if it has one; otherwise installs it once
|
|
275
|
-
* into ~/.cache/react-autolocale/engine (shared by all projects, never touches the project's node_modules).
|
|
276
|
-
*/
|
|
277
|
-
async function loadEngine(log) {
|
|
278
|
-
try {
|
|
279
|
-
return await import("@huggingface/transformers");
|
|
280
|
-
} catch {}
|
|
281
|
-
const require = createRequire(path.join(ENGINE_DIR, "package.json"));
|
|
282
|
-
const find = () => require.resolve("@huggingface/transformers");
|
|
283
|
-
let entry;
|
|
284
|
-
try {
|
|
285
|
-
entry = find();
|
|
286
|
-
} catch {
|
|
287
|
-
log("[react-autolocale] Installing the translation engine (one time, about 440 MB)...");
|
|
288
|
-
fs.mkdirSync(ENGINE_DIR, { recursive: true });
|
|
289
|
-
const pkg = path.join(ENGINE_DIR, "package.json");
|
|
290
|
-
if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, JSON.stringify({
|
|
291
|
-
name: "react-autolocale-engine",
|
|
292
|
-
private: true
|
|
293
|
-
}));
|
|
294
|
-
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
295
|
-
const res = spawnSync(npm, [
|
|
296
|
-
"install",
|
|
297
|
-
"--prefix",
|
|
298
|
-
ENGINE_DIR,
|
|
299
|
-
"--no-audit",
|
|
300
|
-
"--no-fund",
|
|
301
|
-
"--loglevel=error",
|
|
302
|
-
ENGINE_PACKAGE
|
|
303
|
-
], {
|
|
304
|
-
stdio: "inherit",
|
|
305
|
-
shell: process.platform === "win32"
|
|
306
|
-
});
|
|
307
|
-
try {
|
|
308
|
-
if (res.status !== 0) throw new Error("npm failed");
|
|
309
|
-
entry = find();
|
|
310
|
-
} catch {
|
|
311
|
-
throw new Error(`Could not install the translation engine automatically. Install it yourself: npm install -D @huggingface/transformers`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
const mod = await import(pathToFileURL(entry).href);
|
|
315
|
-
return mod.pipeline ? mod : mod.default;
|
|
316
|
-
}
|
|
317
|
-
const NLLB_MODEL = "Xenova/nllb-200-distilled-600M";
|
|
318
|
-
const BATCH = 1;
|
|
319
|
-
/**
|
|
320
|
-
* Free, offline translation using Transformers.js (ONNX models run on the CPU).
|
|
321
|
-
* Models are downloaded once from Hugging Face and cached in ~/.cache/react-autolocale.
|
|
322
|
-
* English -> X uses small per-language OPUS-MT models where they exist; everything else uses NLLB-200.
|
|
323
|
-
*/
|
|
324
|
-
function createLocalTranslator(log = () => {}) {
|
|
325
|
-
const pipes = /* @__PURE__ */ new Map();
|
|
326
|
-
let hf = null;
|
|
327
|
-
const getPipe = (model) => {
|
|
328
|
-
let p = pipes.get(model);
|
|
329
|
-
if (!p) {
|
|
330
|
-
hf ??= loadEngine(log).then((m) => {
|
|
331
|
-
m.env.cacheDir = path.join(os.homedir(), ".cache", "react-autolocale", "models");
|
|
332
|
-
return m;
|
|
333
|
-
});
|
|
334
|
-
log(`Loading model ${model} (first run downloads it)...`);
|
|
335
|
-
let lastPct = -10;
|
|
336
|
-
p = hf.then((m) => m.pipeline("translation", model, {
|
|
337
|
-
dtype: "q8",
|
|
338
|
-
progress_callback: (e) => {
|
|
339
|
-
if (e.status === "progress" && e.progress - lastPct >= 10) {
|
|
340
|
-
lastPct = e.progress;
|
|
341
|
-
log(` downloading ${e.file}: ${Math.round(e.progress)}%`);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}));
|
|
345
|
-
pipes.set(model, p);
|
|
346
|
-
}
|
|
347
|
-
return p;
|
|
348
|
-
};
|
|
349
|
-
const run = async (model, texts, options) => {
|
|
350
|
-
const pipe = await getPipe(model);
|
|
351
|
-
const out = [];
|
|
352
|
-
for (let i = 0; i < texts.length; i += BATCH) {
|
|
353
|
-
const res = await pipe(texts.slice(i, i + BATCH), {
|
|
354
|
-
max_new_tokens: 256,
|
|
355
|
-
...options
|
|
356
|
-
});
|
|
357
|
-
out.push(...res.map((r) => r.translation_text.trim()));
|
|
358
|
-
}
|
|
359
|
-
return out;
|
|
360
|
-
};
|
|
361
|
-
return { async translate(texts, from, to) {
|
|
362
|
-
const src = getLanguage(from);
|
|
363
|
-
const dst = getLanguage(to);
|
|
364
|
-
if (!src || !dst) throw new Error(`Unsupported language: ${!src ? from : to}`);
|
|
365
|
-
if (from === "en" && dst.opus) try {
|
|
366
|
-
return await run(`Xenova/opus-mt-en-${dst.opus}`, texts, {});
|
|
367
|
-
} catch (err) {
|
|
368
|
-
log(` OPUS-MT model for "${to}" unavailable (${err.message}); falling back to NLLB.`);
|
|
369
|
-
}
|
|
370
|
-
return run(NLLB_MODEL, texts, {
|
|
371
|
-
src_lang: src.nllb,
|
|
372
|
-
tgt_lang: dst.nllb
|
|
373
|
-
});
|
|
374
|
-
} };
|
|
375
|
-
}
|
|
376
|
-
const token = (i) => `X${i}X`;
|
|
377
|
-
/** Swaps excluded phrases for placeholders so the model leaves them alone. */
|
|
378
|
-
function mask(text, exclude) {
|
|
379
|
-
const phrases = [];
|
|
380
|
-
let masked = text;
|
|
381
|
-
for (const phrase of [...exclude].sort((a, b) => b.length - a.length)) {
|
|
382
|
-
if (!phrase || !masked.includes(phrase)) continue;
|
|
383
|
-
masked = masked.split(phrase).join(token(phrases.length));
|
|
384
|
-
phrases.push(phrase);
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
387
|
-
masked,
|
|
388
|
-
phrases
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
/** Puts excluded phrases back. Returns null if the model dropped or mangled a placeholder. */
|
|
392
|
-
function unmask(translated, phrases) {
|
|
393
|
-
let out = translated;
|
|
394
|
-
for (let i = 0; i < phrases.length; i++) {
|
|
395
|
-
if (!out.includes(token(i))) return null;
|
|
396
|
-
out = out.split(token(i)).join(phrases[i]);
|
|
397
|
-
}
|
|
398
|
-
return /X\d+X/.test(out) ? null : out;
|
|
399
|
-
}
|
|
400
|
-
//#endregion
|
|
401
|
-
//#region src/core/cache.ts
|
|
402
|
-
/** Generated translations live here, outside the source tree. */
|
|
403
|
-
function cacheDir(root) {
|
|
404
|
-
return path.join(root, "node_modules", ".cache", "react-autolocale");
|
|
405
|
-
}
|
|
406
|
-
function cacheFile(root, lang) {
|
|
407
|
-
return path.join(cacheDir(root), `${lang}.json`);
|
|
408
|
-
}
|
|
409
|
-
function readDict(file) {
|
|
410
|
-
try {
|
|
411
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
412
|
-
} catch {
|
|
413
|
-
return {};
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
/** Strings that are entirely an excluded phrase need no translation. */
|
|
417
|
-
function needsTranslation(s, exclude) {
|
|
418
|
-
return !exclude.includes(s.trim());
|
|
419
|
-
}
|
|
420
|
-
/** Translates only strings missing from the cache, drops unused ones, and returns every language's dictionary. */
|
|
421
|
-
async function ensureTranslations(opts) {
|
|
422
|
-
const { root, site, strings } = opts;
|
|
423
|
-
const log = opts.log ?? (() => {});
|
|
424
|
-
const wanted = strings.filter((s) => needsTranslation(s, site.exclude));
|
|
425
|
-
const result = {};
|
|
426
|
-
fs.mkdirSync(cacheDir(root), { recursive: true });
|
|
427
|
-
for (const lang of site.languages) {
|
|
428
|
-
const file = cacheFile(root, lang);
|
|
429
|
-
const existing = readDict(file);
|
|
430
|
-
const fixed = opts.overrides?.[lang] ?? {};
|
|
431
|
-
const missing = wanted.filter((s) => !existing[s] && !fixed[s]);
|
|
432
|
-
const next = {};
|
|
433
|
-
for (const s of wanted) if (existing[s]) next[s] = existing[s];
|
|
434
|
-
for (const s of wanted) if (fixed[s]) next[s] = fixed[s];
|
|
435
|
-
if (missing.length) {
|
|
436
|
-
log(`[react-autolocale] ${lang}: translating ${missing.length} new string${missing.length === 1 ? "" : "s"}...`);
|
|
437
|
-
const jobs = missing.map((s) => ({
|
|
438
|
-
source: s,
|
|
439
|
-
...mask(s, site.exclude)
|
|
440
|
-
}));
|
|
441
|
-
const out = await opts.getTranslator().translate(jobs.map((j) => j.masked), site.original, lang);
|
|
442
|
-
jobs.forEach((job, i) => {
|
|
443
|
-
const restored = unmask(out[i] ?? "", job.phrases);
|
|
444
|
-
if (restored) next[job.source] = restored;
|
|
445
|
-
else log(`[react-autolocale] ${lang}: kept "${job.source}" untranslated (could not preserve excluded text)`);
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
if (!(JSON.stringify(existing) === JSON.stringify(next))) fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
|
|
449
|
-
result[lang] = next;
|
|
450
|
-
}
|
|
451
|
-
return result;
|
|
452
|
-
}
|
|
453
|
-
//#endregion
|
|
454
11
|
//#region src/core/transform.ts
|
|
455
12
|
const traverse$2 = _traverse.default ?? _traverse;
|
|
456
13
|
const generate = _generate.default ?? _generate;
|
|
@@ -474,6 +31,7 @@ const SKIP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
474
31
|
"textarea"
|
|
475
32
|
]);
|
|
476
33
|
const T_IMPORT = "__RLT";
|
|
34
|
+
const TD_IMPORT = "__RLTD";
|
|
477
35
|
const HOOK_IMPORT = "__rlUseT";
|
|
478
36
|
const HOOK_VAR = "__rl";
|
|
479
37
|
const RUNTIME = "react-autolocale";
|
|
@@ -503,13 +61,6 @@ function cleanJsxText(raw) {
|
|
|
503
61
|
function isTranslatable(text) {
|
|
504
62
|
return /\p{L}/u.test(text);
|
|
505
63
|
}
|
|
506
|
-
/**
|
|
507
|
-
* Text next to a `{expression}` is a sentence fragment ("Hello {name}"): translating the
|
|
508
|
-
* pieces separately gives wrong results, so dynamic content is left for v2.
|
|
509
|
-
*/
|
|
510
|
-
function hasDynamicChild(node) {
|
|
511
|
-
return node.children.some((c) => t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression));
|
|
512
|
-
}
|
|
513
64
|
function isComponentName(name) {
|
|
514
65
|
return !!name && /^[A-Z]/.test(name);
|
|
515
66
|
}
|
|
@@ -532,6 +83,58 @@ function enclosingComponent(path) {
|
|
|
532
83
|
}
|
|
533
84
|
return null;
|
|
534
85
|
}
|
|
86
|
+
/** `translate="no"` on an element (or any ancestor) keeps its text and attributes exactly as written. */
|
|
87
|
+
function hasNoTranslate(opening) {
|
|
88
|
+
return opening.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "translate" }) && (t.isStringLiteral(a.value, { value: "no" }) || t.isJSXExpressionContainer(a.value) && t.isStringLiteral(a.value.expression, { value: "no" })));
|
|
89
|
+
}
|
|
90
|
+
function insideNoTranslate(path) {
|
|
91
|
+
for (let p = path; p; p = p.parentPath) if (p.isJSXElement() && hasNoTranslate(p.node.openingElement)) return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/** A value we can safely hand to the runtime: `name`, `user.name`, `this.props.count`, `items?.[0]`. */
|
|
95
|
+
function simpleValueName(node) {
|
|
96
|
+
const root = (n) => t.isIdentifier(n) || t.isThisExpression(n) || (t.isMemberExpression(n) || t.isOptionalMemberExpression(n)) && root(n.object) && (n.computed ? t.isNumericLiteral(n.property) || t.isStringLiteral(n.property) : t.isIdentifier(n.property));
|
|
97
|
+
if (!root(node)) return null;
|
|
98
|
+
if (t.isIdentifier(node)) return node.name;
|
|
99
|
+
if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && !node.computed && t.isIdentifier(node.property)) return node.property.name;
|
|
100
|
+
return "value";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* "Hello {name}!" -> template "Hello {name}!" plus the values to fill in at runtime.
|
|
104
|
+
* Only text mixed with plain variables qualifies: no nested elements, calls, conditions or JSX.
|
|
105
|
+
*/
|
|
106
|
+
function buildTemplate(node) {
|
|
107
|
+
let text = "";
|
|
108
|
+
const values = [];
|
|
109
|
+
const used = /* @__PURE__ */ new Set();
|
|
110
|
+
for (const child of node.children) if (t.isJSXText(child)) text += cleanJsxText(child.value);
|
|
111
|
+
else if (t.isJSXExpressionContainer(child)) {
|
|
112
|
+
const e = child.expression;
|
|
113
|
+
if (t.isJSXEmptyExpression(e)) continue;
|
|
114
|
+
if (t.isStringLiteral(e)) {
|
|
115
|
+
text += e.value;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const base = simpleValueName(e);
|
|
119
|
+
if (!base) return null;
|
|
120
|
+
let name = base;
|
|
121
|
+
for (let i = 2; used.has(name); i++) name = `${base}_${i}`;
|
|
122
|
+
used.add(name);
|
|
123
|
+
values.push({
|
|
124
|
+
name,
|
|
125
|
+
expr: e
|
|
126
|
+
});
|
|
127
|
+
text += `{${name}}`;
|
|
128
|
+
} else return null;
|
|
129
|
+
const letters = text.replace(/\{\w+\}/g, "");
|
|
130
|
+
if (!values.length || !isTranslatable(letters)) return null;
|
|
131
|
+
return {
|
|
132
|
+
text: text.trim(),
|
|
133
|
+
values,
|
|
134
|
+
lead: /^\s/.test(text),
|
|
135
|
+
trail: /\s$/.test(text)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
535
138
|
/** Parses a file, extracts translatable strings, and rewrites JSX to render translations. */
|
|
536
139
|
function transformJsx(code, file, opts = {}) {
|
|
537
140
|
const rewrite = opts.rewrite !== false;
|
|
@@ -547,14 +150,37 @@ function transformJsx(code, file, opts = {}) {
|
|
|
547
150
|
return null;
|
|
548
151
|
}
|
|
549
152
|
const strings = /* @__PURE__ */ new Set();
|
|
153
|
+
/** What kind of element each string sits in ("button", "h1", "placeholder", ...), for context-aware engines. */
|
|
154
|
+
const contexts = {};
|
|
155
|
+
const record = (text, context) => {
|
|
156
|
+
strings.add(text);
|
|
157
|
+
contexts[text] ??= context;
|
|
158
|
+
};
|
|
550
159
|
const hookTargets = /* @__PURE__ */ new Set();
|
|
551
160
|
let usedT = false;
|
|
161
|
+
let usedTD = false;
|
|
162
|
+
const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
|
|
552
163
|
traverse$2(ast, {
|
|
553
164
|
"JSXElement|JSXFragment"(path) {
|
|
554
165
|
const node = path.node;
|
|
555
166
|
const name = t.isJSXElement(node) && t.isJSXIdentifier(node.openingElement.name) ? node.openingElement.name.name : null;
|
|
556
167
|
if (name && SKIP_ELEMENTS.has(name)) return;
|
|
557
|
-
if (
|
|
168
|
+
if (insideNoTranslate(path)) return;
|
|
169
|
+
const context = name ?? "text";
|
|
170
|
+
if (node.children.some((c) => t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression))) {
|
|
171
|
+
const tpl = buildTemplate(node);
|
|
172
|
+
if (!tpl) return;
|
|
173
|
+
record(tpl.text, context);
|
|
174
|
+
if (!rewrite) return;
|
|
175
|
+
const valueObject = t.objectExpression(tpl.values.map((v) => t.objectProperty(t.identifier(v.name), t.cloneNode(v.expr))));
|
|
176
|
+
node.children = [
|
|
177
|
+
...tpl.lead ? [space()] : [],
|
|
178
|
+
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(TD_IMPORT), [t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(tpl.text)), t.jsxAttribute(t.jsxIdentifier("v"), t.jsxExpressionContainer(valueObject))], true), null, []),
|
|
179
|
+
...tpl.trail ? [space()] : []
|
|
180
|
+
];
|
|
181
|
+
usedTD = true;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
558
184
|
const children = [];
|
|
559
185
|
let changed = false;
|
|
560
186
|
for (const child of node.children) {
|
|
@@ -564,12 +190,11 @@ function transformJsx(code, file, opts = {}) {
|
|
|
564
190
|
children.push(child);
|
|
565
191
|
continue;
|
|
566
192
|
}
|
|
567
|
-
|
|
193
|
+
record(text, context);
|
|
568
194
|
if (!rewrite) {
|
|
569
195
|
children.push(child);
|
|
570
196
|
continue;
|
|
571
197
|
}
|
|
572
|
-
const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
|
|
573
198
|
if (/^\s/.test(cleaned)) children.push(space());
|
|
574
199
|
children.push(t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(T_IMPORT), [t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(text))], true), null, []));
|
|
575
200
|
if (/\s$/.test(cleaned)) children.push(space());
|
|
@@ -584,11 +209,12 @@ function transformJsx(code, file, opts = {}) {
|
|
|
584
209
|
if (!t.isStringLiteral(value)) return;
|
|
585
210
|
const opening = path.parent;
|
|
586
211
|
if (!t.isJSXIdentifier(opening.name) || !/^[a-z]/.test(opening.name.name)) return;
|
|
212
|
+
if (insideNoTranslate(path.parentPath.parentPath)) return;
|
|
587
213
|
const text = value.value.trim();
|
|
588
214
|
if (!text || !isTranslatable(text)) return;
|
|
589
215
|
const component = enclosingComponent(path);
|
|
590
216
|
if (!component) return;
|
|
591
|
-
|
|
217
|
+
record(text, name.name);
|
|
592
218
|
if (!rewrite) return;
|
|
593
219
|
path.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier(HOOK_VAR), [t.stringLiteral(text)]));
|
|
594
220
|
hookTargets.add(component);
|
|
@@ -598,7 +224,8 @@ function transformJsx(code, file, opts = {}) {
|
|
|
598
224
|
if (!rewrite) return {
|
|
599
225
|
code,
|
|
600
226
|
map: null,
|
|
601
|
-
strings: [...strings]
|
|
227
|
+
strings: [...strings],
|
|
228
|
+
contexts
|
|
602
229
|
};
|
|
603
230
|
for (const fn of hookTargets) {
|
|
604
231
|
if (!t.isBlockStatement(fn.node.body)) fn.node.body = t.blockStatement([t.returnStatement(fn.node.body)]);
|
|
@@ -606,6 +233,7 @@ function transformJsx(code, file, opts = {}) {
|
|
|
606
233
|
}
|
|
607
234
|
const specifiers = [];
|
|
608
235
|
if (usedT) specifiers.push(t.importSpecifier(t.identifier(T_IMPORT), t.identifier("__T")));
|
|
236
|
+
if (usedTD) specifiers.push(t.importSpecifier(t.identifier(TD_IMPORT), t.identifier("__TD")));
|
|
609
237
|
if (hookTargets.size) specifiers.push(t.importSpecifier(t.identifier(HOOK_IMPORT), t.identifier("__useT")));
|
|
610
238
|
ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME)));
|
|
611
239
|
const out = generate(ast, {
|
|
@@ -615,12 +243,17 @@ function transformJsx(code, file, opts = {}) {
|
|
|
615
243
|
return {
|
|
616
244
|
code: out.code,
|
|
617
245
|
map: out.map,
|
|
618
|
-
strings: [...strings]
|
|
246
|
+
strings: [...strings],
|
|
247
|
+
contexts
|
|
619
248
|
};
|
|
620
249
|
}
|
|
621
|
-
/** Strings
|
|
622
|
-
function
|
|
623
|
-
|
|
250
|
+
/** Strings (and where they appear), without rewriting. */
|
|
251
|
+
function extractItems(code, file) {
|
|
252
|
+
const r = transformJsx(code, file, { rewrite: false });
|
|
253
|
+
return {
|
|
254
|
+
strings: r?.strings ?? [],
|
|
255
|
+
contexts: r?.contexts ?? {}
|
|
256
|
+
};
|
|
624
257
|
}
|
|
625
258
|
//#endregion
|
|
626
259
|
//#region src/core/scan.ts
|
|
@@ -648,11 +281,21 @@ function* walk(dir) {
|
|
|
648
281
|
else if (isSourceFile(full)) yield full;
|
|
649
282
|
}
|
|
650
283
|
}
|
|
651
|
-
/** Every translatable string under the configured folders, in stable file order. */
|
|
652
|
-
function
|
|
284
|
+
/** Every translatable string under the configured folders, in stable file order, and where each appears. */
|
|
285
|
+
function scanProjectDetailed(root, include) {
|
|
653
286
|
const found = /* @__PURE__ */ new Set();
|
|
654
|
-
|
|
655
|
-
|
|
287
|
+
const contexts = {};
|
|
288
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
289
|
+
const items = extractItems(fs.readFileSync(file, "utf8"), file);
|
|
290
|
+
for (const s of items.strings) {
|
|
291
|
+
found.add(s);
|
|
292
|
+
contexts[s] ??= items.contexts[s] ?? "";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
strings: [...found],
|
|
297
|
+
contexts
|
|
298
|
+
};
|
|
656
299
|
}
|
|
657
300
|
//#endregion
|
|
658
301
|
//#region src/core/head.ts
|
|
@@ -685,6 +328,46 @@ function translateHead(doc, dict) {
|
|
|
685
328
|
}
|
|
686
329
|
}
|
|
687
330
|
//#endregion
|
|
331
|
+
//#region src/core/banner.ts
|
|
332
|
+
const FILE = (root) => path.join(cacheDir(root), "_banner.json");
|
|
333
|
+
/** The banner's fixed phrases in every language, so a visitor sees them in their own language. */
|
|
334
|
+
async function ensureBanner(opts) {
|
|
335
|
+
const source = JSON.stringify(BANNER_EN);
|
|
336
|
+
let cached = {};
|
|
337
|
+
try {
|
|
338
|
+
const file = JSON.parse(fs.readFileSync(FILE(opts.root), "utf8"));
|
|
339
|
+
if (file._source === source) cached = file;
|
|
340
|
+
} catch {}
|
|
341
|
+
const out = { en: BANNER_EN };
|
|
342
|
+
let changed = false;
|
|
343
|
+
const keys = Object.keys(BANNER_EN);
|
|
344
|
+
for (const lang of opts.langs) {
|
|
345
|
+
if (lang === "en") continue;
|
|
346
|
+
if (cached[lang]) {
|
|
347
|
+
out[lang] = cached[lang];
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
opts.log?.(`[react-autolocale] ${lang}: translating banner text...`);
|
|
351
|
+
const jobs = keys.map((k) => mask(BANNER_EN[k], ["{language}"]));
|
|
352
|
+
const translated = await opts.getTranslator().translate(jobs.map((j) => j.masked), "en", lang);
|
|
353
|
+
const text = { ...BANNER_EN };
|
|
354
|
+
keys.forEach((k, i) => {
|
|
355
|
+
text[k] = unmask(translated[i] ?? "", jobs[i].phrases) ?? BANNER_EN[k];
|
|
356
|
+
});
|
|
357
|
+
out[lang] = text;
|
|
358
|
+
cached[lang] = text;
|
|
359
|
+
changed = true;
|
|
360
|
+
}
|
|
361
|
+
if (changed) {
|
|
362
|
+
fs.mkdirSync(cacheDir(opts.root), { recursive: true });
|
|
363
|
+
fs.writeFileSync(FILE(opts.root), JSON.stringify({
|
|
364
|
+
_source: source,
|
|
365
|
+
...cached
|
|
366
|
+
}, null, 2) + "\n");
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
688
371
|
//#region src/core/routes.ts
|
|
689
372
|
const traverse$1 = _traverse.default ?? _traverse;
|
|
690
373
|
function str(node) {
|
|
@@ -1120,27 +803,36 @@ function reactAutolocale(options = {}) {
|
|
|
1120
803
|
let includeDirs = [];
|
|
1121
804
|
let site;
|
|
1122
805
|
let dicts = {};
|
|
806
|
+
let banner = {};
|
|
1123
807
|
let translator;
|
|
1124
808
|
let server;
|
|
1125
809
|
let rendered = false;
|
|
1126
810
|
const log = (msg) => console.log(msg);
|
|
1127
|
-
const getTranslator = () => translator ??= createLocalTranslator(log);
|
|
811
|
+
const getTranslator = () => translator ??= options.engine ? toTranslator(options.engine) : createLocalTranslator(log);
|
|
1128
812
|
async function refresh() {
|
|
1129
813
|
include = options.include ?? defaultInclude(root);
|
|
1130
814
|
includeDirs = include.map((d) => path.resolve(root, d) + path.sep);
|
|
1131
815
|
site = findSite(root, include);
|
|
1132
|
-
const strings =
|
|
816
|
+
const { strings, contexts } = scanProjectDetailed(root, include);
|
|
1133
817
|
if (site.seo) {
|
|
1134
818
|
const html = path.join(root, "index.html");
|
|
1135
819
|
if (fs.existsSync(html)) strings.push(...headStrings(fs.readFileSync(html, "utf8")));
|
|
1136
820
|
}
|
|
821
|
+
banner = await ensureBanner({
|
|
822
|
+
root,
|
|
823
|
+
langs: [site.original, ...site.languages],
|
|
824
|
+
getTranslator,
|
|
825
|
+
log
|
|
826
|
+
});
|
|
1137
827
|
dicts = await ensureTranslations({
|
|
1138
828
|
root,
|
|
1139
829
|
site,
|
|
1140
830
|
strings,
|
|
1141
831
|
getTranslator,
|
|
1142
832
|
log,
|
|
1143
|
-
|
|
833
|
+
contexts,
|
|
834
|
+
glossary: options.glossary,
|
|
835
|
+
overrides: mergeOverrides(readOverrides(root), options.overrides)
|
|
1144
836
|
});
|
|
1145
837
|
}
|
|
1146
838
|
function virtualModule() {
|
|
@@ -1154,6 +846,7 @@ function reactAutolocale(options = {}) {
|
|
|
1154
846
|
`export const original = ${JSON.stringify(site.original)};`,
|
|
1155
847
|
`export const originalNative = ${JSON.stringify(getLanguage(site.original)?.native ?? site.original)};`,
|
|
1156
848
|
`export const seo = ${JSON.stringify(site.seo)};`,
|
|
849
|
+
`export const banner = ${JSON.stringify(banner)};`,
|
|
1157
850
|
`export const languages = ${JSON.stringify(meta)};`,
|
|
1158
851
|
`export const loaders = { ${loaders.join(", ")} };`
|
|
1159
852
|
].join("\n");
|
|
@@ -1193,6 +886,9 @@ function reactAutolocale(options = {}) {
|
|
|
1193
886
|
},
|
|
1194
887
|
configureServer(s) {
|
|
1195
888
|
server = s;
|
|
889
|
+
const file = path.resolve(root, OVERRIDES_FILE);
|
|
890
|
+
s.watcher.add(file);
|
|
891
|
+
s.watcher.on("change", (changed) => changed === file && scheduleRefresh());
|
|
1196
892
|
},
|
|
1197
893
|
async buildStart() {
|
|
1198
894
|
if (config.build.ssr) return;
|
|
@@ -1239,4 +935,4 @@ function reactAutolocale(options = {}) {
|
|
|
1239
935
|
};
|
|
1240
936
|
}
|
|
1241
937
|
//#endregion
|
|
1242
|
-
export { reactAutolocale as default, reactAutolocale };
|
|
938
|
+
export { reactAutolocale as default, reactAutolocale, ollama };
|