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
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,11 @@
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
+ import { scaffoldInlangProject } from './utils/inlang-scaffold.js';
6
7
 
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
- }
8
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__init-wizard';
56
9
  /**
57
10
  * Determines if the current project is configured as an ESM project.
58
11
  * Checks the package.json file for `"type": "module"`.
@@ -202,6 +155,14 @@ async function runInit(options = {}) {
202
155
  ],
203
156
  default: 'local',
204
157
  },
158
+ {
159
+ type: 'confirm',
160
+ name: 'inlang',
161
+ message: 'Also set up inlang tooling (Sherlock VS Code extension, Fink editor, Paraglide) on these translation files?',
162
+ default: false,
163
+ // Skip the question when already requested via the --inlang flag.
164
+ when: () => !options.inlang,
165
+ },
205
166
  ]);
206
167
  let locizeConfig;
207
168
  if (answers.backend === 'locize') {
@@ -210,29 +171,7 @@ async function runInit(options = {}) {
210
171
  if (!opened) {
211
172
  console.log(`\n👉 Open this URL manually: ${LOCIZE_SIGNUP_URL}\n`);
212
173
  }
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;
174
+ locizeConfig = await promptLocizeCredentials();
236
175
  }
237
176
  const isTypeScript = answers.fileType.includes('TypeScript');
238
177
  const isEsm = await isEsmProject();
@@ -317,6 +256,13 @@ module.exports = ${toJs(configObject)}`;
317
256
  const outputPath = resolve(process.cwd(), fileName);
318
257
  await writeFile(outputPath, fileContent.trim());
319
258
  console.log(`✅ Configuration file created at: ${outputPath}`);
259
+ if (options.inlang || answers.inlang) {
260
+ await scaffoldInlangProject({
261
+ locales: answers.locales,
262
+ primaryLanguage: answers.locales[0],
263
+ output: answers.output,
264
+ });
265
+ }
320
266
  if (locizeConfig) {
321
267
  console.log('\nNext steps for Locize:');
322
268
  console.log(' 1. Push your local translations to Locize:');
@@ -1460,6 +1460,25 @@ 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, native
1468
+ * platform separators), or null.
1469
+ */
1470
+ async function findExistingI18nInitFile() {
1471
+ const cwd = process.cwd();
1472
+ const searchDirs = ['src', '.'];
1473
+ for (const dir of searchDirs) {
1474
+ for (const name of I18N_INIT_FILE_NAMES) {
1475
+ if (await fileExists(join(cwd, dir, name))) {
1476
+ return join(dir, name);
1477
+ }
1478
+ }
1479
+ }
1480
+ return null;
1481
+ }
1463
1482
  /**
1464
1483
  * Computes a POSIX-style relative path from the init-file directory to the
1465
1484
  * output template path (which still contains {{language}} / {{namespace}} placeholders).
@@ -1485,13 +1504,8 @@ function buildDynamicImportPath(outputTemplate, initDir) {
1485
1504
  async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT) {
1486
1505
  const cwd = process.cwd();
1487
1506
  // 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
- }
1507
+ if (await findExistingI18nInitFile()) {
1508
+ return null; // Init file already exists
1495
1509
  }
1496
1510
  // Check if i18next.init() is called anywhere in the source
1497
1511
  try {
@@ -1939,4 +1953,4 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
1939
1953
  return candidates;
1940
1954
  }
1941
1955
 
1942
- export { runInstrumenter, writeExtractedKeys };
1956
+ 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 };