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.
Files changed (48) hide show
  1. package/README.md +118 -0
  2. package/dist/cjs/cli.js +35 -4
  3. package/dist/cjs/config.js +5 -1
  4. package/dist/cjs/index.js +6 -0
  5. package/dist/cjs/init.js +9 -75
  6. package/dist/cjs/instrumenter/core/instrumenter.js +32 -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/locize-onboarding.js +91 -0
  16. package/dist/cjs/utils/wrap-ora.js +9 -5
  17. package/dist/esm/cli.js +28 -1
  18. package/dist/esm/index.js +4 -0
  19. package/dist/esm/init.js +3 -73
  20. package/dist/esm/instrumenter/core/instrumenter.js +21 -8
  21. package/dist/esm/localize/agent-prompt.js +47 -0
  22. package/dist/esm/localize/detect.js +85 -0
  23. package/dist/esm/localize/localize.js +469 -0
  24. package/dist/esm/locize.js +75 -9
  25. package/dist/esm/utils/locize-onboarding.js +83 -0
  26. package/package.json +10 -10
  27. package/types/cli.d.ts.map +1 -1
  28. package/types/index.d.ts +2 -0
  29. package/types/index.d.ts.map +1 -1
  30. package/types/init.d.ts.map +1 -1
  31. package/types/instrumenter/core/instrumenter.d.ts +27 -0
  32. package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
  33. package/types/instrumenter/index.d.ts +2 -1
  34. package/types/instrumenter/index.d.ts.map +1 -1
  35. package/types/localize/agent-prompt.d.ts +11 -0
  36. package/types/localize/agent-prompt.d.ts.map +1 -0
  37. package/types/localize/detect.d.ts +37 -0
  38. package/types/localize/detect.d.ts.map +1 -0
  39. package/types/localize/index.d.ts +6 -0
  40. package/types/localize/index.d.ts.map +1 -0
  41. package/types/localize/localize.d.ts +20 -0
  42. package/types/localize/localize.d.ts.map +1 -0
  43. package/types/locize.d.ts +20 -0
  44. package/types/locize.d.ts.map +1 -1
  45. package/types/types.d.ts +12 -0
  46. package/types/types.d.ts.map +1 -1
  47. package/types/utils/locize-onboarding.d.ts +19 -0
  48. 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 = 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,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.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=i18next-cli+init+wizard';
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
- const credentials = await inquirer.prompt([
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
- const searchDirs = ['src', '.'];
1489
- for (const dir of searchDirs) {
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 };