i18next-cli 1.61.1 → 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 +118 -0
- package/dist/cjs/cli.js +35 -4
- 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 +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/locize-onboarding.js +91 -0
- package/dist/cjs/utils/wrap-ora.js +9 -5
- package/dist/esm/cli.js +28 -1
- 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 +75 -9
- 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
|
@@ -13,6 +13,11 @@ var json5Parser = require('@croct/json5-parser');
|
|
|
13
13
|
var yaml = require('yaml');
|
|
14
14
|
var vm = require('node:vm');
|
|
15
15
|
|
|
16
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
17
|
+
|
|
18
|
+
var yaml__default = /*#__PURE__*/_interopDefault(yaml);
|
|
19
|
+
var vm__default = /*#__PURE__*/_interopDefault(vm);
|
|
20
|
+
|
|
16
21
|
async function loadFile(file) {
|
|
17
22
|
const ext = node_path.extname(file);
|
|
18
23
|
if (['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].includes(ext)) {
|
|
@@ -31,14 +36,14 @@ async function loadFile(file) {
|
|
|
31
36
|
});
|
|
32
37
|
const exports = {};
|
|
33
38
|
const module = { exports };
|
|
34
|
-
const context =
|
|
39
|
+
const context = vm__default.default.createContext({
|
|
35
40
|
exports,
|
|
36
41
|
module,
|
|
37
42
|
require: (id) => require(id),
|
|
38
43
|
console,
|
|
39
44
|
process
|
|
40
45
|
});
|
|
41
|
-
const script = new
|
|
46
|
+
const script = new vm__default.default.Script(code, { filename: file });
|
|
42
47
|
script.runInContext(context);
|
|
43
48
|
// @ts-ignore
|
|
44
49
|
const exported = context.module.exports?.default || context.module.exports;
|
|
@@ -46,7 +51,7 @@ async function loadFile(file) {
|
|
|
46
51
|
}
|
|
47
52
|
const content = await promises.readFile(file, 'utf-8');
|
|
48
53
|
if (ext === '.yaml' || ext === '.yml') {
|
|
49
|
-
return
|
|
54
|
+
return yaml__default.default.parse(content);
|
|
50
55
|
}
|
|
51
56
|
if (ext === '.json5') {
|
|
52
57
|
return json5Parser.JsonParser.parse(content, json5Parser.JsonObjectNode).toJSON();
|
|
@@ -7,6 +7,10 @@ var config = require('../config.js');
|
|
|
7
7
|
var json5Parser = require('@croct/json5-parser');
|
|
8
8
|
var yaml = require('yaml');
|
|
9
9
|
|
|
10
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
11
|
+
|
|
12
|
+
var yaml__default = /*#__PURE__*/_interopDefault(yaml);
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
15
|
* Thrown when an existing translation file in a structured data format
|
|
12
16
|
* (JSON/JSON5/YAML) exists on disk but cannot be parsed. Callers should treat
|
|
@@ -97,7 +101,7 @@ async function loadTranslationFile(filePath) {
|
|
|
97
101
|
else if (ext === '.yaml' || ext === '.yml') {
|
|
98
102
|
const content = await promises.readFile(fullPath, 'utf-8');
|
|
99
103
|
try {
|
|
100
|
-
return
|
|
104
|
+
return yaml__default.default.parse(content);
|
|
101
105
|
}
|
|
102
106
|
catch (error) {
|
|
103
107
|
throw new ParseTranslationFileError(filePath, error);
|
|
@@ -165,7 +169,7 @@ function serializeTranslationFile(data, format = 'json', indentation = 2, rawCon
|
|
|
165
169
|
return node.toString({ object: { indentationSize: Number(indentation) ?? 2 } });
|
|
166
170
|
}
|
|
167
171
|
case 'yaml':
|
|
168
|
-
return
|
|
172
|
+
return yaml__default.default.stringify(data, { indent: Number(indentation) || 2, lineWidth: 0 });
|
|
169
173
|
case 'js':
|
|
170
174
|
case 'js-esm':
|
|
171
175
|
return `export default ${jsonString};\n`;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var inquirer = require('inquirer');
|
|
4
|
+
var execa = require('execa');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
|
|
9
|
+
|
|
10
|
+
/** Rough 8-4-4-4-12 hex UUID shape — not strict (locize project IDs may evolve). */
|
|
11
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Opens the given URL in the user's default browser using the platform-native command.
|
|
14
|
+
* Returns true on success, false if there's nowhere to open one (CI, headless Linux)
|
|
15
|
+
* or if spawning the command failed.
|
|
16
|
+
*/
|
|
17
|
+
async function openBrowser(url, opts = {}) {
|
|
18
|
+
// Short-circuit: no point spawning a browser-opener in CI or headless Linux.
|
|
19
|
+
if (opts.ci || process.env.CI === 'true')
|
|
20
|
+
return false;
|
|
21
|
+
const isWSL = !!process.env.WSL_DISTRO_NAME;
|
|
22
|
+
if (process.platform === 'linux' && !isWSL &&
|
|
23
|
+
!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (process.platform === 'darwin') {
|
|
28
|
+
await execa.execa('open', [url], { stdio: 'ignore' });
|
|
29
|
+
}
|
|
30
|
+
else if (process.platform === 'win32') {
|
|
31
|
+
// `start` is a cmd.exe builtin; the empty "" is the window-title slot
|
|
32
|
+
await execa.execa('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
|
|
33
|
+
}
|
|
34
|
+
else if (isWSL) {
|
|
35
|
+
// WSL: try the wslu / wsl-open shims that bridge to the Windows side
|
|
36
|
+
// before falling back to xdg-open (which usually isn't installed there).
|
|
37
|
+
try {
|
|
38
|
+
await execa.execa('wslview', [url], { stdio: 'ignore' });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
try {
|
|
42
|
+
await execa.execa('wsl-open', [url], { stdio: 'ignore' });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
await execa.execa('xdg-open', [url], { stdio: 'ignore' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
await execa.execa('xdg-open', [url], { stdio: 'ignore' });
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Prompts for Locize credentials (Project ID + optional API key) and returns them.
|
|
60
|
+
* Warns (but does not block) when the Project ID does not look like a UUID.
|
|
61
|
+
*/
|
|
62
|
+
async function promptLocizeCredentials() {
|
|
63
|
+
const credentials = await inquirer__default.default.prompt([
|
|
64
|
+
{
|
|
65
|
+
type: 'input',
|
|
66
|
+
name: 'projectId',
|
|
67
|
+
message: 'Locize Project ID (e.g. 4eeb5ce0-a7a7-453f-8eb3-078f6eeb56fe):',
|
|
68
|
+
validate: (input) => input.trim().length > 0 || 'Project ID cannot be empty.',
|
|
69
|
+
filter: (input) => input.trim(),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'password',
|
|
73
|
+
name: 'apiKey',
|
|
74
|
+
message: 'Locize API key (needed for saveMissing / auto-publish / sync during development; leave empty to skip and add later via env var):',
|
|
75
|
+
filter: (input) => input.trim(),
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
if (!UUID_SHAPE.test(credentials.projectId)) {
|
|
79
|
+
console.log("⚠️ The Project ID doesn't look like a UUID (8-4-4-4-12 hex). It will still be written — double-check it in your Locize project settings.");
|
|
80
|
+
}
|
|
81
|
+
// API keys come in multiple shapes (UUID, `lz_pat_…`, `lz_api_…`, etc.) —
|
|
82
|
+
// treat them as opaque; no client-side format check.
|
|
83
|
+
const result = { projectId: credentials.projectId };
|
|
84
|
+
if (credentials.apiKey)
|
|
85
|
+
result.apiKey = credentials.apiKey;
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
exports.UUID_SHAPE = UUID_SHAPE;
|
|
90
|
+
exports.openBrowser = openBrowser;
|
|
91
|
+
exports.promptLocizeCredentials = promptLocizeCredentials;
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
var ora = require('ora');
|
|
4
4
|
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var ora__default = /*#__PURE__*/_interopDefault(ora);
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* Creates a spinner-like object that either:
|
|
7
11
|
* - is fully silent (quiet mode),
|
|
@@ -16,7 +20,7 @@ function createSpinnerLike(initialText, options = {}) {
|
|
|
16
20
|
// If interactive (no logger and not quiet), create a single real ora spinner
|
|
17
21
|
let realSpinner = null;
|
|
18
22
|
if (!quiet && !logger) {
|
|
19
|
-
realSpinner =
|
|
23
|
+
realSpinner = ora__default.default({ text }).start();
|
|
20
24
|
}
|
|
21
25
|
const self = {
|
|
22
26
|
get text() { return text; },
|
|
@@ -40,7 +44,7 @@ function createSpinnerLike(initialText, options = {}) {
|
|
|
40
44
|
}
|
|
41
45
|
else {
|
|
42
46
|
if (!realSpinner)
|
|
43
|
-
realSpinner =
|
|
47
|
+
realSpinner = ora__default.default({ text }).start();
|
|
44
48
|
realSpinner.succeed(message);
|
|
45
49
|
}
|
|
46
50
|
},
|
|
@@ -58,7 +62,7 @@ function createSpinnerLike(initialText, options = {}) {
|
|
|
58
62
|
}
|
|
59
63
|
else {
|
|
60
64
|
if (!realSpinner)
|
|
61
|
-
realSpinner =
|
|
65
|
+
realSpinner = ora__default.default({ text }).start();
|
|
62
66
|
realSpinner.fail(message);
|
|
63
67
|
}
|
|
64
68
|
},
|
|
@@ -76,7 +80,7 @@ function createSpinnerLike(initialText, options = {}) {
|
|
|
76
80
|
}
|
|
77
81
|
else {
|
|
78
82
|
if (!realSpinner)
|
|
79
|
-
realSpinner =
|
|
83
|
+
realSpinner = ora__default.default({ text }).start();
|
|
80
84
|
try {
|
|
81
85
|
realSpinner.warn?.(message);
|
|
82
86
|
}
|
|
@@ -101,7 +105,7 @@ function createSpinnerLike(initialText, options = {}) {
|
|
|
101
105
|
}
|
|
102
106
|
else {
|
|
103
107
|
if (!realSpinner)
|
|
104
|
-
realSpinner =
|
|
108
|
+
realSpinner = ora__default.default({ text }).start();
|
|
105
109
|
realSpinner.text = String(msg);
|
|
106
110
|
}
|
|
107
111
|
}
|
package/dist/esm/cli.js
CHANGED
|
@@ -25,12 +25,13 @@ import { runRenameKey } from './rename-key.js';
|
|
|
25
25
|
import { runInstrumenter } from './instrumenter/core/instrumenter.js';
|
|
26
26
|
import './utils/jsx-attributes.js';
|
|
27
27
|
import 'magic-string';
|
|
28
|
+
import { runLocalize } from './localize/localize.js';
|
|
28
29
|
|
|
29
30
|
const program = new Command();
|
|
30
31
|
program
|
|
31
32
|
.name('i18next-cli')
|
|
32
33
|
.description('A unified, high-performance i18next CLI.')
|
|
33
|
-
.version('1.
|
|
34
|
+
.version('1.62.0'); // This string is replaced with the actual version at build time by rollup
|
|
34
35
|
// new: global config override option
|
|
35
36
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
36
37
|
program
|
|
@@ -284,6 +285,29 @@ program
|
|
|
284
285
|
process.exit(1);
|
|
285
286
|
}
|
|
286
287
|
});
|
|
288
|
+
program
|
|
289
|
+
.command('localize')
|
|
290
|
+
.description('One command from hardcoded strings to a fully localized app: detect, instrument, extract, connect to Locize, auto-translate, deliver.')
|
|
291
|
+
.option('--dry-run', 'Preview every step; nothing is written or pushed.')
|
|
292
|
+
.option('-y, --yes', 'Accept defaults; auto-approve instrumentation candidates (no per-string prompts).')
|
|
293
|
+
.option('--ci', 'Non-interactive: never open a browser or prompt; instrument is skipped (combine with --yes to force non-interactive instrumentation).')
|
|
294
|
+
.option('--skip-instrument', 'Skip the code-instrumentation step (use when your code already calls t()).')
|
|
295
|
+
.option('--skip-translate', 'Sync to Locize but do not request AI auto-translation.')
|
|
296
|
+
.option('--skip-locize', 'Stop after extraction (local files only; steps 5-6 skipped).')
|
|
297
|
+
.option('--namespace <ns>', 'Target namespace for instrumented keys (forwarded to instrument).')
|
|
298
|
+
.option('--update-values', 'Also update existing translation values on Locize (forwarded to sync).')
|
|
299
|
+
.option('--cdn-type <standard|pro>', 'Specify the cdn endpoint that should be used (depends on which cdn type you\'ve in your Locize project)')
|
|
300
|
+
.option('--print-agent-prompt', 'Print a copy-paste prompt for AI coding agents (Claude Code, Cursor) that performs the same steps, then exit.')
|
|
301
|
+
.action(async (options) => {
|
|
302
|
+
try {
|
|
303
|
+
const cfgPath = program.opts().config;
|
|
304
|
+
await runLocalize(options, cfgPath);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
console.error(styleText('red', 'Error running localize command:'), error);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
287
311
|
program
|
|
288
312
|
.command('locize-sync')
|
|
289
313
|
.description('Synchronize local translations with your Locize project.')
|
|
@@ -292,6 +316,9 @@ program
|
|
|
292
316
|
.option('--compare-mtime', 'Compare modification times when syncing.')
|
|
293
317
|
.option('--dry-run', 'Run the command without making any changes.')
|
|
294
318
|
.option('--cdn-type <standard|pro>', 'Specify the cdn endpoint that should be used (depends on which cdn type you\'ve in your locize project)')
|
|
319
|
+
.option('--auto-translate <true|false>', 'Trigger AI/MT auto-translation of newly synced keys (requires auto-translation enabled in your Locize project; on by default for new projects).')
|
|
320
|
+
.option('--auto-translate-review <true|false>', 'Route auto-translated segments through the review workflow for languages that have review enabled.')
|
|
321
|
+
.option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages (comma separated; defaults to all languages).')
|
|
295
322
|
.action(async (options) => {
|
|
296
323
|
const cfgPath = program.opts().config;
|
|
297
324
|
const config = await ensureConfig(cfgPath);
|
package/dist/esm/index.js
CHANGED
|
@@ -11,3 +11,7 @@ export { runRenameKey } from './rename-key.js';
|
|
|
11
11
|
export { runInstrumenter, writeExtractedKeys } from './instrumenter/core/instrumenter.js';
|
|
12
12
|
import './utils/jsx-attributes.js';
|
|
13
13
|
import 'magic-string';
|
|
14
|
+
export { runLocalize } from './localize/localize.js';
|
|
15
|
+
import 'node:fs/promises';
|
|
16
|
+
import 'node:path';
|
|
17
|
+
export { AGENT_PROMPT } from './localize/agent-prompt.js';
|
package/dist/esm/init.js
CHANGED
|
@@ -1,58 +1,10 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
2
|
import { writeFile, readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import { execa } from 'execa';
|
|
5
4
|
import { detectConfig } from './heuristic-config.js';
|
|
5
|
+
import { openBrowser, promptLocizeCredentials } from './utils/locize-onboarding.js';
|
|
6
6
|
|
|
7
|
-
const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=
|
|
8
|
-
/** Rough 8-4-4-4-12 hex UUID shape — not strict (locize project IDs may evolve). */
|
|
9
|
-
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
-
/**
|
|
11
|
-
* Opens the given URL in the user's default browser using the platform-native command.
|
|
12
|
-
* Returns true on success, false if there's nowhere to open one (CI, headless Linux)
|
|
13
|
-
* or if spawning the command failed.
|
|
14
|
-
*/
|
|
15
|
-
async function openBrowser(url, opts = {}) {
|
|
16
|
-
// Short-circuit: no point spawning a browser-opener in CI or headless Linux.
|
|
17
|
-
if (opts.ci || process.env.CI === 'true')
|
|
18
|
-
return false;
|
|
19
|
-
const isWSL = !!process.env.WSL_DISTRO_NAME;
|
|
20
|
-
if (process.platform === 'linux' && !isWSL &&
|
|
21
|
-
!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
if (process.platform === 'darwin') {
|
|
26
|
-
await execa('open', [url], { stdio: 'ignore' });
|
|
27
|
-
}
|
|
28
|
-
else if (process.platform === 'win32') {
|
|
29
|
-
// `start` is a cmd.exe builtin; the empty "" is the window-title slot
|
|
30
|
-
await execa('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
|
|
31
|
-
}
|
|
32
|
-
else if (isWSL) {
|
|
33
|
-
// WSL: try the wslu / wsl-open shims that bridge to the Windows side
|
|
34
|
-
// before falling back to xdg-open (which usually isn't installed there).
|
|
35
|
-
try {
|
|
36
|
-
await execa('wslview', [url], { stdio: 'ignore' });
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
try {
|
|
40
|
-
await execa('wsl-open', [url], { stdio: 'ignore' });
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
await execa('xdg-open', [url], { stdio: 'ignore' });
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
await execa('xdg-open', [url], { stdio: 'ignore' });
|
|
49
|
-
}
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
7
|
+
const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__init-wizard';
|
|
56
8
|
/**
|
|
57
9
|
* Determines if the current project is configured as an ESM project.
|
|
58
10
|
* Checks the package.json file for `"type": "module"`.
|
|
@@ -210,29 +162,7 @@ async function runInit(options = {}) {
|
|
|
210
162
|
if (!opened) {
|
|
211
163
|
console.log(`\n👉 Open this URL manually: ${LOCIZE_SIGNUP_URL}\n`);
|
|
212
164
|
}
|
|
213
|
-
|
|
214
|
-
{
|
|
215
|
-
type: 'input',
|
|
216
|
-
name: 'projectId',
|
|
217
|
-
message: 'Locize Project ID (e.g. 4eeb5ce0-a7a7-453f-8eb3-078f6eeb56fe):',
|
|
218
|
-
validate: (input) => input.trim().length > 0 || 'Project ID cannot be empty.',
|
|
219
|
-
filter: (input) => input.trim(),
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
type: 'password',
|
|
223
|
-
name: 'apiKey',
|
|
224
|
-
message: 'Locize API key (needed for saveMissing / auto-publish / sync during development; leave empty to skip and add later via env var):',
|
|
225
|
-
filter: (input) => input.trim(),
|
|
226
|
-
},
|
|
227
|
-
]);
|
|
228
|
-
if (!UUID_SHAPE.test(credentials.projectId)) {
|
|
229
|
-
console.log("⚠️ The Project ID doesn't look like a UUID (8-4-4-4-12 hex). It will still be written — double-check it in your Locize project settings.");
|
|
230
|
-
}
|
|
231
|
-
// API keys come in multiple shapes (UUID, `lz_pat_…`, `lz_api_…`, etc.) —
|
|
232
|
-
// treat them as opaque; no client-side format check.
|
|
233
|
-
locizeConfig = { projectId: credentials.projectId };
|
|
234
|
-
if (credentials.apiKey)
|
|
235
|
-
locizeConfig.apiKey = credentials.apiKey;
|
|
165
|
+
locizeConfig = await promptLocizeCredentials();
|
|
236
166
|
}
|
|
237
167
|
const isTypeScript = answers.fileType.includes('TypeScript');
|
|
238
168
|
const isEsm = await isEsmProject();
|
|
@@ -1460,6 +1460,24 @@ const I18N_INIT_FILE_NAMES = [
|
|
|
1460
1460
|
'i18n/index.ts', 'i18n/index.js', 'i18n/index.mjs',
|
|
1461
1461
|
'i18next/index.ts', 'i18next/index.js'
|
|
1462
1462
|
];
|
|
1463
|
+
/**
|
|
1464
|
+
* Searches the common locations (`src/` and the project root) for an existing
|
|
1465
|
+
* i18n initialization file.
|
|
1466
|
+
*
|
|
1467
|
+
* @returns The path of the first init file found (relative to cwd), or null.
|
|
1468
|
+
*/
|
|
1469
|
+
async function findExistingI18nInitFile() {
|
|
1470
|
+
const cwd = process.cwd();
|
|
1471
|
+
const searchDirs = ['src', '.'];
|
|
1472
|
+
for (const dir of searchDirs) {
|
|
1473
|
+
for (const name of I18N_INIT_FILE_NAMES) {
|
|
1474
|
+
if (await fileExists(join(cwd, dir, name))) {
|
|
1475
|
+
return join(dir, name);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1463
1481
|
/**
|
|
1464
1482
|
* Computes a POSIX-style relative path from the init-file directory to the
|
|
1465
1483
|
* output template path (which still contains {{language}} / {{namespace}} placeholders).
|
|
@@ -1485,13 +1503,8 @@ function buildDynamicImportPath(outputTemplate, initDir) {
|
|
|
1485
1503
|
async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT) {
|
|
1486
1504
|
const cwd = process.cwd();
|
|
1487
1505
|
// Check for existing init files in common locations
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
for (const name of I18N_INIT_FILE_NAMES) {
|
|
1491
|
-
if (await fileExists(join(cwd, dir, name))) {
|
|
1492
|
-
return null; // Init file already exists
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1506
|
+
if (await findExistingI18nInitFile()) {
|
|
1507
|
+
return null; // Init file already exists
|
|
1495
1508
|
}
|
|
1496
1509
|
// Check if i18next.init() is called anywhere in the source
|
|
1497
1510
|
try {
|
|
@@ -1939,4 +1952,4 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
|
|
|
1939
1952
|
return candidates;
|
|
1940
1953
|
}
|
|
1941
1954
|
|
|
1942
|
-
export { runInstrumenter, writeExtractedKeys };
|
|
1955
|
+
export { detectProjectEnvironment, findExistingI18nInitFile, isProjectUsingReact, isProjectUsingTypeScript, runInstrumenter, writeExtractedKeys };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy-paste prompt for AI coding agents (Claude Code, Cursor, …) that walks
|
|
3
|
+
* an agent through the same steps the `localize` command orchestrates.
|
|
4
|
+
*
|
|
5
|
+
* This constant is the single source of truth — it is printed by
|
|
6
|
+
* `i18next-cli localize --print-agent-prompt` and mirrored in the README.
|
|
7
|
+
* A test guards that it mentions every orchestrated command, so flow changes
|
|
8
|
+
* that forget this prompt fail CI.
|
|
9
|
+
*/
|
|
10
|
+
const AGENT_PROMPT = `You are localizing this app with i18next + Locize. Execute these steps in order,
|
|
11
|
+
verifying each before continuing. Use \`npx i18next-cli\` for all commands.
|
|
12
|
+
|
|
13
|
+
1. Detect: confirm this is a React/Next.js project (check package.json).
|
|
14
|
+
- If Vue/Svelte: install a stack plugin (\`i18next-cli-vue\` /
|
|
15
|
+
\`i18next-cli-plugin-svelte\`) and add it to the \`plugins\` array of
|
|
16
|
+
i18next.config.ts, or write one via the plugin hooks
|
|
17
|
+
(instrumentOnLoad/onLoad) instead of wrapping strings manually.
|
|
18
|
+
- If the app uses inlang Paraglide (\`@inlang/paraglide-js\`), STOP —
|
|
19
|
+
instrumenting i18next calls would conflict; ask the user how to proceed.
|
|
20
|
+
2. Config: if no i18next.config.{ts,js} exists, run \`npx i18next-cli init\`
|
|
21
|
+
and answer the prompts (pick Locize as backend if the user wants managed
|
|
22
|
+
translations and AI auto-translate).
|
|
23
|
+
3. Instrument: run \`npx i18next-cli instrument --dry-run\` and review the
|
|
24
|
+
planned changes; then \`npx i18next-cli instrument\` to apply. Inspect the
|
|
25
|
+
git diff carefully: fix any t() wrapping inside Next.js *server components*
|
|
26
|
+
(add 'use client' or refactor to a server-side t() pattern). Commit.
|
|
27
|
+
4. Extract: run \`npx i18next-cli extract\`. Verify the locale JSON files were
|
|
28
|
+
written (check the extract.output path in the config).
|
|
29
|
+
5. Locize: ask the user for LOCIZE_PROJECTID and LOCIZE_API_KEY (they create
|
|
30
|
+
the project at https://www.locize.app/register?from=i18next_cli__agent-prompt
|
|
31
|
+
— any write-capable API key works; the target languages from
|
|
32
|
+
i18next.config.ts are created automatically on the first sync.
|
|
33
|
+
Auto-translation and quality estimation are enabled by default for new
|
|
34
|
+
projects; translations run once the project is subscribed or an AI/MT
|
|
35
|
+
provider is configured). Export both as environment variables.
|
|
36
|
+
6. Translate & deliver:
|
|
37
|
+
\`npx i18next-cli locize-sync --auto-translate true\`
|
|
38
|
+
then \`npx i18next-cli locize-download\` to pull the AI translations, and
|
|
39
|
+
\`npx i18next-cli status\` — confirm all languages are (near) 100%.
|
|
40
|
+
AI translation is asynchronous; if targets are still empty, wait a minute
|
|
41
|
+
and re-run locize-download.
|
|
42
|
+
7. Optionally switch runtime loading to i18next-locize-backend (CDN delivery,
|
|
43
|
+
so translation fixes go live without redeploying). NEVER put the API key
|
|
44
|
+
in client-side code — the CDN only needs the project ID.
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export { AGENT_PROMPT };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
async function pathExists(path) {
|
|
5
|
+
try {
|
|
6
|
+
await access(path);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function readPackageDeps() {
|
|
14
|
+
try {
|
|
15
|
+
const content = await readFile(join(process.cwd(), 'package.json'), 'utf-8');
|
|
16
|
+
const packageJson = JSON.parse(content);
|
|
17
|
+
return { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Detects the project stack relevant to the `localize` orchestrator:
|
|
25
|
+
* frontend framework, i18next presence, an existing i18n init file,
|
|
26
|
+
* Next.js App Router usage and inlang Paraglide usage.
|
|
27
|
+
*
|
|
28
|
+
* All checks are `process.cwd()`-relative (run from the package directory
|
|
29
|
+
* in monorepos).
|
|
30
|
+
*
|
|
31
|
+
* @param findInitFile - locator for an existing i18n init file
|
|
32
|
+
* (injected to reuse the instrumenter's implementation)
|
|
33
|
+
*/
|
|
34
|
+
async function detectStack(findInitFile) {
|
|
35
|
+
const deps = await readPackageDeps();
|
|
36
|
+
const has = (name) => !!deps[name];
|
|
37
|
+
let framework = 'unknown';
|
|
38
|
+
if (has('next'))
|
|
39
|
+
framework = 'next';
|
|
40
|
+
else if (has('react') || has('react-i18next'))
|
|
41
|
+
framework = 'react';
|
|
42
|
+
else if (has('vue') || has('nuxt'))
|
|
43
|
+
framework = 'vue';
|
|
44
|
+
else if (has('svelte') || has('@sveltejs/kit'))
|
|
45
|
+
framework = 'svelte';
|
|
46
|
+
else if (has('@angular/core'))
|
|
47
|
+
framework = 'angular';
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
const hasAppRouter = framework === 'next' &&
|
|
50
|
+
(await pathExists(join(cwd, 'app')) || await pathExists(join(cwd, 'src', 'app')));
|
|
51
|
+
const hasParaglide = has('@inlang/paraglide-js') || await pathExists(join(cwd, 'project.inlang'));
|
|
52
|
+
return {
|
|
53
|
+
framework,
|
|
54
|
+
hasI18next: has('i18next') || has('react-i18next'),
|
|
55
|
+
hasTypeScript: await pathExists(join(cwd, 'tsconfig.json')),
|
|
56
|
+
initFile: await findInitFile(),
|
|
57
|
+
hasAppRouter,
|
|
58
|
+
hasParaglide,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** File extensions associated with frameworks the instrumenter cannot transform natively. */
|
|
62
|
+
const STACK_EXTENSIONS = {
|
|
63
|
+
vue: ['.vue', 'vue'],
|
|
64
|
+
svelte: ['.svelte', 'svelte'],
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Checks whether a configured plugin covers the detected stack's file
|
|
68
|
+
* extension via `instrumentExtensions` or `lintExtensions` — in which case
|
|
69
|
+
* the instrument/extract runners can process the stack's files through the
|
|
70
|
+
* plugin hooks and `localize` runs the full flow.
|
|
71
|
+
*/
|
|
72
|
+
function hasStackPlugin(config, framework) {
|
|
73
|
+
const extensions = STACK_EXTENSIONS[framework];
|
|
74
|
+
if (!extensions || !config.plugins?.length)
|
|
75
|
+
return false;
|
|
76
|
+
return config.plugins.some((plugin) => {
|
|
77
|
+
const declared = [
|
|
78
|
+
...(plugin.instrumentExtensions || []),
|
|
79
|
+
...(plugin.lintExtensions || []),
|
|
80
|
+
];
|
|
81
|
+
return declared.some(ext => extensions.includes(ext));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { detectStack, hasStackPlugin };
|