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