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,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var promises = require('node:fs/promises');
|
|
4
|
+
var node_path = require('node:path');
|
|
5
|
+
|
|
6
|
+
async function pathExists(path) {
|
|
7
|
+
try {
|
|
8
|
+
await promises.access(path);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function readPackageDeps() {
|
|
16
|
+
try {
|
|
17
|
+
const content = await promises.readFile(node_path.join(process.cwd(), 'package.json'), 'utf-8');
|
|
18
|
+
const packageJson = JSON.parse(content);
|
|
19
|
+
return { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Detects the project stack relevant to the `localize` orchestrator:
|
|
27
|
+
* frontend framework, i18next presence, an existing i18n init file,
|
|
28
|
+
* Next.js App Router usage and inlang Paraglide usage.
|
|
29
|
+
*
|
|
30
|
+
* All checks are `process.cwd()`-relative (run from the package directory
|
|
31
|
+
* in monorepos).
|
|
32
|
+
*
|
|
33
|
+
* @param findInitFile - locator for an existing i18n init file
|
|
34
|
+
* (injected to reuse the instrumenter's implementation)
|
|
35
|
+
*/
|
|
36
|
+
async function detectStack(findInitFile) {
|
|
37
|
+
const deps = await readPackageDeps();
|
|
38
|
+
const has = (name) => !!deps[name];
|
|
39
|
+
let framework = 'unknown';
|
|
40
|
+
if (has('next'))
|
|
41
|
+
framework = 'next';
|
|
42
|
+
else if (has('react') || has('react-i18next'))
|
|
43
|
+
framework = 'react';
|
|
44
|
+
else if (has('vue') || has('nuxt'))
|
|
45
|
+
framework = 'vue';
|
|
46
|
+
else if (has('svelte') || has('@sveltejs/kit'))
|
|
47
|
+
framework = 'svelte';
|
|
48
|
+
else if (has('@angular/core'))
|
|
49
|
+
framework = 'angular';
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
const hasAppRouter = framework === 'next' &&
|
|
52
|
+
(await pathExists(node_path.join(cwd, 'app')) || await pathExists(node_path.join(cwd, 'src', 'app')));
|
|
53
|
+
const hasParaglide = has('@inlang/paraglide-js') || await pathExists(node_path.join(cwd, 'project.inlang'));
|
|
54
|
+
return {
|
|
55
|
+
framework,
|
|
56
|
+
hasI18next: has('i18next') || has('react-i18next'),
|
|
57
|
+
hasTypeScript: await pathExists(node_path.join(cwd, 'tsconfig.json')),
|
|
58
|
+
initFile: await findInitFile(),
|
|
59
|
+
hasAppRouter,
|
|
60
|
+
hasParaglide,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** File extensions associated with frameworks the instrumenter cannot transform natively. */
|
|
64
|
+
const STACK_EXTENSIONS = {
|
|
65
|
+
vue: ['.vue', 'vue'],
|
|
66
|
+
svelte: ['.svelte', 'svelte'],
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Checks whether a configured plugin covers the detected stack's file
|
|
70
|
+
* extension via `instrumentExtensions` or `lintExtensions` β in which case
|
|
71
|
+
* the instrument/extract runners can process the stack's files through the
|
|
72
|
+
* plugin hooks and `localize` runs the full flow.
|
|
73
|
+
*/
|
|
74
|
+
function hasStackPlugin(config, framework) {
|
|
75
|
+
const extensions = STACK_EXTENSIONS[framework];
|
|
76
|
+
if (!extensions || !config.plugins?.length)
|
|
77
|
+
return false;
|
|
78
|
+
return config.plugins.some((plugin) => {
|
|
79
|
+
const declared = [
|
|
80
|
+
...(plugin.instrumentExtensions || []),
|
|
81
|
+
...(plugin.lintExtensions || []),
|
|
82
|
+
];
|
|
83
|
+
return declared.some(ext => extensions.includes(ext));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
exports.detectStack = detectStack;
|
|
88
|
+
exports.hasStackPlugin = hasStackPlugin;
|
|
@@ -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;
|