react-autolocale 0.2.0 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -32
- package/dist/cli.js +879 -51
- package/dist/config-U-SpTtCW.js +24 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +8 -7
- package/dist/project-BazDFtnp.js +1400 -0
- package/dist/{suggestion-UZQ8ngd0.js → redirect-pDFblLdm.js} +1 -24
- package/dist/suggestion-CkZQqMvZ.js +24 -0
- package/dist/translate-COrJX1KS.d.ts +22 -0
- package/dist/vite.d.ts +9 -28
- package/dist/vite.js +79 -475
- package/package.json +20 -6
- package/dist/overrides-ZQ6meLck.js +0 -557
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,467 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { F as toTranslator, I as getLanguage, M as TranslationError, N as createLocalTranslator, P as ollama, S as scanProject, a as findSite, b as manifestFile, c as hasRootSrc, d as OVERRIDES_FILE, f as exportTranslations, g as importLegacyCache, h as catalogFile, i as defaultInclude, j as writeJson, k as ensureBanner, l as resolveOutput, m as readOverrides, n as loadConfigFile, o as META_DIR, r as loadProject, s as defaultOutput, t as findConfigFile, u as toProjectPath, v as removeFromCatalogs, x as readManifest, y as translateMissing } from "./project-BazDFtnp.js";
|
|
3
|
+
import { n as DEFAULT_MODELS, t as DEFAULT_KEY_ENV } from "./config-U-SpTtCW.js";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import readline from "node:readline/promises";
|
|
10
|
+
//#region src/core/env.ts
|
|
11
|
+
/** Parses KEY=value lines (optional quotes, # comments, `export ` prefix). */
|
|
12
|
+
function parseEnv(text) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
15
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
let value = m[2];
|
|
18
|
+
const quoted = /^(['"])(.*)\1$/.exec(value);
|
|
19
|
+
value = quoted ? quoted[2] : value.replace(/\s+#.*$/, "");
|
|
20
|
+
out[m[1]] = value;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Loads .env.local and .env into process.env for the CLI. Variables already set always win. */
|
|
25
|
+
function loadEnv(root) {
|
|
26
|
+
for (const name of [".env.local", ".env"]) {
|
|
27
|
+
const file = path.join(root, name);
|
|
28
|
+
if (!fs.existsSync(file)) continue;
|
|
29
|
+
for (const [key, value] of Object.entries(parseEnv(fs.readFileSync(file, "utf8")))) if (process.env[key] === void 0) process.env[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/core/providers.ts
|
|
34
|
+
/**
|
|
35
|
+
* Builds the configured engine. Called only by the `translate`/`generate`/`sync` commands; the Vite plugin never
|
|
36
|
+
* loads this module. Throws a fatal TranslationError (before any request) when a key or package is missing.
|
|
37
|
+
*/
|
|
38
|
+
async function createProvider(project, log) {
|
|
39
|
+
const p = project.config.provider ?? { name: "local" };
|
|
40
|
+
if (typeof p === "function" || "translate" in p) return {
|
|
41
|
+
translator: toTranslator(p),
|
|
42
|
+
name: "custom",
|
|
43
|
+
chunkSize: 40
|
|
44
|
+
};
|
|
45
|
+
switch (p.name) {
|
|
46
|
+
case "local": return {
|
|
47
|
+
translator: createLocalTranslator(log),
|
|
48
|
+
name: "local",
|
|
49
|
+
chunkSize: 40
|
|
50
|
+
};
|
|
51
|
+
case "ollama": return {
|
|
52
|
+
translator: ollama({
|
|
53
|
+
model: p.model,
|
|
54
|
+
url: p.url
|
|
55
|
+
}),
|
|
56
|
+
name: `ollama:${p.model}`,
|
|
57
|
+
chunkSize: 20
|
|
58
|
+
};
|
|
59
|
+
case "openai": return {
|
|
60
|
+
translator: await openaiTranslator(project.root, p),
|
|
61
|
+
name: `openai:${p.model ?? DEFAULT_MODELS.openai}`,
|
|
62
|
+
chunkSize: 100
|
|
63
|
+
};
|
|
64
|
+
case "anthropic": return {
|
|
65
|
+
translator: await anthropicTranslator(project.root, p),
|
|
66
|
+
name: `anthropic:${p.model ?? DEFAULT_MODELS.anthropic}`,
|
|
67
|
+
chunkSize: 100
|
|
68
|
+
};
|
|
69
|
+
case "gemini": return {
|
|
70
|
+
translator: geminiTranslator(p),
|
|
71
|
+
name: `gemini:${p.model ?? DEFAULT_MODELS.gemini}`,
|
|
72
|
+
chunkSize: 100
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function apiKey(provider, envName) {
|
|
77
|
+
const name = envName ?? DEFAULT_KEY_ENV[provider];
|
|
78
|
+
const key = process.env[name];
|
|
79
|
+
if (!key) throw new TranslationError(`${name} is not set. Add it to your environment or to .env in the project root (${name}=...), or run \`npx react-autolocale init\` again. No request was made.`, true);
|
|
80
|
+
return key;
|
|
81
|
+
}
|
|
82
|
+
/** Loads a provider SDK from the user's project first (that's where they install it), then from ours. */
|
|
83
|
+
async function importFromProject(root, pkg) {
|
|
84
|
+
try {
|
|
85
|
+
const entry = createRequire(path.join(root, "package.json")).resolve(pkg);
|
|
86
|
+
return await import(pathToFileURL(entry).href);
|
|
87
|
+
} catch {}
|
|
88
|
+
try {
|
|
89
|
+
return await import(pkg);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new TranslationError(`This provider needs the "${pkg}" package in your project: npm install -D ${pkg}`, true);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const BATCH = 25;
|
|
95
|
+
const CONCURRENCY = 3;
|
|
96
|
+
const TRANSLATIONS_SCHEMA = {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: { translations: {
|
|
99
|
+
type: "array",
|
|
100
|
+
items: { type: "string" }
|
|
101
|
+
} },
|
|
102
|
+
required: ["translations"],
|
|
103
|
+
additionalProperties: false
|
|
104
|
+
};
|
|
105
|
+
function systemPrompt(from, to) {
|
|
106
|
+
const name = (code) => `${getLanguage(code)?.english ?? code} (${code})`;
|
|
107
|
+
return [
|
|
108
|
+
`You translate user-interface text for a website from ${name(from)} to ${name(to)}.`,
|
|
109
|
+
`The user sends JSON {"items": [{"text", "element"?}]}; "element" is the HTML element or attribute the text appears in.`,
|
|
110
|
+
`Reply with {"translations": [...]}: exactly one translation per item, in the same order.`,
|
|
111
|
+
`Keep tokens such as X0X, X1X and {name} exactly as written. Keep labels as short as the original.`,
|
|
112
|
+
`Translate only; add no notes, quotes or explanations.`
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
function userPrompt(texts, contexts) {
|
|
116
|
+
return JSON.stringify({ items: texts.map((text, i) => contexts[i] ? {
|
|
117
|
+
text,
|
|
118
|
+
element: contexts[i]
|
|
119
|
+
} : { text }) });
|
|
120
|
+
}
|
|
121
|
+
/** Parses {"translations": [...]} and checks there is one string per input. */
|
|
122
|
+
function parseTranslations(raw, expected) {
|
|
123
|
+
try {
|
|
124
|
+
const out = JSON.parse(raw).translations;
|
|
125
|
+
return Array.isArray(out) && out.length === expected && out.every((t) => typeof t === "string") ? out : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Splits work into batches, runs a few at a time, and retries a batch one string at a time if the model
|
|
132
|
+
* returns the wrong number of items. Provider SDKs handle timeouts and retries of transient HTTP errors.
|
|
133
|
+
*/
|
|
134
|
+
function batchedTranslator(call) {
|
|
135
|
+
const one = async (texts, contexts, from, to) => {
|
|
136
|
+
const parsed = parseTranslations(await call(texts, contexts, from, to), texts.length);
|
|
137
|
+
if (parsed) return parsed;
|
|
138
|
+
if (texts.length === 1) return [""];
|
|
139
|
+
const out = [];
|
|
140
|
+
for (let i = 0; i < texts.length; i++) out.push(...await one([texts[i]], [contexts[i] ?? ""], from, to));
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
return { async translate(texts, from, to, contexts = []) {
|
|
144
|
+
const batches = [];
|
|
145
|
+
for (let i = 0; i < texts.length; i += BATCH) batches.push(i);
|
|
146
|
+
const results = new Array(batches.length);
|
|
147
|
+
let next = 0;
|
|
148
|
+
const worker = async () => {
|
|
149
|
+
while (next < batches.length) {
|
|
150
|
+
const b = next++;
|
|
151
|
+
const start = batches[b];
|
|
152
|
+
results[b] = await one(texts.slice(start, start + BATCH), contexts.slice(start, start + BATCH), from, to);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, batches.length) }, worker));
|
|
156
|
+
return results.flat();
|
|
157
|
+
} };
|
|
158
|
+
}
|
|
159
|
+
/** Turns SDK errors into TranslationErrors; credential, permission and request errors stop the run. */
|
|
160
|
+
function classify(err, sdk, label) {
|
|
161
|
+
const msg = err.message;
|
|
162
|
+
if (err instanceof sdk.AuthenticationError) throw new TranslationError(`${label}: the API key was rejected (${msg}).`, true);
|
|
163
|
+
if (err instanceof sdk.PermissionDeniedError) throw new TranslationError(`${label}: permission denied (${msg}).`, true);
|
|
164
|
+
if (err instanceof sdk.NotFoundError) throw new TranslationError(`${label}: not found; check the model name (${msg}).`, true);
|
|
165
|
+
if (err instanceof sdk.BadRequestError) throw new TranslationError(`${label}: request rejected (${msg}).`, true);
|
|
166
|
+
if (err instanceof sdk.RateLimitError) throw new TranslationError(`${label}: rate limit or quota reached after retries (${msg}). Run the command again later.`, true);
|
|
167
|
+
if (err instanceof sdk.APIError || err instanceof sdk.APIConnectionError) throw new TranslationError(`${label}: ${msg}`);
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
async function openaiTranslator(root, p) {
|
|
171
|
+
const key = apiKey("openai", p.apiKeyEnv);
|
|
172
|
+
const OpenAI = (await importFromProject(root, "openai")).default;
|
|
173
|
+
const client = new OpenAI({
|
|
174
|
+
apiKey: key,
|
|
175
|
+
baseURL: p.baseURL,
|
|
176
|
+
timeout: 12e4,
|
|
177
|
+
maxRetries: 3
|
|
178
|
+
});
|
|
179
|
+
const model = p.model ?? DEFAULT_MODELS.openai;
|
|
180
|
+
return batchedTranslator(async (texts, contexts, from, to) => {
|
|
181
|
+
try {
|
|
182
|
+
return (await client.responses.create({
|
|
183
|
+
model,
|
|
184
|
+
instructions: systemPrompt(from, to),
|
|
185
|
+
input: userPrompt(texts, contexts),
|
|
186
|
+
text: { format: {
|
|
187
|
+
type: "json_schema",
|
|
188
|
+
name: "translations",
|
|
189
|
+
strict: true,
|
|
190
|
+
schema: { ...TRANSLATIONS_SCHEMA }
|
|
191
|
+
} }
|
|
192
|
+
})).output_text;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
classify(err, OpenAI, "OpenAI");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** Models that accept server-side refusal fallbacks (`fallbacks: "default"`). */
|
|
199
|
+
const FALLBACK_MODELS = /* @__PURE__ */ new Set([
|
|
200
|
+
"claude-fable-5-1",
|
|
201
|
+
"claude-opus-5-5",
|
|
202
|
+
"claude-opus-5",
|
|
203
|
+
"claude-sonnet-5-5"
|
|
204
|
+
]);
|
|
205
|
+
async function anthropicTranslator(root, p) {
|
|
206
|
+
const key = apiKey("anthropic", p.apiKeyEnv);
|
|
207
|
+
const Anthropic = (await importFromProject(root, "@anthropic-ai/sdk")).default;
|
|
208
|
+
const client = new Anthropic({
|
|
209
|
+
apiKey: key,
|
|
210
|
+
timeout: 12e4,
|
|
211
|
+
maxRetries: 3
|
|
212
|
+
});
|
|
213
|
+
const model = p.model ?? DEFAULT_MODELS.anthropic;
|
|
214
|
+
const fallback = FALLBACK_MODELS.has(model);
|
|
215
|
+
return batchedTranslator(async (texts, contexts, from, to) => {
|
|
216
|
+
try {
|
|
217
|
+
const res = await client.beta.messages.create({
|
|
218
|
+
model,
|
|
219
|
+
max_tokens: 16e3,
|
|
220
|
+
...fallback ? {
|
|
221
|
+
betas: ["server-side-fallback-2026-07-01"],
|
|
222
|
+
fallbacks: "default"
|
|
223
|
+
} : {},
|
|
224
|
+
system: systemPrompt(from, to),
|
|
225
|
+
messages: [{
|
|
226
|
+
role: "user",
|
|
227
|
+
content: userPrompt(texts, contexts)
|
|
228
|
+
}],
|
|
229
|
+
output_config: {
|
|
230
|
+
effort: p.effort ?? "low",
|
|
231
|
+
format: {
|
|
232
|
+
type: "json_schema",
|
|
233
|
+
schema: { ...TRANSLATIONS_SCHEMA }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (res.stop_reason === "refusal") throw new TranslationError("Claude declined to translate this batch.");
|
|
238
|
+
if (res.stop_reason === "max_tokens") return "";
|
|
239
|
+
return res.content.map((b) => b.type === "text" ? b.text : "").join("");
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err instanceof TranslationError) throw err;
|
|
242
|
+
classify(err, Anthropic, "Anthropic");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/** Gemini's schema dialect (types in capitals) for {"translations": [string, ...]}. */
|
|
247
|
+
const GEMINI_SCHEMA = {
|
|
248
|
+
type: "OBJECT",
|
|
249
|
+
properties: { translations: {
|
|
250
|
+
type: "ARRAY",
|
|
251
|
+
items: { type: "STRING" }
|
|
252
|
+
} },
|
|
253
|
+
required: ["translations"]
|
|
254
|
+
};
|
|
255
|
+
const GEMINI_RETRYABLE = /* @__PURE__ */ new Set([
|
|
256
|
+
429,
|
|
257
|
+
500,
|
|
258
|
+
502,
|
|
259
|
+
503,
|
|
260
|
+
504
|
|
261
|
+
]);
|
|
262
|
+
const GEMINI_RETRIES = 3;
|
|
263
|
+
/** The model stopped because of its safety rules, not because it finished. */
|
|
264
|
+
const GEMINI_DECLINED = /* @__PURE__ */ new Set([
|
|
265
|
+
"SAFETY",
|
|
266
|
+
"BLOCKLIST",
|
|
267
|
+
"PROHIBITED_CONTENT",
|
|
268
|
+
"SPII",
|
|
269
|
+
"IMAGE_SAFETY",
|
|
270
|
+
"RECITATION"
|
|
271
|
+
]);
|
|
272
|
+
/** Maps an HTTP failure to a TranslationError. Credential, permission and request errors stop the run. */
|
|
273
|
+
function geminiError(status, data) {
|
|
274
|
+
const msg = String(data?.error?.message ?? `HTTP ${status}`).replace(/[.\s]+$/, "");
|
|
275
|
+
if (status === 401 || status === 403 || /API key not valid|API_KEY_INVALID/i.test(msg)) return new TranslationError(`Gemini: the API key was rejected or lacks permission (${msg})`, true);
|
|
276
|
+
if (status === 404) return new TranslationError(`Gemini: model not found; check the model name (${msg})`, true);
|
|
277
|
+
if (status === 400) return new TranslationError(`Gemini: request rejected (${msg})`, true);
|
|
278
|
+
if (status === 429) return new TranslationError(`Gemini: rate limit or quota reached after retries (${msg}). Run the command again later`, true);
|
|
279
|
+
return new TranslationError(`Gemini: ${msg}`);
|
|
280
|
+
}
|
|
281
|
+
function geminiTranslator(p, deps = {}) {
|
|
282
|
+
const key = apiKey("gemini", p.apiKeyEnv);
|
|
283
|
+
const model = p.model ?? DEFAULT_MODELS.gemini;
|
|
284
|
+
const url = `${(p.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "")}/models/${encodeURIComponent(model)}:generateContent`;
|
|
285
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
286
|
+
const backoff = (attempt) => Math.min(3e4, 1e3 * 2 ** attempt + Math.random() * 500);
|
|
287
|
+
return batchedTranslator(async (texts, contexts, from, to) => {
|
|
288
|
+
const body = JSON.stringify({
|
|
289
|
+
systemInstruction: { parts: [{ text: systemPrompt(from, to) }] },
|
|
290
|
+
contents: [{
|
|
291
|
+
role: "user",
|
|
292
|
+
parts: [{ text: userPrompt(texts, contexts) }]
|
|
293
|
+
}],
|
|
294
|
+
generationConfig: {
|
|
295
|
+
temperature: 0,
|
|
296
|
+
responseMimeType: "application/json",
|
|
297
|
+
responseSchema: GEMINI_SCHEMA
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
for (let attempt = 0;; attempt++) {
|
|
301
|
+
let res;
|
|
302
|
+
try {
|
|
303
|
+
res = await fetch(url, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
"x-goog-api-key": key
|
|
308
|
+
},
|
|
309
|
+
body,
|
|
310
|
+
signal: AbortSignal.timeout(12e4)
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (attempt >= GEMINI_RETRIES) throw new TranslationError(`Gemini: could not reach the API (${err.message})`);
|
|
314
|
+
await sleep(backoff(attempt));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (GEMINI_RETRYABLE.has(res.status) && attempt < GEMINI_RETRIES) {
|
|
318
|
+
const wait = Number(res.headers.get("retry-after"));
|
|
319
|
+
await sleep(Number.isFinite(wait) && wait > 0 ? Math.min(wait, 60) * 1e3 : backoff(attempt));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const data = await res.json().catch(() => ({}));
|
|
323
|
+
if (!res.ok) throw geminiError(res.status, data);
|
|
324
|
+
if (data.promptFeedback?.blockReason) throw new TranslationError(`Gemini declined to translate this batch (${data.promptFeedback.blockReason})`);
|
|
325
|
+
const candidate = data.candidates?.[0];
|
|
326
|
+
if (!candidate || candidate.finishReason === "MAX_TOKENS") return "";
|
|
327
|
+
if (GEMINI_DECLINED.has(candidate.finishReason)) throw new TranslationError(`Gemini declined to translate this batch (${candidate.finishReason})`);
|
|
328
|
+
return (candidate.content?.parts ?? []).map((part) => part.text ?? "").join("");
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/cli/commands.ts
|
|
334
|
+
const ok = (out, msg) => out.log(`✓ ${msg}`);
|
|
335
|
+
const note = (out, msg) => out.log(`! ${msg}`);
|
|
336
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
337
|
+
/** `scan`: extract strings and update the manifest + source catalog. No translation, no network. */
|
|
338
|
+
function runScan(project, out) {
|
|
339
|
+
const report = scanProject(project);
|
|
340
|
+
ok(out, `Scanned ${plural(report.fileCount, "source file")}`);
|
|
341
|
+
ok(out, `Found ${plural(report.added.length, "new string")} (${report.active.length} in use)`);
|
|
342
|
+
if (report.removed.length) note(out, `${plural(report.removed.length, "string")} no longer in the source; translations kept (remove them with \`npx react-autolocale clean\`).`);
|
|
343
|
+
if (report.previousSource) note(out, `The source language changed from ${report.previousSource} to ${project.site.original}. Existing translations were made from ${report.previousSource}.`);
|
|
344
|
+
if (report.previousOutput) migrateOutput(project, report.previousOutput, out);
|
|
345
|
+
return report;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The catalogs moved (a new `output`, or the default changed). Old files are never moved or deleted: target
|
|
349
|
+
* catalogs missing at the new location are copied over, and the old folder is left for the developer to remove.
|
|
350
|
+
*/
|
|
351
|
+
function migrateOutput(project, previous, out) {
|
|
352
|
+
const oldDir = path.resolve(project.root, previous);
|
|
353
|
+
if (!fs.existsSync(oldDir) || oldDir === project.output) return;
|
|
354
|
+
const copied = [];
|
|
355
|
+
for (const lang of project.site.languages) {
|
|
356
|
+
const from = path.join(oldDir, `${lang}.json`);
|
|
357
|
+
const to = catalogFile(project, lang);
|
|
358
|
+
if (fs.existsSync(from) && !fs.existsSync(to)) fs.copyFileSync(from, to), copied.push(lang);
|
|
359
|
+
}
|
|
360
|
+
note(out, `Catalogs were previously in ${previous}/ and are now written to ${project.outputRel}/.` + (copied.length ? ` Copied ${copied.join(", ")} catalogs to the new folder.` : "") + ` The old folder was left untouched: check the new one, then delete ${previous}/ yourself (or set output: "${previous}" in auto-locale.config.ts to keep using it).`);
|
|
361
|
+
}
|
|
362
|
+
/** `translate`: fill missing catalog entries with the configured provider. */
|
|
363
|
+
async function runTranslate(project, flags, out) {
|
|
364
|
+
const manifest = fs.existsSync(manifestFile(project)) ? readManifest(project) : null;
|
|
365
|
+
if (!manifest) throw new Error("No manifest yet. Run `npx react-autolocale generate` (or `scan` first).");
|
|
366
|
+
const langs = flags.langs ?? project.site.languages;
|
|
367
|
+
const unknown = langs.filter((l) => !project.site.languages.includes(l));
|
|
368
|
+
if (unknown.length) throw new Error(`--lang ${unknown.join(",")} is not a target language (targets: ${project.site.languages.join(", ")}).`);
|
|
369
|
+
const imported = importLegacyCache(project);
|
|
370
|
+
if (imported) ok(out, `Imported ${plural(imported, "translation")} made by an older version (node_modules/.cache/react-autolocale, left in place)`);
|
|
371
|
+
loadEnv(project.root);
|
|
372
|
+
let provider;
|
|
373
|
+
try {
|
|
374
|
+
provider = await createProvider(project, out.log);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof TranslationError) throw new Error(err.message);
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
if (provider.name === "local") out.log("Using the local models (CPU). Downloaded once into ~/.cache/react-autolocale and reused by every project: about 110 MB per English->X language (OPUS-MT), 870 MB for other pairs (NLLB-200), plus a 470 MB engine if your project has no @huggingface/transformers.");
|
|
380
|
+
else out.log(`Using ${provider.name}.`);
|
|
381
|
+
let interrupted = false;
|
|
382
|
+
const onSigint = () => {
|
|
383
|
+
interrupted = true;
|
|
384
|
+
out.log("\nStopping after the current batch (press Ctrl+C again to quit now)...");
|
|
385
|
+
process.once("SIGINT", () => process.exit(130));
|
|
386
|
+
};
|
|
387
|
+
process.once("SIGINT", onSigint);
|
|
388
|
+
let report;
|
|
389
|
+
try {
|
|
390
|
+
report = await translateMissing(project, manifest, {
|
|
391
|
+
translator: provider.translator,
|
|
392
|
+
providerName: provider.name,
|
|
393
|
+
overrides: readOverrides(project.root),
|
|
394
|
+
langs,
|
|
395
|
+
chunkSize: provider.chunkSize,
|
|
396
|
+
shouldStop: () => interrupted,
|
|
397
|
+
log: (m) => out.log(` ${m}`)
|
|
398
|
+
});
|
|
399
|
+
} finally {
|
|
400
|
+
process.removeListener("SIGINT", onSigint);
|
|
401
|
+
}
|
|
402
|
+
if (!report.stopped) try {
|
|
403
|
+
await ensureBanner({
|
|
404
|
+
output: project.output,
|
|
405
|
+
langs: [project.site.original, ...project.site.languages],
|
|
406
|
+
getTranslator: () => provider.translator
|
|
407
|
+
});
|
|
408
|
+
} catch (err) {
|
|
409
|
+
note(out, `Language-suggestion banner not translated (${err.message}); it shows in English.`);
|
|
410
|
+
}
|
|
411
|
+
printTranslateReport(project, report, out);
|
|
412
|
+
return report;
|
|
413
|
+
}
|
|
414
|
+
function printTranslateReport(project, report, out) {
|
|
415
|
+
const langs = Object.entries(report.langs);
|
|
416
|
+
const sum = (k) => langs.reduce((n, [, r]) => n + r[k], 0);
|
|
417
|
+
const failedTotal = langs.reduce((n, [, r]) => n + r.failed.length, 0);
|
|
418
|
+
ok(out, `Kept ${plural(sum("kept"), "existing translation")}${sum("reused") ? `, reused ${sum("reused")} from the cache/glossary` : ""}`);
|
|
419
|
+
if (sum("overridden")) ok(out, `${plural(sum("overridden"), "string")} handled by react-autolocale.overrides.json`);
|
|
420
|
+
ok(out, `Translated ${plural(sum("translated"), "string")}${sum("translated") ? ` (${langs.map(([l, r]) => `${l} ${r.translated}`).join(", ")})` : ""}`);
|
|
421
|
+
ok(out, `Updated ${plural(langs.length, "locale catalog")} in ${project.outputRel}/`);
|
|
422
|
+
for (const [lang, r] of langs) {
|
|
423
|
+
if (!r.failed.length) continue;
|
|
424
|
+
out.log(`✗ ${lang}: ${plural(r.failed.length, "string")} failed (shown in ${project.site.original} until fixed)`);
|
|
425
|
+
for (const f of r.failed.slice(0, 5)) out.log(` ${f.id} "${f.text.slice(0, 60)}": ${f.reason}`);
|
|
426
|
+
if (r.failed.length > 5) out.log(` ...and ${r.failed.length - 5} more`);
|
|
427
|
+
}
|
|
428
|
+
if (report.stopped) out.log(`✗ Stopped: ${report.stopped}. Everything finished so far was saved.`);
|
|
429
|
+
if (failedTotal || report.stopped) out.log("Retry the remaining strings with: npx react-autolocale translate");
|
|
430
|
+
}
|
|
431
|
+
/** `generate` / `sync`: scan, then translate what is missing. Both are incremental. */
|
|
432
|
+
async function runGenerate(project, flags, out) {
|
|
433
|
+
out.log("React Auto Locale\n");
|
|
434
|
+
const scan = runScan(project, out);
|
|
435
|
+
const translate = await runTranslate(project, flags, out);
|
|
436
|
+
out.log("\nNo source files modified.");
|
|
437
|
+
return {
|
|
438
|
+
scan,
|
|
439
|
+
translate
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** `clean`: list messages no longer in the source and, once confirmed, remove them from the catalogs. */
|
|
443
|
+
async function runClean(project, flags, prompt, out) {
|
|
444
|
+
const { manifest } = scanProject(project);
|
|
445
|
+
const unused = Object.entries(manifest.messages).filter(([, m]) => m.status === "unused");
|
|
446
|
+
if (!unused.length) {
|
|
447
|
+
ok(out, "Nothing to clean: every catalog entry is still used.");
|
|
448
|
+
return 0;
|
|
449
|
+
}
|
|
450
|
+
out.log(`${plural(unused.length, "string")} no longer appear in the source:`);
|
|
451
|
+
for (const [id, m] of unused.slice(0, 20)) out.log(` ${id} "${m.text.slice(0, 70)}"`);
|
|
452
|
+
if (unused.length > 20) out.log(` ...and ${unused.length - 20} more`);
|
|
453
|
+
if (!(flags.yes || await prompt.confirm("Remove them and their translations from every catalog?", false))) {
|
|
454
|
+
out.log(prompt.interactive ? "Nothing removed." : "Nothing removed. Run `npx react-autolocale clean --yes` to remove them.");
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
const ids = unused.map(([id]) => id);
|
|
458
|
+
const removed = removeFromCatalogs(project, ids);
|
|
459
|
+
for (const id of ids) delete manifest.messages[id];
|
|
460
|
+
writeJson(manifestFile(project), manifest);
|
|
461
|
+
ok(out, `Removed ${plural(ids.length, "string")} (${plural(removed, "catalog entry")}).`);
|
|
462
|
+
return ids.length;
|
|
463
|
+
}
|
|
464
|
+
//#endregion
|
|
5
465
|
//#region src/cli/viteConfig.ts
|
|
6
466
|
const IMPORT_LINE = `import reactAutolocale from "react-autolocale/vite";`;
|
|
7
467
|
/** Adds the react-autolocale plugin to a vite.config source string. Returns null if it can't be done safely. */
|
|
@@ -27,19 +487,351 @@ function patchViteConfig(source) {
|
|
|
27
487
|
};
|
|
28
488
|
}
|
|
29
489
|
//#endregion
|
|
490
|
+
//#region src/cli/init.ts
|
|
491
|
+
const VITE_CONFIGS = [
|
|
492
|
+
"vite.config.ts",
|
|
493
|
+
"vite.config.mts",
|
|
494
|
+
"vite.config.js",
|
|
495
|
+
"vite.config.mjs"
|
|
496
|
+
];
|
|
497
|
+
const SDK = {
|
|
498
|
+
openai: "openai",
|
|
499
|
+
anthropic: "@anthropic-ai/sdk"
|
|
500
|
+
};
|
|
501
|
+
function detect(root) {
|
|
502
|
+
let deps = {};
|
|
503
|
+
try {
|
|
504
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
505
|
+
deps = {
|
|
506
|
+
...pkg.dependencies,
|
|
507
|
+
...pkg.devDependencies
|
|
508
|
+
};
|
|
509
|
+
} catch {}
|
|
510
|
+
const lock = (f) => fs.existsSync(path.join(root, f));
|
|
511
|
+
const output = resolveOutput(root);
|
|
512
|
+
let autoScale = null;
|
|
513
|
+
let wrapped = false;
|
|
514
|
+
try {
|
|
515
|
+
const site = findSite(root, defaultInclude(root), { required: false });
|
|
516
|
+
wrapped = !!site;
|
|
517
|
+
if (site?.original) autoScale = {
|
|
518
|
+
original: site.original,
|
|
519
|
+
languages: site.languages
|
|
520
|
+
};
|
|
521
|
+
} catch {}
|
|
522
|
+
return {
|
|
523
|
+
react: "react" in deps,
|
|
524
|
+
viteConfig: VITE_CONFIGS.find((f) => fs.existsSync(path.join(root, f))),
|
|
525
|
+
typescript: fs.existsSync(path.join(root, "tsconfig.json")),
|
|
526
|
+
packageManager: lock("pnpm-lock.yaml") ? "pnpm" : lock("yarn.lock") ? "yarn" : lock("bun.lockb") || lock("bun.lock") ? "bun" : "npm",
|
|
527
|
+
configFile: findConfigFile(root),
|
|
528
|
+
hasSrc: hasRootSrc(root),
|
|
529
|
+
existingCatalogs: fs.existsSync(output) ? fs.readdirSync(output).filter((f) => f.endsWith(".json")) : [],
|
|
530
|
+
autoScale,
|
|
531
|
+
wrapped
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const langName = (code) => getLanguage(code)?.english ?? code;
|
|
535
|
+
function parseLocales(input) {
|
|
536
|
+
const codes = [...new Set(input.split(/[\s,]+/).map((c) => c.trim().toLowerCase()).filter(Boolean))];
|
|
537
|
+
return {
|
|
538
|
+
ok: codes.filter((c) => getLanguage(c)),
|
|
539
|
+
bad: codes.filter((c) => !getLanguage(c))
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/** Writes auto-locale.config.ts. `output` is written only when it differs from the automatic default. */
|
|
543
|
+
function configSource(cfg, root) {
|
|
544
|
+
const lines = [` sourceLocale: ${JSON.stringify(cfg.sourceLocale)},`, ` locales: ${JSON.stringify(cfg.locales)},`];
|
|
545
|
+
const p = cfg.provider;
|
|
546
|
+
const fields = Object.entries(p).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
547
|
+
lines.push(` provider: { ${fields.join(", ")} },`);
|
|
548
|
+
const auto = defaultOutput(root);
|
|
549
|
+
if (cfg.output && path.resolve(root, cfg.output) !== path.resolve(root, auto)) lines.push(` output: ${JSON.stringify(cfg.output)},`);
|
|
550
|
+
else lines.push(` // Catalogs are written to ${auto}/ by default. To choose another folder:`, ` // output: "./${auto}",`);
|
|
551
|
+
return `import { defineConfig } from "react-autolocale/config";\n\nexport default defineConfig({\n${lines.join("\n")}\n});\n`;
|
|
552
|
+
}
|
|
553
|
+
/** Adds lines to .gitignore that are not already there; never rewrites what exists. */
|
|
554
|
+
function ensureGitignore(root, entries) {
|
|
555
|
+
const file = path.join(root, ".gitignore");
|
|
556
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
557
|
+
const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
558
|
+
const add = entries.filter((e) => !have.has(e) && !have.has("/" + e));
|
|
559
|
+
if (add.length) fs.writeFileSync(file, current + (current && !current.endsWith("\n") ? "\n" : "") + add.join("\n") + "\n");
|
|
560
|
+
return add;
|
|
561
|
+
}
|
|
562
|
+
/** Appends KEY=value to .env unless KEY is already defined there. The value is never printed. */
|
|
563
|
+
function saveKeyToEnv(root, name, value) {
|
|
564
|
+
const file = path.join(root, ".env");
|
|
565
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
566
|
+
if (name in parseEnv(current)) return false;
|
|
567
|
+
fs.writeFileSync(file, current + (current && !current.endsWith("\n") ? "\n" : "") + `${name}=${value}\n`, { mode: 384 });
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
function keyIsAvailable(root, name) {
|
|
571
|
+
if (process.env[name]) return true;
|
|
572
|
+
return [".env.local", ".env"].some((f) => {
|
|
573
|
+
const file = path.join(root, f);
|
|
574
|
+
return fs.existsSync(file) && !!parseEnv(fs.readFileSync(file, "utf8"))[name];
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function sdkInstalled(root, pkg) {
|
|
578
|
+
try {
|
|
579
|
+
createRequire(path.join(root, "package.json")).resolve(pkg);
|
|
580
|
+
return true;
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function runInit(root, flags, prompt, out) {
|
|
586
|
+
const d = detect(root);
|
|
587
|
+
const stack = [d.react ? "React" : null, d.viteConfig ? "Vite" : null].filter(Boolean).join(" + ") || "unknown (React + Vite expected)";
|
|
588
|
+
out.log("React Auto Locale setup\n");
|
|
589
|
+
out.log(`Detected project: ${stack}${d.typescript ? " (TypeScript)" : ""}, ${d.packageManager}`);
|
|
590
|
+
out.log(`Source directory: ${d.hasSrc ? "src/" : "not found"}`);
|
|
591
|
+
if (d.existingCatalogs.length) out.log(`Existing catalogs: ${toProjectPath(root, resolveOutput(root))}/ (${d.existingCatalogs.join(", ")})`);
|
|
592
|
+
let configFile = d.configFile;
|
|
593
|
+
let config;
|
|
594
|
+
if (configFile) {
|
|
595
|
+
const existing = await loadConfigFile(configFile, root);
|
|
596
|
+
const p = existing.provider;
|
|
597
|
+
const providerName = !p ? "local" : typeof p === "function" || "translate" in p ? "custom" : p.name;
|
|
598
|
+
out.log(`\n${path.basename(configFile)} already exists: source ${existing.sourceLocale ?? "?"}, locales ${(existing.locales ?? []).join(", ")}, provider ${providerName}.`);
|
|
599
|
+
if (!(flags.force || await prompt.confirm("Reconfigure it?", false))) {
|
|
600
|
+
out.log("Keeping it as is.");
|
|
601
|
+
config = existing;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (!config) {
|
|
605
|
+
let source = (flags.source ?? await prompt.ask("\nSource language (the language your code is written in)", d.autoScale?.original ?? "en")).toLowerCase();
|
|
606
|
+
while (!getLanguage(source)) {
|
|
607
|
+
if (!prompt.interactive) throw new Error(`Unsupported source language "${source}".`);
|
|
608
|
+
source = (await prompt.ask(`"${source}" is not a supported code. Source language`, "en")).toLowerCase();
|
|
609
|
+
}
|
|
610
|
+
let targets = (flags.locales ?? []).filter((l) => l !== source);
|
|
611
|
+
if (!flags.locales) {
|
|
612
|
+
const suggested = d.autoScale?.languages.join(",") ?? "";
|
|
613
|
+
for (;;) {
|
|
614
|
+
const { ok, bad } = parseLocales(await prompt.ask("Target languages, comma-separated (e.g. fr,es,de)", suggested));
|
|
615
|
+
if (bad.length) out.log(`Unsupported: ${bad.join(", ")}.`);
|
|
616
|
+
targets = ok.filter((l) => l !== source);
|
|
617
|
+
if (targets.length && !bad.length) break;
|
|
618
|
+
if (!prompt.interactive) throw new Error("Pass target languages with --locales fr,es (no <AutoScale languages> found to reuse).");
|
|
619
|
+
}
|
|
620
|
+
} else {
|
|
621
|
+
const bad = flags.locales.filter((l) => !getLanguage(l));
|
|
622
|
+
if (bad.length) throw new Error(`Unsupported language code${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}.`);
|
|
623
|
+
}
|
|
624
|
+
const name = flags.provider ?? await prompt.choose("How would you like to translate your application?", [
|
|
625
|
+
{
|
|
626
|
+
value: "local",
|
|
627
|
+
label: "Local model (OPUS-MT / NLLB-200)",
|
|
628
|
+
help: "Free and runs on your machine; no API key. Models are large (about 110 MB per English->X language, 870 MB for\nother pairs) and translating on the CPU uses RAM and time, so big apps or many languages can be slow.\nModels are cached in ~/.cache/react-autolocale and downloaded only when you run `translate` or `generate`,\nnever during init or builds."
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
value: "openai",
|
|
632
|
+
label: "Cloud AI: OpenAI",
|
|
633
|
+
help: "Uses a hosted AI model. No large downloads, and usually faster for big translation jobs. Needs an API key;\nprovider usage may be billed to your account."
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
value: "anthropic",
|
|
637
|
+
label: "Cloud AI: Anthropic Claude",
|
|
638
|
+
help: "Uses a hosted AI model. No large downloads, and usually faster for big translation jobs. Needs an API key;\nprovider usage may be billed to your account."
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
value: "gemini",
|
|
642
|
+
label: "Cloud AI: Google Gemini",
|
|
643
|
+
help: "Uses a hosted AI model. No large downloads, and usually faster for big translation jobs. Needs an API key;\nprovider usage may be billed to your account."
|
|
644
|
+
}
|
|
645
|
+
], "local");
|
|
646
|
+
let provider;
|
|
647
|
+
if (name === "openai" || name === "anthropic" || name === "gemini") provider = {
|
|
648
|
+
name,
|
|
649
|
+
model: flags.model ?? DEFAULT_MODELS[name],
|
|
650
|
+
apiKeyEnv: flags.apiKeyEnv ?? DEFAULT_KEY_ENV[name]
|
|
651
|
+
};
|
|
652
|
+
else if (name === "ollama") {
|
|
653
|
+
if (!flags.model) throw new Error("Ollama needs a model: --provider ollama --model llama3.1");
|
|
654
|
+
provider = {
|
|
655
|
+
name,
|
|
656
|
+
model: flags.model
|
|
657
|
+
};
|
|
658
|
+
} else if (name === "local") provider = { name };
|
|
659
|
+
else throw new Error(`Unknown provider "${name}". Choose local, openai, anthropic, gemini or ollama.`);
|
|
660
|
+
const auto = defaultOutput(root);
|
|
661
|
+
out.log(`\nGenerated output:\n ${auto}/\nInternal metadata:\n ${META_DIR}/`);
|
|
662
|
+
let output = flags.output ?? await prompt.ask("Catalog folder (press Enter to keep)", auto);
|
|
663
|
+
output = output.replace(/\/+$/, "");
|
|
664
|
+
config = {
|
|
665
|
+
sourceLocale: source,
|
|
666
|
+
locales: [source, ...targets],
|
|
667
|
+
provider,
|
|
668
|
+
...output !== auto ? { output } : {}
|
|
669
|
+
};
|
|
670
|
+
configFile = path.join(root, "auto-locale.config.ts");
|
|
671
|
+
fs.writeFileSync(configFile, configSource(config, root));
|
|
672
|
+
out.log(`\nWrote ${path.basename(configFile)}.`);
|
|
673
|
+
}
|
|
674
|
+
const provider = config.provider;
|
|
675
|
+
const ignore = [`${META_DIR}/cache.json`];
|
|
676
|
+
let modelNote = "";
|
|
677
|
+
if (provider && typeof provider === "object" && "name" in provider && (provider.name === "openai" || provider.name === "anthropic" || provider.name === "gemini")) {
|
|
678
|
+
const keyEnv = provider.apiKeyEnv ?? DEFAULT_KEY_ENV[provider.name];
|
|
679
|
+
ignore.push(".env", ".env.local");
|
|
680
|
+
if (keyIsAvailable(root, keyEnv)) out.log(`${keyEnv} is already set (value hidden).`);
|
|
681
|
+
else {
|
|
682
|
+
const key = await prompt.secret(`Paste your ${{
|
|
683
|
+
openai: "OpenAI",
|
|
684
|
+
anthropic: "Anthropic",
|
|
685
|
+
gemini: "Google Gemini"
|
|
686
|
+
}[provider.name]} API key to save it in .env (leave empty to do it later)`);
|
|
687
|
+
if (key) {
|
|
688
|
+
saveKeyToEnv(root, keyEnv, key);
|
|
689
|
+
out.log(`Saved ${keyEnv} to .env (value hidden).`);
|
|
690
|
+
} else out.log(`Add ${keyEnv}=... to .env (or your environment) before running translate. No request is made without it.`);
|
|
691
|
+
}
|
|
692
|
+
const pkg = provider.name === "gemini" ? null : SDK[provider.name];
|
|
693
|
+
if (pkg && !sdkInstalled(root, pkg)) {
|
|
694
|
+
const cmd = {
|
|
695
|
+
npm: [
|
|
696
|
+
"install",
|
|
697
|
+
"-D",
|
|
698
|
+
pkg
|
|
699
|
+
],
|
|
700
|
+
pnpm: [
|
|
701
|
+
"add",
|
|
702
|
+
"-D",
|
|
703
|
+
pkg
|
|
704
|
+
],
|
|
705
|
+
yarn: [
|
|
706
|
+
"add",
|
|
707
|
+
"-D",
|
|
708
|
+
pkg
|
|
709
|
+
],
|
|
710
|
+
bun: [
|
|
711
|
+
"add",
|
|
712
|
+
"-d",
|
|
713
|
+
pkg
|
|
714
|
+
]
|
|
715
|
+
}[d.packageManager];
|
|
716
|
+
if (await prompt.confirm(`Install ${pkg} (used only by the CLI, never bundled) now with ${d.packageManager}?`, false)) {
|
|
717
|
+
if (spawnSync(d.packageManager, cmd, {
|
|
718
|
+
cwd: root,
|
|
719
|
+
stdio: "inherit",
|
|
720
|
+
shell: process.platform === "win32"
|
|
721
|
+
}).status !== 0) out.log(`Installing failed. Run it yourself: ${d.packageManager} ${cmd.join(" ")}`);
|
|
722
|
+
} else out.log(`Install the provider SDK before translating: ${d.packageManager} ${cmd.join(" ")}`);
|
|
723
|
+
}
|
|
724
|
+
} else if (!provider || typeof provider === "object" && "name" in provider && provider.name === "local") modelNote = "Local models are not downloaded yet; the first `generate` downloads them.";
|
|
725
|
+
const added = ensureGitignore(root, ignore);
|
|
726
|
+
if (added.length) out.log(`Added to .gitignore: ${added.join(", ")}`);
|
|
727
|
+
if (d.viteConfig) {
|
|
728
|
+
const file = path.join(root, d.viteConfig);
|
|
729
|
+
const patched = patchViteConfig(fs.readFileSync(file, "utf8"));
|
|
730
|
+
if (!patched) out.log(`Could not edit ${d.viteConfig} automatically. Add:\n import reactAutolocale from "react-autolocale/vite";\n plugins: [reactAutolocale(), ...]`);
|
|
731
|
+
else if (patched.changed) fs.writeFileSync(file, patched.code), out.log(`Added the react-autolocale plugin to ${d.viteConfig}.`);
|
|
732
|
+
} else out.log("No vite.config found. Add the plugin yourself: plugins: [reactAutolocale()] from \"react-autolocale/vite\".");
|
|
733
|
+
const p = config.provider;
|
|
734
|
+
const providerLabel = !p ? "Local model" : typeof p === "function" || "translate" in p ? "Custom" : {
|
|
735
|
+
local: "Local model",
|
|
736
|
+
openai: "OpenAI",
|
|
737
|
+
anthropic: "Anthropic Claude",
|
|
738
|
+
gemini: "Google Gemini",
|
|
739
|
+
ollama: "Ollama"
|
|
740
|
+
}[p.name];
|
|
741
|
+
const out_ = toProjectPath(root, resolveOutput(root, config.output));
|
|
742
|
+
out.log(`\nReact Auto Locale initialized.\n`);
|
|
743
|
+
out.log(`Source language: ${langName(config.sourceLocale ?? "")}`);
|
|
744
|
+
out.log(`Target languages: ${(config.locales ?? []).filter((l) => l !== config.sourceLocale).map(langName).join(", ")}`);
|
|
745
|
+
out.log(`Provider: ${providerLabel}`);
|
|
746
|
+
out.log(`Generated output:\n ${out_}/`);
|
|
747
|
+
out.log(`Internal metadata:\n ${META_DIR}/`);
|
|
748
|
+
out.log(`Configuration:\n ${path.basename(configFile)}`);
|
|
749
|
+
if (modelNote) out.log(modelNote);
|
|
750
|
+
if (!d.wrapped) out.log(`\nWrap your app once (for example in main.tsx):\n import { AutoScale } from "react-autolocale";\n <AutoScale><App /></AutoScale>`);
|
|
751
|
+
out.log(`\nNext:\n npx react-autolocale generate`);
|
|
752
|
+
return {
|
|
753
|
+
config,
|
|
754
|
+
configFile
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/cli/prompt.ts
|
|
759
|
+
function createPrompter(interactive) {
|
|
760
|
+
const open = () => readline.createInterface({
|
|
761
|
+
input: process.stdin,
|
|
762
|
+
output: process.stdout
|
|
763
|
+
});
|
|
764
|
+
const ask = async (question, fallback = "") => {
|
|
765
|
+
if (!interactive) return fallback;
|
|
766
|
+
const rl = open();
|
|
767
|
+
try {
|
|
768
|
+
return (await rl.question(`${question}${fallback ? ` (${fallback})` : ""}: `)).trim() || fallback;
|
|
769
|
+
} finally {
|
|
770
|
+
rl.close();
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
return {
|
|
774
|
+
interactive,
|
|
775
|
+
ask,
|
|
776
|
+
async secret(question) {
|
|
777
|
+
if (!interactive) return "";
|
|
778
|
+
const rl = open();
|
|
779
|
+
process.stdout.write(`${question}: `);
|
|
780
|
+
rl._writeToOutput = () => {};
|
|
781
|
+
try {
|
|
782
|
+
return (await rl.question("")).trim();
|
|
783
|
+
} finally {
|
|
784
|
+
rl.close();
|
|
785
|
+
process.stdout.write("\n");
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
async confirm(question, fallback) {
|
|
789
|
+
const answer = (await ask(`${question} ${fallback ? "[Y/n]" : "[y/N]"}`, "")).toLowerCase();
|
|
790
|
+
return answer ? answer.startsWith("y") : fallback;
|
|
791
|
+
},
|
|
792
|
+
async choose(question, options, fallback) {
|
|
793
|
+
if (!interactive) return fallback;
|
|
794
|
+
console.log(`\n${question}\n`);
|
|
795
|
+
options.forEach((o, i) => {
|
|
796
|
+
console.log(` ${i + 1}) ${o.label}`);
|
|
797
|
+
if (o.help) console.log(o.help.replace(/^/gm, " ") + "\n");
|
|
798
|
+
});
|
|
799
|
+
const def = String(options.findIndex((o) => o.value === fallback) + 1);
|
|
800
|
+
for (;;) {
|
|
801
|
+
const answer = await ask("Choose a number", def);
|
|
802
|
+
const picked = options[Number(answer) - 1];
|
|
803
|
+
if (picked) return picked.value;
|
|
804
|
+
console.log(`Please enter a number from 1 to ${options.length}.`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
30
810
|
//#region src/cli/index.ts
|
|
31
811
|
const HELP = `react-autolocale
|
|
32
812
|
|
|
33
813
|
Usage:
|
|
814
|
+
react-autolocale init [--source en] [--locales fr,es] [--provider local|openai|anthropic|gemini|ollama]
|
|
815
|
+
[--model <id>] [--api-key-env <NAME>] [--output <dir>] [--yes] [--force]
|
|
816
|
+
Sets up auto-locale.config.ts, the translation provider and the Vite plugin. Downloads nothing.
|
|
817
|
+
react-autolocale generate [--lang fr,es]
|
|
818
|
+
Scans the source, then translates new strings and writes the catalogs. Incremental.
|
|
819
|
+
react-autolocale sync [--lang fr,es]
|
|
820
|
+
Same as generate: run it after changing text in your components.
|
|
821
|
+
react-autolocale scan
|
|
822
|
+
Extracts strings and updates .auto-locale/manifest.json. No translation, no network.
|
|
823
|
+
react-autolocale translate [--lang fr,es]
|
|
824
|
+
Translates strings missing from the catalogs (retries anything that failed before).
|
|
825
|
+
react-autolocale clean [--yes]
|
|
826
|
+
Lists strings no longer in the source and removes their translations once confirmed.
|
|
34
827
|
react-autolocale export
|
|
35
|
-
|
|
36
|
-
Anything in that file always wins over machine translation.
|
|
37
|
-
react-autolocale
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Copies an editable <LanguageBanner /> (suggests the visitor's browser language) into your project.
|
|
828
|
+
Copies the generated translations to ${OVERRIDES_FILE} so you can edit them.
|
|
829
|
+
Anything in that file always wins over machine translation.
|
|
830
|
+
react-autolocale add switcher|banner [--dir src/components]
|
|
831
|
+
Copies an editable <LanguageSwitcher /> or <LanguageBanner /> into your project.
|
|
832
|
+
|
|
833
|
+
Options:
|
|
834
|
+
--root <dir> Project root (default: the nearest folder with a package.json).
|
|
43
835
|
`;
|
|
44
836
|
const TSX = `import { useLanguage } from "react-autolocale";
|
|
45
837
|
|
|
@@ -83,32 +875,6 @@ function flag(args, name) {
|
|
|
83
875
|
const i = args.indexOf(name);
|
|
84
876
|
return i >= 0 ? args[i + 1] : void 0;
|
|
85
877
|
}
|
|
86
|
-
const VITE_CONFIGS = [
|
|
87
|
-
"vite.config.ts",
|
|
88
|
-
"vite.config.mts",
|
|
89
|
-
"vite.config.js",
|
|
90
|
-
"vite.config.mjs"
|
|
91
|
-
];
|
|
92
|
-
const NEXT_STEP = `
|
|
93
|
-
Next, wrap your app once (for example in main.tsx):
|
|
94
|
-
|
|
95
|
-
import { AutoScale } from "react-autolocale";
|
|
96
|
-
|
|
97
|
-
<AutoScale original="en" languages={["fr", "es"]}>
|
|
98
|
-
<App />
|
|
99
|
-
</AutoScale>
|
|
100
|
-
|
|
101
|
-
Then run your build.`;
|
|
102
|
-
function init(root) {
|
|
103
|
-
const file = VITE_CONFIGS.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
104
|
-
if (!file) throw new Error("No vite.config found. This package works with Vite projects.");
|
|
105
|
-
const name = path.basename(file);
|
|
106
|
-
const patched = patchViteConfig(fs.readFileSync(file, "utf8"));
|
|
107
|
-
if (!patched) throw new Error(`Could not edit ${name} automatically. Add this yourself:\n import reactAutolocale from "react-autolocale/vite";\n plugins: [reactAutolocale(), ...]`);
|
|
108
|
-
if (patched.changed) fs.writeFileSync(file, patched.code);
|
|
109
|
-
console.log(patched.changed ? `Added the react-autolocale plugin to ${name}.` : `${name} already uses react-autolocale.`);
|
|
110
|
-
console.log(NEXT_STEP);
|
|
111
|
-
}
|
|
112
878
|
function addComponent(root, args, name) {
|
|
113
879
|
const dir = path.resolve(root, flag(args, "--dir") ?? (fs.existsSync(path.join(root, "src")) ? "src/components" : "components"));
|
|
114
880
|
const ts = fs.existsSync(path.join(root, "tsconfig.json"));
|
|
@@ -118,21 +884,83 @@ function addComponent(root, args, name) {
|
|
|
118
884
|
fs.writeFileSync(file, name === "LanguageBanner" ? BANNER_TSX : ts ? TSX : JSX);
|
|
119
885
|
console.log(`Created ${path.relative(root, file)}\nImport it anywhere: import { ${name} } from "./components/${name}";`);
|
|
120
886
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
887
|
+
/** --name value / --flag pairs. */
|
|
888
|
+
function parseArgs(argv) {
|
|
889
|
+
const positional = [];
|
|
890
|
+
const flags = {};
|
|
891
|
+
for (let i = 0; i < argv.length; i++) {
|
|
892
|
+
const a = argv[i];
|
|
893
|
+
if (!a.startsWith("--")) positional.push(a);
|
|
894
|
+
else if (argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) flags[a.slice(2)] = argv[++i];
|
|
895
|
+
else flags[a.slice(2)] = true;
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
positional,
|
|
899
|
+
flags
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
/** The project the user means: --root, else the nearest folder (from cwd up) with a package.json. */
|
|
903
|
+
function findRoot(explicit) {
|
|
904
|
+
if (explicit) return path.resolve(explicit);
|
|
905
|
+
for (let dir = process.cwd();; dir = path.dirname(dir)) {
|
|
906
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
907
|
+
if (path.dirname(dir) === dir) return process.cwd();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const list = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
911
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
912
|
+
async function main() {
|
|
913
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
914
|
+
const [cmd, what] = positional;
|
|
915
|
+
const root = findRoot(str(flags.root));
|
|
916
|
+
const out = { log: (m) => console.log(m) };
|
|
917
|
+
const interactive = !!process.stdin.isTTY && !flags.yes;
|
|
918
|
+
switch (cmd) {
|
|
919
|
+
case "init":
|
|
920
|
+
await runInit(root, {
|
|
921
|
+
source: str(flags.source),
|
|
922
|
+
locales: list(flags.locales),
|
|
923
|
+
provider: str(flags.provider),
|
|
924
|
+
model: str(flags.model),
|
|
925
|
+
apiKeyEnv: str(flags["api-key-env"]),
|
|
926
|
+
output: str(flags.output),
|
|
927
|
+
force: !!flags.force
|
|
928
|
+
}, createPrompter(interactive), out);
|
|
929
|
+
return;
|
|
930
|
+
case "scan":
|
|
931
|
+
out.log("React Auto Locale\n");
|
|
932
|
+
runScan(await loadProject(root, out), out);
|
|
933
|
+
out.log("\nNo source files modified.");
|
|
934
|
+
return;
|
|
935
|
+
case "translate": {
|
|
936
|
+
const report = await runTranslate(await loadProject(root, out), { langs: list(flags.lang) }, out);
|
|
937
|
+
if (report && (report.stopped || Object.values(report.langs).some((r) => r.failed.length))) process.exitCode = 1;
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
case "generate":
|
|
941
|
+
case "sync": {
|
|
942
|
+
const { translate } = await runGenerate(await loadProject(root, out), { langs: list(flags.lang) }, out);
|
|
943
|
+
if (translate && (translate.stopped || Object.values(translate.langs).some((r) => r.failed.length))) process.exitCode = 1;
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
case "clean":
|
|
947
|
+
await runClean(await loadProject(root, out), { yes: !!flags.yes }, createPrompter(!!process.stdin.isTTY && !flags.yes), out);
|
|
948
|
+
return;
|
|
949
|
+
case "export": {
|
|
950
|
+
const { file, languages } = exportTranslations(await loadProject(root, out));
|
|
951
|
+
out.log(`Wrote ${path.relative(root, file)}: ${Object.entries(languages).map(([l, n]) => `${l} ${n}`).join(", ")} strings.`);
|
|
952
|
+
out.log("Edit any value, commit the file, and rebuild. Your edits always win over machine translation.");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
case "add":
|
|
956
|
+
if (what === "switcher") return addComponent(root, process.argv.slice(2), "LanguageSwitcher");
|
|
957
|
+
if (what === "banner") return addComponent(root, process.argv.slice(2), "LanguageBanner");
|
|
958
|
+
}
|
|
959
|
+
console.log(HELP);
|
|
960
|
+
if (cmd && cmd !== "help" && cmd !== "--help") process.exitCode = 1;
|
|
961
|
+
}
|
|
962
|
+
main().catch((err) => {
|
|
135
963
|
console.error(`react-autolocale: ${err.message}`);
|
|
136
964
|
process.exit(1);
|
|
137
|
-
}
|
|
965
|
+
});
|
|
138
966
|
//#endregion
|