@i18n-micro/cli 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +29 -2
- package/bin/i18n-micro.mjs +3 -0
- package/dist/chunks/check-CwSzATDI.mjs +175 -0
- package/dist/chunks/clean-Do9z6D5j.mjs +68 -0
- package/dist/chunks/clean-bQVjG_2M.mjs +1 -0
- package/dist/chunks/export-C2J-pagB.mjs +219 -0
- package/dist/chunks/export-usBaNIpp.mjs +1 -0
- package/dist/chunks/extract-CJ8UfrVJ.mjs +30 -0
- package/dist/chunks/format-6CtMgO-M.mjs +26 -0
- package/dist/chunks/http-whB7a5ye.mjs +100 -0
- package/dist/chunks/import-CnL8U6Za.mjs +99 -0
- package/dist/chunks/info-Lom_wHos.mjs +32 -0
- package/dist/chunks/json-BV_apCMu.mjs +439 -0
- package/dist/chunks/lint-DDGSQAXd.mjs +125 -0
- package/dist/chunks/nuxt-dKxG087V.mjs +366 -0
- package/dist/chunks/package-BejjFS6s.mjs +3 -0
- package/dist/chunks/project-DgWgr7pF.mjs +206 -0
- package/dist/chunks/protect-D1HIuxmJ.mjs +36 -0
- package/dist/chunks/pseudo-oXBaONgF.mjs +32 -0
- package/dist/chunks/rename-uJnBX86A.mjs +117 -0
- package/dist/chunks/runner-CsPPffV1.mjs +200 -0
- package/dist/chunks/search-Dj_Ynq_x.mjs +67 -0
- package/dist/chunks/source-Bx2lHSW7.mjs +744 -0
- package/dist/chunks/spec-DwveYr8X.mjs +507 -0
- package/dist/chunks/stats-BU-SmrVR.mjs +156 -0
- package/dist/chunks/sync-BY2EFEda.mjs +1 -0
- package/dist/chunks/sync-C0NVDabK.mjs +56 -0
- package/dist/chunks/sync-remote-y3ZATbF2.mjs +148 -0
- package/dist/chunks/text-to-i18n-DweavFLm.mjs +404 -0
- package/dist/chunks/translate-CmnmgAHX.mjs +543 -0
- package/dist/cli.mjs +4 -0
- package/dist/index.mjs +3 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/commands/check.d.ts +28 -0
- package/dist/src/commands/clean.d.ts +26 -0
- package/dist/src/commands/export.d.ts +10 -0
- package/dist/src/commands/extract.d.ts +6 -0
- package/dist/src/commands/format.d.ts +8 -0
- package/dist/src/commands/import.d.ts +8 -0
- package/dist/src/commands/info.d.ts +5 -0
- package/dist/src/commands/lint.d.ts +16 -0
- package/dist/src/commands/pseudo.d.ts +9 -0
- package/dist/src/commands/rename.d.ts +9 -0
- package/dist/src/commands/search.d.ts +20 -0
- package/dist/src/commands/stats.d.ts +20 -0
- package/dist/src/commands/sync-remote.d.ts +30 -0
- package/dist/src/commands/sync.d.ts +20 -0
- package/dist/src/commands/text-to-i18n.d.ts +10 -0
- package/dist/src/commands/translate.d.ts +14 -0
- package/dist/src/csv.d.ts +3 -0
- package/dist/src/errors.d.ts +22 -0
- package/dist/src/fs.d.ts +45 -0
- package/dist/src/glossary.d.ts +12 -0
- package/dist/src/help.d.ts +3 -0
- package/dist/src/http.d.ts +10 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/json.d.ts +34 -0
- package/dist/src/lint-rules.d.ts +9 -0
- package/dist/src/log.d.ts +88 -0
- package/dist/src/nuxt.d.ts +66 -0
- package/dist/src/po.d.ts +14 -0
- package/dist/src/project.d.ts +64 -0
- package/dist/src/runner.d.ts +6 -0
- package/dist/src/source.d.ts +109 -0
- package/dist/src/spec.d.ts +57 -0
- package/dist/src/text-to-i18n.d.ts +46 -0
- package/dist/src/translate/ai.d.ts +2 -0
- package/dist/src/translate/drivers.d.ts +26 -0
- package/dist/src/translate/engine.d.ts +33 -0
- package/dist/src/translate/protect.d.ts +11 -0
- package/package.json +71 -3
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { A as log, C as addSecret, E as choose, M as plural, N as spinner, T as ask, _ as findingsError, d as ownValue, g as commandFailed, j as pick, v as notFoundError, w as addUrlSecrets, x as usageError } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { d as splitList, o as readJsonObject } from "./nuxt-dKxG087V.mjs";
|
|
3
|
+
import { t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { n as requestJson, t as jsonBody } from "./http-whB7a5ye.mjs";
|
|
5
|
+
import { i as restore, n as isIntact, r as protect, t as decodeEntities } from "./protect-D1HIuxmJ.mjs";
|
|
6
|
+
import { trimTrailingSlashes } from "@i18n-micro/utils/app-path";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
// src/glossary.ts
|
|
10
|
+
var optionalString = (value) => typeof value === "string" && value !== "" ? value : void 0;
|
|
11
|
+
function loadGlossary(path) {
|
|
12
|
+
const raw = readJsonObject(path).entries;
|
|
13
|
+
if (!Array.isArray(raw)) return { entries: [] };
|
|
14
|
+
return { entries: raw.flatMap((item) => {
|
|
15
|
+
const entry = item;
|
|
16
|
+
if (typeof entry?.source !== "string" || typeof entry.target !== "string") return [];
|
|
17
|
+
if (entry.source.trim() === "" || entry.target.trim() === "") return [];
|
|
18
|
+
return [{
|
|
19
|
+
source: entry.source,
|
|
20
|
+
target: entry.target,
|
|
21
|
+
from: optionalString(entry.from),
|
|
22
|
+
to: optionalString(entry.to)
|
|
23
|
+
}];
|
|
24
|
+
}) };
|
|
25
|
+
}
|
|
26
|
+
var matches = (bound, locale) => !bound || bound.toLowerCase() === locale.toLowerCase();
|
|
27
|
+
/** Terms for one language pair, as the lines a translation prompt quotes. At most 100: a prompt has a budget. */
|
|
28
|
+
function glossaryContext(glossary, from, to) {
|
|
29
|
+
const terms = glossary.entries.filter((entry) => matches(entry.from, from) && matches(entry.to, to)).slice(0, 100);
|
|
30
|
+
return terms.length === 0 ? void 0 : terms.map((entry) => `${entry.source} => ${entry.target}`).join("\n");
|
|
31
|
+
}
|
|
32
|
+
// src/translate/ai.ts
|
|
33
|
+
var GATEWAY_URL = "https://ai-gateway.vercel.sh/v1";
|
|
34
|
+
var OPENAI_URL = "https://api.openai.com/v1";
|
|
35
|
+
var ANTHROPIC_URL = "https://api.anthropic.com/v1";
|
|
36
|
+
var DEFAULT_GATEWAY_MODEL = "anthropic/claude-sonnet-4.5";
|
|
37
|
+
var OUTPUT_SCHEMA = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { translations: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" }
|
|
42
|
+
} },
|
|
43
|
+
required: ["translations"],
|
|
44
|
+
additionalProperties: false
|
|
45
|
+
};
|
|
46
|
+
function systemPrompt({ from, to, options }) {
|
|
47
|
+
const lines = [
|
|
48
|
+
`You are a professional localizer translating software UI text from ${from} to ${to}.`,
|
|
49
|
+
"Rules:",
|
|
50
|
+
"1. Tags such as <v0/> are placeholders: keep each one exactly as it is, in the place the grammar needs.",
|
|
51
|
+
"2. Preserve all other HTML/XML tags.",
|
|
52
|
+
"3. Text separated by \"|\" holds plural forms: keep the separators and translate each form.",
|
|
53
|
+
"4. Answer with JSON: {\"translations\": [...]}, one string per input string, in the same order. No explanations."
|
|
54
|
+
];
|
|
55
|
+
if (options.translationContext) lines.push(`Context: ${options.translationContext}`);
|
|
56
|
+
if (options.glossaryContext) lines.push("Glossary (must be followed when a term appears):", options.glossaryContext);
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
var userPrompt = (texts) => JSON.stringify(texts);
|
|
60
|
+
function parseTranslations(content, reason, count) {
|
|
61
|
+
if (reason === "length" || reason === "max_tokens") throw usageError("AI: the answer was cut off.", ["Lower --chunkSize or raise --options maxTokens:<n>."]);
|
|
62
|
+
if (!content) throw usageError("AI: empty answer.");
|
|
63
|
+
const translations = JSON.parse(content).translations;
|
|
64
|
+
if (!Array.isArray(translations) || translations.length !== count) throw usageError(`AI: expected ${plural(count, "translation")}.`, ["Lower --chunkSize."]);
|
|
65
|
+
return translations;
|
|
66
|
+
}
|
|
67
|
+
var envKey = (name) => process.env[name]?.trim() || void 0;
|
|
68
|
+
function resolveKey(provider, token) {
|
|
69
|
+
const name = `${provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()}_API_KEY`;
|
|
70
|
+
const key = token.trim() || envKey(name);
|
|
71
|
+
if (!key) throw usageError(`AI provider "${provider}" needs a key.`, [`Set I18N_TRANSLATE_TOKEN or ${name}.`]);
|
|
72
|
+
addSecret(key);
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
async function viaChatCompletions(request, url, model, key, strict) {
|
|
76
|
+
const { texts, options, http } = request;
|
|
77
|
+
const { headers, ...payload } = jsonBody({
|
|
78
|
+
model,
|
|
79
|
+
max_tokens: options.maxTokens ? Number(options.maxTokens) : void 0,
|
|
80
|
+
messages: [{
|
|
81
|
+
role: "system",
|
|
82
|
+
content: systemPrompt(request)
|
|
83
|
+
}, {
|
|
84
|
+
role: "user",
|
|
85
|
+
content: userPrompt(texts)
|
|
86
|
+
}],
|
|
87
|
+
response_format: {
|
|
88
|
+
type: "json_schema",
|
|
89
|
+
json_schema: {
|
|
90
|
+
name: "translations",
|
|
91
|
+
schema: OUTPUT_SCHEMA,
|
|
92
|
+
...strict ? { strict: true } : {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const init = {
|
|
97
|
+
method: "POST",
|
|
98
|
+
...http,
|
|
99
|
+
...payload,
|
|
100
|
+
headers: key ? {
|
|
101
|
+
...headers,
|
|
102
|
+
Authorization: `Bearer ${key}`
|
|
103
|
+
} : headers
|
|
104
|
+
};
|
|
105
|
+
const choice = (await requestJson(`${url}/chat/completions`, init)).choices?.[0];
|
|
106
|
+
if (choice?.message?.refusal) throw usageError(`AI refused: ${choice.message.refusal}`);
|
|
107
|
+
return parseTranslations(choice?.message?.content, choice?.finish_reason, texts.length);
|
|
108
|
+
}
|
|
109
|
+
/** Anthropic's OpenAI layer ignores `response_format`, so structured output needs the native API. */
|
|
110
|
+
async function viaAnthropic(request, url, model, key) {
|
|
111
|
+
const { texts, options, http } = request;
|
|
112
|
+
const { headers, ...payload } = jsonBody({
|
|
113
|
+
model,
|
|
114
|
+
max_tokens: Number(options.maxTokens ?? 8192),
|
|
115
|
+
system: systemPrompt(request),
|
|
116
|
+
messages: [{
|
|
117
|
+
role: "user",
|
|
118
|
+
content: userPrompt(texts)
|
|
119
|
+
}],
|
|
120
|
+
output_config: { format: {
|
|
121
|
+
type: "json_schema",
|
|
122
|
+
schema: OUTPUT_SCHEMA
|
|
123
|
+
} }
|
|
124
|
+
});
|
|
125
|
+
const data = await requestJson(`${url}/messages`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
...http,
|
|
128
|
+
...payload,
|
|
129
|
+
headers: {
|
|
130
|
+
...headers,
|
|
131
|
+
"x-api-key": key,
|
|
132
|
+
"anthropic-version": "2023-06-01"
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return parseTranslations(data.content?.find((block) => block.type === "text")?.text, data.stop_reason, texts.length);
|
|
136
|
+
}
|
|
137
|
+
var aiDriver = {
|
|
138
|
+
label: "AI",
|
|
139
|
+
maxBatch: 20,
|
|
140
|
+
keyOptional: true,
|
|
141
|
+
translate(request) {
|
|
142
|
+
const { options, token } = request;
|
|
143
|
+
const baseUrl = options.baseUrl && trimTrailingSlashes(options.baseUrl);
|
|
144
|
+
const provider = (options.provider ?? (baseUrl ? "custom" : "gateway")).toLowerCase();
|
|
145
|
+
const model = options.model ?? (provider === "gateway" ? DEFAULT_GATEWAY_MODEL : void 0);
|
|
146
|
+
if (!model) throw usageError("AI translation needs a model.", ["Example: --options provider:openai,model:gpt-4o-mini"]);
|
|
147
|
+
if (provider === "gateway" && !model.includes("/")) throw usageError("Gateway models are written provider/model.", [`Example: --options model:${DEFAULT_GATEWAY_MODEL}`]);
|
|
148
|
+
const named = provider === "gateway" || provider === "openai" || provider === "anthropic";
|
|
149
|
+
if (!named && !baseUrl) throw usageError(`Unknown AI provider "${provider}".`, ["Use gateway, openai or anthropic, or set baseUrl to an OpenAI-compatible server."]);
|
|
150
|
+
const key = named ? resolveKey(provider, token) : token.trim();
|
|
151
|
+
if (provider === "anthropic") return viaAnthropic(request, baseUrl ?? ANTHROPIC_URL, model, key);
|
|
152
|
+
if (provider === "openai") return viaChatCompletions(request, baseUrl ?? OPENAI_URL, model, key, true);
|
|
153
|
+
return viaChatCompletions(request, baseUrl ?? GATEWAY_URL, model, key, false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
// src/translate/drivers.ts
|
|
157
|
+
var base = (options, fallback) => trimTrailingSlashes(options.baseUrl ?? fallback);
|
|
158
|
+
var bareLanguage = (code) => code.split(/[-_]/)[0].toLowerCase();
|
|
159
|
+
var fromTable = (table) => (code) => table[code.toLowerCase()] ?? table[bareLanguage(code)] ?? bareLanguage(code);
|
|
160
|
+
var google = {
|
|
161
|
+
label: "Google Translate",
|
|
162
|
+
maxBatch: 128,
|
|
163
|
+
async translate({ texts, from, to, token, options, http }) {
|
|
164
|
+
const url = `${base(options, "https://translation.googleapis.com")}/language/translate/v2?key=${encodeURIComponent(token)}`;
|
|
165
|
+
const body = {
|
|
166
|
+
q: texts,
|
|
167
|
+
target: to,
|
|
168
|
+
source: from,
|
|
169
|
+
format: options.format ?? "html",
|
|
170
|
+
model: options.model
|
|
171
|
+
};
|
|
172
|
+
return ((await requestJson(url, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
...http,
|
|
175
|
+
...jsonBody(body)
|
|
176
|
+
})).data?.translations ?? []).map((item) => decodeEntities(item.translatedText));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/** Spellings of Chinese in Traditional script. The bare `zh` a lookup falls back to means Simplified. */
|
|
180
|
+
var TRADITIONAL = [
|
|
181
|
+
"zh-tw",
|
|
182
|
+
"zh-hk",
|
|
183
|
+
"zh-mo",
|
|
184
|
+
"zh-hant",
|
|
185
|
+
"zh-hant-tw",
|
|
186
|
+
"zh-hant-hk",
|
|
187
|
+
"zh-hant-mo"
|
|
188
|
+
];
|
|
189
|
+
var traditionalAs = (name) => Object.fromEntries(TRADITIONAL.map((code) => [code, name]));
|
|
190
|
+
var DEEPL_TARGETS = {
|
|
191
|
+
en: "EN-US",
|
|
192
|
+
"en-us": "EN-US",
|
|
193
|
+
"en-gb": "EN-GB",
|
|
194
|
+
es: "ES",
|
|
195
|
+
"es-419": "ES-419",
|
|
196
|
+
pt: "PT-PT",
|
|
197
|
+
"pt-pt": "PT-PT",
|
|
198
|
+
"pt-br": "PT-BR",
|
|
199
|
+
zh: "ZH-HANS",
|
|
200
|
+
"zh-cn": "ZH-HANS",
|
|
201
|
+
"zh-hans": "ZH-HANS",
|
|
202
|
+
...traditionalAs("ZH-HANT")
|
|
203
|
+
};
|
|
204
|
+
var deeplHost = (token) => token.endsWith(":fx") ? "https://api-free.deepl.com" : "https://api.deepl.com";
|
|
205
|
+
var deepl = {
|
|
206
|
+
label: "DeepL",
|
|
207
|
+
maxBatch: 50,
|
|
208
|
+
maxChars: 1e5,
|
|
209
|
+
sourceLang: (code) => bareLanguage(code).toUpperCase(),
|
|
210
|
+
targetLang: (code) => DEEPL_TARGETS[code.toLowerCase()] ?? bareLanguage(code).toUpperCase(),
|
|
211
|
+
async translate({ texts, from, to, token, options, http }) {
|
|
212
|
+
const body = {
|
|
213
|
+
text: texts,
|
|
214
|
+
source_lang: from,
|
|
215
|
+
target_lang: to,
|
|
216
|
+
tag_handling: options.tagHandling ?? "xml",
|
|
217
|
+
formality: options.formality,
|
|
218
|
+
glossary_id: options.glossaryId,
|
|
219
|
+
context: options.context,
|
|
220
|
+
model_type: options.modelType
|
|
221
|
+
};
|
|
222
|
+
const { headers, ...payload } = jsonBody(body);
|
|
223
|
+
return (await requestJson(`${base(options, deeplHost(token))}/v2/translate`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
...http,
|
|
226
|
+
...payload,
|
|
227
|
+
headers: {
|
|
228
|
+
...headers,
|
|
229
|
+
Authorization: `DeepL-Auth-Key ${token}`
|
|
230
|
+
}
|
|
231
|
+
})).translations.map((item) => item.text);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
var yandexcloud = {
|
|
235
|
+
label: "Yandex Cloud Translate",
|
|
236
|
+
maxBatch: 100,
|
|
237
|
+
maxChars: 1e4,
|
|
238
|
+
sourceLang: bareLanguage,
|
|
239
|
+
targetLang: bareLanguage,
|
|
240
|
+
async translate({ texts, from, to, token, options, http }) {
|
|
241
|
+
const scheme = options.authScheme ?? "Api-Key";
|
|
242
|
+
const { headers, ...payload } = jsonBody({
|
|
243
|
+
texts,
|
|
244
|
+
sourceLanguageCode: from,
|
|
245
|
+
targetLanguageCode: to,
|
|
246
|
+
format: "HTML",
|
|
247
|
+
folderId: options.folderId,
|
|
248
|
+
model: options.model
|
|
249
|
+
});
|
|
250
|
+
return ((await requestJson(`${base(options, "https://translate.api.cloud.yandex.net")}/translate/v2/translate`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
...http,
|
|
253
|
+
...payload,
|
|
254
|
+
headers: {
|
|
255
|
+
...headers,
|
|
256
|
+
Authorization: `${scheme} ${token}`
|
|
257
|
+
}
|
|
258
|
+
})).translations ?? []).map((item) => item.text ?? "");
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
var azureLang = fromTable({
|
|
262
|
+
zh: "zh-Hans",
|
|
263
|
+
"zh-cn": "zh-Hans",
|
|
264
|
+
...traditionalAs("zh-Hant"),
|
|
265
|
+
"pt-br": "pt",
|
|
266
|
+
"pt-pt": "pt-pt"
|
|
267
|
+
});
|
|
268
|
+
var drivers = {
|
|
269
|
+
ai: aiDriver,
|
|
270
|
+
azure: {
|
|
271
|
+
label: "Azure Translator",
|
|
272
|
+
maxBatch: 1e3,
|
|
273
|
+
maxChars: 5e4,
|
|
274
|
+
sourceLang: azureLang,
|
|
275
|
+
targetLang: azureLang,
|
|
276
|
+
async translate({ texts, from, to, token, options, http }) {
|
|
277
|
+
const query = new URLSearchParams({
|
|
278
|
+
"api-version": "3.0",
|
|
279
|
+
from,
|
|
280
|
+
to,
|
|
281
|
+
textType: "html"
|
|
282
|
+
});
|
|
283
|
+
const { headers, ...payload } = jsonBody(texts.map((text) => ({ Text: text })));
|
|
284
|
+
const auth = { "Ocp-Apim-Subscription-Key": token };
|
|
285
|
+
if (options.region) auth["Ocp-Apim-Subscription-Region"] = options.region;
|
|
286
|
+
return (await requestJson(`${base(options, "https://api.cognitive.microsofttranslator.com")}/translate?${query}`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
...http,
|
|
289
|
+
...payload,
|
|
290
|
+
headers: {
|
|
291
|
+
...headers,
|
|
292
|
+
...auth
|
|
293
|
+
}
|
|
294
|
+
})).map((item) => item.translations[0]?.text ?? "");
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
deepl,
|
|
298
|
+
google,
|
|
299
|
+
libretranslate: {
|
|
300
|
+
label: "LibreTranslate",
|
|
301
|
+
maxBatch: 100,
|
|
302
|
+
keyOptional: true,
|
|
303
|
+
async translate({ texts, from, to, token, options, http }) {
|
|
304
|
+
const body = {
|
|
305
|
+
q: texts,
|
|
306
|
+
source: from,
|
|
307
|
+
target: to,
|
|
308
|
+
format: "html",
|
|
309
|
+
api_key: token || void 0
|
|
310
|
+
};
|
|
311
|
+
return [(await requestJson(`${base(options, "https://libretranslate.com")}/translate`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
...http,
|
|
314
|
+
...jsonBody(body)
|
|
315
|
+
})).translatedText].flat();
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
yandexcloud
|
|
319
|
+
};
|
|
320
|
+
/** Services the old `nuxt-i18n-micro-cli` had: each name fails with the reason it is gone. */
|
|
321
|
+
var removedServices = {
|
|
322
|
+
googlefree: "it used an undocumented endpoint. Use libretranslate for free translation.",
|
|
323
|
+
yandex: "Yandex closed the v1.5 API. Use yandexcloud.",
|
|
324
|
+
ibm: "IBM withdrew Watson Language Translator in 2024.",
|
|
325
|
+
lingvatranslate: "public instances return the source text unchanged. Use libretranslate.",
|
|
326
|
+
modernmt: "ModernMT sunsets on 2026-12-31.",
|
|
327
|
+
lilt: "its API needs a translation memory per language pair, which this CLI has no model for.",
|
|
328
|
+
unbabel: "its public API no longer answers.",
|
|
329
|
+
reverso: "the unofficial endpoint is blocked.",
|
|
330
|
+
baidu: "it was dropped from the CLI. Use ai, deepl, google or azure.",
|
|
331
|
+
papago: "it was dropped from the CLI. Use ai, deepl, google or azure.",
|
|
332
|
+
tencent: "it was dropped from the CLI. Use ai, deepl, google or azure.",
|
|
333
|
+
systran: "it was dropped from the CLI. Use ai, deepl, google or azure.",
|
|
334
|
+
mymemory: "its 500-character limit and missing markup mode break placeholders. Use libretranslate for free translation."
|
|
335
|
+
};
|
|
336
|
+
// src/translate/engine.ts
|
|
337
|
+
function resolveDriver(service) {
|
|
338
|
+
const name = service.toLowerCase();
|
|
339
|
+
const driver = ownValue(drivers, name);
|
|
340
|
+
if (driver) return driver;
|
|
341
|
+
const reason = ownValue(removedServices, name);
|
|
342
|
+
if (reason) throw usageError(`Translation service "${service}" was removed: ${reason}`);
|
|
343
|
+
throw usageError(`Unsupported translation service: ${service}`, [`Supported: ${Object.keys(drivers).join(", ")}`]);
|
|
344
|
+
}
|
|
345
|
+
/** Parses `key:value,key:value`. Only the first colon splits: values are often URLs. */
|
|
346
|
+
function parseOptions(text) {
|
|
347
|
+
const options = {};
|
|
348
|
+
for (const pair of (text ?? "").split(",")) {
|
|
349
|
+
const separator = pair.indexOf(":");
|
|
350
|
+
if (separator <= 0) continue;
|
|
351
|
+
options[pair.slice(0, separator).trim()] = pair.slice(separator + 1).trim();
|
|
352
|
+
}
|
|
353
|
+
return options;
|
|
354
|
+
}
|
|
355
|
+
/** Batches within what the service accepts, by count and by characters. */
|
|
356
|
+
function buildBatches(items, size, maxCount, maxChars = Number.POSITIVE_INFINITY) {
|
|
357
|
+
const batches = [];
|
|
358
|
+
let current = [];
|
|
359
|
+
let chars = 0;
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
const itemChars = size(item);
|
|
362
|
+
if (current.length >= maxCount || current.length > 0 && chars + itemChars > maxChars) {
|
|
363
|
+
batches.push(current);
|
|
364
|
+
current = [];
|
|
365
|
+
chars = 0;
|
|
366
|
+
}
|
|
367
|
+
current.push(item);
|
|
368
|
+
chars += itemChars;
|
|
369
|
+
}
|
|
370
|
+
if (current.length > 0) batches.push(current);
|
|
371
|
+
return batches;
|
|
372
|
+
}
|
|
373
|
+
var httpOptions = (options) => ({
|
|
374
|
+
retries: options.retries ? Number(options.retries) : void 0,
|
|
375
|
+
retryDelayMs: options.retryDelayMs ? Number(options.retryDelayMs) : void 0,
|
|
376
|
+
timeoutMs: options.timeoutMs ? Number(options.timeoutMs) : void 0
|
|
377
|
+
});
|
|
378
|
+
function splitForms(text, separator) {
|
|
379
|
+
return separator && text.includes(separator) ? text.split(separator).map((part) => part.trim()) : [text];
|
|
380
|
+
}
|
|
381
|
+
/** The finished value of an item, or `null` when a placeholder got lost on the way. */
|
|
382
|
+
function assemble(segments, joiner) {
|
|
383
|
+
const values = segments.map((segment) => restore(segment.translated, segment.tokens));
|
|
384
|
+
return values.every((value, position) => value !== "" && isIntact(value, segments[position].tokens)) ? values.join(joiner) : null;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Translate one list of messages, plural forms and placeholders handled, into `to`.
|
|
388
|
+
*
|
|
389
|
+
* `onBatch` hears about every item as soon as its last form is back, so what was paid for can
|
|
390
|
+
* be saved before the next request. A failed request throws, and costs only what it carried.
|
|
391
|
+
*/
|
|
392
|
+
async function translateItems(items, from, to, config, extra = {}, onBatch = () => {}) {
|
|
393
|
+
const driver = resolveDriver(config.service);
|
|
394
|
+
const toSegment = (index, form) => {
|
|
395
|
+
const { masked, tokens } = protect(form);
|
|
396
|
+
return {
|
|
397
|
+
index,
|
|
398
|
+
masked,
|
|
399
|
+
tokens
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
const formsOf = items.map((item, index) => splitForms(item.text, config.pluralSeparator).map((form) => toSegment(index, form)));
|
|
403
|
+
const segments = formsOf.flat();
|
|
404
|
+
const options = {
|
|
405
|
+
...config.options,
|
|
406
|
+
...extra
|
|
407
|
+
};
|
|
408
|
+
const base = {
|
|
409
|
+
from: driver.sourceLang?.(from) ?? from,
|
|
410
|
+
to: driver.targetLang?.(to) ?? to,
|
|
411
|
+
token: config.token,
|
|
412
|
+
options,
|
|
413
|
+
http: httpOptions(options)
|
|
414
|
+
};
|
|
415
|
+
const total = {
|
|
416
|
+
translated: [],
|
|
417
|
+
rejected: []
|
|
418
|
+
};
|
|
419
|
+
const joiner = ` ${config.pluralSeparator} `;
|
|
420
|
+
const maxCount = Math.min(config.chunkSize ?? driver.maxBatch, driver.maxBatch);
|
|
421
|
+
for (const batch of buildBatches(segments, (segment) => segment.masked.length, maxCount, driver.maxChars)) {
|
|
422
|
+
const translated = await driver.translate({
|
|
423
|
+
...base,
|
|
424
|
+
texts: batch.map((segment) => segment.masked)
|
|
425
|
+
});
|
|
426
|
+
if (translated.length !== batch.length) throw commandFailed(`${driver.label} returned ${plural(translated.length, "translation")} for ${plural(batch.length, "string")}.`, ["Nothing from this batch was written."]);
|
|
427
|
+
for (const [position, segment] of batch.entries()) {
|
|
428
|
+
const text = translated[position];
|
|
429
|
+
segment.translated = typeof text === "string" ? text : "";
|
|
430
|
+
}
|
|
431
|
+
const done = {
|
|
432
|
+
translated: [],
|
|
433
|
+
rejected: []
|
|
434
|
+
};
|
|
435
|
+
for (const index of new Set(batch.map((segment) => segment.index))) {
|
|
436
|
+
if (formsOf[index].some((form) => form.translated === void 0)) continue;
|
|
437
|
+
const value = assemble(formsOf[index], joiner);
|
|
438
|
+
if (value === null) done.rejected.push(items[index].key);
|
|
439
|
+
else done.translated.push({
|
|
440
|
+
key: items[index].key,
|
|
441
|
+
value
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
onBatch(done);
|
|
445
|
+
total.translated.push(...done.translated);
|
|
446
|
+
total.rejected.push(...done.rejected);
|
|
447
|
+
}
|
|
448
|
+
return total;
|
|
449
|
+
}
|
|
450
|
+
// src/commands/translate.ts
|
|
451
|
+
var DEFAULT_GLOSSARY = ".i18n-glossary.json";
|
|
452
|
+
function jobsFor(reference, locale, replace) {
|
|
453
|
+
return reference.scopes.flatMap((scope) => {
|
|
454
|
+
const items = Object.entries(reference.strings(scope)).filter(([key, text]) => text !== "" && (replace || (locale.resolve(key, scope) ?? "") === "")).map(([key, text]) => ({
|
|
455
|
+
key,
|
|
456
|
+
text
|
|
457
|
+
}));
|
|
458
|
+
return items.length > 0 ? [{
|
|
459
|
+
scope,
|
|
460
|
+
items
|
|
461
|
+
}] : [];
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
async function resolveService(args) {
|
|
465
|
+
const service = args.service ?? await choose("Choose a translation service", Object.keys(drivers));
|
|
466
|
+
if (!service) throw usageError("Translation service is required.", ["Pass --service <name>."]);
|
|
467
|
+
return service;
|
|
468
|
+
}
|
|
469
|
+
async function resolveToken(args, keyOptional) {
|
|
470
|
+
const token = args.token ?? process.env.I18N_TRANSLATE_TOKEN;
|
|
471
|
+
if (token || keyOptional) return token ?? "";
|
|
472
|
+
const answer = await ask("API key:");
|
|
473
|
+
if (!answer) throw usageError("This service needs an API key.", ["Set I18N_TRANSLATE_TOKEN or pass --token."]);
|
|
474
|
+
return answer;
|
|
475
|
+
}
|
|
476
|
+
function resolveGlossary(args, rootDir) {
|
|
477
|
+
const path = resolve(rootDir, args.glossaryFile ?? DEFAULT_GLOSSARY);
|
|
478
|
+
if (existsSync(path)) return loadGlossary(path);
|
|
479
|
+
if (args.glossaryFile) throw notFoundError(`Glossary not found: ${path}`);
|
|
480
|
+
}
|
|
481
|
+
var contextOf = (scope, keys) => {
|
|
482
|
+
const shown = keys.slice(0, 10).join(", ");
|
|
483
|
+
return `scope=${scope === "" ? "global" : scope}; keys=${shown}${keys.length > 10 ? ` (+${keys.length - 10} more)` : ""}`;
|
|
484
|
+
};
|
|
485
|
+
function printPlan(project, codes, replace) {
|
|
486
|
+
const rows = codes.map((code) => {
|
|
487
|
+
const items = jobsFor(project.reference, project.locale(code), replace).flatMap((job) => job.items);
|
|
488
|
+
const chars = items.reduce((sum, item) => sum + item.text.length, 0);
|
|
489
|
+
return [code, `${plural(items.length, "key")}, ${plural(chars, "character")}`];
|
|
490
|
+
});
|
|
491
|
+
log.section("To translate");
|
|
492
|
+
log.rows(rows);
|
|
493
|
+
log.note("Dry run: nothing was sent.");
|
|
494
|
+
}
|
|
495
|
+
async function run(args) {
|
|
496
|
+
addSecret(args.token);
|
|
497
|
+
const project = await Project.load(args);
|
|
498
|
+
const { reference } = project;
|
|
499
|
+
const { defaultLocale, rootDir } = project.config;
|
|
500
|
+
const codes = project.selectLocales(splitList(args.locale));
|
|
501
|
+
if (args.dryRun) {
|
|
502
|
+
printPlan(project, codes, Boolean(args.replace));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const service = await resolveService(args);
|
|
506
|
+
const driver = resolveDriver(service);
|
|
507
|
+
const token = await resolveToken(args, Boolean(driver.keyOptional));
|
|
508
|
+
addSecret(token);
|
|
509
|
+
const glossary = resolveGlossary(args, rootDir);
|
|
510
|
+
const options = parseOptions(args.options);
|
|
511
|
+
addUrlSecrets(options.baseUrl);
|
|
512
|
+
const config = {
|
|
513
|
+
service,
|
|
514
|
+
token,
|
|
515
|
+
options,
|
|
516
|
+
chunkSize: args.chunkSize,
|
|
517
|
+
pluralSeparator: args.pluralSeparator
|
|
518
|
+
};
|
|
519
|
+
const rejected = [];
|
|
520
|
+
let translated = 0;
|
|
521
|
+
for (const code of codes) {
|
|
522
|
+
const locale = project.locale(code);
|
|
523
|
+
log.note(`Translating locale: ${code}`);
|
|
524
|
+
spinner.update(`Translating ${code}`);
|
|
525
|
+
const glossaryText = glossary && glossaryContext(glossary, defaultLocale, code);
|
|
526
|
+
for (const { scope, items } of jobsFor(reference, locale, Boolean(args.replace))) await translateItems(items, defaultLocale, code, config, {
|
|
527
|
+
translationContext: contextOf(scope, items.map((item) => item.key)),
|
|
528
|
+
...glossaryText ? { glossaryContext: glossaryText } : {}
|
|
529
|
+
}, (result) => {
|
|
530
|
+
for (const { key, value } of result.translated) {
|
|
531
|
+
const conflict = locale.set(key, value, scope);
|
|
532
|
+
if (conflict) rejected.push(`${code}: ${key} (${conflict} already holds a value)`);
|
|
533
|
+
else translated += 1;
|
|
534
|
+
}
|
|
535
|
+
rejected.push(...result.rejected.map((key) => `${code}: ${key} (a placeholder was lost, or the answer held no text)`));
|
|
536
|
+
project.save();
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
log.list("Not written", rejected);
|
|
540
|
+
log.success(`Translated ${plural(translated, "string")}.`);
|
|
541
|
+
if (rejected.length > 0) throw findingsError(`${plural(rejected.length, "string")} ${pick(rejected.length, "was", "were")} not written.`, ["Fill them in by hand."]);
|
|
542
|
+
}
|
|
543
|
+
export { run as default };
|
package/dist/cli.mjs
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Project } from '../project.js';
|
|
2
|
+
import { ProjectScan } from '../source.js';
|
|
3
|
+
import { CheckType } from '../spec.js';
|
|
4
|
+
export interface CheckArgs {
|
|
5
|
+
cwd?: string;
|
|
6
|
+
translationDir?: string;
|
|
7
|
+
only?: string;
|
|
8
|
+
ignore?: string;
|
|
9
|
+
duplicates?: boolean;
|
|
10
|
+
json?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface Finding {
|
|
13
|
+
type: CheckType;
|
|
14
|
+
/** Absent for what is not about one locale. */
|
|
15
|
+
locale?: string;
|
|
16
|
+
file: string;
|
|
17
|
+
key: string;
|
|
18
|
+
detail?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CheckReport {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
summary: Record<string, number>;
|
|
23
|
+
findings: Finding[];
|
|
24
|
+
/** Why `unused` was not checked: nothing can be called unused while some keys are computed at runtime. */
|
|
25
|
+
unusedSkipped?: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function runChecks(project: Project, scan: ProjectScan, types: Set<CheckType>, askedForUnused?: boolean): CheckReport;
|
|
28
|
+
export default function run(args: CheckArgs): Promise<void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Project } from '../project.js';
|
|
2
|
+
import { ProjectScan } from '../source.js';
|
|
3
|
+
export interface CleanArgs {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
translationDir?: string;
|
|
6
|
+
include?: string;
|
|
7
|
+
exclude?: string;
|
|
8
|
+
ignore?: string;
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface UnusedKey {
|
|
13
|
+
code: string;
|
|
14
|
+
scope: string;
|
|
15
|
+
key: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Keys of this project's own files that nothing in the project can read. A key used anywhere
|
|
19
|
+
* stays in every scope: a page's route name is not always knowable from its file.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findUnused(project: Project, scan: ProjectScan): UnusedKey[];
|
|
22
|
+
/** Why removing keys cannot be trusted here. Empty when it can. */
|
|
23
|
+
export declare function deletionDoubts(project: Project, scan: ProjectScan): string[];
|
|
24
|
+
/** Nothing scanned proves nothing: no flag changes that. */
|
|
25
|
+
export declare function assertScanned(scan: ProjectScan): void;
|
|
26
|
+
export default function run(args: CleanArgs): Promise<void>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ExportArgs {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
translationDir?: string;
|
|
4
|
+
format: 'po' | 'csv';
|
|
5
|
+
dir?: string;
|
|
6
|
+
force?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const defaultExchangeDir: (format: string) => string;
|
|
9
|
+
export declare function readManifest(dir: string): Record<string, unknown>;
|
|
10
|
+
export default function run(args: ExportArgs): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface LintArgs {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
translationDir?: string;
|
|
4
|
+
rules?: string;
|
|
5
|
+
fix?: boolean;
|
|
6
|
+
json?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface LintResult {
|
|
9
|
+
file: string;
|
|
10
|
+
locale: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
rule: string;
|
|
15
|
+
}
|
|
16
|
+
export default function run(args: LintArgs): Promise<void>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PseudoArgs {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
translationDir?: string;
|
|
4
|
+
sourceLocale?: string;
|
|
5
|
+
targetLocale: string;
|
|
6
|
+
replace?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function pseudoLocalize(text: string): string;
|
|
9
|
+
export default function run(args: PseudoArgs): Promise<void>;
|