react-autolocale 0.1.0 → 0.2.0

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