react-autolocale 0.2.0 → 0.3.0-beta.1
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 +169 -32
- package/dist/cli.js +879 -51
- package/dist/config-U-SpTtCW.js +24 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +8 -7
- package/dist/project-BazDFtnp.js +1400 -0
- package/dist/{suggestion-UZQ8ngd0.js → redirect-pDFblLdm.js} +1 -24
- package/dist/suggestion-CkZQqMvZ.js +24 -0
- package/dist/translate-COrJX1KS.d.ts +22 -0
- package/dist/vite.d.ts +9 -28
- package/dist/vite.js +79 -475
- package/package.json +20 -6
- package/dist/overrides-ZQ6meLck.js +0 -557
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
import { t as BANNER_EN } from "./suggestion-CkZQqMvZ.js";
|
|
2
|
+
import { r as PROVIDER_NAMES } from "./config-U-SpTtCW.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { JSDOM } from "jsdom";
|
|
11
|
+
import { parse } from "@babel/parser";
|
|
12
|
+
import _traverse from "@babel/traverse";
|
|
13
|
+
import _generate from "@babel/generator";
|
|
14
|
+
import * as t from "@babel/types";
|
|
15
|
+
const byCode = new Map([
|
|
16
|
+
{
|
|
17
|
+
code: "en",
|
|
18
|
+
native: "English",
|
|
19
|
+
english: "English",
|
|
20
|
+
nllb: "eng_Latn"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
code: "fr",
|
|
24
|
+
native: "Français",
|
|
25
|
+
english: "French",
|
|
26
|
+
nllb: "fra_Latn",
|
|
27
|
+
opus: "fr"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
code: "es",
|
|
31
|
+
native: "Español",
|
|
32
|
+
english: "Spanish",
|
|
33
|
+
nllb: "spa_Latn",
|
|
34
|
+
opus: "es"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: "de",
|
|
38
|
+
native: "Deutsch",
|
|
39
|
+
english: "German",
|
|
40
|
+
nllb: "deu_Latn",
|
|
41
|
+
opus: "de"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
code: "it",
|
|
45
|
+
native: "Italiano",
|
|
46
|
+
english: "Italian",
|
|
47
|
+
nllb: "ita_Latn",
|
|
48
|
+
opus: "it"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
code: "pt",
|
|
52
|
+
native: "Português",
|
|
53
|
+
english: "Portuguese",
|
|
54
|
+
nllb: "por_Latn"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
code: "nl",
|
|
58
|
+
native: "Nederlands",
|
|
59
|
+
english: "Dutch",
|
|
60
|
+
nllb: "nld_Latn",
|
|
61
|
+
opus: "nl"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
code: "ru",
|
|
65
|
+
native: "Русский",
|
|
66
|
+
english: "Russian",
|
|
67
|
+
nllb: "rus_Cyrl",
|
|
68
|
+
opus: "ru"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
code: "zh",
|
|
72
|
+
native: "中文",
|
|
73
|
+
english: "Chinese (Simplified)",
|
|
74
|
+
nllb: "zho_Hans",
|
|
75
|
+
opus: "zh"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
code: "ja",
|
|
79
|
+
native: "日本語",
|
|
80
|
+
english: "Japanese",
|
|
81
|
+
nllb: "jpn_Jpan"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
code: "ko",
|
|
85
|
+
native: "한국어",
|
|
86
|
+
english: "Korean",
|
|
87
|
+
nllb: "kor_Hang"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
code: "ar",
|
|
91
|
+
native: "العربية",
|
|
92
|
+
english: "Arabic",
|
|
93
|
+
nllb: "arb_Arab",
|
|
94
|
+
opus: "ar",
|
|
95
|
+
rtl: true
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
code: "hi",
|
|
99
|
+
native: "हिन्दी",
|
|
100
|
+
english: "Hindi",
|
|
101
|
+
nllb: "hin_Deva",
|
|
102
|
+
opus: "hi"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
code: "tr",
|
|
106
|
+
native: "Türkçe",
|
|
107
|
+
english: "Turkish",
|
|
108
|
+
nllb: "tur_Latn"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
code: "pl",
|
|
112
|
+
native: "Polski",
|
|
113
|
+
english: "Polish",
|
|
114
|
+
nllb: "pol_Latn"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
code: "sv",
|
|
118
|
+
native: "Svenska",
|
|
119
|
+
english: "Swedish",
|
|
120
|
+
nllb: "swe_Latn",
|
|
121
|
+
opus: "sv"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
code: "da",
|
|
125
|
+
native: "Dansk",
|
|
126
|
+
english: "Danish",
|
|
127
|
+
nllb: "dan_Latn",
|
|
128
|
+
opus: "da"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
code: "no",
|
|
132
|
+
native: "Norsk",
|
|
133
|
+
english: "Norwegian",
|
|
134
|
+
nllb: "nob_Latn"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
code: "fi",
|
|
138
|
+
native: "Suomi",
|
|
139
|
+
english: "Finnish",
|
|
140
|
+
nllb: "fin_Latn",
|
|
141
|
+
opus: "fi"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
code: "cs",
|
|
145
|
+
native: "Čeština",
|
|
146
|
+
english: "Czech",
|
|
147
|
+
nllb: "ces_Latn",
|
|
148
|
+
opus: "cs"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
code: "hu",
|
|
152
|
+
native: "Magyar",
|
|
153
|
+
english: "Hungarian",
|
|
154
|
+
nllb: "hun_Latn",
|
|
155
|
+
opus: "hu"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
code: "ro",
|
|
159
|
+
native: "Română",
|
|
160
|
+
english: "Romanian",
|
|
161
|
+
nllb: "ron_Latn",
|
|
162
|
+
opus: "ro"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
code: "uk",
|
|
166
|
+
native: "Українська",
|
|
167
|
+
english: "Ukrainian",
|
|
168
|
+
nllb: "ukr_Cyrl",
|
|
169
|
+
opus: "uk"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
code: "el",
|
|
173
|
+
native: "Ελληνικά",
|
|
174
|
+
english: "Greek",
|
|
175
|
+
nllb: "ell_Grek"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
code: "he",
|
|
179
|
+
native: "עברית",
|
|
180
|
+
english: "Hebrew",
|
|
181
|
+
nllb: "heb_Hebr",
|
|
182
|
+
rtl: true
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
code: "vi",
|
|
186
|
+
native: "Tiếng Việt",
|
|
187
|
+
english: "Vietnamese",
|
|
188
|
+
nllb: "vie_Latn",
|
|
189
|
+
opus: "vi"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
code: "id",
|
|
193
|
+
native: "Bahasa Indonesia",
|
|
194
|
+
english: "Indonesian",
|
|
195
|
+
nllb: "ind_Latn",
|
|
196
|
+
opus: "id"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
code: "th",
|
|
200
|
+
native: "ไทย",
|
|
201
|
+
english: "Thai",
|
|
202
|
+
nllb: "tha_Thai"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
code: "bn",
|
|
206
|
+
native: "বাংলা",
|
|
207
|
+
english: "Bengali",
|
|
208
|
+
nllb: "ben_Beng"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
code: "fa",
|
|
212
|
+
native: "فارسی",
|
|
213
|
+
english: "Persian",
|
|
214
|
+
nllb: "pes_Arab",
|
|
215
|
+
rtl: true
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
code: "ur",
|
|
219
|
+
native: "اردو",
|
|
220
|
+
english: "Urdu",
|
|
221
|
+
nllb: "urd_Arab",
|
|
222
|
+
rtl: true
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
code: "bg",
|
|
226
|
+
native: "Български",
|
|
227
|
+
english: "Bulgarian",
|
|
228
|
+
nllb: "bul_Cyrl"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
code: "hr",
|
|
232
|
+
native: "Hrvatski",
|
|
233
|
+
english: "Croatian",
|
|
234
|
+
nllb: "hrv_Latn"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
code: "sk",
|
|
238
|
+
native: "Slovenčina",
|
|
239
|
+
english: "Slovak",
|
|
240
|
+
nllb: "slk_Latn"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
code: "ca",
|
|
244
|
+
native: "Català",
|
|
245
|
+
english: "Catalan",
|
|
246
|
+
nllb: "cat_Latn"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
code: "ms",
|
|
250
|
+
native: "Bahasa Melayu",
|
|
251
|
+
english: "Malay",
|
|
252
|
+
nllb: "zsm_Latn"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
code: "sw",
|
|
256
|
+
native: "Kiswahili",
|
|
257
|
+
english: "Swahili",
|
|
258
|
+
nllb: "swh_Latn"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
code: "tl",
|
|
262
|
+
native: "Tagalog",
|
|
263
|
+
english: "Tagalog",
|
|
264
|
+
nllb: "tgl_Latn"
|
|
265
|
+
}
|
|
266
|
+
].map((l) => [l.code, l]));
|
|
267
|
+
function getLanguage(code) {
|
|
268
|
+
return byCode.get(code);
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/core/translate.ts
|
|
272
|
+
/**
|
|
273
|
+
* A translation that could not be done. `fatal` errors (missing key, bad credentials, unknown model, missing
|
|
274
|
+
* package) stop the whole run, since every further request would fail the same way.
|
|
275
|
+
*/
|
|
276
|
+
var TranslationError = class extends Error {
|
|
277
|
+
fatal;
|
|
278
|
+
constructor(message, fatal = false) {
|
|
279
|
+
super(message);
|
|
280
|
+
this.fatal = fatal;
|
|
281
|
+
this.name = "TranslationError";
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
function toTranslator(engine) {
|
|
285
|
+
if (typeof engine !== "function") return engine;
|
|
286
|
+
return { translate: (texts, from, to, contexts = []) => engine({
|
|
287
|
+
texts,
|
|
288
|
+
contexts: texts.map((_, i) => contexts[i] ?? ""),
|
|
289
|
+
from,
|
|
290
|
+
to
|
|
291
|
+
}) };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Uses a local LLM through Ollama (free, runs on your machine). Unlike the default models it is told
|
|
295
|
+
* where each string appears, which fixes most one-word mistakes ("Home" in a nav bar is not "Sommaire").
|
|
296
|
+
*/
|
|
297
|
+
function ollama(options = { model: "llama3.1" }) {
|
|
298
|
+
const url = (options.url ?? "http://localhost:11434").replace(/\/+$/, "");
|
|
299
|
+
return { async translate(texts, from, to, contexts = []) {
|
|
300
|
+
const out = [];
|
|
301
|
+
for (let i = 0; i < texts.length; i++) {
|
|
302
|
+
const where = contexts[i] ? ` It appears in a website ${contexts[i]} element.` : "";
|
|
303
|
+
const res = await fetch(`${url}/api/chat`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
signal: AbortSignal.timeout(12e4),
|
|
306
|
+
headers: { "content-type": "application/json" },
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
model: options.model,
|
|
309
|
+
stream: false,
|
|
310
|
+
options: { temperature: 0 },
|
|
311
|
+
messages: [{
|
|
312
|
+
role: "system",
|
|
313
|
+
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.`
|
|
314
|
+
}, {
|
|
315
|
+
role: "user",
|
|
316
|
+
content: texts[i]
|
|
317
|
+
}]
|
|
318
|
+
})
|
|
319
|
+
}).catch((err) => {
|
|
320
|
+
throw new TranslationError(`Could not reach Ollama at ${url} (${err.message}). Is it running?`, err.name !== "TimeoutError");
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) throw new TranslationError(`Ollama request failed (${res.status}). Is the model "${options.model}" pulled?`, res.status === 404);
|
|
323
|
+
const body = await res.json();
|
|
324
|
+
out.push((body.message?.content ?? "").trim());
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
} };
|
|
328
|
+
}
|
|
329
|
+
const ENGINE_PACKAGE = "@huggingface/transformers@^4.3.1";
|
|
330
|
+
const ENGINE_DIR = path.join(os.homedir(), ".cache", "react-autolocale", "engine");
|
|
331
|
+
/**
|
|
332
|
+
* Finds the translation engine. Uses the project's own copy if it has one; otherwise installs it once
|
|
333
|
+
* into ~/.cache/react-autolocale/engine (shared by all projects, never touches the project's node_modules).
|
|
334
|
+
*/
|
|
335
|
+
async function loadEngine(log) {
|
|
336
|
+
try {
|
|
337
|
+
return await import("@huggingface/transformers");
|
|
338
|
+
} catch {}
|
|
339
|
+
const require = createRequire(path.join(ENGINE_DIR, "package.json"));
|
|
340
|
+
const find = () => require.resolve("@huggingface/transformers");
|
|
341
|
+
let entry;
|
|
342
|
+
try {
|
|
343
|
+
entry = find();
|
|
344
|
+
} catch {
|
|
345
|
+
log("[react-autolocale] Installing the translation engine (one time, about 440 MB)...");
|
|
346
|
+
fs.mkdirSync(ENGINE_DIR, { recursive: true });
|
|
347
|
+
const pkg = path.join(ENGINE_DIR, "package.json");
|
|
348
|
+
if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, JSON.stringify({
|
|
349
|
+
name: "react-autolocale-engine",
|
|
350
|
+
private: true
|
|
351
|
+
}));
|
|
352
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
353
|
+
const res = spawnSync(npm, [
|
|
354
|
+
"install",
|
|
355
|
+
"--prefix",
|
|
356
|
+
ENGINE_DIR,
|
|
357
|
+
"--no-audit",
|
|
358
|
+
"--no-fund",
|
|
359
|
+
"--loglevel=error",
|
|
360
|
+
ENGINE_PACKAGE
|
|
361
|
+
], {
|
|
362
|
+
stdio: "inherit",
|
|
363
|
+
shell: process.platform === "win32"
|
|
364
|
+
});
|
|
365
|
+
try {
|
|
366
|
+
if (res.status !== 0) throw new Error("npm failed");
|
|
367
|
+
entry = find();
|
|
368
|
+
} catch {
|
|
369
|
+
throw new TranslationError(`Could not install the translation engine automatically. Install it yourself: npm install -D @huggingface/transformers`, true);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
373
|
+
return mod.pipeline ? mod : mod.default;
|
|
374
|
+
}
|
|
375
|
+
const NLLB_MODEL = "Xenova/nllb-200-distilled-600M";
|
|
376
|
+
const batchSize = (model) => model.includes("opus-mt") ? 8 : 1;
|
|
377
|
+
/**
|
|
378
|
+
* Free, offline translation using Transformers.js (ONNX models run on the CPU).
|
|
379
|
+
* Models are downloaded once from Hugging Face and cached in ~/.cache/react-autolocale.
|
|
380
|
+
* English -> X uses small per-language OPUS-MT models where they exist; everything else uses NLLB-200.
|
|
381
|
+
*/
|
|
382
|
+
function createLocalTranslator(log = () => {}) {
|
|
383
|
+
const pipes = /* @__PURE__ */ new Map();
|
|
384
|
+
let hf = null;
|
|
385
|
+
const getPipe = (model) => {
|
|
386
|
+
let p = pipes.get(model);
|
|
387
|
+
if (!p) {
|
|
388
|
+
hf ??= loadEngine(log).then((m) => {
|
|
389
|
+
m.env.cacheDir = path.join(os.homedir(), ".cache", "react-autolocale", "models");
|
|
390
|
+
return m;
|
|
391
|
+
});
|
|
392
|
+
log(`Loading model ${model} (downloaded once into ~/.cache/react-autolocale/models)...`);
|
|
393
|
+
const lastPct = /* @__PURE__ */ new Map();
|
|
394
|
+
p = hf.then((m) => m.pipeline("translation", model, {
|
|
395
|
+
dtype: "q8",
|
|
396
|
+
progress_callback: (e) => {
|
|
397
|
+
if (e.status === "progress" && e.progress - (lastPct.get(e.file) ?? -10) >= 10) {
|
|
398
|
+
lastPct.set(e.file, e.progress);
|
|
399
|
+
log(` model ${model.split("/").pop()} ${e.file}: ${Math.round(e.progress)}% (download or cache read)`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}));
|
|
403
|
+
pipes.set(model, p);
|
|
404
|
+
}
|
|
405
|
+
return p;
|
|
406
|
+
};
|
|
407
|
+
const run = async (model, texts, options) => {
|
|
408
|
+
const pipe = await getPipe(model);
|
|
409
|
+
const out = [];
|
|
410
|
+
const step = batchSize(model);
|
|
411
|
+
for (let i = 0; i < texts.length; i += step) {
|
|
412
|
+
const res = await pipe(texts.slice(i, i + step), {
|
|
413
|
+
max_new_tokens: 256,
|
|
414
|
+
...options
|
|
415
|
+
});
|
|
416
|
+
out.push(...res.map((r) => r.translation_text.trim()));
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
};
|
|
420
|
+
return { async translate(texts, from, to) {
|
|
421
|
+
const src = getLanguage(from);
|
|
422
|
+
const dst = getLanguage(to);
|
|
423
|
+
if (!src || !dst) throw new Error(`Unsupported language: ${!src ? from : to}`);
|
|
424
|
+
if (from === "en" && dst.opus) try {
|
|
425
|
+
return await run(`Xenova/opus-mt-en-${dst.opus}`, texts, {});
|
|
426
|
+
} catch (err) {
|
|
427
|
+
log(` OPUS-MT model for "${to}" unavailable (${err.message}); falling back to NLLB.`);
|
|
428
|
+
}
|
|
429
|
+
return run(NLLB_MODEL, texts, {
|
|
430
|
+
src_lang: src.nllb,
|
|
431
|
+
tgt_lang: dst.nllb
|
|
432
|
+
});
|
|
433
|
+
} };
|
|
434
|
+
}
|
|
435
|
+
const token = (i) => `X${i}X`;
|
|
436
|
+
/** Prices, phone numbers, versions, hours, percentages: kept exactly as written. */
|
|
437
|
+
const NUMBER = /[$€£¥]?\d+(?:[.,:\-/]\d+)*(?:[a-zA-Z]{1,2}\b)?%?/g;
|
|
438
|
+
/** Runtime values like {name} must survive translation untouched. */
|
|
439
|
+
const PLACEHOLDER = /\{\w+\}/g;
|
|
440
|
+
/**
|
|
441
|
+
* Swaps protected pieces for placeholders so the model leaves them alone: excluded phrases and glossary terms
|
|
442
|
+
* (restored as given), {placeholders}, and numbers. `phrases[i]` is what `X{i}X` turns back into.
|
|
443
|
+
*/
|
|
444
|
+
function mask(text, exclude, opts = {}) {
|
|
445
|
+
const phrases = [];
|
|
446
|
+
const mark = (restore) => {
|
|
447
|
+
phrases.push(restore);
|
|
448
|
+
return String.fromCharCode(57344) + String.fromCharCode(57600 + phrases.length - 1) + String.fromCharCode(57345);
|
|
449
|
+
};
|
|
450
|
+
let masked = text;
|
|
451
|
+
const entries = [...exclude.map((p) => [p, p]), ...Object.entries(opts.glossary ?? {})].sort((a, b) => b[0].length - a[0].length);
|
|
452
|
+
for (const [from, to] of entries) if (from && masked.includes(from)) masked = masked.split(from).join(mark(to));
|
|
453
|
+
masked = masked.replace(PLACEHOLDER, (m) => mark(m));
|
|
454
|
+
if (opts.numbers !== false) masked = masked.replace(NUMBER, (m) => mark(m));
|
|
455
|
+
return {
|
|
456
|
+
masked: masked.replace(/\uE000([\uE100-\uE9FF])\uE001/g, (_, c) => token(c.charCodeAt(0) - 57600)),
|
|
457
|
+
phrases
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/** Puts protected pieces back. Returns null if the model dropped or mangled a placeholder. */
|
|
461
|
+
function unmask(translated, phrases) {
|
|
462
|
+
let out = translated;
|
|
463
|
+
for (let i = 0; i < phrases.length; i++) {
|
|
464
|
+
if (!out.includes(token(i))) return null;
|
|
465
|
+
out = out.split(token(i)).join(phrases[i]);
|
|
466
|
+
}
|
|
467
|
+
return /X\d+X/.test(out) ? null : out;
|
|
468
|
+
}
|
|
469
|
+
/** True if every run of digits in the source still appears in the translation. */
|
|
470
|
+
function digitsPreserved(source, translated) {
|
|
471
|
+
const key = (s) => (s.match(/\d+/g) ?? []).sort().join(",");
|
|
472
|
+
return key(source) === key(translated);
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/core/cache.ts
|
|
476
|
+
/** Where versions before the CLI workflow kept translations (keyed by source text). Read only, for migration. */
|
|
477
|
+
function legacyCacheDir(root) {
|
|
478
|
+
return path.join(root, "node_modules", ".cache", "react-autolocale");
|
|
479
|
+
}
|
|
480
|
+
function legacyCacheFile(root, lang) {
|
|
481
|
+
return path.join(legacyCacheDir(root), `${lang}.json`);
|
|
482
|
+
}
|
|
483
|
+
/** Strings that are entirely an excluded phrase need no translation. */
|
|
484
|
+
function needsTranslation(s, exclude) {
|
|
485
|
+
return !exclude.includes(s.trim());
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Machine-translates `strings` with protected pieces masked. Strings whose result lost a placeholder or changed
|
|
489
|
+
* a number are retried once with numbers locked; if that fails too they are reported in `failed`, never
|
|
490
|
+
* returned as a wrong or empty translation. Provider errors propagate to the caller.
|
|
491
|
+
*/
|
|
492
|
+
async function translateTexts(opts) {
|
|
493
|
+
const { strings, exclude, translator } = opts;
|
|
494
|
+
const terms = opts.glossary ?? {};
|
|
495
|
+
const translated = {};
|
|
496
|
+
const failed = [];
|
|
497
|
+
for (const s of strings) if (terms[s]) translated[s] = terms[s];
|
|
498
|
+
const todo = strings.filter((s) => !terms[s]);
|
|
499
|
+
if (!todo.length) return {
|
|
500
|
+
translated,
|
|
501
|
+
failed
|
|
502
|
+
};
|
|
503
|
+
const ctx = todo.map((s) => opts.contexts?.[s] ?? "");
|
|
504
|
+
const run = async (items, contextList, numbers) => {
|
|
505
|
+
const jobs = items.map((s) => mask(s, exclude, {
|
|
506
|
+
glossary: terms,
|
|
507
|
+
numbers
|
|
508
|
+
}));
|
|
509
|
+
const out = await translator.translate(jobs.map((j) => j.masked), opts.from, opts.to, contextList);
|
|
510
|
+
return jobs.map((job, i) => unmask(out[i] ?? "", job.phrases));
|
|
511
|
+
};
|
|
512
|
+
const first = await run(todo, ctx, false);
|
|
513
|
+
const redo = todo.map((_, i) => i).filter((i) => !first[i] || !digitsPreserved(todo[i], first[i]));
|
|
514
|
+
const second = redo.length ? await run(redo.map((i) => todo[i]), redo.map((i) => ctx[i]), true) : [];
|
|
515
|
+
todo.forEach((source, i) => {
|
|
516
|
+
const result = redo.includes(i) ? second[redo.indexOf(i)] : first[i];
|
|
517
|
+
if (result && result.trim()) translated[source] = result;
|
|
518
|
+
else failed.push(source);
|
|
519
|
+
});
|
|
520
|
+
return {
|
|
521
|
+
translated,
|
|
522
|
+
failed
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/core/files.ts
|
|
527
|
+
/** Writes through a temp file and a rename, so an interrupted run never leaves half a file behind. */
|
|
528
|
+
function writeFileAtomic(file, content) {
|
|
529
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
530
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
531
|
+
fs.writeFileSync(tmp, content);
|
|
532
|
+
fs.renameSync(tmp, file);
|
|
533
|
+
}
|
|
534
|
+
/** Pretty JSON with sorted keys, so new entries land in place and diffs stay small. Skips unchanged files. */
|
|
535
|
+
function writeJson(file, data) {
|
|
536
|
+
const content = JSON.stringify(sortKeys(data), null, 2) + "\n";
|
|
537
|
+
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === content) return false;
|
|
538
|
+
writeFileAtomic(file, content);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
/** Missing file: `fallback`. Broken file: an error naming it (never silently treated as empty). */
|
|
542
|
+
function readJson(file, fallback) {
|
|
543
|
+
if (!fs.existsSync(file)) return fallback;
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
546
|
+
} catch (err) {
|
|
547
|
+
throw new Error(`${file} is not valid JSON (${err.message}). Fix or delete it, then run the command again.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function sortKeys(value) {
|
|
551
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
552
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/core/banner.ts
|
|
557
|
+
/** The banner phrases live next to the catalogs. */
|
|
558
|
+
function bannerFile(output) {
|
|
559
|
+
return path.join(output, "banner.json");
|
|
560
|
+
}
|
|
561
|
+
/** Banner phrases for the plugin: the generated file, else what older versions cached, else English only. */
|
|
562
|
+
function readBanner(output, root) {
|
|
563
|
+
const source = JSON.stringify(BANNER_EN);
|
|
564
|
+
for (const file of [bannerFile(output), path.join(legacyCacheDir(root), "_banner.json")]) try {
|
|
565
|
+
const { _source, ...langs } = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
566
|
+
if (_source === source) return {
|
|
567
|
+
...langs,
|
|
568
|
+
en: BANNER_EN
|
|
569
|
+
};
|
|
570
|
+
} catch {}
|
|
571
|
+
return { en: BANNER_EN };
|
|
572
|
+
}
|
|
573
|
+
/** The banner's fixed phrases in every language, so a visitor sees them in their own language. */
|
|
574
|
+
async function ensureBanner(opts) {
|
|
575
|
+
const source = JSON.stringify(BANNER_EN);
|
|
576
|
+
let cached = {};
|
|
577
|
+
try {
|
|
578
|
+
const file = JSON.parse(fs.readFileSync(bannerFile(opts.output), "utf8"));
|
|
579
|
+
if (file._source === source) cached = file;
|
|
580
|
+
} catch {}
|
|
581
|
+
const out = { en: BANNER_EN };
|
|
582
|
+
let changed = false;
|
|
583
|
+
const keys = Object.keys(BANNER_EN);
|
|
584
|
+
for (const lang of opts.langs) {
|
|
585
|
+
if (lang === "en") continue;
|
|
586
|
+
if (cached[lang]) {
|
|
587
|
+
out[lang] = cached[lang];
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
opts.log?.(`[react-autolocale] ${lang}: translating banner text...`);
|
|
591
|
+
const jobs = keys.map((k) => mask(BANNER_EN[k], ["{language}"]));
|
|
592
|
+
const translated = await opts.getTranslator().translate(jobs.map((j) => j.masked), "en", lang);
|
|
593
|
+
const text = { ...BANNER_EN };
|
|
594
|
+
keys.forEach((k, i) => {
|
|
595
|
+
text[k] = unmask(translated[i] ?? "", jobs[i].phrases) ?? BANNER_EN[k];
|
|
596
|
+
});
|
|
597
|
+
out[lang] = text;
|
|
598
|
+
cached[lang] = text;
|
|
599
|
+
changed = true;
|
|
600
|
+
}
|
|
601
|
+
if (changed) writeJson(bannerFile(opts.output), {
|
|
602
|
+
_source: source,
|
|
603
|
+
...cached
|
|
604
|
+
});
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/core/ids.ts
|
|
609
|
+
/**
|
|
610
|
+
* Stable id for a source string: a hash of its normalized text, so moving code or editing other files never
|
|
611
|
+
* changes it. Identical text shares one id (and one translation). 40 bits keeps collisions out of reach for
|
|
612
|
+
* any real app; `findCollisions` reports one if it ever happens.
|
|
613
|
+
*/
|
|
614
|
+
function messageId(text) {
|
|
615
|
+
return "msg_" + createHash("sha256").update(normalize(text)).digest("hex").slice(0, 10);
|
|
616
|
+
}
|
|
617
|
+
/** Whitespace runs collapse to one space, as they render in the browser. */
|
|
618
|
+
function normalize(text) {
|
|
619
|
+
return text.replace(/\s+/g, " ").trim();
|
|
620
|
+
}
|
|
621
|
+
/** Different texts that hash to the same id. */
|
|
622
|
+
function findCollisions(texts) {
|
|
623
|
+
const seen = /* @__PURE__ */ new Map();
|
|
624
|
+
const out = [];
|
|
625
|
+
for (const text of texts) {
|
|
626
|
+
const id = messageId(text);
|
|
627
|
+
const prev = seen.get(id);
|
|
628
|
+
if (prev !== void 0 && normalize(prev) !== normalize(text)) out.push([prev, text]);
|
|
629
|
+
else seen.set(id, text);
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/core/head.ts
|
|
635
|
+
const META_SELECTORS = [
|
|
636
|
+
"meta[name=\"description\"]",
|
|
637
|
+
"meta[property=\"og:title\"]",
|
|
638
|
+
"meta[property=\"og:description\"]",
|
|
639
|
+
"meta[name=\"twitter:title\"]",
|
|
640
|
+
"meta[name=\"twitter:description\"]"
|
|
641
|
+
];
|
|
642
|
+
/** Title and description-like tags worth translating (only used when seo is on). */
|
|
643
|
+
function headStrings(html) {
|
|
644
|
+
const doc = new JSDOM(html).window.document;
|
|
645
|
+
const out = /* @__PURE__ */ new Set();
|
|
646
|
+
const title = doc.title.trim();
|
|
647
|
+
if (title && /\p{L}/u.test(title)) out.add(title);
|
|
648
|
+
for (const sel of META_SELECTORS) {
|
|
649
|
+
const v = doc.querySelector(sel)?.getAttribute("content")?.trim();
|
|
650
|
+
if (v && /\p{L}/u.test(v)) out.add(v);
|
|
651
|
+
}
|
|
652
|
+
return [...out];
|
|
653
|
+
}
|
|
654
|
+
function translateHead(doc, dict) {
|
|
655
|
+
const title = doc.title.trim();
|
|
656
|
+
if (title && dict[title]) doc.title = dict[title];
|
|
657
|
+
for (const sel of META_SELECTORS) {
|
|
658
|
+
const el = doc.querySelector(sel);
|
|
659
|
+
const v = el?.getAttribute("content")?.trim();
|
|
660
|
+
if (el && v && dict[v]) el.setAttribute("content", dict[v]);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/core/transform.ts
|
|
665
|
+
const traverse$1 = _traverse.default ?? _traverse;
|
|
666
|
+
const generate = _generate.default ?? _generate;
|
|
667
|
+
/** Attributes whose string value is user-visible text. */
|
|
668
|
+
const TRANSLATABLE_ATTRS = /* @__PURE__ */ new Set([
|
|
669
|
+
"placeholder",
|
|
670
|
+
"title",
|
|
671
|
+
"alt",
|
|
672
|
+
"aria-label",
|
|
673
|
+
"aria-placeholder",
|
|
674
|
+
"aria-description"
|
|
675
|
+
]);
|
|
676
|
+
/** Elements whose text content is code, not copy. */
|
|
677
|
+
const SKIP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
678
|
+
"script",
|
|
679
|
+
"style",
|
|
680
|
+
"code",
|
|
681
|
+
"pre",
|
|
682
|
+
"kbd",
|
|
683
|
+
"samp",
|
|
684
|
+
"svg",
|
|
685
|
+
"textarea"
|
|
686
|
+
]);
|
|
687
|
+
const T_IMPORT = "__RLT";
|
|
688
|
+
const TD_IMPORT = "__RLTD";
|
|
689
|
+
const HOOK_IMPORT = "__rlUseT";
|
|
690
|
+
const HOOK_VAR = "__rl";
|
|
691
|
+
const RUNTIME = "react-autolocale";
|
|
692
|
+
function parserPlugins(file) {
|
|
693
|
+
if (/\.tsx$/.test(file)) return ["typescript", "jsx"];
|
|
694
|
+
if (/\.ts$/.test(file)) return ["typescript"];
|
|
695
|
+
return ["jsx"];
|
|
696
|
+
}
|
|
697
|
+
/** Collapses JSX text the way React does: lines are trimmed and joined with one space. */
|
|
698
|
+
function cleanJsxText(raw) {
|
|
699
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
700
|
+
let last = 0;
|
|
701
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i])) last = i;
|
|
702
|
+
let out = "";
|
|
703
|
+
for (let i = 0; i < lines.length; i++) {
|
|
704
|
+
let line = lines[i].replace(/\t/g, " ");
|
|
705
|
+
if (i !== 0) line = line.replace(/^[ ]+/, "");
|
|
706
|
+
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, "");
|
|
707
|
+
if (line) {
|
|
708
|
+
if (i !== last) line += " ";
|
|
709
|
+
out += line;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
/** Text worth translating: must contain at least one letter in any script. */
|
|
715
|
+
function isTranslatable(text) {
|
|
716
|
+
return /\p{L}/u.test(text);
|
|
717
|
+
}
|
|
718
|
+
function isComponentName(name) {
|
|
719
|
+
return !!name && /^[A-Z]/.test(name);
|
|
720
|
+
}
|
|
721
|
+
/** Finds the function that is a React component (PascalCase name, possibly wrapped in memo/forwardRef). */
|
|
722
|
+
function enclosingComponent(path) {
|
|
723
|
+
let p = path.parentPath;
|
|
724
|
+
while (p) {
|
|
725
|
+
if (p.isFunction()) {
|
|
726
|
+
const fn = p;
|
|
727
|
+
let name;
|
|
728
|
+
if ((fn.node.type === "FunctionDeclaration" || fn.node.type === "FunctionExpression") && fn.node.id) name = fn.node.id.name;
|
|
729
|
+
if (!name) {
|
|
730
|
+
let up = fn.parentPath;
|
|
731
|
+
while (up && up.isCallExpression()) up = up.parentPath;
|
|
732
|
+
if (up?.isVariableDeclarator() && t.isIdentifier(up.node.id)) name = up.node.id.name;
|
|
733
|
+
}
|
|
734
|
+
if (isComponentName(name)) return fn;
|
|
735
|
+
}
|
|
736
|
+
p = p.parentPath;
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
/** `translate="no"` on an element (or any ancestor) keeps its text and attributes exactly as written. */
|
|
741
|
+
function hasNoTranslate(opening) {
|
|
742
|
+
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" })));
|
|
743
|
+
}
|
|
744
|
+
function insideNoTranslate(path) {
|
|
745
|
+
for (let p = path; p; p = p.parentPath) if (p.isJSXElement() && hasNoTranslate(p.node.openingElement)) return true;
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
/** A value we can safely hand to the runtime: `name`, `user.name`, `this.props.count`, `items?.[0]`. */
|
|
749
|
+
function simpleValueName(node) {
|
|
750
|
+
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));
|
|
751
|
+
if (!root(node)) return null;
|
|
752
|
+
if (t.isIdentifier(node)) return node.name;
|
|
753
|
+
if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && !node.computed && t.isIdentifier(node.property)) return node.property.name;
|
|
754
|
+
return "value";
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* "Hello {name}!" -> template "Hello {name}!" plus the values to fill in at runtime.
|
|
758
|
+
* Only text mixed with plain variables qualifies: no nested elements, calls, conditions or JSX.
|
|
759
|
+
*/
|
|
760
|
+
function buildTemplate(node) {
|
|
761
|
+
let text = "";
|
|
762
|
+
const values = [];
|
|
763
|
+
const used = /* @__PURE__ */ new Set();
|
|
764
|
+
for (const child of node.children) if (t.isJSXText(child)) text += cleanJsxText(child.value);
|
|
765
|
+
else if (t.isJSXExpressionContainer(child)) {
|
|
766
|
+
const e = child.expression;
|
|
767
|
+
if (t.isJSXEmptyExpression(e)) continue;
|
|
768
|
+
if (t.isStringLiteral(e)) {
|
|
769
|
+
text += e.value;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const base = simpleValueName(e);
|
|
773
|
+
if (!base) return null;
|
|
774
|
+
let name = base;
|
|
775
|
+
for (let i = 2; used.has(name); i++) name = `${base}_${i}`;
|
|
776
|
+
used.add(name);
|
|
777
|
+
values.push({
|
|
778
|
+
name,
|
|
779
|
+
expr: e
|
|
780
|
+
});
|
|
781
|
+
text += `{${name}}`;
|
|
782
|
+
} else return null;
|
|
783
|
+
const letters = text.replace(/\{\w+\}/g, "");
|
|
784
|
+
if (!values.length || !isTranslatable(letters)) return null;
|
|
785
|
+
return {
|
|
786
|
+
text: text.trim(),
|
|
787
|
+
values,
|
|
788
|
+
lead: /^\s/.test(text),
|
|
789
|
+
trail: /\s$/.test(text)
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/** Parses a file, extracts translatable strings, and rewrites JSX to render translations. */
|
|
793
|
+
function transformJsx(code, file, opts = {}) {
|
|
794
|
+
const rewrite = opts.rewrite !== false;
|
|
795
|
+
if (!code.includes("<")) return null;
|
|
796
|
+
let ast;
|
|
797
|
+
try {
|
|
798
|
+
ast = parse(code, {
|
|
799
|
+
sourceType: "module",
|
|
800
|
+
sourceFilename: file,
|
|
801
|
+
plugins: parserPlugins(file)
|
|
802
|
+
});
|
|
803
|
+
} catch {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
const strings = /* @__PURE__ */ new Set();
|
|
807
|
+
/** What kind of element each string sits in ("button", "h1", "placeholder", ...), for context-aware engines. */
|
|
808
|
+
const contexts = {};
|
|
809
|
+
const record = (text, context) => {
|
|
810
|
+
strings.add(text);
|
|
811
|
+
contexts[text] ??= context;
|
|
812
|
+
};
|
|
813
|
+
const hookTargets = /* @__PURE__ */ new Set();
|
|
814
|
+
let usedT = false;
|
|
815
|
+
let usedTD = false;
|
|
816
|
+
const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
|
|
817
|
+
traverse$1(ast, {
|
|
818
|
+
"JSXElement|JSXFragment"(path) {
|
|
819
|
+
const node = path.node;
|
|
820
|
+
const name = t.isJSXElement(node) && t.isJSXIdentifier(node.openingElement.name) ? node.openingElement.name.name : null;
|
|
821
|
+
if (name && SKIP_ELEMENTS.has(name)) return;
|
|
822
|
+
if (insideNoTranslate(path)) return;
|
|
823
|
+
const context = name ?? "text";
|
|
824
|
+
if (node.children.some((c) => t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression))) {
|
|
825
|
+
const tpl = buildTemplate(node);
|
|
826
|
+
if (!tpl) return;
|
|
827
|
+
record(tpl.text, context);
|
|
828
|
+
if (!rewrite) return;
|
|
829
|
+
const valueObject = t.objectExpression(tpl.values.map((v) => t.objectProperty(t.identifier(v.name), t.cloneNode(v.expr))));
|
|
830
|
+
node.children = [
|
|
831
|
+
...tpl.lead ? [space()] : [],
|
|
832
|
+
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(TD_IMPORT), [
|
|
833
|
+
t.jsxAttribute(t.jsxIdentifier("i"), t.stringLiteral(messageId(tpl.text))),
|
|
834
|
+
t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(tpl.text)),
|
|
835
|
+
t.jsxAttribute(t.jsxIdentifier("v"), t.jsxExpressionContainer(valueObject))
|
|
836
|
+
], true), null, []),
|
|
837
|
+
...tpl.trail ? [space()] : []
|
|
838
|
+
];
|
|
839
|
+
usedTD = true;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const children = [];
|
|
843
|
+
let changed = false;
|
|
844
|
+
for (const child of node.children) {
|
|
845
|
+
const cleaned = t.isJSXText(child) ? cleanJsxText(child.value) : "";
|
|
846
|
+
const text = cleaned.trim();
|
|
847
|
+
if (!t.isJSXText(child) || !text || !isTranslatable(text)) {
|
|
848
|
+
children.push(child);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
record(text, context);
|
|
852
|
+
if (!rewrite) {
|
|
853
|
+
children.push(child);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (/^\s/.test(cleaned)) children.push(space());
|
|
857
|
+
children.push(t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(T_IMPORT), [t.jsxAttribute(t.jsxIdentifier("i"), t.stringLiteral(messageId(text))), t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(text))], true), null, []));
|
|
858
|
+
if (/\s$/.test(cleaned)) children.push(space());
|
|
859
|
+
usedT = true;
|
|
860
|
+
changed = true;
|
|
861
|
+
}
|
|
862
|
+
if (changed) node.children = children;
|
|
863
|
+
},
|
|
864
|
+
JSXAttribute(path) {
|
|
865
|
+
const { name, value } = path.node;
|
|
866
|
+
if (!t.isJSXIdentifier(name) || !TRANSLATABLE_ATTRS.has(name.name)) return;
|
|
867
|
+
if (!t.isStringLiteral(value)) return;
|
|
868
|
+
const opening = path.parent;
|
|
869
|
+
if (!t.isJSXIdentifier(opening.name) || !/^[a-z]/.test(opening.name.name)) return;
|
|
870
|
+
if (insideNoTranslate(path.parentPath.parentPath)) return;
|
|
871
|
+
const text = value.value.trim();
|
|
872
|
+
if (!text || !isTranslatable(text)) return;
|
|
873
|
+
const component = enclosingComponent(path);
|
|
874
|
+
if (!component) return;
|
|
875
|
+
record(text, name.name);
|
|
876
|
+
if (!rewrite) return;
|
|
877
|
+
path.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier(HOOK_VAR), [t.stringLiteral(messageId(text)), t.stringLiteral(text)]));
|
|
878
|
+
hookTargets.add(component);
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
if (strings.size === 0) return null;
|
|
882
|
+
if (!rewrite) return {
|
|
883
|
+
code,
|
|
884
|
+
map: null,
|
|
885
|
+
strings: [...strings],
|
|
886
|
+
contexts
|
|
887
|
+
};
|
|
888
|
+
for (const fn of hookTargets) {
|
|
889
|
+
if (!t.isBlockStatement(fn.node.body)) fn.node.body = t.blockStatement([t.returnStatement(fn.node.body)]);
|
|
890
|
+
fn.node.body.body.unshift(t.variableDeclaration("const", [t.variableDeclarator(t.identifier(HOOK_VAR), t.callExpression(t.identifier(HOOK_IMPORT), []))]));
|
|
891
|
+
}
|
|
892
|
+
const specifiers = [];
|
|
893
|
+
if (usedT) specifiers.push(t.importSpecifier(t.identifier(T_IMPORT), t.identifier("__T")));
|
|
894
|
+
if (usedTD) specifiers.push(t.importSpecifier(t.identifier(TD_IMPORT), t.identifier("__TD")));
|
|
895
|
+
if (hookTargets.size) specifiers.push(t.importSpecifier(t.identifier(HOOK_IMPORT), t.identifier("__useT")));
|
|
896
|
+
ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME)));
|
|
897
|
+
const out = generate(ast, {
|
|
898
|
+
sourceMaps: true,
|
|
899
|
+
sourceFileName: file
|
|
900
|
+
}, code);
|
|
901
|
+
return {
|
|
902
|
+
code: out.code,
|
|
903
|
+
map: out.map,
|
|
904
|
+
strings: [...strings],
|
|
905
|
+
contexts
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
/** Strings (and where they appear), without rewriting. */
|
|
909
|
+
function extractItems(code, file) {
|
|
910
|
+
const r = transformJsx(code, file, { rewrite: false });
|
|
911
|
+
return {
|
|
912
|
+
strings: r?.strings ?? [],
|
|
913
|
+
contexts: r?.contexts ?? {}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/core/scan.ts
|
|
918
|
+
const EXTENSIONS = /* @__PURE__ */ new Set([
|
|
919
|
+
".tsx",
|
|
920
|
+
".jsx",
|
|
921
|
+
".ts",
|
|
922
|
+
".js"
|
|
923
|
+
]);
|
|
924
|
+
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
925
|
+
"node_modules",
|
|
926
|
+
"dist",
|
|
927
|
+
"build",
|
|
928
|
+
"coverage"
|
|
929
|
+
]);
|
|
930
|
+
function isSourceFile(file) {
|
|
931
|
+
return EXTENSIONS.has(path.extname(file)) && !file.endsWith(".d.ts") && !/\.(test|spec)\./.test(file);
|
|
932
|
+
}
|
|
933
|
+
function* walk(dir) {
|
|
934
|
+
if (!fs.existsSync(dir)) return;
|
|
935
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
936
|
+
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
|
|
937
|
+
const full = path.join(dir, entry.name);
|
|
938
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
939
|
+
else if (isSourceFile(full)) yield full;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Every translatable string under the configured folders, in stable file order, and where each appears. */
|
|
943
|
+
function scanProjectDetailed(root, include) {
|
|
944
|
+
const found = /* @__PURE__ */ new Set();
|
|
945
|
+
const contexts = {};
|
|
946
|
+
const files = {};
|
|
947
|
+
let fileCount = 0;
|
|
948
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
949
|
+
fileCount++;
|
|
950
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
951
|
+
const items = extractItems(fs.readFileSync(file, "utf8"), file);
|
|
952
|
+
for (const s of items.strings) {
|
|
953
|
+
found.add(s);
|
|
954
|
+
contexts[s] ??= items.contexts[s] ?? "";
|
|
955
|
+
(files[s] ??= []).push(rel);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
strings: [...found],
|
|
960
|
+
contexts,
|
|
961
|
+
files,
|
|
962
|
+
fileCount
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function manifestFile(project) {
|
|
966
|
+
return path.join(project.meta, "manifest.json");
|
|
967
|
+
}
|
|
968
|
+
function readManifest(project) {
|
|
969
|
+
const m = readJson(manifestFile(project), null);
|
|
970
|
+
if (m && m.version !== 1) throw new Error(`${manifestFile(project)} has version ${m.version}; this react-autolocale understands version 1. Update the package.`);
|
|
971
|
+
return m;
|
|
972
|
+
}
|
|
973
|
+
/** Scans the source, updates .auto-locale/manifest.json and the source-language catalog. Never translates. */
|
|
974
|
+
function scanProject(project) {
|
|
975
|
+
const { root, site } = project;
|
|
976
|
+
const scan = scanProjectDetailed(root, project.include);
|
|
977
|
+
if (site.seo) {
|
|
978
|
+
const html = path.join(root, "index.html");
|
|
979
|
+
if (fs.existsSync(html)) for (const s of headStrings(fs.readFileSync(html, "utf8"))) {
|
|
980
|
+
if (!scan.files[s]) scan.strings.push(s);
|
|
981
|
+
scan.contexts[s] ??= "head";
|
|
982
|
+
(scan.files[s] ??= []).push("index.html");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const collisions = findCollisions(scan.strings);
|
|
986
|
+
if (collisions.length) throw new Error("Two different strings got the same message id (extremely rare). Reword one of them slightly:\n" + collisions.map(([a, b]) => ` "${a}" vs "${b}"`).join("\n"));
|
|
987
|
+
const prev = readManifest(project);
|
|
988
|
+
const messages = {};
|
|
989
|
+
for (const [id, m] of Object.entries(prev?.messages ?? {})) messages[id] = {
|
|
990
|
+
...m,
|
|
991
|
+
status: "unused"
|
|
992
|
+
};
|
|
993
|
+
const active = [];
|
|
994
|
+
const added = [];
|
|
995
|
+
for (const text of scan.strings) {
|
|
996
|
+
const id = messageId(text);
|
|
997
|
+
if (!prev?.messages[id]) added.push(id);
|
|
998
|
+
active.push(id);
|
|
999
|
+
messages[id] = {
|
|
1000
|
+
text,
|
|
1001
|
+
context: scan.contexts[text] ?? "",
|
|
1002
|
+
files: [...new Set(scan.files[text] ?? [])].sort(),
|
|
1003
|
+
status: "active"
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
const removed = Object.keys(messages).filter((id) => messages[id].status === "unused" && prev?.messages[id]?.status === "active");
|
|
1007
|
+
const manifest = {
|
|
1008
|
+
version: 1,
|
|
1009
|
+
sourceLocale: site.original,
|
|
1010
|
+
output: project.outputRel,
|
|
1011
|
+
messages
|
|
1012
|
+
};
|
|
1013
|
+
writeJson(manifestFile(project), manifest);
|
|
1014
|
+
writeJson(path.join(project.output, `${site.original}.json`), Object.fromEntries(Object.entries(messages).map(([id, m]) => [id, m.text])));
|
|
1015
|
+
writeIndex(project);
|
|
1016
|
+
return {
|
|
1017
|
+
manifest,
|
|
1018
|
+
fileCount: scan.fileCount,
|
|
1019
|
+
active,
|
|
1020
|
+
added,
|
|
1021
|
+
removed,
|
|
1022
|
+
previousOutput: prev && prev.output !== project.outputRel ? prev.output : void 0,
|
|
1023
|
+
previousSource: prev && prev.sourceLocale !== site.original ? prev.sourceLocale : void 0
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
/** A small, stable module describing the catalogs, for code that wants to read them directly. */
|
|
1027
|
+
function writeIndex(project) {
|
|
1028
|
+
const ts = fs.existsSync(path.join(project.root, "tsconfig.json"));
|
|
1029
|
+
const file = path.join(project.output, ts ? "index.ts" : "index.js");
|
|
1030
|
+
const locales = [project.site.original, ...project.site.languages];
|
|
1031
|
+
const content = `// Generated by react-autolocale. Do not edit: run \`npx react-autolocale generate\` to update.\nexport const sourceLocale = ${JSON.stringify(project.site.original)};\nexport const locales = ${JSON.stringify(locales)}${ts ? " as const" : ""};\n/** Catalog files in this folder: message id -> text. */\nexport const catalogFiles = {${locales.map((l) => ` ${JSON.stringify(l)}: "./${l}.json"`).join(",")} };\n`;
|
|
1032
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !== content) writeFileAtomic(file, content);
|
|
1033
|
+
}
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/core/catalog.ts
|
|
1036
|
+
function catalogFile(project, lang) {
|
|
1037
|
+
return path.join(project.output, `${lang}.json`);
|
|
1038
|
+
}
|
|
1039
|
+
function readCatalog(project, lang) {
|
|
1040
|
+
return readJson(catalogFile(project, lang), {});
|
|
1041
|
+
}
|
|
1042
|
+
/** A usable catalog value: non-empty text. Anything else counts as missing. */
|
|
1043
|
+
const valid = (v) => typeof v === "string" && v.trim() !== "";
|
|
1044
|
+
const cachePath = (project) => path.join(project.meta, "cache.json");
|
|
1045
|
+
function readCache(project) {
|
|
1046
|
+
return readJson(cachePath(project), {
|
|
1047
|
+
version: 1,
|
|
1048
|
+
entries: {}
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Copies translations made by older versions (node_modules/.cache/react-autolocale, keyed by text) into the
|
|
1053
|
+
* cache, so upgrading never retranslates. The old files are left in place.
|
|
1054
|
+
*/
|
|
1055
|
+
function importLegacyCache(project) {
|
|
1056
|
+
if (!fs.existsSync(legacyCacheDir(project.root))) return 0;
|
|
1057
|
+
const cache = readCache(project);
|
|
1058
|
+
let added = 0;
|
|
1059
|
+
for (const lang of project.site.languages) {
|
|
1060
|
+
const old = readJson(legacyCacheFile(project.root, lang), {});
|
|
1061
|
+
const entries = cache.entries[lang] ??= {};
|
|
1062
|
+
for (const [text, value] of Object.entries(old)) if (valid(value) && !entries[text]) entries[text] = {
|
|
1063
|
+
value,
|
|
1064
|
+
provider: "legacy"
|
|
1065
|
+
}, added++;
|
|
1066
|
+
}
|
|
1067
|
+
if (added) writeJson(cachePath(project), cache);
|
|
1068
|
+
return added;
|
|
1069
|
+
}
|
|
1070
|
+
/** Fills every active message missing from each target catalog. Valid existing entries are never replaced. */
|
|
1071
|
+
async function translateMissing(project, manifest, opts) {
|
|
1072
|
+
const { site } = project;
|
|
1073
|
+
const log = opts.log ?? (() => {});
|
|
1074
|
+
const chunkSize = opts.chunkSize ?? 40;
|
|
1075
|
+
const cache = readCache(project);
|
|
1076
|
+
const report = { langs: {} };
|
|
1077
|
+
const active = Object.entries(manifest.messages).filter(([, m]) => m.status === "active" && needsTranslation(m.text, site.exclude));
|
|
1078
|
+
const contexts = Object.fromEntries(active.map(([, m]) => [m.text, m.context]));
|
|
1079
|
+
for (const lang of opts.langs ?? site.languages) {
|
|
1080
|
+
const r = {
|
|
1081
|
+
kept: 0,
|
|
1082
|
+
reused: 0,
|
|
1083
|
+
translated: 0,
|
|
1084
|
+
overridden: 0,
|
|
1085
|
+
failed: []
|
|
1086
|
+
};
|
|
1087
|
+
report.langs[lang] = r;
|
|
1088
|
+
const catalog = readCatalog(project, lang);
|
|
1089
|
+
const cached = cache.entries[lang] ??= {};
|
|
1090
|
+
const fixed = opts.overrides?.[lang] ?? {};
|
|
1091
|
+
const terms = project.glossary[lang] ?? {};
|
|
1092
|
+
const missing = [];
|
|
1093
|
+
for (const [id, m] of active) if (valid(catalog[id])) r.kept++;
|
|
1094
|
+
else if (valid(fixed[m.text])) r.overridden++;
|
|
1095
|
+
else if (valid(cached[m.text]?.value)) catalog[id] = cached[m.text].value, r.reused++;
|
|
1096
|
+
else if (valid(terms[m.text])) catalog[id] = terms[m.text], r.reused++;
|
|
1097
|
+
else missing.push([id, m.text]);
|
|
1098
|
+
if (r.reused) writeJson(catalogFile(project, lang), catalog);
|
|
1099
|
+
if (!missing.length) continue;
|
|
1100
|
+
log(`${lang}: translating ${missing.length} string${missing.length === 1 ? "" : "s"}...`);
|
|
1101
|
+
for (let i = 0; i < missing.length; i += chunkSize) {
|
|
1102
|
+
if (opts.shouldStop?.()) {
|
|
1103
|
+
report.stopped ??= "interrupted";
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
const chunk = missing.slice(i, i + chunkSize);
|
|
1107
|
+
try {
|
|
1108
|
+
const { translated, failed } = await translateTexts({
|
|
1109
|
+
strings: chunk.map(([, text]) => text),
|
|
1110
|
+
from: site.original,
|
|
1111
|
+
to: lang,
|
|
1112
|
+
exclude: site.exclude,
|
|
1113
|
+
contexts,
|
|
1114
|
+
glossary: terms,
|
|
1115
|
+
translator: opts.translator
|
|
1116
|
+
});
|
|
1117
|
+
for (const [id, text] of chunk) if (valid(translated[text])) {
|
|
1118
|
+
catalog[id] = translated[text];
|
|
1119
|
+
cached[text] = {
|
|
1120
|
+
value: translated[text],
|
|
1121
|
+
provider: opts.providerName
|
|
1122
|
+
};
|
|
1123
|
+
r.translated++;
|
|
1124
|
+
}
|
|
1125
|
+
for (const text of failed) r.failed.push({
|
|
1126
|
+
id: messageId(text),
|
|
1127
|
+
text,
|
|
1128
|
+
reason: "the result lost a number, placeholder or protected phrase"
|
|
1129
|
+
});
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
const reason = err.message;
|
|
1132
|
+
for (const [id, text] of chunk) r.failed.push({
|
|
1133
|
+
id,
|
|
1134
|
+
text,
|
|
1135
|
+
reason
|
|
1136
|
+
});
|
|
1137
|
+
if (err.fatal) report.stopped = reason;
|
|
1138
|
+
}
|
|
1139
|
+
writeJson(catalogFile(project, lang), catalog);
|
|
1140
|
+
writeJson(cachePath(project), cache);
|
|
1141
|
+
log(`${lang}: ${Math.min(i + chunkSize, missing.length)}/${missing.length}`);
|
|
1142
|
+
if (report.stopped) break;
|
|
1143
|
+
}
|
|
1144
|
+
if (report.stopped) break;
|
|
1145
|
+
}
|
|
1146
|
+
return report;
|
|
1147
|
+
}
|
|
1148
|
+
/** Removes ids from every catalog (source and targets). Returns how many entries were removed. */
|
|
1149
|
+
function removeFromCatalogs(project, ids) {
|
|
1150
|
+
let removed = 0;
|
|
1151
|
+
for (const lang of [project.site.original, ...project.site.languages]) {
|
|
1152
|
+
const file = catalogFile(project, lang);
|
|
1153
|
+
if (!fs.existsSync(file)) continue;
|
|
1154
|
+
const catalog = readCatalog(project, lang);
|
|
1155
|
+
for (const id of ids) if (id in catalog) delete catalog[id], removed++;
|
|
1156
|
+
writeJson(file, catalog);
|
|
1157
|
+
}
|
|
1158
|
+
return removed;
|
|
1159
|
+
}
|
|
1160
|
+
/** Reads the generated catalogs (no translation, no network). Overrides win; legacy translations fill gaps. */
|
|
1161
|
+
function loadDicts(project, overrides = {}) {
|
|
1162
|
+
const manifest = fs.existsSync(path.join(project.meta, "manifest.json")) ? readManifest(project) : null;
|
|
1163
|
+
const source = readCatalog(project, project.site.original);
|
|
1164
|
+
const legacyText = {};
|
|
1165
|
+
const textOf = (id) => manifest?.messages[id]?.text ?? source[id] ?? legacyText[id];
|
|
1166
|
+
const isActive = (id) => !manifest || manifest.messages[id]?.status === "active";
|
|
1167
|
+
const out = {
|
|
1168
|
+
byId: {},
|
|
1169
|
+
byText: {},
|
|
1170
|
+
missing: {},
|
|
1171
|
+
usedLegacy: false
|
|
1172
|
+
};
|
|
1173
|
+
for (const lang of project.site.languages) {
|
|
1174
|
+
const byId = {};
|
|
1175
|
+
for (const [id, value] of Object.entries(readCatalog(project, lang))) if (valid(value) && isActive(id)) byId[id] = value;
|
|
1176
|
+
for (const [text, value] of Object.entries(readJson(legacyCacheFile(project.root, lang), {}))) {
|
|
1177
|
+
const id = messageId(text);
|
|
1178
|
+
if (valid(value) && !byId[id] && (!manifest || isActive(id))) {
|
|
1179
|
+
byId[id] = value;
|
|
1180
|
+
legacyText[id] = text;
|
|
1181
|
+
out.usedLegacy = true;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
for (const [text, value] of Object.entries(overrides[lang] ?? {})) if (valid(value)) byId[messageId(text)] = value;
|
|
1185
|
+
const byText = {};
|
|
1186
|
+
for (const [id, value] of Object.entries(byId)) {
|
|
1187
|
+
const text = textOf(id);
|
|
1188
|
+
if (text) byText[text] = value;
|
|
1189
|
+
}
|
|
1190
|
+
for (const [text, value] of Object.entries(overrides[lang] ?? {})) if (valid(value)) byText[text] = value;
|
|
1191
|
+
out.byId[lang] = byId;
|
|
1192
|
+
out.byText[lang] = byText;
|
|
1193
|
+
out.missing[lang] = manifest ? Object.entries(manifest.messages).filter(([id, m]) => m.status === "active" && needsTranslation(m.text, project.site.exclude) && !byId[id]).length : 0;
|
|
1194
|
+
}
|
|
1195
|
+
return out;
|
|
1196
|
+
}
|
|
1197
|
+
//#endregion
|
|
1198
|
+
//#region src/core/overrides.ts
|
|
1199
|
+
/** A committed file the developer edits to correct translations. It always wins over machine translation. */
|
|
1200
|
+
const OVERRIDES_FILE = "react-autolocale.overrides.json";
|
|
1201
|
+
function readOverrides(root) {
|
|
1202
|
+
const file = path.join(root, OVERRIDES_FILE);
|
|
1203
|
+
if (!fs.existsSync(file)) return {};
|
|
1204
|
+
try {
|
|
1205
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
throw new Error(`${OVERRIDES_FILE} is not valid JSON: ${err.message}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/** Later arguments win. */
|
|
1211
|
+
function mergeOverrides(...all) {
|
|
1212
|
+
const out = {};
|
|
1213
|
+
for (const o of all) for (const [lang, strings] of Object.entries(o ?? {})) out[lang] = {
|
|
1214
|
+
...out[lang],
|
|
1215
|
+
...strings
|
|
1216
|
+
};
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Writes every generated translation into the overrides file (keeping entries already there) so it can be
|
|
1221
|
+
* edited. Reads the catalogs through `loadDicts`, which also covers translations made by older versions.
|
|
1222
|
+
*/
|
|
1223
|
+
function exportTranslations(project) {
|
|
1224
|
+
const { byText } = loadDicts(project);
|
|
1225
|
+
const generated = Object.fromEntries(Object.entries(byText).filter(([, d]) => Object.keys(d).length));
|
|
1226
|
+
if (!Object.keys(generated).length) throw new Error("No generated translations found. Run `npx react-autolocale generate` first.");
|
|
1227
|
+
const merged = mergeOverrides(generated, readOverrides(project.root));
|
|
1228
|
+
const file = path.join(project.root, OVERRIDES_FILE);
|
|
1229
|
+
writeJson(file, merged);
|
|
1230
|
+
return {
|
|
1231
|
+
file,
|
|
1232
|
+
languages: Object.fromEntries(Object.entries(merged).map(([l, s]) => [l, Object.keys(s).length]))
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/core/paths.ts
|
|
1237
|
+
/** Internal bookkeeping (manifest, translation cache), kept apart from the catalogs the app uses. */
|
|
1238
|
+
const META_DIR = ".auto-locale";
|
|
1239
|
+
function hasRootSrc(root) {
|
|
1240
|
+
try {
|
|
1241
|
+
return fs.statSync(path.join(root, "src")).isDirectory();
|
|
1242
|
+
} catch {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Where catalogs are written, relative to the project root: an explicit `output` wins; otherwise
|
|
1248
|
+
* `src/generated/auto-locale` when the project has a root-level `src/`, else `generated/auto-locale`.
|
|
1249
|
+
*/
|
|
1250
|
+
function defaultOutput(root) {
|
|
1251
|
+
return hasRootSrc(root) ? "src/generated/auto-locale" : "generated/auto-locale";
|
|
1252
|
+
}
|
|
1253
|
+
function resolveOutput(root, output) {
|
|
1254
|
+
return path.resolve(root, output ?? defaultOutput(root));
|
|
1255
|
+
}
|
|
1256
|
+
function metaDir(root) {
|
|
1257
|
+
return path.join(root, META_DIR);
|
|
1258
|
+
}
|
|
1259
|
+
/** Project-relative, forward slashes: how paths are stored in config and the manifest. */
|
|
1260
|
+
function toProjectPath(root, file) {
|
|
1261
|
+
return path.relative(root, file).split(path.sep).join("/") || ".";
|
|
1262
|
+
}
|
|
1263
|
+
//#endregion
|
|
1264
|
+
//#region src/core/site.ts
|
|
1265
|
+
const traverse = _traverse.default ?? _traverse;
|
|
1266
|
+
function literal(node) {
|
|
1267
|
+
if (!node) return void 0;
|
|
1268
|
+
if (t.isJSXExpressionContainer(node)) return literal(node.expression);
|
|
1269
|
+
if (t.isStringLiteral(node)) return node.value;
|
|
1270
|
+
if (t.isBooleanLiteral(node)) return node.value;
|
|
1271
|
+
if (t.isTemplateLiteral(node) && node.expressions.length === 0) return node.quasis[0].value.cooked ?? void 0;
|
|
1272
|
+
if (t.isArrayExpression(node)) {
|
|
1273
|
+
const items = node.elements.map((e) => literal(e));
|
|
1274
|
+
return items.every((i) => typeof i === "string") ? items : void 0;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Reads the literal props of `<AutoScale ...>` from source code. Returns null if the file has none.
|
|
1279
|
+
* With `required: false` (languages come from auto-locale.config.ts), missing props are allowed.
|
|
1280
|
+
*/
|
|
1281
|
+
function parseAutoScale(code, file, opts = {}) {
|
|
1282
|
+
if (!code.includes("AutoScale")) return null;
|
|
1283
|
+
let ast;
|
|
1284
|
+
try {
|
|
1285
|
+
ast = parse(code, {
|
|
1286
|
+
sourceType: "module",
|
|
1287
|
+
plugins: parserPlugins(file)
|
|
1288
|
+
});
|
|
1289
|
+
} catch {
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
let found = null;
|
|
1293
|
+
traverse(ast, { JSXOpeningElement(path) {
|
|
1294
|
+
const name = path.node.name;
|
|
1295
|
+
if (found || !t.isJSXIdentifier(name) || name.name !== "AutoScale") return;
|
|
1296
|
+
const props = {};
|
|
1297
|
+
for (const attr of path.node.attributes) if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) props[attr.name.name] = attr.value === null ? true : literal(attr.value);
|
|
1298
|
+
const { original, languages, exclude, seo, siteUrl } = props;
|
|
1299
|
+
if (opts.required !== false && (typeof original !== "string" || !Array.isArray(languages))) throw new Error(`${file}: <AutoScale> needs literal props, e.g. original="en" languages={["fr", "es"]}. Values must be written inline (not variables) so they can be read at build time.`);
|
|
1300
|
+
found = {
|
|
1301
|
+
original: typeof original === "string" ? original : "",
|
|
1302
|
+
languages: Array.isArray(languages) ? languages.filter((l) => l !== original) : [],
|
|
1303
|
+
exclude: Array.isArray(exclude) ? exclude : [],
|
|
1304
|
+
seo: seo === true || seo === "true",
|
|
1305
|
+
siteUrl: typeof siteUrl === "string" ? siteUrl : void 0
|
|
1306
|
+
};
|
|
1307
|
+
} });
|
|
1308
|
+
return found;
|
|
1309
|
+
}
|
|
1310
|
+
function findSite(root, include, opts = {}) {
|
|
1311
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
1312
|
+
const site = parseAutoScale(fs.readFileSync(file, "utf8"), file, opts);
|
|
1313
|
+
if (site) return site;
|
|
1314
|
+
}
|
|
1315
|
+
if (opts.required === false) return null;
|
|
1316
|
+
throw new Error(`No <AutoScale> found in ${include.join(", ")}. Wrap your app: <AutoScale original="en" languages={["fr", "es"]}><App /></AutoScale>`);
|
|
1317
|
+
}
|
|
1318
|
+
/** Folders scanned for JSX. */
|
|
1319
|
+
function defaultInclude(root) {
|
|
1320
|
+
return fs.existsSync(path.join(root, "src")) ? ["src"] : ["."];
|
|
1321
|
+
}
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/core/project.ts
|
|
1324
|
+
const CONFIG_FILES = [
|
|
1325
|
+
"auto-locale.config.ts",
|
|
1326
|
+
"auto-locale.config.mts",
|
|
1327
|
+
"auto-locale.config.js",
|
|
1328
|
+
"auto-locale.config.mjs"
|
|
1329
|
+
];
|
|
1330
|
+
function findConfigFile(root) {
|
|
1331
|
+
return CONFIG_FILES.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
1332
|
+
}
|
|
1333
|
+
/** Loads auto-locale.config.* through Vite's own config loader, so TypeScript works without extra tooling. */
|
|
1334
|
+
async function loadConfigFile(file, root) {
|
|
1335
|
+
const { loadConfigFromFile } = await import("vite");
|
|
1336
|
+
const loaded = await loadConfigFromFile({
|
|
1337
|
+
command: "build",
|
|
1338
|
+
mode: "production"
|
|
1339
|
+
}, file, root, "silent");
|
|
1340
|
+
if (!loaded) throw new Error(`Could not load ${path.basename(file)}.`);
|
|
1341
|
+
return loaded.config;
|
|
1342
|
+
}
|
|
1343
|
+
/** Throws a message that names the bad setting. */
|
|
1344
|
+
function validateConfig(config, file = "auto-locale.config.ts") {
|
|
1345
|
+
const fail = (msg) => {
|
|
1346
|
+
throw new Error(`${file}: ${msg}`);
|
|
1347
|
+
};
|
|
1348
|
+
if (config === null || typeof config !== "object") fail("must export an object, e.g. export default defineConfig({ ... }).");
|
|
1349
|
+
const { sourceLocale, locales, output, include, exclude, provider } = config;
|
|
1350
|
+
if (sourceLocale !== void 0 && (typeof sourceLocale !== "string" || !getLanguage(sourceLocale))) fail(`sourceLocale "${sourceLocale}" is not a supported language code.`);
|
|
1351
|
+
if (locales !== void 0) {
|
|
1352
|
+
if (!Array.isArray(locales) || !locales.length) fail(`locales must be a non-empty array, e.g. ["en", "fr"].`);
|
|
1353
|
+
const bad = locales.filter((l) => typeof l !== "string" || !getLanguage(l));
|
|
1354
|
+
if (bad.length) fail(`unsupported language code${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}.`);
|
|
1355
|
+
}
|
|
1356
|
+
if (sourceLocale === void 0 !== (locales === void 0)) fail("set both sourceLocale and locales, or neither.");
|
|
1357
|
+
if (output !== void 0 && (typeof output !== "string" || !output.trim())) fail("output must be a path string.");
|
|
1358
|
+
for (const [key, value] of Object.entries({
|
|
1359
|
+
include,
|
|
1360
|
+
exclude
|
|
1361
|
+
})) if (value !== void 0 && (!Array.isArray(value) || value.some((v) => typeof v !== "string"))) fail(`${key} must be an array of strings.`);
|
|
1362
|
+
if (provider && typeof provider === "object" && !("translate" in provider)) {
|
|
1363
|
+
if (!PROVIDER_NAMES.includes(provider.name)) fail(`provider.name must be one of ${PROVIDER_NAMES.join(", ")} (got "${provider.name}").`);
|
|
1364
|
+
if (provider.name === "ollama" && !provider.model) fail(`provider "ollama" needs a model, e.g. { name: "ollama", model: "llama3.1" }.`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
async function loadProject(root, opts = {}) {
|
|
1368
|
+
const configFile = findConfigFile(root);
|
|
1369
|
+
const config = configFile ? await loadConfigFile(configFile, root) : {};
|
|
1370
|
+
validateConfig(config, configFile ? path.basename(configFile) : void 0);
|
|
1371
|
+
const include = config.include ?? defaultInclude(root);
|
|
1372
|
+
let site;
|
|
1373
|
+
if (config.sourceLocale && config.locales) {
|
|
1374
|
+
const props = findSite(root, include, { required: false });
|
|
1375
|
+
const languages = config.locales.filter((l) => l !== config.sourceLocale);
|
|
1376
|
+
if (props?.original && (props.original !== config.sourceLocale || props.languages.join() !== languages.join())) opts.log?.(`[react-autolocale] <AutoScale> languages differ from ${path.basename(configFile)}; using the config file.`);
|
|
1377
|
+
site = {
|
|
1378
|
+
original: config.sourceLocale,
|
|
1379
|
+
languages,
|
|
1380
|
+
exclude: props?.exclude ?? [],
|
|
1381
|
+
seo: props?.seo ?? false,
|
|
1382
|
+
siteUrl: props?.siteUrl
|
|
1383
|
+
};
|
|
1384
|
+
} else site = findSite(root, include);
|
|
1385
|
+
site.exclude = [.../* @__PURE__ */ new Set([...site.exclude, ...config.exclude ?? []])];
|
|
1386
|
+
const output = resolveOutput(root, config.output);
|
|
1387
|
+
return {
|
|
1388
|
+
root,
|
|
1389
|
+
configFile,
|
|
1390
|
+
config,
|
|
1391
|
+
site,
|
|
1392
|
+
include,
|
|
1393
|
+
output,
|
|
1394
|
+
outputRel: toProjectPath(root, output),
|
|
1395
|
+
meta: metaDir(root),
|
|
1396
|
+
glossary: config.glossary ?? {}
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
//#endregion
|
|
1400
|
+
export { readBanner as A, isSourceFile as C, translateHead as D, transformJsx as E, toTranslator as F, getLanguage as I, TranslationError as M, createLocalTranslator as N, messageId as O, ollama as P, scanProject as S, parserPlugins as T, loadDicts as _, findSite as a, manifestFile as b, hasRootSrc as c, OVERRIDES_FILE as d, exportTranslations as f, importLegacyCache as g, catalogFile as h, defaultInclude as i, writeJson as j, ensureBanner as k, resolveOutput as l, readOverrides as m, loadConfigFile as n, META_DIR as o, mergeOverrides as p, loadProject as r, defaultOutput as s, findConfigFile as t, toProjectPath as u, removeFromCatalogs as v, walk as w, readManifest as x, translateMissing as y };
|