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
package/dist/cjs/locize.js
CHANGED
|
@@ -6,6 +6,11 @@ var ora = require('ora');
|
|
|
6
6
|
var inquirer = require('inquirer');
|
|
7
7
|
var node_path = require('node:path');
|
|
8
8
|
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var ora__default = /*#__PURE__*/_interopDefault(ora);
|
|
12
|
+
var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* Resolves the locize-cli executable to use.
|
|
11
16
|
*
|
|
@@ -63,7 +68,7 @@ async function resolveLocizeBin() {
|
|
|
63
68
|
*/
|
|
64
69
|
async function interactiveCredentialSetup(config) {
|
|
65
70
|
console.log(node_util.styleText('yellow', '\nLocize configuration is missing or invalid. Let\'s set it up!'));
|
|
66
|
-
const answers = await
|
|
71
|
+
const answers = await inquirer__default.default.prompt([
|
|
67
72
|
{
|
|
68
73
|
type: 'input',
|
|
69
74
|
name: 'projectId',
|
|
@@ -73,7 +78,7 @@ async function interactiveCredentialSetup(config) {
|
|
|
73
78
|
{
|
|
74
79
|
type: 'password',
|
|
75
80
|
name: 'apiKey',
|
|
76
|
-
message: 'Enter your Locize API key (Project settings → API → API Keys).
|
|
81
|
+
message: 'Enter your Locize API key (Project settings → API → API Keys). Any write-capable key works — missing languages are created automatically for new projects.',
|
|
77
82
|
validate: input => !!input || 'API Key cannot be empty.',
|
|
78
83
|
},
|
|
79
84
|
{
|
|
@@ -87,7 +92,7 @@ async function interactiveCredentialSetup(config) {
|
|
|
87
92
|
console.error(node_util.styleText('red', 'Project ID is required to continue.'));
|
|
88
93
|
return undefined;
|
|
89
94
|
}
|
|
90
|
-
const { save } = await
|
|
95
|
+
const { save } = await inquirer__default.default.prompt([{
|
|
91
96
|
type: 'confirm',
|
|
92
97
|
name: 'save',
|
|
93
98
|
message: 'Would you like to see how to save these credentials for future use?',
|
|
@@ -119,11 +124,42 @@ LOCIZE_API_KEY=${answers.apiKey}
|
|
|
119
124
|
};
|
|
120
125
|
}
|
|
121
126
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
127
|
+
* Error thrown by {@link runLocizeCommand} when `throwOnError` is set,
|
|
128
|
+
* carrying the captured output of the failed locize-cli invocation so
|
|
129
|
+
* orchestrating commands (e.g. `localize`) can inspect and react to it.
|
|
130
|
+
*/
|
|
131
|
+
class LocizeCommandError extends Error {
|
|
132
|
+
stdout;
|
|
133
|
+
stderr;
|
|
134
|
+
constructor(message, output = {}) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = 'LocizeCommandError';
|
|
137
|
+
this.stdout = output.stdout || '';
|
|
138
|
+
this.stderr = output.stderr || '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Prefixed Locize token formats (PATs and the newer api-keys). */
|
|
142
|
+
const SECRET_PREFIXES = ['lz_pat_', 'lz_api_'];
|
|
143
|
+
const PREFIXED_VISIBLE_RANDOM_CHARS = 4;
|
|
144
|
+
const PREFIXED_VISIBLE_END_CHARS = 4;
|
|
145
|
+
/**
|
|
146
|
+
* Masks an API key / PAT for safe console output, mirroring Locize's own
|
|
147
|
+
* maskSecret format:
|
|
148
|
+
* - prefixed tokens: `lz_pat_4xK9****************************oZ1i`
|
|
149
|
+
* - legacy UUID keys: first and last 3 characters visible
|
|
124
150
|
*/
|
|
125
151
|
function maskApiKey(apiKey) {
|
|
126
|
-
if (!apiKey
|
|
152
|
+
if (!apiKey)
|
|
153
|
+
return apiKey;
|
|
154
|
+
const matchedPrefix = SECRET_PREFIXES.find(p => apiKey.startsWith(p));
|
|
155
|
+
if (matchedPrefix) {
|
|
156
|
+
const visibleStart = matchedPrefix.length + PREFIXED_VISIBLE_RANDOM_CHARS;
|
|
157
|
+
const start = apiKey.substring(0, visibleStart);
|
|
158
|
+
const end = apiKey.substring(apiKey.length - PREFIXED_VISIBLE_END_CHARS);
|
|
159
|
+
const middle = apiKey.substring(visibleStart, apiKey.length - PREFIXED_VISIBLE_END_CHARS);
|
|
160
|
+
return `${start}${middle.replace(/[0-9a-zA-Z]/g, '*')}${end}`;
|
|
161
|
+
}
|
|
162
|
+
if (apiKey.length <= 6)
|
|
127
163
|
return apiKey;
|
|
128
164
|
const first3 = apiKey.substring(0, 3);
|
|
129
165
|
const last3 = apiKey.substring(apiKey.length - 3);
|
|
@@ -147,16 +183,23 @@ function maskArgs(args) {
|
|
|
147
183
|
function buildArgs(command, config, cliOptions) {
|
|
148
184
|
const { locize: locizeConfig = {}, extract } = config;
|
|
149
185
|
const commandArgs = [command];
|
|
150
|
-
|
|
186
|
+
// Resolve credentials explicitly (CLI option → config → env var) and always
|
|
187
|
+
// forward them as flags. locize-cli would pick env vars up itself, but its
|
|
188
|
+
// own precedence puts a ~/.locize config file ABOVE the environment — a
|
|
189
|
+
// stale ~/.locize would silently redirect the run to another project.
|
|
190
|
+
const projectId = cliOptions.projectId ?? locizeConfig.projectId ?? process.env.LOCIZE_PROJECTID ?? process.env.LOCIZE_PID;
|
|
151
191
|
if (projectId)
|
|
152
192
|
commandArgs.push('--project-id', projectId);
|
|
153
|
-
const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey;
|
|
193
|
+
const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey ?? process.env.LOCIZE_API_KEY ?? process.env.LOCIZE_KEY;
|
|
154
194
|
if (apiKey)
|
|
155
195
|
commandArgs.push('--api-key', apiKey);
|
|
156
|
-
const version = cliOptions.version ?? locizeConfig.version;
|
|
196
|
+
const version = cliOptions.version ?? locizeConfig.version ?? process.env.LOCIZE_VERSION ?? process.env.LOCIZE_VER;
|
|
157
197
|
if (version)
|
|
158
198
|
commandArgs.push('--ver', version);
|
|
159
|
-
const
|
|
199
|
+
const apiEndpoint = cliOptions.apiEndpoint ?? locizeConfig.apiEndpoint ?? process.env.LOCIZE_API_ENDPOINT;
|
|
200
|
+
if (apiEndpoint)
|
|
201
|
+
commandArgs.push('--api-endpoint', apiEndpoint);
|
|
202
|
+
const cdnType = cliOptions.cdnType ?? locizeConfig.cdnType ?? process.env.LOCIZE_CDN_TYPE;
|
|
160
203
|
if (cdnType)
|
|
161
204
|
commandArgs.push('--cdn-type', cdnType);
|
|
162
205
|
// TODO: there might be more configurable locize-cli options in future
|
|
@@ -179,6 +222,20 @@ function buildArgs(command, config, cliOptions) {
|
|
|
179
222
|
const dryRun = cliOptions.dryRun ?? locizeConfig.dryRun;
|
|
180
223
|
if (dryRun)
|
|
181
224
|
commandArgs.push('--dry', 'true');
|
|
225
|
+
const autoTranslate = cliOptions.autoTranslate ?? locizeConfig.autoTranslate;
|
|
226
|
+
if (autoTranslate !== undefined) {
|
|
227
|
+
commandArgs.push('--auto-translate', String(autoTranslate === true || autoTranslate === 'true'));
|
|
228
|
+
}
|
|
229
|
+
const autoTranslateReview = cliOptions.autoTranslateReview ?? locizeConfig.autoTranslateReview;
|
|
230
|
+
if (autoTranslateReview !== undefined) {
|
|
231
|
+
commandArgs.push('--auto-translate-review', String(autoTranslateReview === true || autoTranslateReview === 'true'));
|
|
232
|
+
}
|
|
233
|
+
const autoTranslateLanguages = cliOptions.autoTranslateLanguages ?? locizeConfig.autoTranslateLanguages;
|
|
234
|
+
if (autoTranslateLanguages) {
|
|
235
|
+
const languages = Array.isArray(autoTranslateLanguages) ? autoTranslateLanguages.join(',') : String(autoTranslateLanguages);
|
|
236
|
+
if (languages)
|
|
237
|
+
commandArgs.push('--auto-translate-languages', languages);
|
|
238
|
+
}
|
|
182
239
|
}
|
|
183
240
|
// Derive a sensible base path for locize from the configured output.
|
|
184
241
|
// If output is a string template we can strip the language placeholder.
|
|
@@ -249,8 +306,16 @@ function buildArgs(command, config, cliOptions) {
|
|
|
249
306
|
* ```
|
|
250
307
|
*/
|
|
251
308
|
async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
309
|
+
const throwOnError = !!cliOptions.throwOnError;
|
|
252
310
|
const resolved = await resolveLocizeBin();
|
|
253
311
|
if (!resolved) {
|
|
312
|
+
const installHint = 'Error: `locize-cli` command not found.\n' +
|
|
313
|
+
'Please install it to use the Locize integration:\n' +
|
|
314
|
+
' npm install -g locize-cli\n' +
|
|
315
|
+
'Or make sure npx is available so it can be fetched on demand.';
|
|
316
|
+
if (throwOnError) {
|
|
317
|
+
throw new LocizeCommandError(installHint);
|
|
318
|
+
}
|
|
254
319
|
console.error(node_util.styleText('red', 'Error: `locize-cli` command not found.'));
|
|
255
320
|
console.log(node_util.styleText('yellow', 'Please install it to use the Locize integration:'));
|
|
256
321
|
console.log(node_util.styleText('cyan', ' npm install -g locize-cli'));
|
|
@@ -259,7 +324,7 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
|
259
324
|
return;
|
|
260
325
|
}
|
|
261
326
|
const { cmd, prefixArgs } = resolved;
|
|
262
|
-
const spinner =
|
|
327
|
+
const spinner = ora__default.default(`Running 'locize ${command}'...\n`).start();
|
|
263
328
|
let effectiveConfig = config;
|
|
264
329
|
try {
|
|
265
330
|
// 1. First attempt
|
|
@@ -272,6 +337,12 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
|
|
|
272
337
|
}
|
|
273
338
|
catch (error) {
|
|
274
339
|
const stderr = error.stderr || '';
|
|
340
|
+
if (throwOnError) {
|
|
341
|
+
// Orchestrating callers (e.g. `localize`) handle credentials and
|
|
342
|
+
// messaging themselves — no interactive retry, no process.exit.
|
|
343
|
+
spinner.fail(node_util.styleText('red', `Error executing 'locize ${command}'.`));
|
|
344
|
+
throw new LocizeCommandError(stderr || error.stdout || error.message, { stdout: error.stdout, stderr });
|
|
345
|
+
}
|
|
275
346
|
if (stderr.includes('missing required argument')) {
|
|
276
347
|
// 2. Auth failure, trigger interactive setup
|
|
277
348
|
spinner.stop();
|
|
@@ -312,6 +383,8 @@ const runLocizeSync = (config, cliOptions) => runLocizeCommand('sync', config, c
|
|
|
312
383
|
const runLocizeDownload = (config, cliOptions) => runLocizeCommand('download', config, cliOptions);
|
|
313
384
|
const runLocizeMigrate = (config, cliOptions) => runLocizeCommand('migrate', config, cliOptions);
|
|
314
385
|
|
|
386
|
+
exports.LocizeCommandError = LocizeCommandError;
|
|
387
|
+
exports.maskApiKey = maskApiKey;
|
|
315
388
|
exports.runLocizeDownload = runLocizeDownload;
|
|
316
389
|
exports.runLocizeMigrate = runLocizeMigrate;
|
|
317
390
|
exports.runLocizeSync = runLocizeSync;
|
package/dist/cjs/status.js
CHANGED
|
@@ -15,6 +15,10 @@ var contextVariants = require('./utils/context-variants.js');
|
|
|
15
15
|
var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
|
|
16
16
|
require('node:module');
|
|
17
17
|
|
|
18
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
19
|
+
|
|
20
|
+
var ora__default = /*#__PURE__*/_interopDefault(ora);
|
|
21
|
+
|
|
18
22
|
function classifyValue(value) {
|
|
19
23
|
if (value === undefined || value === null)
|
|
20
24
|
return 'absent';
|
|
@@ -44,7 +48,7 @@ function classifyValue(value) {
|
|
|
44
48
|
async function runStatus(config, options = {}) {
|
|
45
49
|
config.extract.primaryLanguage ||= config.locales[0] || 'en';
|
|
46
50
|
config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
|
|
47
|
-
const spinner =
|
|
51
|
+
const spinner = ora__default.default('Analyzing project localization status...\n').start();
|
|
48
52
|
try {
|
|
49
53
|
const report = await generateStatusReport(config);
|
|
50
54
|
spinner.succeed('Analysis complete.');
|
|
@@ -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,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var promises = require('node:fs/promises');
|
|
4
|
+
var node_path = require('node:path');
|
|
5
|
+
var jsoncParser = require('jsonc-parser');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The plugin that teaches inlang tools (Sherlock, Fink, Paraglide) to read and
|
|
9
|
+
* write i18next JSON resource files directly.
|
|
10
|
+
*
|
|
11
|
+
* Pinned to an exact version on purpose: 6.2.0 is the first release with
|
|
12
|
+
* verified round-trip support for plurals, context, `_zero` and ordinal keys,
|
|
13
|
+
* and jsDelivr serves floating range URLs (`@6`) from edge caches that can
|
|
14
|
+
* lag releases by days. Bump deliberately when newer verified versions ship.
|
|
15
|
+
*/
|
|
16
|
+
const INLANG_PLUGIN_MODULE = 'https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@6.2.0/dist/index.js';
|
|
17
|
+
/** VS Code marketplace id of the inlang Sherlock extension. */
|
|
18
|
+
const SHERLOCK_EXTENSION_ID = 'inlang.vs-code-extension';
|
|
19
|
+
/**
|
|
20
|
+
* Scaffolds an inlang project (`project.inlang/settings.json`) next to the
|
|
21
|
+
* i18next configuration so that inlang tooling (Sherlock VS Code extension,
|
|
22
|
+
* Fink editor, Paraglide compiler) operates on the EXISTING i18next JSON
|
|
23
|
+
* files. The i18next files remain the single source of truth — the scaffold
|
|
24
|
+
* is just the adapter.
|
|
25
|
+
*
|
|
26
|
+
* Behavior:
|
|
27
|
+
* - Derives `plugin.inlang.i18next.pathPattern` from the `extract.output`
|
|
28
|
+
* template: the namespaced object form when the template contains a
|
|
29
|
+
* `{{namespace}}` placeholder (namespaces are discovered from the files of
|
|
30
|
+
* the primary language), the plain string form otherwise.
|
|
31
|
+
* - Never overwrites an existing `project.inlang/settings.json`.
|
|
32
|
+
* - Adds the Sherlock extension to `.vscode/extensions.json` recommendations
|
|
33
|
+
* (creating or comment-preservingly merging the file).
|
|
34
|
+
*/
|
|
35
|
+
async function scaffoldInlangProject(options) {
|
|
36
|
+
const { locales, output } = options;
|
|
37
|
+
const baseLocale = options.primaryLanguage || locales[0] || 'en';
|
|
38
|
+
if (typeof output !== 'string') {
|
|
39
|
+
console.log('⚠️ Skipping inlang setup: extract.output is a function, so the file layout cannot be derived automatically. Create project.inlang/settings.json manually (see https://inlang.com/m/3i8bor92/plugin-inlang-i18next).');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Normalize Windows separators (heuristic-detected templates may be built
|
|
43
|
+
// with path.join) — settings.json patterns must be POSIX for portability.
|
|
44
|
+
// {{lng}} is a supported alias for {{language}}.
|
|
45
|
+
const template = output.replace(/\\/g, '/').replace(/\{\{lng\}\}/g, '{{language}}');
|
|
46
|
+
if (!template.endsWith('.json')) {
|
|
47
|
+
console.log('⚠️ Skipping inlang setup: the inlang i18next plugin supports JSON resource files only, but extract.output points to non-JSON files.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const settingsDir = node_path.resolve(process.cwd(), 'project.inlang');
|
|
51
|
+
const settingsPath = node_path.resolve(settingsDir, 'settings.json');
|
|
52
|
+
if (await fileExists(settingsPath)) {
|
|
53
|
+
console.log('ℹ️ project.inlang/settings.json already exists — leaving it untouched.');
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const pathPattern = template.includes('{{namespace}}')
|
|
57
|
+
? await deriveNamespacedPathPattern(template, baseLocale, options.defaultNS)
|
|
58
|
+
: toInlangPattern(template);
|
|
59
|
+
const settings = {
|
|
60
|
+
$schema: 'https://inlang.com/schema/project-settings',
|
|
61
|
+
baseLocale,
|
|
62
|
+
locales,
|
|
63
|
+
modules: [INLANG_PLUGIN_MODULE],
|
|
64
|
+
'plugin.inlang.i18next': { pathPattern },
|
|
65
|
+
};
|
|
66
|
+
await promises.mkdir(settingsDir, { recursive: true });
|
|
67
|
+
await promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
68
|
+
console.log(`✅ inlang project created at: ${settingsPath}`);
|
|
69
|
+
console.log(' Your i18next JSON files stay the single source of truth — inlang tools read and write them directly.');
|
|
70
|
+
console.log(` • Sherlock (VS Code): install the recommended "${SHERLOCK_EXTENSION_ID}" extension`);
|
|
71
|
+
console.log(' • Fink (web editor for translators): https://fink.inlang.com');
|
|
72
|
+
console.log(' • Paraglide (compiled i18n): npx @inlang/paraglide-js compile --project ./project.inlang');
|
|
73
|
+
}
|
|
74
|
+
await recommendSherlockExtension();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Converts an i18next-cli output template into an inlang `pathPattern`:
|
|
78
|
+
* `{{language}}` becomes `{locale}`, and relative paths are prefixed with
|
|
79
|
+
* `./` as required by the plugin's settings schema (which also permits
|
|
80
|
+
* `../`-relative and absolute paths, so those pass through unchanged).
|
|
81
|
+
*/
|
|
82
|
+
function toInlangPattern(template) {
|
|
83
|
+
const pattern = template.replace(/\{\{language\}\}/g, '{locale}');
|
|
84
|
+
if (pattern.startsWith('./') || pattern.startsWith('../') || pattern.startsWith('/')) {
|
|
85
|
+
return pattern;
|
|
86
|
+
}
|
|
87
|
+
return `./${pattern}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Builds the namespaced (object) form of `pathPattern` by discovering the
|
|
91
|
+
* project's namespaces from the existing resource files of the primary
|
|
92
|
+
* language. Falls back to the default namespace when no files exist yet
|
|
93
|
+
* (e.g. `init` ran before the first `extract`).
|
|
94
|
+
*/
|
|
95
|
+
async function deriveNamespacedPathPattern(template, baseLocale, defaultNS) {
|
|
96
|
+
const namespaces = await discoverNamespaces(template, baseLocale);
|
|
97
|
+
if (namespaces.length === 0) {
|
|
98
|
+
namespaces.push(typeof defaultNS === 'string' ? defaultNS : 'translation');
|
|
99
|
+
}
|
|
100
|
+
const pathPattern = {};
|
|
101
|
+
for (const ns of namespaces.sort()) {
|
|
102
|
+
pathPattern[ns] = toInlangPattern(template.replace(/\{\{namespace\}\}/g, ns));
|
|
103
|
+
}
|
|
104
|
+
return pathPattern;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Discovers namespace names by listing the directory entries that match the
|
|
108
|
+
* `{{namespace}}` segment of the output template, resolved for the primary
|
|
109
|
+
* language. Works for namespaces in the file name
|
|
110
|
+
* (`locales/en/{{namespace}}.json`) as well as in a directory segment
|
|
111
|
+
* (`locales/{{namespace}}/en.json`).
|
|
112
|
+
*/
|
|
113
|
+
async function discoverNamespaces(template, baseLocale) {
|
|
114
|
+
const resolved = template.replace(/\{\{language\}\}/g, baseLocale);
|
|
115
|
+
const segments = resolved.split('/');
|
|
116
|
+
const nsIndex = segments.findIndex(segment => segment.includes('{{namespace}}'));
|
|
117
|
+
if (nsIndex === -1)
|
|
118
|
+
return [];
|
|
119
|
+
const baseDir = node_path.resolve(process.cwd(), segments.slice(0, nsIndex).join('/'));
|
|
120
|
+
const [prefix, suffix = ''] = segments[nsIndex].split('{{namespace}}');
|
|
121
|
+
try {
|
|
122
|
+
const entries = await promises.readdir(baseDir);
|
|
123
|
+
return entries
|
|
124
|
+
.filter(entry => entry.startsWith(prefix) &&
|
|
125
|
+
entry.endsWith(suffix) &&
|
|
126
|
+
entry.length > prefix.length + suffix.length)
|
|
127
|
+
.map(entry => entry.slice(prefix.length, entry.length - suffix.length));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Adds the Sherlock VS Code extension to `.vscode/extensions.json`
|
|
135
|
+
* recommendations. Creates the file when missing; otherwise merges into the
|
|
136
|
+
* existing one while preserving comments and formatting (JSONC-aware). Bails
|
|
137
|
+
* gracefully — with a notice, never an error — when the existing file cannot
|
|
138
|
+
* be parsed.
|
|
139
|
+
*/
|
|
140
|
+
async function recommendSherlockExtension() {
|
|
141
|
+
const extensionsPath = node_path.resolve(process.cwd(), '.vscode', 'extensions.json');
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
text = await promises.readFile(extensionsPath, 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// File doesn't exist yet — create it.
|
|
148
|
+
}
|
|
149
|
+
if (text === undefined || text.trim() === '') {
|
|
150
|
+
await promises.mkdir(node_path.dirname(extensionsPath), { recursive: true });
|
|
151
|
+
await promises.writeFile(extensionsPath, JSON.stringify({ recommendations: [SHERLOCK_EXTENSION_ID] }, null, 2) + '\n');
|
|
152
|
+
console.log('✅ Added the Sherlock extension to .vscode/extensions.json recommendations.');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const errors = [];
|
|
156
|
+
const current = jsoncParser.parse(text, errors, { allowTrailingComma: true });
|
|
157
|
+
if (errors.length > 0 || typeof current !== 'object' || current === null || Array.isArray(current)) {
|
|
158
|
+
console.log('⚠️ Could not parse .vscode/extensions.json — please add "inlang.vs-code-extension" to its recommendations manually.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const recommendations = Array.isArray(current.recommendations) ? current.recommendations : [];
|
|
162
|
+
const alreadyRecommended = recommendations.some(entry => typeof entry === 'string' && entry.toLowerCase() === SHERLOCK_EXTENSION_ID);
|
|
163
|
+
if (alreadyRecommended)
|
|
164
|
+
return;
|
|
165
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: '\n' };
|
|
166
|
+
const edits = Array.isArray(current.recommendations)
|
|
167
|
+
// Append to the existing array (preserves comments and formatting).
|
|
168
|
+
? jsoncParser.modify(text, ['recommendations', recommendations.length], SHERLOCK_EXTENSION_ID, { isArrayInsertion: true, formattingOptions })
|
|
169
|
+
// No recommendations key yet — add one.
|
|
170
|
+
: jsoncParser.modify(text, ['recommendations'], [SHERLOCK_EXTENSION_ID], { formattingOptions });
|
|
171
|
+
await promises.writeFile(extensionsPath, jsoncParser.applyEdits(text, edits));
|
|
172
|
+
console.log('✅ Added the Sherlock extension to .vscode/extensions.json recommendations.');
|
|
173
|
+
}
|
|
174
|
+
async function fileExists(path) {
|
|
175
|
+
try {
|
|
176
|
+
await promises.readFile(path);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
exports.scaffoldInlangProject = scaffoldInlangProject;
|
|
@@ -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.63.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
|
|
@@ -190,7 +191,8 @@ program
|
|
|
190
191
|
.command('init')
|
|
191
192
|
.description('Create a new i18next.config.ts/js file with an interactive setup wizard.')
|
|
192
193
|
.option('--ci', 'Skip the browser launch when a backend (e.g. Locize) is selected. The signup URL is printed instead.')
|
|
193
|
-
.
|
|
194
|
+
.option('--inlang', 'Also scaffold an inlang project (project.inlang/settings.json) so inlang tooling (Sherlock, Fink, Paraglide) works on the translation files. Skips the corresponding wizard question.')
|
|
195
|
+
.action((options) => runInit({ ci: !!options.ci, inlang: !!options.inlang }));
|
|
194
196
|
program
|
|
195
197
|
.command('lint')
|
|
196
198
|
.description('Find potential issues like hardcoded strings in your codebase.')
|
|
@@ -284,6 +286,29 @@ program
|
|
|
284
286
|
process.exit(1);
|
|
285
287
|
}
|
|
286
288
|
});
|
|
289
|
+
program
|
|
290
|
+
.command('localize')
|
|
291
|
+
.description('One command from hardcoded strings to a fully localized app: detect, instrument, extract, connect to Locize, auto-translate, deliver.')
|
|
292
|
+
.option('--dry-run', 'Preview every step; nothing is written or pushed.')
|
|
293
|
+
.option('-y, --yes', 'Accept defaults; auto-approve instrumentation candidates (no per-string prompts).')
|
|
294
|
+
.option('--ci', 'Non-interactive: never open a browser or prompt; instrument is skipped (combine with --yes to force non-interactive instrumentation).')
|
|
295
|
+
.option('--skip-instrument', 'Skip the code-instrumentation step (use when your code already calls t()).')
|
|
296
|
+
.option('--skip-translate', 'Sync to Locize but do not request AI auto-translation.')
|
|
297
|
+
.option('--skip-locize', 'Stop after extraction (local files only; steps 5-6 skipped).')
|
|
298
|
+
.option('--namespace <ns>', 'Target namespace for instrumented keys (forwarded to instrument).')
|
|
299
|
+
.option('--update-values', 'Also update existing translation values on Locize (forwarded to sync).')
|
|
300
|
+
.option('--cdn-type <standard|pro>', 'Specify the cdn endpoint that should be used (depends on which cdn type you\'ve in your Locize project)')
|
|
301
|
+
.option('--print-agent-prompt', 'Print a copy-paste prompt for AI coding agents (Claude Code, Cursor) that performs the same steps, then exit.')
|
|
302
|
+
.action(async (options) => {
|
|
303
|
+
try {
|
|
304
|
+
const cfgPath = program.opts().config;
|
|
305
|
+
await runLocalize(options, cfgPath);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
console.error(styleText('red', 'Error running localize command:'), error);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
287
312
|
program
|
|
288
313
|
.command('locize-sync')
|
|
289
314
|
.description('Synchronize local translations with your Locize project.')
|
|
@@ -292,6 +317,9 @@ program
|
|
|
292
317
|
.option('--compare-mtime', 'Compare modification times when syncing.')
|
|
293
318
|
.option('--dry-run', 'Run the command without making any changes.')
|
|
294
319
|
.option('--cdn-type <standard|pro>', 'Specify the cdn endpoint that should be used (depends on which cdn type you\'ve in your locize project)')
|
|
320
|
+
.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).')
|
|
321
|
+
.option('--auto-translate-review <true|false>', 'Route auto-translated segments through the review workflow for languages that have review enabled.')
|
|
322
|
+
.option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages (comma separated; defaults to all languages).')
|
|
295
323
|
.action(async (options) => {
|
|
296
324
|
const cfgPath = program.opts().config;
|
|
297
325
|
const config = await ensureConfig(cfgPath);
|