i18next-cli 1.61.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -1
- package/dist/cjs/cli.js +36 -5
- package/dist/cjs/config.js +5 -1
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/init.js +9 -75
- package/dist/cjs/instrumenter/core/instrumenter.js +32 -11
- package/dist/cjs/instrumenter/core/transformer.js +5 -1
- package/dist/cjs/localize/agent-prompt.js +49 -0
- package/dist/cjs/localize/detect.js +88 -0
- package/dist/cjs/localize/localize.js +475 -0
- package/dist/cjs/locize.js +91 -13
- package/dist/cjs/status.js +5 -1
- package/dist/cjs/types-generator.js +8 -3
- package/dist/cjs/utils/file-utils.js +6 -2
- package/dist/cjs/utils/locize-onboarding.js +91 -0
- package/dist/cjs/utils/wrap-ora.js +9 -5
- package/dist/esm/cli.js +29 -2
- package/dist/esm/index.js +4 -0
- package/dist/esm/init.js +3 -73
- package/dist/esm/instrumenter/core/instrumenter.js +21 -8
- package/dist/esm/localize/agent-prompt.js +47 -0
- package/dist/esm/localize/detect.js +85 -0
- package/dist/esm/localize/localize.js +469 -0
- package/dist/esm/locize.js +82 -11
- package/dist/esm/utils/locize-onboarding.js +83 -0
- package/package.json +10 -10
- package/types/cli.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/init.d.ts.map +1 -1
- package/types/instrumenter/core/instrumenter.d.ts +27 -0
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
- package/types/instrumenter/index.d.ts +2 -1
- package/types/instrumenter/index.d.ts.map +1 -1
- package/types/localize/agent-prompt.d.ts +11 -0
- package/types/localize/agent-prompt.d.ts.map +1 -0
- package/types/localize/detect.d.ts +37 -0
- package/types/localize/detect.d.ts.map +1 -0
- package/types/localize/index.d.ts +6 -0
- package/types/localize/index.d.ts.map +1 -0
- package/types/localize/localize.d.ts +20 -0
- package/types/localize/localize.d.ts.map +1 -0
- package/types/locize.d.ts +20 -0
- package/types/locize.d.ts.map +1 -1
- package/types/types.d.ts +12 -0
- package/types/types.d.ts.map +1 -1
- package/types/utils/locize-onboarding.d.ts +19 -0
- package/types/utils/locize-onboarding.d.ts.map +1 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var node_util = require('node:util');
|
|
4
|
+
var promises = require('node:fs/promises');
|
|
5
|
+
var execa = require('execa');
|
|
6
|
+
var inquirer = require('inquirer');
|
|
7
|
+
var glob = require('glob');
|
|
8
|
+
var config = require('../config.js');
|
|
9
|
+
var heuristicConfig = require('../heuristic-config.js');
|
|
10
|
+
var extractor = require('../extractor/core/extractor.js');
|
|
11
|
+
require('node:module');
|
|
12
|
+
require('node:path');
|
|
13
|
+
var nestedObject = require('../utils/nested-object.js');
|
|
14
|
+
require('jiti');
|
|
15
|
+
require('@croct/json5-parser');
|
|
16
|
+
require('yaml');
|
|
17
|
+
var instrumenter = require('../instrumenter/core/instrumenter.js');
|
|
18
|
+
require('../utils/jsx-attributes.js');
|
|
19
|
+
require('magic-string');
|
|
20
|
+
var locize = require('../locize.js');
|
|
21
|
+
var locizeOnboarding = require('../utils/locize-onboarding.js');
|
|
22
|
+
var detect = require('./detect.js');
|
|
23
|
+
var agentPrompt = require('./agent-prompt.js');
|
|
24
|
+
|
|
25
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
26
|
+
|
|
27
|
+
var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
|
|
28
|
+
|
|
29
|
+
const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__localize';
|
|
30
|
+
const TOTAL_STEPS = 6;
|
|
31
|
+
/**
|
|
32
|
+
* Server-side errors indicating auto-translation is not enabled/available on
|
|
33
|
+
* the project: the output must both mention auto-translation/MT and describe
|
|
34
|
+
* a disabled state. Checked against the captured stderr/stdout only — never
|
|
35
|
+
* `error.message`, which can echo the invoked command line (and therefore
|
|
36
|
+
* always contains "--auto-translate").
|
|
37
|
+
*/
|
|
38
|
+
const AI_MENTION_PATTERN = /auto.?translat|machine translation/i;
|
|
39
|
+
const AI_DISABLED_PATTERN = /not (enabled|allowed|activated|available)|disabled/i;
|
|
40
|
+
/** Delays between status-poll rounds while waiting for async AI translation (rounds = delays + 1). */
|
|
41
|
+
const POLL_DELAYS_MS = [15000, 20000];
|
|
42
|
+
function step(n, label) {
|
|
43
|
+
console.log(node_util.styleText('bold', `\n[${n}/${TOTAL_STEPS}] ${label}`));
|
|
44
|
+
}
|
|
45
|
+
function ok(message) {
|
|
46
|
+
console.log(node_util.styleText('green', ` ✔ ${message}`));
|
|
47
|
+
}
|
|
48
|
+
function warn(message) {
|
|
49
|
+
console.log(node_util.styleText('yellow', ` ⚠ ${message}`));
|
|
50
|
+
}
|
|
51
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
|
+
/**
|
|
53
|
+
* Checks the git working tree. Returns:
|
|
54
|
+
* - true: dirty (uncommitted changes)
|
|
55
|
+
* - false: clean
|
|
56
|
+
* - null: not a git repository (or git unavailable)
|
|
57
|
+
*/
|
|
58
|
+
async function isGitTreeDirty() {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execa.execa('git', ['status', '--porcelain']);
|
|
61
|
+
return stdout.trim().length > 0;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Computes per-locale translation completeness from the local translation
|
|
69
|
+
* files (post-download these mirror the remote state). Intentionally
|
|
70
|
+
* tolerant: non-JSON output formats or unreadable files simply yield no data
|
|
71
|
+
* — this powers an informational summary, not a gate.
|
|
72
|
+
*/
|
|
73
|
+
async function computeCompleteness(config) {
|
|
74
|
+
const output = config.extract.output;
|
|
75
|
+
if (typeof output !== 'string')
|
|
76
|
+
return [];
|
|
77
|
+
const primary = config.extract.primaryLanguage || config.locales[0] || 'en';
|
|
78
|
+
const secondaries = config.locales.filter(l => l !== primary);
|
|
79
|
+
const rawSep = config.extract.keySeparator;
|
|
80
|
+
const keySeparator = rawSep === false ? false : (rawSep ?? '.');
|
|
81
|
+
const primaryTemplate = output.replace('{{language}}', primary);
|
|
82
|
+
const hasNamespace = primaryTemplate.includes('{{namespace}}');
|
|
83
|
+
const primaryFiles = hasNamespace
|
|
84
|
+
? await glob.glob(primaryTemplate.replace('{{namespace}}', '*'), { nodir: true })
|
|
85
|
+
: [primaryTemplate];
|
|
86
|
+
const readJson = async (path) => {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(await promises.readFile(path, 'utf-8'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// namespace → flattened primary keys
|
|
95
|
+
const namespaces = new Map();
|
|
96
|
+
const [prefix, suffix] = hasNamespace
|
|
97
|
+
? primaryTemplate.replace(/\\/g, '/').split('{{namespace}}')
|
|
98
|
+
: ['', ''];
|
|
99
|
+
for (const file of primaryFiles) {
|
|
100
|
+
const json = await readJson(file);
|
|
101
|
+
if (!json)
|
|
102
|
+
continue;
|
|
103
|
+
const normalized = file.replace(/\\/g, '/');
|
|
104
|
+
const ns = hasNamespace
|
|
105
|
+
? normalized.slice(prefix.length, suffix ? normalized.length - suffix.length : undefined)
|
|
106
|
+
: '';
|
|
107
|
+
namespaces.set(ns, nestedObject.getNestedKeys(json, keySeparator));
|
|
108
|
+
}
|
|
109
|
+
// No parseable primary files (e.g. yaml/js output formats): make no claim
|
|
110
|
+
// rather than reporting every language as 0/0 = 100% complete.
|
|
111
|
+
if (namespaces.size === 0)
|
|
112
|
+
return [];
|
|
113
|
+
const totalKeys = [...namespaces.values()].reduce((sum, keys) => sum + keys.length, 0);
|
|
114
|
+
const results = [];
|
|
115
|
+
for (const locale of secondaries) {
|
|
116
|
+
let translated = 0;
|
|
117
|
+
for (const [ns, keys] of namespaces) {
|
|
118
|
+
const path = output.replace('{{language}}', locale).replace('{{namespace}}', ns);
|
|
119
|
+
const json = await readJson(path);
|
|
120
|
+
if (!json)
|
|
121
|
+
continue;
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
const value = nestedObject.getNestedValue(json, key, keySeparator);
|
|
124
|
+
if (typeof value === 'string' ? value.trim() !== '' : (value !== undefined && value !== null)) {
|
|
125
|
+
translated++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
results.push({ locale, translated, total: totalKeys });
|
|
130
|
+
}
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
function printCompleteness(completeness) {
|
|
134
|
+
for (const { locale, translated, total } of completeness) {
|
|
135
|
+
const pct = total === 0 ? 100 : Math.round((translated / total) * 100);
|
|
136
|
+
const color = pct === 100 ? 'green' : pct > 0 ? 'yellow' : 'red';
|
|
137
|
+
console.log(node_util.styleText(color, ` ${locale}: ${translated}/${total} (${pct}%)`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function printEpilogue(config) {
|
|
141
|
+
const projectId = config.locize?.projectId || '<your-project-id>';
|
|
142
|
+
console.log(node_util.styleText('green', '\n✅ Done. Your app is localized.'));
|
|
143
|
+
console.log(node_util.styleText('cyan', '\nYour AI translations come with confidence scores; low-confidence ones are flagged for review in Locize.'));
|
|
144
|
+
console.log('\nOptional — switch to CDN delivery so translation fixes go live without redeploying your app:');
|
|
145
|
+
console.log(node_util.styleText('cyan', ' npm install i18next-locize-backend'));
|
|
146
|
+
console.log(node_util.styleText('gray', `
|
|
147
|
+
// in your i18n init file:
|
|
148
|
+
import LocizeBackend from 'i18next-locize-backend'
|
|
149
|
+
|
|
150
|
+
i18next
|
|
151
|
+
.use(LocizeBackend)
|
|
152
|
+
.init({
|
|
153
|
+
backend: {
|
|
154
|
+
projectId: '${projectId}',
|
|
155
|
+
version: 'latest', // no apiKey in production!
|
|
156
|
+
},
|
|
157
|
+
// ...
|
|
158
|
+
})
|
|
159
|
+
`));
|
|
160
|
+
console.log('Docs: https://github.com/locize/i18next-locize-backend');
|
|
161
|
+
console.log('Your current setup (local translation files) keeps working either way; run `i18next-cli locize-download` in CI to refresh the files.');
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The `localize` supercommand: takes a mono-lingual app to fully localized +
|
|
165
|
+
* delivered via Locize in one command, orchestrating the existing pieces —
|
|
166
|
+
* detect → instrument → extract → connect Locize → sync with AI
|
|
167
|
+
* auto-translate → download & verify.
|
|
168
|
+
*/
|
|
169
|
+
async function runLocalize(options = {}, configPath) {
|
|
170
|
+
if (options.printAgentPrompt) {
|
|
171
|
+
console.log(agentPrompt.AGENT_PROMPT);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const isDryRun = !!options.dryRun;
|
|
175
|
+
const isCi = !!options.ci;
|
|
176
|
+
const autoYes = !!options.yes;
|
|
177
|
+
const interactive = !isCi && !autoYes;
|
|
178
|
+
console.log(node_util.styleText('bold', 'i18next-cli localize — from hardcoded strings to a localized app'));
|
|
179
|
+
// ── [1/6] Detect ────────────────────────────────────────────────────────
|
|
180
|
+
step(1, 'Detecting project…');
|
|
181
|
+
const stack = await detect.detectStack(instrumenter.findExistingI18nInitFile);
|
|
182
|
+
ok(`${stack.framework === 'unknown' ? 'unknown framework' : stack.framework}${stack.hasAppRouter ? ' (App Router)' : ''}${stack.hasTypeScript ? ' + TypeScript' : ''}${stack.hasI18next ? ', i18next detected' : ''}`);
|
|
183
|
+
let skipInstrument = !!options.skipInstrument;
|
|
184
|
+
if (stack.hasParaglide) {
|
|
185
|
+
warn('This app uses inlang Paraglide — instrumenting i18next calls would conflict; Locize can still manage these translations.');
|
|
186
|
+
if (!stack.hasI18next) {
|
|
187
|
+
console.log(node_util.styleText('yellow', '\nNo i18next setup found alongside Paraglide, so there is nothing for `localize` to do here.'));
|
|
188
|
+
console.log('To manage Paraglide translations with Locize, see https://www.locize.com — or set up i18next first and re-run.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
skipInstrument = true;
|
|
192
|
+
}
|
|
193
|
+
// ── [2/6] Configuration ─────────────────────────────────────────────────
|
|
194
|
+
step(2, 'Configuration…');
|
|
195
|
+
let config$1;
|
|
196
|
+
if (isCi) {
|
|
197
|
+
let loaded = await config.loadConfig(configPath);
|
|
198
|
+
if (!loaded) {
|
|
199
|
+
const detected = await heuristicConfig.detectConfig();
|
|
200
|
+
if (!detected) {
|
|
201
|
+
console.error(node_util.styleText('red', 'No i18next.config found.'));
|
|
202
|
+
console.log('Run `npx i18next-cli init` locally and commit the config, or pass `--config <path>`.');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
loaded = detected;
|
|
207
|
+
}
|
|
208
|
+
config$1 = loaded;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
config$1 = await config.ensureConfig(configPath);
|
|
212
|
+
}
|
|
213
|
+
ok(`locales: ${config$1.locales.join(', ')}`);
|
|
214
|
+
// Instrument eligibility: React/Next natively, other stacks via a configured plugin
|
|
215
|
+
const instrumentableNatively = stack.framework === 'react' || stack.framework === 'next';
|
|
216
|
+
const stackPluginConfigured = detect.hasStackPlugin(config$1, stack.framework);
|
|
217
|
+
if (!skipInstrument && !instrumentableNatively && !stackPluginConfigured) {
|
|
218
|
+
warn(`instrument transforms React/JSX out of the box${stack.framework !== 'unknown' ? ` — detected ${stack.framework}` : ''}.`);
|
|
219
|
+
console.log(` For ${stack.framework === 'vue' ? 'Vue, add a plugin to i18next.config.ts — community: i18next-cli-vue' : stack.framework === 'svelte' ? 'Svelte, add a plugin to i18next.config.ts — community: i18next-cli-plugin-svelte' : 'this stack, add a plugin to i18next.config.ts'} — or write your own via the instrumentOnLoad/onLoad hooks (see the Plugin System docs). Then re-run \`i18next-cli localize\`.`);
|
|
220
|
+
console.log(' Without a plugin: wrap strings manually (`i18next-cli lint` lists them) and re-run with `--skip-instrument`.');
|
|
221
|
+
skipInstrument = true;
|
|
222
|
+
}
|
|
223
|
+
// ── [3/6] Instrument ────────────────────────────────────────────────────
|
|
224
|
+
step(3, 'Instrumenting code…');
|
|
225
|
+
if (!skipInstrument && isCi && !autoYes) {
|
|
226
|
+
warn('Skipped in CI: instrumentation rewrites source files and needs human review. Run `i18next-cli localize` locally, or pass `--ci --yes` to force.');
|
|
227
|
+
skipInstrument = true;
|
|
228
|
+
}
|
|
229
|
+
if (skipInstrument) {
|
|
230
|
+
if (options.skipInstrument)
|
|
231
|
+
ok('Skipped (--skip-instrument).');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Dirty-git guard: instrument rewrites source files — make sure the diff is reviewable.
|
|
235
|
+
if (!isDryRun) {
|
|
236
|
+
const dirty = await isGitTreeDirty();
|
|
237
|
+
if (dirty === null) {
|
|
238
|
+
warn("Not a git repository — you won't be able to review instrument's changes as a diff.");
|
|
239
|
+
}
|
|
240
|
+
else if (dirty) {
|
|
241
|
+
if (interactive) {
|
|
242
|
+
const { proceed } = await inquirer__default.default.prompt([{
|
|
243
|
+
type: 'confirm',
|
|
244
|
+
name: 'proceed',
|
|
245
|
+
message: 'Working tree has uncommitted changes. instrument rewrites source files — continue?',
|
|
246
|
+
default: false,
|
|
247
|
+
}]);
|
|
248
|
+
if (!proceed) {
|
|
249
|
+
console.log('Aborted. Commit or stash your changes, then re-run `i18next-cli localize`.');
|
|
250
|
+
process.exit(1);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
warn('Working tree has uncommitted changes — instrument changes will mix into your diff.');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Already-using-i18next heuristic (instrument is idempotent either way)
|
|
260
|
+
if (stack.hasI18next && stack.initFile && interactive) {
|
|
261
|
+
const { proceed } = await inquirer__default.default.prompt([{
|
|
262
|
+
type: 'confirm',
|
|
263
|
+
name: 'proceed',
|
|
264
|
+
message: `Your project already uses i18next (found ${stack.initFile}). Run instrumentation anyway to catch remaining hardcoded strings?`,
|
|
265
|
+
default: true,
|
|
266
|
+
}]);
|
|
267
|
+
if (!proceed)
|
|
268
|
+
skipInstrument = true;
|
|
269
|
+
}
|
|
270
|
+
if (stack.hasAppRouter) {
|
|
271
|
+
warn("Next.js App Router detected: instrument injects useTranslation(), which is client-only. Review the diff for server components — add 'use client' or switch those to a server-side t() pattern.");
|
|
272
|
+
}
|
|
273
|
+
if (!skipInstrument) {
|
|
274
|
+
const results = await instrumenter.runInstrumenter(config$1, {
|
|
275
|
+
isDryRun,
|
|
276
|
+
isInteractive: interactive,
|
|
277
|
+
namespace: options.namespace,
|
|
278
|
+
quiet: false,
|
|
279
|
+
});
|
|
280
|
+
if (results.totalCandidates === 0) {
|
|
281
|
+
ok('No hardcoded strings found — your code looks already internationalized.');
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
ok(`${results.totalTransformed}/${results.totalCandidates} candidate string(s) ${isDryRun ? 'would be ' : ''}instrumented (${results.totalSkipped} skipped).`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// ── [4/6] Extract ───────────────────────────────────────────────────────
|
|
289
|
+
step(4, 'Extracting translation keys…');
|
|
290
|
+
const { hasErrors } = await extractor.runExtractor(config$1, { isDryRun, quiet: false });
|
|
291
|
+
if (hasErrors) {
|
|
292
|
+
console.error(node_util.styleText('red', '\nExtraction reported errors (see above).'));
|
|
293
|
+
console.log('Fix the parse errors, then re-run `i18next-cli localize` — completed steps are skipped automatically on re-run.');
|
|
294
|
+
process.exit(1);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
ok(isDryRun ? 'Extraction previewed (dry-run).' : 'Translation keys extracted.');
|
|
298
|
+
if (options.skipLocize) {
|
|
299
|
+
console.log(node_util.styleText('green', '\n✅ Done (local files only — steps 5–6 skipped via --skip-locize).'));
|
|
300
|
+
console.log('When you are ready for managed translations + AI auto-translate, re-run without --skip-locize.');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// ── [5/6] Connect Locize ────────────────────────────────────────────────
|
|
304
|
+
step(5, 'Connecting to Locize…');
|
|
305
|
+
let projectId = config$1.locize?.projectId || process.env.LOCIZE_PROJECTID || process.env.LOCIZE_PID;
|
|
306
|
+
let apiKey = config$1.locize?.apiKey || process.env.LOCIZE_API_KEY || process.env.LOCIZE_KEY;
|
|
307
|
+
if (projectId && apiKey) {
|
|
308
|
+
ok(`Project ${projectId} (API key ${locize.maskApiKey(apiKey)})`);
|
|
309
|
+
config$1.locize = { ...config$1.locize, projectId, apiKey };
|
|
310
|
+
}
|
|
311
|
+
else if (isCi) {
|
|
312
|
+
console.error(node_util.styleText('red', 'Missing Locize credentials.'));
|
|
313
|
+
console.log('Set the LOCIZE_PROJECTID and LOCIZE_API_KEY environment variables (Project settings → "API, CDN, NOTIFICATIONS" tab on www.locize.app), or add locize.projectId to i18next.config.ts.');
|
|
314
|
+
process.exit(1);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
else if (isDryRun) {
|
|
318
|
+
warn('No Locize credentials configured — with credentials, step 6 would sync your keys and request AI auto-translation.');
|
|
319
|
+
console.log(node_util.styleText('blue', '\n📋 Dry run complete — re-run without --dry-run to apply.'));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
console.log(`
|
|
324
|
+
One manual step — in your browser:
|
|
325
|
+
1. Sign up / log in: ${LOCIZE_SIGNUP_URL}
|
|
326
|
+
2. Create a project.
|
|
327
|
+
Your target languages (${config$1.locales.join(', ')}) are created
|
|
328
|
+
automatically on the first sync. Auto-translate and Quality Estimation
|
|
329
|
+
are on by default for new projects: translations with confidence scores
|
|
330
|
+
arrive automatically once the project is subscribed or an AI/MT provider
|
|
331
|
+
is configured; low-confidence ones are flagged for review.
|
|
332
|
+
3. Copy your Project ID and an API key from Project settings →
|
|
333
|
+
"API, CDN, NOTIFICATIONS" tab (any write-capable key works).
|
|
334
|
+
`);
|
|
335
|
+
const opened = await locizeOnboarding.openBrowser(LOCIZE_SIGNUP_URL, { ci: isCi });
|
|
336
|
+
if (!opened) {
|
|
337
|
+
console.log(` 👉 Open this URL manually: ${LOCIZE_SIGNUP_URL}\n`);
|
|
338
|
+
}
|
|
339
|
+
const credentials = await locizeOnboarding.promptLocizeCredentials();
|
|
340
|
+
if (!credentials.apiKey) {
|
|
341
|
+
console.error(node_util.styleText('red', '\nAn API key is required to sync translations.'));
|
|
342
|
+
console.log('Your code is instrumented and keys are extracted — re-run `i18next-cli localize` anytime to finish, or use `--skip-locize`.');
|
|
343
|
+
process.exit(1);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
projectId = credentials.projectId;
|
|
347
|
+
apiKey = credentials.apiKey;
|
|
348
|
+
config$1.locize = { ...config$1.locize, projectId, apiKey };
|
|
349
|
+
console.log(node_util.styleText('cyan', '\nTo persist these credentials for future runs:'));
|
|
350
|
+
console.log(node_util.styleText('green', `
|
|
351
|
+
# .env (add to .gitignore!)
|
|
352
|
+
LOCIZE_API_KEY=${apiKey}
|
|
353
|
+
`));
|
|
354
|
+
console.log(node_util.styleText('green', ` // i18next.config.ts
|
|
355
|
+
locize: {
|
|
356
|
+
projectId: '${projectId}',
|
|
357
|
+
apiKey: process.env.LOCIZE_API_KEY,
|
|
358
|
+
},
|
|
359
|
+
`));
|
|
360
|
+
}
|
|
361
|
+
// ── [6/6] Translate & deliver ───────────────────────────────────────────
|
|
362
|
+
step(6, 'Translating & delivering…');
|
|
363
|
+
const autoTranslate = options.skipTranslate ? undefined : true;
|
|
364
|
+
try {
|
|
365
|
+
await locize.runLocizeSync(config$1, {
|
|
366
|
+
autoTranslate,
|
|
367
|
+
updateValues: options.updateValues,
|
|
368
|
+
cdnType: options.cdnType,
|
|
369
|
+
dryRun: isDryRun,
|
|
370
|
+
throwOnError: true,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
const capturedOutput = error instanceof locize.LocizeCommandError ? `${error.stderr}\n${error.stdout}` : '';
|
|
375
|
+
if (autoTranslate && AI_MENTION_PATTERN.test(capturedOutput) && AI_DISABLED_PATTERN.test(capturedOutput)) {
|
|
376
|
+
warn('Locize rejected auto-translation — AI/MT is not enabled on this project.');
|
|
377
|
+
// Retry once without auto-translate so the key sync itself completes.
|
|
378
|
+
try {
|
|
379
|
+
await locize.runLocizeSync(config$1, {
|
|
380
|
+
updateValues: options.updateValues,
|
|
381
|
+
cdnType: options.cdnType,
|
|
382
|
+
dryRun: isDryRun,
|
|
383
|
+
throwOnError: true,
|
|
384
|
+
});
|
|
385
|
+
ok('Keys synced to Locize (without auto-translation).');
|
|
386
|
+
}
|
|
387
|
+
catch (retryError) {
|
|
388
|
+
console.error(node_util.styleText('red', `Sync failed: ${retryError.message}`));
|
|
389
|
+
}
|
|
390
|
+
console.log(`
|
|
391
|
+
Enable it: www.locize.app → your project → Settings →
|
|
392
|
+
"EDITOR, TM/MT/AI, ORDERING" tab → turn on the Automatic Translation Workflow.
|
|
393
|
+
Then re-run \`i18next-cli localize\` (or \`i18next-cli locize-sync --auto-translate true\`).
|
|
394
|
+
`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (error instanceof locize.LocizeCommandError && /missing required argument/i.test(error.stderr)) {
|
|
399
|
+
console.error(node_util.styleText('red', 'Locize rejected the credentials.'));
|
|
400
|
+
console.log('Check the API key in Project settings → "API, CDN, NOTIFICATIONS" tab (any write-capable key works for new projects; readonly keys cannot sync).');
|
|
401
|
+
process.exit(1);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// Empty-project case (project exists but has no languages yet): newer
|
|
405
|
+
// locize-cli versions bootstrap the languages automatically during sync —
|
|
406
|
+
// guide users running an older global install. The wrong-cdnType variant
|
|
407
|
+
// of this error is actionable as-is and passes through below.
|
|
408
|
+
if (error instanceof locize.LocizeCommandError &&
|
|
409
|
+
/Project with id .* not found/i.test(capturedOutput) &&
|
|
410
|
+
!/wrong cdnType/i.test(capturedOutput)) {
|
|
411
|
+
console.error(node_util.styleText('red', 'The Locize project was not found — or it has no languages yet.'));
|
|
412
|
+
console.log('If the project ID is correct, your project has no languages yet:');
|
|
413
|
+
console.log(' - update locize-cli (`npm i -g locize-cli`) so sync can create them automatically, or');
|
|
414
|
+
console.log(' - add your languages in the project settings on www.locize.app,');
|
|
415
|
+
console.log('then re-run `i18next-cli localize`.');
|
|
416
|
+
process.exit(1);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
console.error(node_util.styleText('red', `Sync failed: ${error.message}`));
|
|
420
|
+
process.exit(1);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (isDryRun) {
|
|
424
|
+
console.log(node_util.styleText('blue', '\n📋 Dry run complete — re-run without --dry-run to apply.'));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
ok(`Synced to Locize${autoTranslate ? ' with AI auto-translate requested' : ''}.`);
|
|
428
|
+
// Poll-then-download: AI translation is asynchronous server-side — watch
|
|
429
|
+
// the translations arrive instead of downloading a still-empty snapshot.
|
|
430
|
+
const isComplete = (completeness) => completeness.length > 0 && completeness.every(c => c.translated >= c.total);
|
|
431
|
+
let downloadFailed = false;
|
|
432
|
+
const download = async () => {
|
|
433
|
+
try {
|
|
434
|
+
await locize.runLocizeDownload(config$1, { cdnType: options.cdnType, throwOnError: true });
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
downloadFailed = true;
|
|
439
|
+
warn(`Download failed: ${error.message}`);
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
let completeness = [];
|
|
444
|
+
if (autoTranslate && !isCi) {
|
|
445
|
+
console.log(node_util.styleText('cyan', ' Waiting for AI translations to arrive…'));
|
|
446
|
+
for (let round = 0; round <= POLL_DELAYS_MS.length; round++) {
|
|
447
|
+
if (!await download())
|
|
448
|
+
break;
|
|
449
|
+
completeness = await computeCompleteness(config$1);
|
|
450
|
+
printCompleteness(completeness);
|
|
451
|
+
// Stop when done — or when completeness cannot be computed (non-JSON
|
|
452
|
+
// output formats); polling blindly would just burn time.
|
|
453
|
+
if (completeness.length === 0 || isComplete(completeness))
|
|
454
|
+
break;
|
|
455
|
+
if (round < POLL_DELAYS_MS.length)
|
|
456
|
+
await sleep(POLL_DELAYS_MS[round]);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
if (await download()) {
|
|
461
|
+
completeness = await computeCompleteness(config$1);
|
|
462
|
+
printCompleteness(completeness);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (downloadFailed || (autoTranslate && completeness.length > 0 && !isComplete(completeness))) {
|
|
466
|
+
warn('Translations may still be processing — run `i18next-cli locize-download` in a minute.');
|
|
467
|
+
console.log(' (On an unsubscribed trial, AI translation needs a subscription or a configured AI/MT provider — see Project settings.)');
|
|
468
|
+
}
|
|
469
|
+
else if (completeness.length > 0) {
|
|
470
|
+
ok('All languages translated and downloaded.');
|
|
471
|
+
}
|
|
472
|
+
printEpilogue(config$1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
exports.runLocalize = runLocalize;
|
package/dist/cjs/locize.js
CHANGED
|
@@ -6,6 +6,11 @@ var ora = require('ora');
|
|
|
6
6
|
var inquirer = require('inquirer');
|
|
7
7
|
var node_path = require('node:path');
|
|
8
8
|
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var ora__default = /*#__PURE__*/_interopDefault(ora);
|
|
12
|
+
var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* Resolves the locize-cli executable to use.
|
|
11
16
|
*
|
|
@@ -63,7 +68,7 @@ async function resolveLocizeBin() {
|
|
|
63
68
|
*/
|
|
64
69
|
async function interactiveCredentialSetup(config) {
|
|
65
70
|
console.log(node_util.styleText('yellow', '\nLocize configuration is missing or invalid. Let\'s set it up!'));
|
|
66
|
-
const answers = await
|
|
71
|
+
const answers = await inquirer__default.default.prompt([
|
|
67
72
|
{
|
|
68
73
|
type: 'input',
|
|
69
74
|
name: 'projectId',
|
|
@@ -73,7 +78,7 @@ async function interactiveCredentialSetup(config) {
|
|
|
73
78
|
{
|
|
74
79
|
type: 'password',
|
|
75
80
|
name: 'apiKey',
|
|
76
|
-
message: 'Enter your Locize API key (Project settings → API → API Keys).
|
|
81
|
+
message: 'Enter your Locize API key (Project settings → API → API Keys). Any write-capable key works — missing languages are created automatically for new projects.',
|
|
77
82
|
validate: input => !!input || 'API Key cannot be empty.',
|
|
78
83
|
},
|
|
79
84
|
{
|
|
@@ -87,7 +92,7 @@ async function interactiveCredentialSetup(config) {
|
|
|
87
92
|
console.error(node_util.styleText('red', 'Project ID is required to continue.'));
|
|
88
93
|
return undefined;
|
|
89
94
|
}
|
|
90
|
-
const { save } = await
|
|
95
|
+
const { save } = await inquirer__default.default.prompt([{
|
|
91
96
|
type: 'confirm',
|
|
92
97
|
name: 'save',
|
|
93
98
|
message: 'Would you like to see how to save these credentials for future use?',
|
|
@@ -119,11 +124,42 @@ LOCIZE_API_KEY=${answers.apiKey}
|
|
|
119
124
|
};
|
|
120
125
|
}
|
|
121
126
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
127
|
+
* Error thrown by {@link runLocizeCommand} when `throwOnError` is set,
|
|
128
|
+
* carrying the captured output of the failed locize-cli invocation so
|
|
129
|
+
* orchestrating commands (e.g. `localize`) can inspect and react to it.
|
|
130
|
+
*/
|
|
131
|
+
class LocizeCommandError extends Error {
|
|
132
|
+
stdout;
|
|
133
|
+
stderr;
|
|
134
|
+
constructor(message, output = {}) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = 'LocizeCommandError';
|
|
137
|
+
this.stdout = output.stdout || '';
|
|
138
|
+
this.stderr = output.stderr || '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Prefixed Locize token formats (PATs and the newer api-keys). */
|
|
142
|
+
const SECRET_PREFIXES = ['lz_pat_', 'lz_api_'];
|
|
143
|
+
const PREFIXED_VISIBLE_RANDOM_CHARS = 4;
|
|
144
|
+
const PREFIXED_VISIBLE_END_CHARS = 4;
|
|
145
|
+
/**
|
|
146
|
+
* Masks an API key / PAT for safe console output, mirroring Locize's own
|
|
147
|
+
* maskSecret format:
|
|
148
|
+
* - prefixed tokens: `lz_pat_4xK9****************************oZ1i`
|
|
149
|
+
* - legacy UUID keys: first and last 3 characters visible
|
|
124
150
|
*/
|
|
125
151
|
function maskApiKey(apiKey) {
|
|
126
|
-
if (!apiKey
|
|
152
|
+
if (!apiKey)
|
|
153
|
+
return apiKey;
|
|
154
|
+
const matchedPrefix = SECRET_PREFIXES.find(p => apiKey.startsWith(p));
|
|
155
|
+
if (matchedPrefix) {
|
|
156
|
+
const visibleStart = matchedPrefix.length + PREFIXED_VISIBLE_RANDOM_CHARS;
|
|
157
|
+
const start = apiKey.substring(0, visibleStart);
|
|
158
|
+
const end = apiKey.substring(apiKey.length - PREFIXED_VISIBLE_END_CHARS);
|
|
159
|
+
const middle = apiKey.substring(visibleStart, apiKey.length - PREFIXED_VISIBLE_END_CHARS);
|
|
160
|
+
return `${start}${middle.replace(/[0-9a-zA-Z]/g, '*')}${end}`;
|
|
161
|
+
}
|
|
162
|
+
if (apiKey.length <= 6)
|
|
127
163
|
return apiKey;
|
|
128
164
|
const first3 = apiKey.substring(0, 3);
|
|
129
165
|
const last3 = apiKey.substring(apiKey.length - 3);
|
|
@@ -147,16 +183,23 @@ function maskArgs(args) {
|
|
|
147
183
|
function buildArgs(command, config, cliOptions) {
|
|
148
184
|
const { locize: locizeConfig = {}, extract } = config;
|
|
149
185
|
const commandArgs = [command];
|
|
150
|
-
|
|
186
|
+
// Resolve credentials explicitly (CLI option → config → env var) and always
|
|
187
|
+
// forward them as flags. locize-cli would pick env vars up itself, but its
|
|
188
|
+
// own precedence puts a ~/.locize config file ABOVE the environment — a
|
|
189
|
+
// stale ~/.locize would silently redirect the run to another project.
|
|
190
|
+
const projectId = cliOptions.projectId ?? locizeConfig.projectId ?? process.env.LOCIZE_PROJECTID ?? process.env.LOCIZE_PID;
|
|
151
191
|
if (projectId)
|
|
152
192
|
commandArgs.push('--project-id', projectId);
|
|
153
|
-
const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey;
|
|
193
|
+
const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey ?? process.env.LOCIZE_API_KEY ?? process.env.LOCIZE_KEY;
|
|
154
194
|
if (apiKey)
|
|
155
195
|
commandArgs.push('--api-key', apiKey);
|
|
156
|
-
const version = cliOptions.version ?? locizeConfig.version;
|
|
196
|
+
const version = cliOptions.version ?? locizeConfig.version ?? process.env.LOCIZE_VERSION ?? process.env.LOCIZE_VER;
|
|
157
197
|
if (version)
|
|
158
198
|
commandArgs.push('--ver', version);
|
|
159
|
-
const
|
|
199
|
+
const apiEndpoint = cliOptions.apiEndpoint ?? locizeConfig.apiEndpoint ?? process.env.LOCIZE_API_ENDPOINT;
|
|
200
|
+
if (apiEndpoint)
|
|
201
|
+
commandArgs.push('--api-endpoint', apiEndpoint);
|
|
202
|
+
const cdnType = cliOptions.cdnType ?? locizeConfig.cdnType ?? process.env.LOCIZE_CDN_TYPE;
|
|
160
203
|
if (cdnType)
|
|
161
204
|
commandArgs.push('--cdn-type', cdnType);
|
|
162
205
|
// TODO: there might be more configurable locize-cli options in future
|
|
@@ -165,15 +208,34 @@ function buildArgs(command, config, cliOptions) {
|
|
|
165
208
|
const updateValues = cliOptions.updateValues ?? locizeConfig.updateValues;
|
|
166
209
|
if (updateValues)
|
|
167
210
|
commandArgs.push('--update-values', 'true');
|
|
211
|
+
// `--reference-language-only` defaults to `true` in locize-cli, so we only
|
|
212
|
+
// forward it when explicitly set – passing `false` is the whole point, as it
|
|
213
|
+
// is the only way to opt out of the source-language-only behavior.
|
|
168
214
|
const srcLngOnly = cliOptions.srcLngOnly ?? locizeConfig.sourceLanguageOnly;
|
|
169
|
-
if (srcLngOnly)
|
|
170
|
-
|
|
215
|
+
if (srcLngOnly !== undefined) {
|
|
216
|
+
const referenceLanguageOnly = srcLngOnly === true || srcLngOnly === 'true';
|
|
217
|
+
commandArgs.push('--reference-language-only', String(referenceLanguageOnly));
|
|
218
|
+
}
|
|
171
219
|
const compareMtime = cliOptions.compareMtime ?? locizeConfig.compareModificationTime;
|
|
172
220
|
if (compareMtime)
|
|
173
221
|
commandArgs.push('--compare-modification-time', 'true');
|
|
174
222
|
const dryRun = cliOptions.dryRun ?? locizeConfig.dryRun;
|
|
175
223
|
if (dryRun)
|
|
176
224
|
commandArgs.push('--dry', 'true');
|
|
225
|
+
const autoTranslate = cliOptions.autoTranslate ?? locizeConfig.autoTranslate;
|
|
226
|
+
if (autoTranslate !== undefined) {
|
|
227
|
+
commandArgs.push('--auto-translate', String(autoTranslate === true || autoTranslate === 'true'));
|
|
228
|
+
}
|
|
229
|
+
const autoTranslateReview = cliOptions.autoTranslateReview ?? locizeConfig.autoTranslateReview;
|
|
230
|
+
if (autoTranslateReview !== undefined) {
|
|
231
|
+
commandArgs.push('--auto-translate-review', String(autoTranslateReview === true || autoTranslateReview === 'true'));
|
|
232
|
+
}
|
|
233
|
+
const autoTranslateLanguages = cliOptions.autoTranslateLanguages ?? locizeConfig.autoTranslateLanguages;
|
|
234
|
+
if (autoTranslateLanguages) {
|
|
235
|
+
const languages = Array.isArray(autoTranslateLanguages) ? autoTranslateLanguages.join(',') : String(autoTranslateLanguages);
|
|
236
|
+
if (languages)
|
|
237
|
+
commandArgs.push('--auto-translate-languages', languages);
|
|
238
|
+
}
|
|
177
239
|
}
|
|
178
240
|
// Derive a sensible base path for locize from the configured output.
|
|
179
241
|
// If output is a string template we can strip the language placeholder.
|
|
@@ -244,8 +306,16 @@ function buildArgs(command, config, cliOptions) {
|
|
|
244
306
|
* ```
|
|
245
307
|
*/
|
|
246
308
|
async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
309
|
+
const throwOnError = !!cliOptions.throwOnError;
|
|
247
310
|
const resolved = await resolveLocizeBin();
|
|
248
311
|
if (!resolved) {
|
|
312
|
+
const installHint = 'Error: `locize-cli` command not found.\n' +
|
|
313
|
+
'Please install it to use the Locize integration:\n' +
|
|
314
|
+
' npm install -g locize-cli\n' +
|
|
315
|
+
'Or make sure npx is available so it can be fetched on demand.';
|
|
316
|
+
if (throwOnError) {
|
|
317
|
+
throw new LocizeCommandError(installHint);
|
|
318
|
+
}
|
|
249
319
|
console.error(node_util.styleText('red', 'Error: `locize-cli` command not found.'));
|
|
250
320
|
console.log(node_util.styleText('yellow', 'Please install it to use the Locize integration:'));
|
|
251
321
|
console.log(node_util.styleText('cyan', ' npm install -g locize-cli'));
|
|
@@ -254,7 +324,7 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
|
254
324
|
return;
|
|
255
325
|
}
|
|
256
326
|
const { cmd, prefixArgs } = resolved;
|
|
257
|
-
const spinner =
|
|
327
|
+
const spinner = ora__default.default(`Running 'locize ${command}'...\n`).start();
|
|
258
328
|
let effectiveConfig = config;
|
|
259
329
|
try {
|
|
260
330
|
// 1. First attempt
|
|
@@ -267,6 +337,12 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
|
267
337
|
}
|
|
268
338
|
catch (error) {
|
|
269
339
|
const stderr = error.stderr || '';
|
|
340
|
+
if (throwOnError) {
|
|
341
|
+
// Orchestrating callers (e.g. `localize`) handle credentials and
|
|
342
|
+
// messaging themselves — no interactive retry, no process.exit.
|
|
343
|
+
spinner.fail(node_util.styleText('red', `Error executing 'locize ${command}'.`));
|
|
344
|
+
throw new LocizeCommandError(stderr || error.stdout || error.message, { stdout: error.stdout, stderr });
|
|
345
|
+
}
|
|
270
346
|
if (stderr.includes('missing required argument')) {
|
|
271
347
|
// 2. Auth failure, trigger interactive setup
|
|
272
348
|
spinner.stop();
|
|
@@ -307,6 +383,8 @@ const runLocizeSync = (config, cliOptions) => runLocizeCommand('sync', config, c
|
|
|
307
383
|
const runLocizeDownload = (config, cliOptions) => runLocizeCommand('download', config, cliOptions);
|
|
308
384
|
const runLocizeMigrate = (config, cliOptions) => runLocizeCommand('migrate', config, cliOptions);
|
|
309
385
|
|
|
386
|
+
exports.LocizeCommandError = LocizeCommandError;
|
|
387
|
+
exports.maskApiKey = maskApiKey;
|
|
310
388
|
exports.runLocizeDownload = runLocizeDownload;
|
|
311
389
|
exports.runLocizeMigrate = runLocizeMigrate;
|
|
312
390
|
exports.runLocizeSync = runLocizeSync;
|