react-autolocale 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +111 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +136 -0
- package/dist/redirect-CKNY4ru5.js +26 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +1242 -0
- package/package.json +71 -4
package/dist/vite.js
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
import { n as redirectScript } from "./redirect-CKNY4ru5.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
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
|
+
import { parse } from "@babel/parser";
|
|
9
|
+
import _traverse from "@babel/traverse";
|
|
10
|
+
import _generate from "@babel/generator";
|
|
11
|
+
import * as t from "@babel/types";
|
|
12
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
13
|
+
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
|
+
//#region src/core/transform.ts
|
|
455
|
+
const traverse$2 = _traverse.default ?? _traverse;
|
|
456
|
+
const generate = _generate.default ?? _generate;
|
|
457
|
+
/** Attributes whose string value is user-visible text. */
|
|
458
|
+
const TRANSLATABLE_ATTRS = /* @__PURE__ */ new Set([
|
|
459
|
+
"placeholder",
|
|
460
|
+
"title",
|
|
461
|
+
"alt",
|
|
462
|
+
"aria-label",
|
|
463
|
+
"aria-description"
|
|
464
|
+
]);
|
|
465
|
+
/** Elements whose text content is code, not copy. */
|
|
466
|
+
const SKIP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
467
|
+
"script",
|
|
468
|
+
"style",
|
|
469
|
+
"code",
|
|
470
|
+
"pre",
|
|
471
|
+
"kbd",
|
|
472
|
+
"samp",
|
|
473
|
+
"svg",
|
|
474
|
+
"textarea"
|
|
475
|
+
]);
|
|
476
|
+
const T_IMPORT = "__RLT";
|
|
477
|
+
const HOOK_IMPORT = "__rlUseT";
|
|
478
|
+
const HOOK_VAR = "__rl";
|
|
479
|
+
const RUNTIME = "react-autolocale";
|
|
480
|
+
function parserPlugins(file) {
|
|
481
|
+
if (/\.tsx$/.test(file)) return ["typescript", "jsx"];
|
|
482
|
+
if (/\.ts$/.test(file)) return ["typescript"];
|
|
483
|
+
return ["jsx"];
|
|
484
|
+
}
|
|
485
|
+
/** Collapses JSX text the way React does: lines are trimmed and joined with one space. */
|
|
486
|
+
function cleanJsxText(raw) {
|
|
487
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
488
|
+
let last = 0;
|
|
489
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i])) last = i;
|
|
490
|
+
let out = "";
|
|
491
|
+
for (let i = 0; i < lines.length; i++) {
|
|
492
|
+
let line = lines[i].replace(/\t/g, " ");
|
|
493
|
+
if (i !== 0) line = line.replace(/^[ ]+/, "");
|
|
494
|
+
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, "");
|
|
495
|
+
if (line) {
|
|
496
|
+
if (i !== last) line += " ";
|
|
497
|
+
out += line;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
/** Text worth translating: must contain at least one letter in any script. */
|
|
503
|
+
function isTranslatable(text) {
|
|
504
|
+
return /\p{L}/u.test(text);
|
|
505
|
+
}
|
|
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
|
+
function isComponentName(name) {
|
|
514
|
+
return !!name && /^[A-Z]/.test(name);
|
|
515
|
+
}
|
|
516
|
+
/** Finds the function that is a React component (PascalCase name, possibly wrapped in memo/forwardRef). */
|
|
517
|
+
function enclosingComponent(path) {
|
|
518
|
+
let p = path.parentPath;
|
|
519
|
+
while (p) {
|
|
520
|
+
if (p.isFunction()) {
|
|
521
|
+
const fn = p;
|
|
522
|
+
let name;
|
|
523
|
+
if ((fn.node.type === "FunctionDeclaration" || fn.node.type === "FunctionExpression") && fn.node.id) name = fn.node.id.name;
|
|
524
|
+
if (!name) {
|
|
525
|
+
let up = fn.parentPath;
|
|
526
|
+
while (up && up.isCallExpression()) up = up.parentPath;
|
|
527
|
+
if (up?.isVariableDeclarator() && t.isIdentifier(up.node.id)) name = up.node.id.name;
|
|
528
|
+
}
|
|
529
|
+
if (isComponentName(name)) return fn;
|
|
530
|
+
}
|
|
531
|
+
p = p.parentPath;
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
/** Parses a file, extracts translatable strings, and rewrites JSX to render translations. */
|
|
536
|
+
function transformJsx(code, file, opts = {}) {
|
|
537
|
+
const rewrite = opts.rewrite !== false;
|
|
538
|
+
if (!code.includes("<")) return null;
|
|
539
|
+
let ast;
|
|
540
|
+
try {
|
|
541
|
+
ast = parse(code, {
|
|
542
|
+
sourceType: "module",
|
|
543
|
+
sourceFilename: file,
|
|
544
|
+
plugins: parserPlugins(file)
|
|
545
|
+
});
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const strings = /* @__PURE__ */ new Set();
|
|
550
|
+
const hookTargets = /* @__PURE__ */ new Set();
|
|
551
|
+
let usedT = false;
|
|
552
|
+
traverse$2(ast, {
|
|
553
|
+
"JSXElement|JSXFragment"(path) {
|
|
554
|
+
const node = path.node;
|
|
555
|
+
const name = t.isJSXElement(node) && t.isJSXIdentifier(node.openingElement.name) ? node.openingElement.name.name : null;
|
|
556
|
+
if (name && SKIP_ELEMENTS.has(name)) return;
|
|
557
|
+
if (hasDynamicChild(node)) return;
|
|
558
|
+
const children = [];
|
|
559
|
+
let changed = false;
|
|
560
|
+
for (const child of node.children) {
|
|
561
|
+
const cleaned = t.isJSXText(child) ? cleanJsxText(child.value) : "";
|
|
562
|
+
const text = cleaned.trim();
|
|
563
|
+
if (!t.isJSXText(child) || !text || !isTranslatable(text)) {
|
|
564
|
+
children.push(child);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
strings.add(text);
|
|
568
|
+
if (!rewrite) {
|
|
569
|
+
children.push(child);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
|
|
573
|
+
if (/^\s/.test(cleaned)) children.push(space());
|
|
574
|
+
children.push(t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(T_IMPORT), [t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(text))], true), null, []));
|
|
575
|
+
if (/\s$/.test(cleaned)) children.push(space());
|
|
576
|
+
usedT = true;
|
|
577
|
+
changed = true;
|
|
578
|
+
}
|
|
579
|
+
if (changed) node.children = children;
|
|
580
|
+
},
|
|
581
|
+
JSXAttribute(path) {
|
|
582
|
+
const { name, value } = path.node;
|
|
583
|
+
if (!t.isJSXIdentifier(name) || !TRANSLATABLE_ATTRS.has(name.name)) return;
|
|
584
|
+
if (!t.isStringLiteral(value)) return;
|
|
585
|
+
const opening = path.parent;
|
|
586
|
+
if (!t.isJSXIdentifier(opening.name) || !/^[a-z]/.test(opening.name.name)) return;
|
|
587
|
+
const text = value.value.trim();
|
|
588
|
+
if (!text || !isTranslatable(text)) return;
|
|
589
|
+
const component = enclosingComponent(path);
|
|
590
|
+
if (!component) return;
|
|
591
|
+
strings.add(text);
|
|
592
|
+
if (!rewrite) return;
|
|
593
|
+
path.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier(HOOK_VAR), [t.stringLiteral(text)]));
|
|
594
|
+
hookTargets.add(component);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
if (strings.size === 0) return null;
|
|
598
|
+
if (!rewrite) return {
|
|
599
|
+
code,
|
|
600
|
+
map: null,
|
|
601
|
+
strings: [...strings]
|
|
602
|
+
};
|
|
603
|
+
for (const fn of hookTargets) {
|
|
604
|
+
if (!t.isBlockStatement(fn.node.body)) fn.node.body = t.blockStatement([t.returnStatement(fn.node.body)]);
|
|
605
|
+
fn.node.body.body.unshift(t.variableDeclaration("const", [t.variableDeclarator(t.identifier(HOOK_VAR), t.callExpression(t.identifier(HOOK_IMPORT), []))]));
|
|
606
|
+
}
|
|
607
|
+
const specifiers = [];
|
|
608
|
+
if (usedT) specifiers.push(t.importSpecifier(t.identifier(T_IMPORT), t.identifier("__T")));
|
|
609
|
+
if (hookTargets.size) specifiers.push(t.importSpecifier(t.identifier(HOOK_IMPORT), t.identifier("__useT")));
|
|
610
|
+
ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME)));
|
|
611
|
+
const out = generate(ast, {
|
|
612
|
+
sourceMaps: true,
|
|
613
|
+
sourceFileName: file
|
|
614
|
+
}, code);
|
|
615
|
+
return {
|
|
616
|
+
code: out.code,
|
|
617
|
+
map: out.map,
|
|
618
|
+
strings: [...strings]
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/** Strings only, without rewriting (used by `react-autolocale sync`). */
|
|
622
|
+
function extractStrings(code, file) {
|
|
623
|
+
return transformJsx(code, file, { rewrite: false })?.strings ?? [];
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/core/scan.ts
|
|
627
|
+
const EXTENSIONS = /* @__PURE__ */ new Set([
|
|
628
|
+
".tsx",
|
|
629
|
+
".jsx",
|
|
630
|
+
".ts",
|
|
631
|
+
".js"
|
|
632
|
+
]);
|
|
633
|
+
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
634
|
+
"node_modules",
|
|
635
|
+
"dist",
|
|
636
|
+
"build",
|
|
637
|
+
"coverage"
|
|
638
|
+
]);
|
|
639
|
+
function isSourceFile(file) {
|
|
640
|
+
return EXTENSIONS.has(path.extname(file)) && !file.endsWith(".d.ts") && !/\.(test|spec)\./.test(file);
|
|
641
|
+
}
|
|
642
|
+
function* walk(dir) {
|
|
643
|
+
if (!fs.existsSync(dir)) return;
|
|
644
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
645
|
+
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
|
|
646
|
+
const full = path.join(dir, entry.name);
|
|
647
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
648
|
+
else if (isSourceFile(full)) yield full;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Every translatable string under the configured folders, in stable file order. */
|
|
652
|
+
function scanProject(root, include) {
|
|
653
|
+
const found = /* @__PURE__ */ new Set();
|
|
654
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) for (const s of extractStrings(fs.readFileSync(file, "utf8"), file)) found.add(s);
|
|
655
|
+
return [...found];
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/core/head.ts
|
|
659
|
+
const META_SELECTORS = [
|
|
660
|
+
"meta[name=\"description\"]",
|
|
661
|
+
"meta[property=\"og:title\"]",
|
|
662
|
+
"meta[property=\"og:description\"]",
|
|
663
|
+
"meta[name=\"twitter:title\"]",
|
|
664
|
+
"meta[name=\"twitter:description\"]"
|
|
665
|
+
];
|
|
666
|
+
/** Title and description-like tags worth translating (only used when seo is on). */
|
|
667
|
+
function headStrings(html) {
|
|
668
|
+
const doc = new JSDOM(html).window.document;
|
|
669
|
+
const out = /* @__PURE__ */ new Set();
|
|
670
|
+
const title = doc.title.trim();
|
|
671
|
+
if (title && /\p{L}/u.test(title)) out.add(title);
|
|
672
|
+
for (const sel of META_SELECTORS) {
|
|
673
|
+
const v = doc.querySelector(sel)?.getAttribute("content")?.trim();
|
|
674
|
+
if (v && /\p{L}/u.test(v)) out.add(v);
|
|
675
|
+
}
|
|
676
|
+
return [...out];
|
|
677
|
+
}
|
|
678
|
+
function translateHead(doc, dict) {
|
|
679
|
+
const title = doc.title.trim();
|
|
680
|
+
if (title && dict[title]) doc.title = dict[title];
|
|
681
|
+
for (const sel of META_SELECTORS) {
|
|
682
|
+
const el = doc.querySelector(sel);
|
|
683
|
+
const v = el?.getAttribute("content")?.trim();
|
|
684
|
+
if (el && v && dict[v]) el.setAttribute("content", dict[v]);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
//#endregion
|
|
688
|
+
//#region src/core/routes.ts
|
|
689
|
+
const traverse$1 = _traverse.default ?? _traverse;
|
|
690
|
+
function str(node) {
|
|
691
|
+
if (t.isJSXExpressionContainer(node)) return str(node.expression);
|
|
692
|
+
if (t.isStringLiteral(node)) return node.value;
|
|
693
|
+
if (t.isTemplateLiteral(node) && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
/** "/a" + "b" -> "/a/b"; an absolute child replaces the parent. */
|
|
697
|
+
function join(parent, child) {
|
|
698
|
+
if (child.startsWith("/")) return child;
|
|
699
|
+
return (parent.replace(/\/+$/, "") + "/" + child).replace(/\/+/g, "/");
|
|
700
|
+
}
|
|
701
|
+
/** Only fixed paths can become static pages: no :params, wildcards or optional segments. */
|
|
702
|
+
function isStatic(p) {
|
|
703
|
+
return p.startsWith("/") && !/[:*?()]/.test(p);
|
|
704
|
+
}
|
|
705
|
+
function jsxRoutePath(el) {
|
|
706
|
+
if (!t.isJSXIdentifier(el.name) || el.name.name !== "Route") return null;
|
|
707
|
+
for (const a of el.attributes) if (t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "path" })) return str(a.value);
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
const PAGE_PROPS = /* @__PURE__ */ new Set([
|
|
711
|
+
"element",
|
|
712
|
+
"Component",
|
|
713
|
+
"lazy",
|
|
714
|
+
"index"
|
|
715
|
+
]);
|
|
716
|
+
/** A <Route> that only groups children (no element, Component, lazy or index) is not a page itself. */
|
|
717
|
+
function jsxIsPage(el, hasChildren) {
|
|
718
|
+
if (!hasChildren) return true;
|
|
719
|
+
return el.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && PAGE_PROPS.has(a.name.name));
|
|
720
|
+
}
|
|
721
|
+
function objectIsPage(obj) {
|
|
722
|
+
if (!obj.properties.some((p) => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: "children" }))) return true;
|
|
723
|
+
return obj.properties.some((p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && PAGE_PROPS.has(p.key.name));
|
|
724
|
+
}
|
|
725
|
+
function objectPath(obj) {
|
|
726
|
+
for (const p of obj.properties) if (t.isObjectProperty(p) && (t.isIdentifier(p.key, { name: "path" }) || t.isStringLiteral(p.key, { value: "path" }))) return str(p.value);
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
/** Static route paths defined in one file (React Router <Route path> and route objects with `path`). */
|
|
730
|
+
function routesInCode(code, file) {
|
|
731
|
+
if (!code.includes("path")) return [];
|
|
732
|
+
let ast;
|
|
733
|
+
try {
|
|
734
|
+
ast = parse(code, {
|
|
735
|
+
sourceType: "module",
|
|
736
|
+
plugins: parserPlugins(file)
|
|
737
|
+
});
|
|
738
|
+
} catch {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
const found = /* @__PURE__ */ new Set();
|
|
742
|
+
const add = (p) => isStatic(p) && found.add(p);
|
|
743
|
+
traverse$1(ast, {
|
|
744
|
+
JSXOpeningElement(p) {
|
|
745
|
+
const own = jsxRoutePath(p.node);
|
|
746
|
+
if (own === null) return;
|
|
747
|
+
const chain = [own];
|
|
748
|
+
for (let up = p.parentPath.parentPath; up; up = up.parentPath) if (up.isJSXElement()) {
|
|
749
|
+
const parentPath = jsxRoutePath(up.node.openingElement);
|
|
750
|
+
if (parentPath !== null) chain.unshift(parentPath);
|
|
751
|
+
}
|
|
752
|
+
const el = p.parent;
|
|
753
|
+
if (jsxIsPage(p.node, !!el.children?.some((c) => t.isJSXElement(c)))) add(chain.reduce((acc, seg) => join(acc, seg), "/"));
|
|
754
|
+
},
|
|
755
|
+
ObjectExpression(p) {
|
|
756
|
+
const own = objectPath(p.node);
|
|
757
|
+
if (own === null) return;
|
|
758
|
+
const chain = [own];
|
|
759
|
+
for (let up = p.parentPath; up; up = up.parentPath) if (up.isObjectProperty() && t.isIdentifier(up.node.key, { name: "children" })) {
|
|
760
|
+
const parentObj = up.parentPath;
|
|
761
|
+
if (parentObj?.isObjectExpression()) {
|
|
762
|
+
const parentPath = objectPath(parentObj.node);
|
|
763
|
+
if (parentPath !== null) chain.unshift(parentPath);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (objectIsPage(p.node)) add(chain.reduce((acc, seg) => join(acc, seg), "/"));
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
return [...found];
|
|
770
|
+
}
|
|
771
|
+
/** Every static route found in the project's source folders. */
|
|
772
|
+
function discoverRoutes(root, include) {
|
|
773
|
+
const all = /* @__PURE__ */ new Set();
|
|
774
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) for (const r of routesInCode(fs.readFileSync(file, "utf8"), file)) all.add(r);
|
|
775
|
+
return [...all];
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/core/site.ts
|
|
779
|
+
const traverse = _traverse.default ?? _traverse;
|
|
780
|
+
function literal(node) {
|
|
781
|
+
if (!node) return void 0;
|
|
782
|
+
if (t.isJSXExpressionContainer(node)) return literal(node.expression);
|
|
783
|
+
if (t.isStringLiteral(node)) return node.value;
|
|
784
|
+
if (t.isBooleanLiteral(node)) return node.value;
|
|
785
|
+
if (t.isTemplateLiteral(node) && node.expressions.length === 0) return node.quasis[0].value.cooked ?? void 0;
|
|
786
|
+
if (t.isArrayExpression(node)) {
|
|
787
|
+
const items = node.elements.map((e) => literal(e));
|
|
788
|
+
return items.every((i) => typeof i === "string") ? items : void 0;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** Reads the literal props of `<AutoScale ...>` from source code. Returns null if the file has none. */
|
|
792
|
+
function parseAutoScale(code, file) {
|
|
793
|
+
if (!code.includes("AutoScale")) return null;
|
|
794
|
+
let ast;
|
|
795
|
+
try {
|
|
796
|
+
ast = parse(code, {
|
|
797
|
+
sourceType: "module",
|
|
798
|
+
plugins: parserPlugins(file)
|
|
799
|
+
});
|
|
800
|
+
} catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
let found = null;
|
|
804
|
+
traverse(ast, { JSXOpeningElement(path) {
|
|
805
|
+
const name = path.node.name;
|
|
806
|
+
if (found || !t.isJSXIdentifier(name) || name.name !== "AutoScale") return;
|
|
807
|
+
const props = {};
|
|
808
|
+
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);
|
|
809
|
+
const { original, languages, exclude, seo, siteUrl } = props;
|
|
810
|
+
if (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.`);
|
|
811
|
+
found = {
|
|
812
|
+
original,
|
|
813
|
+
languages: languages.filter((l) => l !== original),
|
|
814
|
+
exclude: Array.isArray(exclude) ? exclude : [],
|
|
815
|
+
seo: seo === true || seo === "true",
|
|
816
|
+
siteUrl: typeof siteUrl === "string" ? siteUrl : void 0
|
|
817
|
+
};
|
|
818
|
+
} });
|
|
819
|
+
return found;
|
|
820
|
+
}
|
|
821
|
+
/** Finds the first <AutoScale> in the project's source folders. */
|
|
822
|
+
function findSite(root, include) {
|
|
823
|
+
for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
|
|
824
|
+
const site = parseAutoScale(fs.readFileSync(file, "utf8"), file);
|
|
825
|
+
if (site) return site;
|
|
826
|
+
}
|
|
827
|
+
throw new Error(`No <AutoScale> found in ${include.join(", ")}. Wrap your app: <AutoScale original="en" languages={["fr", "es"]}><App /></AutoScale>`);
|
|
828
|
+
}
|
|
829
|
+
/** Folders scanned for JSX. */
|
|
830
|
+
function defaultInclude(root) {
|
|
831
|
+
return fs.existsSync(path.join(root, "src")) ? ["src"] : ["."];
|
|
832
|
+
}
|
|
833
|
+
//#endregion
|
|
834
|
+
//#region src/vite/seo.ts
|
|
835
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
836
|
+
const restOf = (p) => p.pathname.slice(p.lang.length + 1) || "/";
|
|
837
|
+
function buildSitemap(base, pages) {
|
|
838
|
+
const byRest = /* @__PURE__ */ new Map();
|
|
839
|
+
for (const p of pages) {
|
|
840
|
+
const rest = restOf(p);
|
|
841
|
+
byRest.set(rest, [...byRest.get(rest) ?? [], p.lang]);
|
|
842
|
+
}
|
|
843
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n" + pages.map((p) => {
|
|
844
|
+
const rest = restOf(p);
|
|
845
|
+
const alts = byRest.get(rest).map((l) => ` <xhtml:link rel="alternate" hreflang="${l}" href="${esc(base + "/" + l + rest)}"/>`).join("\n");
|
|
846
|
+
return ` <url>\n <loc>${esc(base + p.pathname)}</loc>\n${alts}\n </url>`;
|
|
847
|
+
}).join("\n") + `\n</urlset>\n`;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* seo mode: translated title/meta, canonical, hreflang alternates and a sitemap.
|
|
851
|
+
* Only runs when <AutoScale seo> is set; without it these files are never touched.
|
|
852
|
+
*/
|
|
853
|
+
function applySeo(opts) {
|
|
854
|
+
const { outDir, site, dicts, pages, log } = opts;
|
|
855
|
+
const base = (site.siteUrl ?? "").replace(/\/+$/, "");
|
|
856
|
+
if (!base) log("[react-autolocale] seo: no siteUrl set, so hreflang and canonical use relative URLs and no sitemap is written. Add siteUrl=\"https://your-site.com\" to <AutoScale>.");
|
|
857
|
+
const langsByRest = /* @__PURE__ */ new Map();
|
|
858
|
+
for (const p of pages) langsByRest.set(restOf(p), [...langsByRest.get(restOf(p)) ?? [], p.lang]);
|
|
859
|
+
for (const page of pages) {
|
|
860
|
+
const rest = restOf(page);
|
|
861
|
+
const doc = new JSDOM(fs.readFileSync(page.file, "utf8")).window.document;
|
|
862
|
+
if (page.lang !== site.original) translateHead(doc, dicts[page.lang] ?? {});
|
|
863
|
+
doc.querySelectorAll("link[rel=\"canonical\"]").forEach((n) => n.remove());
|
|
864
|
+
const add = (attrs) => {
|
|
865
|
+
const link = doc.createElement("link");
|
|
866
|
+
for (const [k, v] of Object.entries(attrs)) link.setAttribute(k, v);
|
|
867
|
+
doc.head.append(link);
|
|
868
|
+
};
|
|
869
|
+
add({
|
|
870
|
+
rel: "canonical",
|
|
871
|
+
href: `${base}/${page.lang}${rest}`
|
|
872
|
+
});
|
|
873
|
+
const langs = langsByRest.get(rest);
|
|
874
|
+
for (const l of langs) add({
|
|
875
|
+
rel: "alternate",
|
|
876
|
+
hreflang: l,
|
|
877
|
+
href: `${base}/${l}${rest}`
|
|
878
|
+
});
|
|
879
|
+
add({
|
|
880
|
+
rel: "alternate",
|
|
881
|
+
hreflang: "x-default",
|
|
882
|
+
href: `${base}/${langs.includes(site.original) ? site.original : langs[0]}${rest}`
|
|
883
|
+
});
|
|
884
|
+
fs.writeFileSync(page.file, "<!DOCTYPE html>" + doc.documentElement.outerHTML);
|
|
885
|
+
}
|
|
886
|
+
if (base) {
|
|
887
|
+
const sitemapPages = pages.filter((p) => p.pathname.startsWith(`/${p.lang}/`) && p.file !== path.join(outDir, "index.html"));
|
|
888
|
+
fs.writeFileSync(path.join(outDir, "sitemap.xml"), buildSitemap(base, sitemapPages));
|
|
889
|
+
log("[react-autolocale] seo: wrote sitemap.xml (reference it from robots.txt)");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/vite/prerender.ts
|
|
894
|
+
const MAX_PAGES = 500;
|
|
895
|
+
/** Normalizes an internal link like "/fr/about?x=1#y" to "/fr/about/", or returns [] for anything else. */
|
|
896
|
+
function pageLink(href, lang) {
|
|
897
|
+
let url;
|
|
898
|
+
try {
|
|
899
|
+
url = new URL(href, `http://localhost/${lang}/`);
|
|
900
|
+
} catch {
|
|
901
|
+
return [];
|
|
902
|
+
}
|
|
903
|
+
if (url.origin !== "http://localhost") return [];
|
|
904
|
+
const p = url.pathname.replace(/\/+$/, "");
|
|
905
|
+
if (p !== `/${lang}` && !p.startsWith(`/${lang}/`)) return [];
|
|
906
|
+
if (/\.[a-z0-9]+$/i.test(p)) return [];
|
|
907
|
+
return [p + "/"];
|
|
908
|
+
}
|
|
909
|
+
/** Normalizes a user-supplied route like "thank-you" or "/users/1" to "/thank-you/"; null if unusable. */
|
|
910
|
+
function routePath(route) {
|
|
911
|
+
const p = route.trim().split(/[?#]/)[0].replace(/^\/+|\/+$/g, "");
|
|
912
|
+
if (/^[a-z]{2,3}$/i.test(p) || /\.[a-z0-9]+$/i.test(p)) return p ? null : "/";
|
|
913
|
+
return p ? `/${p}/` : "/";
|
|
914
|
+
}
|
|
915
|
+
/** The module script in the project's index.html, e.g. "/src/main.tsx". */
|
|
916
|
+
function findEntry(root) {
|
|
917
|
+
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
|
|
918
|
+
for (const tag of html.match(/<script\b[^>]*>/gi) ?? []) if (/type=["']module["']/i.test(tag)) {
|
|
919
|
+
const src = /src=["']([^"']+)["']/i.exec(tag)?.[1];
|
|
920
|
+
if (src) return src;
|
|
921
|
+
}
|
|
922
|
+
throw new Error("Could not find <script type=\"module\" src=...> in index.html.");
|
|
923
|
+
}
|
|
924
|
+
/** Makes the jsdom window the global scope, so browser-only code in the app can run in Node. Returns an undo function. */
|
|
925
|
+
function installDom(dom) {
|
|
926
|
+
const win = dom.window;
|
|
927
|
+
const keys = /* @__PURE__ */ new Set([
|
|
928
|
+
"window",
|
|
929
|
+
"document",
|
|
930
|
+
"navigator",
|
|
931
|
+
"localStorage",
|
|
932
|
+
"sessionStorage",
|
|
933
|
+
"location",
|
|
934
|
+
"history"
|
|
935
|
+
]);
|
|
936
|
+
for (const k of Object.getOwnPropertyNames(win)) if (!(k in globalThis)) keys.add(k);
|
|
937
|
+
const saved = /* @__PURE__ */ new Map();
|
|
938
|
+
for (const k of keys) {
|
|
939
|
+
saved.set(k, Object.getOwnPropertyDescriptor(globalThis, k));
|
|
940
|
+
try {
|
|
941
|
+
Object.defineProperty(globalThis, k, {
|
|
942
|
+
configurable: true,
|
|
943
|
+
get: () => win[k]
|
|
944
|
+
});
|
|
945
|
+
} catch {}
|
|
946
|
+
}
|
|
947
|
+
return () => {
|
|
948
|
+
for (const [k, d] of saved) if (d) Object.defineProperty(globalThis, k, d);
|
|
949
|
+
else delete globalThis[k];
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
953
|
+
/** Waits until the page has content and has stopped changing (so lazy routes and Suspense have resolved). */
|
|
954
|
+
async function waitForRender(dom) {
|
|
955
|
+
const doc = dom.window.document;
|
|
956
|
+
const root = doc.getElementById("root");
|
|
957
|
+
const deadline = Date.now() + 1e4;
|
|
958
|
+
let last = "";
|
|
959
|
+
let changedAt = Date.now();
|
|
960
|
+
while (Date.now() < deadline) {
|
|
961
|
+
const now = (root ?? doc.body).innerHTML;
|
|
962
|
+
if (now !== last) last = now, changedAt = Date.now();
|
|
963
|
+
else if (now && Date.now() - changedAt >= 400) return;
|
|
964
|
+
await sleep(25);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Browser APIs apps commonly call during render that jsdom does not implement. */
|
|
968
|
+
function polyfill(win) {
|
|
969
|
+
class Noop {
|
|
970
|
+
observe() {}
|
|
971
|
+
unobserve() {}
|
|
972
|
+
disconnect() {}
|
|
973
|
+
takeRecords() {
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
win.matchMedia ??= (query) => ({
|
|
978
|
+
matches: false,
|
|
979
|
+
media: query,
|
|
980
|
+
onchange: null,
|
|
981
|
+
addListener() {},
|
|
982
|
+
removeListener() {},
|
|
983
|
+
addEventListener() {},
|
|
984
|
+
removeEventListener() {},
|
|
985
|
+
dispatchEvent: () => false
|
|
986
|
+
});
|
|
987
|
+
win.ResizeObserver ??= Noop;
|
|
988
|
+
win.IntersectionObserver ??= Noop;
|
|
989
|
+
win.scrollTo ??= () => {};
|
|
990
|
+
win.Element.prototype.scrollIntoView ??= () => {};
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Renders the app once per language in a DOM and writes static pages: dist/<lang>/index.html
|
|
994
|
+
* (and dist/index.html for the original language). Only #root's content and one inline state script
|
|
995
|
+
* are added; everything else in the page is left as built.
|
|
996
|
+
*/
|
|
997
|
+
async function prerender(opts) {
|
|
998
|
+
const { root, outDir, site, include, dicts, log } = opts;
|
|
999
|
+
const extraRoutes = (opts.routes ?? []).map(routePath).filter((r) => r !== null);
|
|
1000
|
+
const sourceUrls = include.flatMap((dir) => [...walk(path.resolve(root, dir))]).filter(isSourceFile).map((f) => "/" + path.relative(root, f).split(path.sep).join("/"));
|
|
1001
|
+
const template = fs.readFileSync(path.join(outDir, "index.html"), "utf8");
|
|
1002
|
+
const entry = findEntry(root);
|
|
1003
|
+
const server = await createServer({
|
|
1004
|
+
root,
|
|
1005
|
+
appType: "custom",
|
|
1006
|
+
logLevel: "silent",
|
|
1007
|
+
server: {
|
|
1008
|
+
middlewareMode: true,
|
|
1009
|
+
hmr: false,
|
|
1010
|
+
watch: null
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
const failures = [];
|
|
1014
|
+
const pages = [];
|
|
1015
|
+
const ssrEnv = server.environments?.ssr;
|
|
1016
|
+
const runner = ssrEnv?.runner;
|
|
1017
|
+
const loadModule = (url) => runner ? runner.import(url) : server.ssrLoadModule(url);
|
|
1018
|
+
const resetModules = () => runner ? runner.clearCache() : server.moduleGraph.invalidateAll();
|
|
1019
|
+
await Promise.all(sourceUrls.map((u) => (ssrEnv?.transformRequest ? ssrEnv.transformRequest(u) : server.transformRequest(u, { ssr: true })).catch(() => null)));
|
|
1020
|
+
/** Renders one URL and returns the same-language page links found in it. */
|
|
1021
|
+
async function renderPage(lang, pathname) {
|
|
1022
|
+
const dict = lang === site.original ? {} : dicts[lang] ?? {};
|
|
1023
|
+
const errors = [];
|
|
1024
|
+
const virtualConsole = new VirtualConsole();
|
|
1025
|
+
virtualConsole.on("jsdomError", (e) => errors.push(String(e.message).split("\n")[0]));
|
|
1026
|
+
const dom = new JSDOM(template, {
|
|
1027
|
+
url: `http://localhost${pathname}`,
|
|
1028
|
+
pretendToBeVisual: true,
|
|
1029
|
+
virtualConsole
|
|
1030
|
+
});
|
|
1031
|
+
const win = dom.window;
|
|
1032
|
+
win.__AUTOSCALE__ = {
|
|
1033
|
+
lang,
|
|
1034
|
+
dict
|
|
1035
|
+
};
|
|
1036
|
+
win.addEventListener("error", (e) => errors.push(String(e.error?.message ?? e.message ?? e)));
|
|
1037
|
+
polyfill(win);
|
|
1038
|
+
const restore = installDom(dom);
|
|
1039
|
+
try {
|
|
1040
|
+
resetModules();
|
|
1041
|
+
await loadModule(entry);
|
|
1042
|
+
await waitForRender(dom);
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
errors.push(err.message);
|
|
1045
|
+
} finally {
|
|
1046
|
+
restore();
|
|
1047
|
+
}
|
|
1048
|
+
const rootEl = dom.window.document.getElementById("root");
|
|
1049
|
+
if (errors.length || rootEl && !rootEl.childElementCount) {
|
|
1050
|
+
failures.push({
|
|
1051
|
+
pathname,
|
|
1052
|
+
error: errors[0] ?? "the page rendered nothing"
|
|
1053
|
+
});
|
|
1054
|
+
dom.window.close();
|
|
1055
|
+
return [];
|
|
1056
|
+
}
|
|
1057
|
+
const doc = dom.window.document;
|
|
1058
|
+
doc.documentElement.lang = lang;
|
|
1059
|
+
doc.documentElement.dir = getLanguage(lang)?.rtl ? "rtl" : "ltr";
|
|
1060
|
+
const state = doc.createElement("script");
|
|
1061
|
+
state.textContent = `window.__AUTOSCALE__=${JSON.stringify({
|
|
1062
|
+
lang,
|
|
1063
|
+
dict
|
|
1064
|
+
}).replace(/</g, "\\u003c")}`;
|
|
1065
|
+
doc.head.prepend(state);
|
|
1066
|
+
const links = [...doc.querySelectorAll("a[href]")].flatMap((a) => pageLink(a.getAttribute("href"), lang));
|
|
1067
|
+
const html = dom.serialize();
|
|
1068
|
+
const dir = path.join(outDir, ...pathname.split("/").filter(Boolean));
|
|
1069
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1070
|
+
fs.writeFileSync(path.join(dir, "index.html"), html);
|
|
1071
|
+
pages.push({
|
|
1072
|
+
lang,
|
|
1073
|
+
pathname,
|
|
1074
|
+
file: path.join(dir, "index.html")
|
|
1075
|
+
});
|
|
1076
|
+
if (pathname === `/${site.original}/`) {
|
|
1077
|
+
const rootDoc = new JSDOM(html).window.document;
|
|
1078
|
+
const redirect = rootDoc.createElement("script");
|
|
1079
|
+
redirect.textContent = redirectScript([site.original, ...site.languages], site.original);
|
|
1080
|
+
rootDoc.head.prepend(redirect);
|
|
1081
|
+
const rootFile = path.join(outDir, "index.html");
|
|
1082
|
+
fs.writeFileSync(rootFile, "<!DOCTYPE html>" + rootDoc.documentElement.outerHTML);
|
|
1083
|
+
pages.push({
|
|
1084
|
+
lang,
|
|
1085
|
+
pathname,
|
|
1086
|
+
file: rootFile
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
log(`[react-autolocale] wrote ${pathname}`);
|
|
1090
|
+
dom.window.close();
|
|
1091
|
+
return links;
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
for (const lang of [site.original, ...site.languages]) {
|
|
1095
|
+
const queue = [`/${lang}/`, ...extraRoutes.map((r) => `/${lang}${r}`)];
|
|
1096
|
+
const seen = new Set(queue);
|
|
1097
|
+
if (seen.size < queue.length) queue.splice(0, queue.length, ...seen);
|
|
1098
|
+
for (let i = 0; i < queue.length; i++) for (const next of await renderPage(lang, queue[i])) if (!seen.has(next) && seen.size < MAX_PAGES) seen.add(next), queue.push(next);
|
|
1099
|
+
}
|
|
1100
|
+
if (failures.length) throw new Error(`${failures.length} page(s) failed to prerender:\n` + failures.map((f) => ` ${f.pathname} ${f.error}`).join("\n"));
|
|
1101
|
+
if (site.seo) applySeo({
|
|
1102
|
+
outDir,
|
|
1103
|
+
site,
|
|
1104
|
+
dicts,
|
|
1105
|
+
pages,
|
|
1106
|
+
log
|
|
1107
|
+
});
|
|
1108
|
+
} finally {
|
|
1109
|
+
await server.close();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/vite/index.ts
|
|
1114
|
+
const VIRTUAL_ID = "virtual:react-autolocale";
|
|
1115
|
+
const RESOLVED_ID = "\0" + VIRTUAL_ID;
|
|
1116
|
+
function reactAutolocale(options = {}) {
|
|
1117
|
+
let config;
|
|
1118
|
+
let root = process.cwd();
|
|
1119
|
+
let include = [];
|
|
1120
|
+
let includeDirs = [];
|
|
1121
|
+
let site;
|
|
1122
|
+
let dicts = {};
|
|
1123
|
+
let translator;
|
|
1124
|
+
let server;
|
|
1125
|
+
let rendered = false;
|
|
1126
|
+
const log = (msg) => console.log(msg);
|
|
1127
|
+
const getTranslator = () => translator ??= createLocalTranslator(log);
|
|
1128
|
+
async function refresh() {
|
|
1129
|
+
include = options.include ?? defaultInclude(root);
|
|
1130
|
+
includeDirs = include.map((d) => path.resolve(root, d) + path.sep);
|
|
1131
|
+
site = findSite(root, include);
|
|
1132
|
+
const strings = scanProject(root, include);
|
|
1133
|
+
if (site.seo) {
|
|
1134
|
+
const html = path.join(root, "index.html");
|
|
1135
|
+
if (fs.existsSync(html)) strings.push(...headStrings(fs.readFileSync(html, "utf8")));
|
|
1136
|
+
}
|
|
1137
|
+
dicts = await ensureTranslations({
|
|
1138
|
+
root,
|
|
1139
|
+
site,
|
|
1140
|
+
strings,
|
|
1141
|
+
getTranslator,
|
|
1142
|
+
log,
|
|
1143
|
+
overrides: options.overrides
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
function virtualModule() {
|
|
1147
|
+
const meta = site.languages.map((code) => ({
|
|
1148
|
+
code,
|
|
1149
|
+
native: getLanguage(code)?.native ?? code,
|
|
1150
|
+
rtl: !!getLanguage(code)?.rtl
|
|
1151
|
+
}));
|
|
1152
|
+
const loaders = site.languages.filter((code) => fs.existsSync(cacheFile(root, code))).map((code) => `${JSON.stringify(code)}: () => import(${JSON.stringify(cacheFile(root, code))})`);
|
|
1153
|
+
return [
|
|
1154
|
+
`export const original = ${JSON.stringify(site.original)};`,
|
|
1155
|
+
`export const originalNative = ${JSON.stringify(getLanguage(site.original)?.native ?? site.original)};`,
|
|
1156
|
+
`export const seo = ${JSON.stringify(site.seo)};`,
|
|
1157
|
+
`export const languages = ${JSON.stringify(meta)};`,
|
|
1158
|
+
`export const loaders = { ${loaders.join(", ")} };`
|
|
1159
|
+
].join("\n");
|
|
1160
|
+
}
|
|
1161
|
+
let timer;
|
|
1162
|
+
let busy = false;
|
|
1163
|
+
function scheduleRefresh() {
|
|
1164
|
+
clearTimeout(timer);
|
|
1165
|
+
timer = setTimeout(async () => {
|
|
1166
|
+
if (busy || !server) return;
|
|
1167
|
+
busy = true;
|
|
1168
|
+
try {
|
|
1169
|
+
await refresh();
|
|
1170
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
1171
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
1172
|
+
server.ws.send({ type: "full-reload" });
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
log(`[react-autolocale] ${err.message}`);
|
|
1175
|
+
} finally {
|
|
1176
|
+
busy = false;
|
|
1177
|
+
}
|
|
1178
|
+
}, 400);
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
name: "react-autolocale",
|
|
1182
|
+
enforce: "pre",
|
|
1183
|
+
config() {
|
|
1184
|
+
return {
|
|
1185
|
+
optimizeDeps: { exclude: ["react-autolocale"] },
|
|
1186
|
+
ssr: { noExternal: ["react-autolocale"] },
|
|
1187
|
+
test: { server: { deps: { inline: ["react-autolocale"] } } }
|
|
1188
|
+
};
|
|
1189
|
+
},
|
|
1190
|
+
configResolved(resolved) {
|
|
1191
|
+
config = resolved;
|
|
1192
|
+
root = resolved.root;
|
|
1193
|
+
},
|
|
1194
|
+
configureServer(s) {
|
|
1195
|
+
server = s;
|
|
1196
|
+
},
|
|
1197
|
+
async buildStart() {
|
|
1198
|
+
if (config.build.ssr) return;
|
|
1199
|
+
await refresh();
|
|
1200
|
+
},
|
|
1201
|
+
resolveId(id) {
|
|
1202
|
+
return id === VIRTUAL_ID ? RESOLVED_ID : null;
|
|
1203
|
+
},
|
|
1204
|
+
load(id) {
|
|
1205
|
+
return id === RESOLVED_ID ? virtualModule() : null;
|
|
1206
|
+
},
|
|
1207
|
+
transform(code, id) {
|
|
1208
|
+
const file = id.split("?")[0];
|
|
1209
|
+
if (file.startsWith("\0") || file.includes("node_modules") || !isSourceFile(file)) return null;
|
|
1210
|
+
if (!includeDirs.some((d) => file.startsWith(d))) return null;
|
|
1211
|
+
const result = transformJsx(code, file);
|
|
1212
|
+
if (!result) return null;
|
|
1213
|
+
if (config.command === "serve" && site) {
|
|
1214
|
+
if (!site.languages.every((l) => result.strings.every((s) => site.exclude.includes(s.trim()) || dicts[l]?.[s]))) scheduleRefresh();
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
code: result.code,
|
|
1218
|
+
map: result.map
|
|
1219
|
+
};
|
|
1220
|
+
},
|
|
1221
|
+
closeBundle: {
|
|
1222
|
+
order: "post",
|
|
1223
|
+
async handler() {
|
|
1224
|
+
if (config.command !== "build" || config.build.ssr || rendered) return;
|
|
1225
|
+
rendered = true;
|
|
1226
|
+
const extra = typeof options.routes === "function" ? await options.routes() : options.routes ?? [];
|
|
1227
|
+
const routes = [.../* @__PURE__ */ new Set([...discoverRoutes(root, include), ...extra])];
|
|
1228
|
+
await prerender({
|
|
1229
|
+
root,
|
|
1230
|
+
outDir: path.resolve(root, config.build.outDir),
|
|
1231
|
+
site,
|
|
1232
|
+
include,
|
|
1233
|
+
routes,
|
|
1234
|
+
dicts,
|
|
1235
|
+
log
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
//#endregion
|
|
1242
|
+
export { reactAutolocale as default, reactAutolocale };
|