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.
Files changed (53) hide show
  1. package/README.md +147 -0
  2. package/dist/cjs/cli.js +37 -5
  3. package/dist/cjs/config.js +5 -1
  4. package/dist/cjs/index.js +6 -0
  5. package/dist/cjs/init.js +25 -75
  6. package/dist/cjs/instrumenter/core/instrumenter.js +33 -11
  7. package/dist/cjs/instrumenter/core/transformer.js +5 -1
  8. package/dist/cjs/localize/agent-prompt.js +49 -0
  9. package/dist/cjs/localize/detect.js +88 -0
  10. package/dist/cjs/localize/localize.js +475 -0
  11. package/dist/cjs/locize.js +84 -11
  12. package/dist/cjs/status.js +5 -1
  13. package/dist/cjs/types-generator.js +8 -3
  14. package/dist/cjs/utils/file-utils.js +6 -2
  15. package/dist/cjs/utils/inlang-scaffold.js +184 -0
  16. package/dist/cjs/utils/locize-onboarding.js +91 -0
  17. package/dist/cjs/utils/wrap-ora.js +9 -5
  18. package/dist/esm/cli.js +30 -2
  19. package/dist/esm/index.js +4 -0
  20. package/dist/esm/init.js +19 -73
  21. package/dist/esm/instrumenter/core/instrumenter.js +22 -8
  22. package/dist/esm/localize/agent-prompt.js +47 -0
  23. package/dist/esm/localize/detect.js +85 -0
  24. package/dist/esm/localize/localize.js +469 -0
  25. package/dist/esm/locize.js +75 -9
  26. package/dist/esm/utils/inlang-scaffold.js +182 -0
  27. package/dist/esm/utils/locize-onboarding.js +83 -0
  28. package/package.json +10 -10
  29. package/types/cli.d.ts.map +1 -1
  30. package/types/index.d.ts +2 -0
  31. package/types/index.d.ts.map +1 -1
  32. package/types/init.d.ts +1 -0
  33. package/types/init.d.ts.map +1 -1
  34. package/types/instrumenter/core/instrumenter.d.ts +28 -0
  35. package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
  36. package/types/instrumenter/index.d.ts +2 -1
  37. package/types/instrumenter/index.d.ts.map +1 -1
  38. package/types/localize/agent-prompt.d.ts +11 -0
  39. package/types/localize/agent-prompt.d.ts.map +1 -0
  40. package/types/localize/detect.d.ts +37 -0
  41. package/types/localize/detect.d.ts.map +1 -0
  42. package/types/localize/index.d.ts +6 -0
  43. package/types/localize/index.d.ts.map +1 -0
  44. package/types/localize/localize.d.ts +20 -0
  45. package/types/localize/localize.d.ts.map +1 -0
  46. package/types/locize.d.ts +20 -0
  47. package/types/locize.d.ts.map +1 -1
  48. package/types/types.d.ts +12 -0
  49. package/types/types.d.ts.map +1 -1
  50. package/types/utils/inlang-scaffold.d.ts +28 -0
  51. package/types/utils/inlang-scaffold.d.ts.map +1 -0
  52. package/types/utils/locize-onboarding.d.ts +19 -0
  53. package/types/utils/locize-onboarding.d.ts.map +1 -0
@@ -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 inquirer.prompt([
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). If your project has no languages yet, use an API key with admin role.',
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 inquirer.prompt([{
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
- * Masks an API key for safe console output, preserving only the first
123
- * and last 3 characters while replacing everything in between.
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 || apiKey.length <= 6)
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
- const projectId = cliOptions.projectId ?? locizeConfig.projectId;
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 cdnType = cliOptions.cdnType ?? locizeConfig.cdnType;
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 = ora(`Running 'locize ${command}'...\n`).start();
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;
@@ -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 = ora('Analyzing project localization status...\n').start();
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 = vm.createContext({
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 vm.Script(code, { filename: file });
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 yaml.parse(content);
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 yaml.parse(content);
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 yaml.stringify(data, { indent: Number(indentation) || 2, lineWidth: 0 });
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 = ora({ text }).start();
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 = ora({ text }).start();
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 = ora({ text }).start();
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 = ora({ text }).start();
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 = ora({ text }).start();
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.61.1'); // This string is replaced with the actual version at build time by rollup
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
- .action((options) => runInit({ ci: !!options.ci }));
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);