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