i18next-cli 1.47.8 → 1.47.10

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 CHANGED
@@ -249,7 +249,7 @@ npx i18next-cli lint
249
249
 
250
250
  ### `instrument`
251
251
 
252
- Scans your source code for hardcoded user-facing strings and instruments them with i18next translation calls. This is useful for adding i18next instrumentation to an existing codebase that wasn't built with internationalization in mind.
252
+ Scans your source code for hardcoded user-facing strings and instruments them with i18next translation calls. This is useful for adding i18next instrumentation to an existing codebase that wasn't built with internationalization in mind. You can see this in action in [this video](https://youtu.be/aWZnZXwGg34) or in [this blog post](https://www.locize.com/blog/i18next-cli-instrument).
253
253
 
254
254
  > **⚠️ First-Step Tool:** The `instrument` command uses heuristic-based detection and is designed as a **first pass** to identify and suggest transformation candidates. It will **not catch 100% of cases**, and you should expect both false positives and false negatives. Always review the suggested transformations carefully before committing them to your codebase. Think of it as an intelligent code assistant, not an automated compiler.
255
255
 
package/dist/cjs/cli.js CHANGED
@@ -31,7 +31,7 @@ const program = new commander.Command();
31
31
  program
32
32
  .name('i18next-cli')
33
33
  .description('A unified, high-performance i18next CLI.')
34
- .version('1.47.8'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.47.10'); // This string is replaced with the actual version at build time by rollup
35
35
  // new: global config override option
36
36
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
37
37
  program
@@ -39,6 +39,7 @@ async function runInstrumenter(config, options, logger$1 = new logger.ConsoleLog
39
39
  let totalTransformed = 0;
40
40
  let totalSkipped = 0;
41
41
  let totalLanguageChanges = 0;
42
+ let usesI18nextT = false;
42
43
  // Detect framework and language
43
44
  const hasReact = await isProjectUsingReact();
44
45
  const hasTypeScript = await isProjectUsingTypeScript();
@@ -157,6 +158,10 @@ async function runInstrumenter(config, options, logger$1 = new logger.ConsoleLog
157
158
  totalTransformed += transformResult.transformCount;
158
159
  totalLanguageChanges += transformResult.languageChangeCount;
159
160
  totalSkipped += candidates.length - approvedCandidates.length;
161
+ // Track whether any non-component candidate was transformed (i.e. i18next.t() was used)
162
+ if (!usesI18nextT && approvedCandidates.some(c => !c.insideComponent && c.confidence >= 0.7)) {
163
+ usesI18nextT = true;
164
+ }
160
165
  // Log any warnings (e.g. i18next.t() in React files)
161
166
  if (transformResult.warnings?.length) {
162
167
  for (const warning of transformResult.warnings) {
@@ -181,7 +186,7 @@ async function runInstrumenter(config, options, logger$1 = new logger.ConsoleLog
181
186
  spinner.succeed(node_util.styleText('bold', `Scanned complete: ${totalCandidates} candidates, ${totalTransformed} approved${langChangeSuffix}`));
182
187
  // Generate i18n init file if needed and any transformations were made
183
188
  if ((totalTransformed > 0 || totalLanguageChanges > 0) && !options.isDryRun) {
184
- const initFilePath = await ensureI18nInitFile(hasReact, hasTypeScript, config, logger$1);
189
+ const initFilePath = await ensureI18nInitFile(hasReact, hasTypeScript, config, logger$1, usesI18nextT);
185
190
  if (initFilePath) {
186
191
  await injectI18nImportIntoEntryFile(initFilePath, logger$1);
187
192
  }
@@ -1384,6 +1389,70 @@ async function isProjectUsingReact() {
1384
1389
  return false;
1385
1390
  }
1386
1391
  }
1392
+ /** Well-known frontend framework packages (presence → browser environment). */
1393
+ const FRONTEND_FRAMEWORKS = [
1394
+ 'react', 'react-i18next', 'vue', 'vue-i18next',
1395
+ '@angular/core', 'angular-i18next',
1396
+ 'svelte', 'svelte-i18next',
1397
+ 'preact', 'solid-js', 'jquery', 'lit', 'ember-source', 'stimulus',
1398
+ 'next', 'nuxt', 'gatsby', '@remix-run/react', 'astro'
1399
+ ];
1400
+ /** Well-known bundlers whose presence implies a browser build target. */
1401
+ const BUNDLERS = [
1402
+ 'webpack', 'vite', '@vitejs/plugin-react', 'rollup', 'parcel',
1403
+ 'esbuild', 'turbopack', 'snowpack'
1404
+ ];
1405
+ /** Edge/serverless markers (no filesystem access). */
1406
+ const EDGE_MARKERS = [
1407
+ '@cloudflare/workers-types', 'wrangler', '@cloudflare/next-on-pages',
1408
+ '@vercel/edge', '@netlify/edge-functions', '@deno/kv'
1409
+ ];
1410
+ /** Well-known Node.js server frameworks. */
1411
+ const SERVER_FRAMEWORKS = [
1412
+ 'express', 'fastify', 'koa', 'hapi', '@hapi/hapi',
1413
+ '@nestjs/core', 'restify', 'micro', 'polka', 'h3'
1414
+ ];
1415
+ /**
1416
+ * Analyses `package.json` dependencies (and a few project-root files) to
1417
+ * classify the project's runtime environment.
1418
+ *
1419
+ * Priority order:
1420
+ * 1. Edge / serverless markers → `'edge'` (no filesystem)
1421
+ * 2. Frontend framework or bundler → `'browser'`
1422
+ * 3. Node.js server framework → `'node-server'`
1423
+ * 4. Fallback → `'unknown'`
1424
+ */
1425
+ async function detectProjectEnvironment() {
1426
+ try {
1427
+ const packageJsonPath = process.cwd() + '/package.json';
1428
+ const raw = await promises.readFile(packageJsonPath, 'utf-8');
1429
+ const packageJson = JSON.parse(raw);
1430
+ const allDeps = {
1431
+ ...packageJson.dependencies,
1432
+ ...packageJson.devDependencies
1433
+ };
1434
+ const has = (list) => list.some(dep => !!allDeps[dep]);
1435
+ // 1. Edge / serverless (check first — these projects may also list a
1436
+ // bundler or even a framework, but they have no filesystem)
1437
+ if (has(EDGE_MARKERS))
1438
+ return 'edge';
1439
+ // Also check for wrangler.toml / wrangler.json
1440
+ const cwd = process.cwd();
1441
+ if (await fileExists(node_path.join(cwd, 'wrangler.toml')) || await fileExists(node_path.join(cwd, 'wrangler.json'))) {
1442
+ return 'edge';
1443
+ }
1444
+ // 2. Browser / frontend
1445
+ if (has(FRONTEND_FRAMEWORKS) || has(BUNDLERS))
1446
+ return 'browser';
1447
+ // 3. Node.js server
1448
+ if (has(SERVER_FRAMEWORKS))
1449
+ return 'node-server';
1450
+ return 'unknown';
1451
+ }
1452
+ catch {
1453
+ return 'unknown';
1454
+ }
1455
+ }
1387
1456
  /**
1388
1457
  * Checks if the project uses TypeScript (looks for tsconfig.json).
1389
1458
  */
@@ -1434,14 +1503,12 @@ function buildDynamicImportPath(outputTemplate, initDir) {
1434
1503
  * Ensures that an i18n initialization file exists in the project.
1435
1504
  * If no existing init file is found, generates a sensible default.
1436
1505
  *
1437
- * When extract.output is a string template, the generated file uses
1438
- * i18next-resources-to-backend with a dynamic import derived from the
1439
- * output path making i18next ready to load translations out of the box.
1440
- *
1441
- * For React projects: creates i18n.ts with react-i18next integration.
1442
- * For non-React projects: creates i18n.ts with basic i18next init.
1506
+ * The generated file's backend strategy depends on the project context:
1507
+ * - React app without i18next.t() → `i18next-resources-to-backend` (async dynamic imports)
1508
+ * - React app with i18next.t() → bundled resources (static imports, synchronous)
1509
+ * - Server-side (no React) → `i18next-fs-backend` (filesystem, initImmediate: false + preload)
1443
1510
  */
1444
- async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1511
+ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT) {
1445
1512
  const cwd = process.cwd();
1446
1513
  // Check for existing init files in common locations
1447
1514
  const searchDirs = ['src', '.'];
@@ -1475,125 +1542,17 @@ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1475
1542
  const initDir = srcExists ? node_path.join(cwd, 'src') : cwd;
1476
1543
  const initFileExt = hasTypeScript ? '.ts' : '.js';
1477
1544
  const initFilePath = node_path.join(initDir, 'i18n' + initFileExt);
1478
- const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1479
- const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1480
- // Build the .init({...}) options block
1481
- const initOptions = [
1482
- ' returnEmptyString: false, // allows empty string as valid translation',
1483
- ` // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user`,
1484
- ` fallbackLng: '${primaryLang}'`,
1485
- ];
1486
- if (defaultNS) {
1487
- initOptions.push(` defaultNS: '${defaultNS}'`);
1488
- }
1489
- const initBlock = initOptions.join(',\n');
1490
- let initContent;
1491
- if (typeof config.extract.output === 'string') {
1492
- // Derive a dynamic import path so the generated init file loads translations automatically
1493
- const dynamicImportPath = buildDynamicImportPath(config.extract.output, initDir);
1494
- const importPathTemplate = dynamicImportPath
1495
- // eslint-disable-next-line no-template-curly-in-string
1496
- .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1497
- // eslint-disable-next-line no-template-curly-in-string
1498
- .replace(/\{\{namespace\}\}/g, '${namespace}');
1499
- const hasNamespace = config.extract.output.includes('{{namespace}}');
1500
- const callbackParams = hasNamespace ? hasTypeScript ? 'language: string, namespace: string' : 'language, namespace' : hasTypeScript ? 'language: string' : 'language';
1501
- const backendUseLine = ' .use(resourcesToBackend((' + callbackParams + ') => import(`' + importPathTemplate + '`)))';
1502
- if (hasReact) {
1503
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1504
- // You may need to install dependencies: npm install i18next react-i18next i18next-resources-to-backend
1505
- //
1506
- // Other translation loading approaches:
1507
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1508
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1509
- // • Manage translations with your team via Locize: https://www.locize.com
1510
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1511
- import i18next from 'i18next'
1512
- import { initReactI18next } from 'react-i18next'
1513
- import resourcesToBackend from 'i18next-resources-to-backend'
1514
-
1515
- i18next
1516
- .use(initReactI18next)
1517
- ${backendUseLine}
1518
- .init({
1519
- ${initBlock}
1520
- })
1521
-
1522
- export default i18next
1523
- `;
1524
- }
1525
- else {
1526
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1527
- // You may need to install the dependency: npm install i18next i18next-resources-to-backend
1528
- //
1529
- // Other translation loading approaches:
1530
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1531
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1532
- // • Manage translations with your team via Locize: https://www.locize.com
1533
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1534
- import i18next from 'i18next'
1535
- import resourcesToBackend from 'i18next-resources-to-backend'
1536
-
1537
- i18next
1538
- ${backendUseLine}
1539
- .init({
1540
- ${initBlock}
1541
- })
1542
-
1543
- export default i18next
1544
- `;
1545
- }
1546
- }
1547
- else {
1548
- // Output is a function — can't derive import path, fall back to comments only
1549
- if (hasReact) {
1550
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1551
- // You may need to install dependencies: npm install i18next react-i18next
1552
- //
1553
- // Loading translations:
1554
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1555
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1556
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1557
- // • Manage translations with your team via Locize: https://www.locize.com
1558
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1559
- import i18next from 'i18next'
1560
- import { initReactI18next } from 'react-i18next'
1561
-
1562
- i18next
1563
- .use(initReactI18next)
1564
- .init({
1565
- returnEmptyString: false, // allows empty string as valid translation
1566
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1567
- fallbackLng: '${primaryLang}'
1568
- // resources: { ... } — or use a backend plugin to load translations
1569
- })
1570
-
1571
- export default i18next
1572
- `;
1573
- }
1574
- else {
1575
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1576
- // You may need to install the dependency: npm install i18next
1577
- //
1578
- // Loading translations:
1579
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1580
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1581
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1582
- // • Manage translations with your team via Locize: https://www.locize.com
1583
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1584
- import i18next from 'i18next'
1585
-
1586
- i18next.init({
1587
- returnEmptyString: false, // allows empty string as valid translation
1588
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1589
- fallbackLng: '${primaryLang}'
1590
- // resources: { ... } — or use a backend plugin to load translations
1591
- })
1592
-
1593
- export default i18next
1594
- `;
1595
- }
1596
- }
1545
+ const environment = await detectProjectEnvironment();
1546
+ const strategy = determineBackendStrategy(environment, usesI18nextT);
1547
+ const outputTemplate = typeof config.extract.output === 'string' ? config.extract.output : null;
1548
+ const initContent = buildInitFileContent({
1549
+ strategy,
1550
+ hasReact,
1551
+ hasTypeScript,
1552
+ config,
1553
+ initDir,
1554
+ outputTemplate
1555
+ });
1597
1556
  try {
1598
1557
  await promises.mkdir(initDir, { recursive: true });
1599
1558
  await promises.writeFile(initFilePath, initContent);
@@ -1605,6 +1564,171 @@ export default i18next
1605
1564
  return null;
1606
1565
  }
1607
1566
  }
1567
+ /**
1568
+ * Determines which backend strategy to use for the i18n init file.
1569
+ *
1570
+ * Decision logic:
1571
+ * 1. Node.js server with filesystem → `fs-backend`
1572
+ * (synchronous with `initImmediate: false` + `preload`)
1573
+ * 2. Browser / edge / unknown with `i18next.t()` outside React components →
1574
+ * `bundled-resources` (static imports so resources are available synchronously)
1575
+ * 3. Otherwise → `resources-to-backend` (async dynamic imports, lazy-loaded)
1576
+ */
1577
+ function determineBackendStrategy(environment, usesI18nextT) {
1578
+ if (environment === 'node-server')
1579
+ return 'fs-backend';
1580
+ if (usesI18nextT)
1581
+ return 'bundled-resources';
1582
+ return 'resources-to-backend';
1583
+ }
1584
+ /**
1585
+ * Builds the full i18n init file content from composable parts,
1586
+ * avoiding repetition across different strategies.
1587
+ */
1588
+ function buildInitFileContent(opts) {
1589
+ const { strategy, hasReact, hasTypeScript, config, initDir, outputTemplate } = opts;
1590
+ const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1591
+ const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1592
+ const ns = defaultNS || 'translation';
1593
+ // ── Dependencies for the install hint ──
1594
+ const deps = ['i18next'];
1595
+ if (hasReact)
1596
+ deps.push('react-i18next');
1597
+ if (strategy === 'resources-to-backend' && outputTemplate)
1598
+ deps.push('i18next-resources-to-backend');
1599
+ if (strategy === 'fs-backend' && outputTemplate)
1600
+ deps.push('i18next-fs-backend');
1601
+ const lines = [];
1602
+ // ── Header comment ──
1603
+ lines.push("// Generated by i18next-cli — review and adapt to your project's needs.", `// You may need to install dependencies: npm install ${deps.join(' ')}`, '//', '// Other translation loading approaches:', '// • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations', '// • Lazy-load from a server: https://github.com/i18next/i18next-http-backend', '// • Manage translations with your team via Locize: https://www.locize.com', '// (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)');
1604
+ // ── Import declarations ──
1605
+ lines.push("import i18next from 'i18next'");
1606
+ if (hasReact)
1607
+ lines.push("import { initReactI18next } from 'react-i18next'");
1608
+ if (outputTemplate) {
1609
+ switch (strategy) {
1610
+ case 'resources-to-backend':
1611
+ lines.push("import resourcesToBackend from 'i18next-resources-to-backend'");
1612
+ break;
1613
+ case 'bundled-resources':
1614
+ for (const locale of config.locales) {
1615
+ const importPath = buildResourceImportPath(outputTemplate, initDir, locale, ns);
1616
+ lines.push(`import ${toResourceVarName(locale, ns)} from '${importPath}'`);
1617
+ }
1618
+ break;
1619
+ case 'fs-backend':
1620
+ lines.push("import Backend from 'i18next-fs-backend'");
1621
+ lines.push("import { resolve, dirname } from 'node:path'");
1622
+ lines.push("import { fileURLToPath } from 'node:url'");
1623
+ break;
1624
+ }
1625
+ }
1626
+ // ── Pre-init statements ──
1627
+ lines.push('');
1628
+ if (strategy === 'fs-backend' && outputTemplate) {
1629
+ lines.push('const __dirname = dirname(fileURLToPath(import.meta.url))');
1630
+ lines.push('');
1631
+ }
1632
+ // ── .use() chain entries ──
1633
+ const useEntries = [];
1634
+ if (hasReact)
1635
+ useEntries.push(' .use(initReactI18next)');
1636
+ if (outputTemplate) {
1637
+ if (strategy === 'resources-to-backend') {
1638
+ const dynamicPath = buildDynamicImportPath(outputTemplate, initDir);
1639
+ const importPathTemplate = dynamicPath
1640
+ // eslint-disable-next-line no-template-curly-in-string
1641
+ .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1642
+ // eslint-disable-next-line no-template-curly-in-string
1643
+ .replace(/\{\{namespace\}\}/g, '${namespace}');
1644
+ const hasNamespace = outputTemplate.includes('{{namespace}}');
1645
+ const cbParams = hasNamespace
1646
+ ? (hasTypeScript ? 'language: string, namespace: string' : 'language, namespace')
1647
+ : (hasTypeScript ? 'language: string' : 'language');
1648
+ useEntries.push(` .use(resourcesToBackend((${cbParams}) => import(\`${importPathTemplate}\`)))`);
1649
+ }
1650
+ else if (strategy === 'fs-backend') {
1651
+ useEntries.push(' .use(Backend)');
1652
+ }
1653
+ }
1654
+ // Emit the i18next chain — use compact form if no .use() calls
1655
+ const awaitPrefix = (strategy === 'fs-backend' && outputTemplate) ? 'await ' : '';
1656
+ if (useEntries.length > 0) {
1657
+ lines.push(`${awaitPrefix}i18next`);
1658
+ lines.push(...useEntries);
1659
+ lines.push(' .init({');
1660
+ }
1661
+ else {
1662
+ lines.push(`${awaitPrefix}i18next.init({`);
1663
+ }
1664
+ // ── .init() options ──
1665
+ const initOpts = [];
1666
+ if (strategy === 'fs-backend' && outputTemplate) {
1667
+ initOpts.push(' initImmediate: false,');
1668
+ }
1669
+ initOpts.push(' returnEmptyString: false, // allows empty string as valid translation');
1670
+ initOpts.push(` // lng: '${config.locales.at(-1)}', // or add a language detector to detect the preferred language of your user`);
1671
+ initOpts.push(` fallbackLng: '${primaryLang}',`);
1672
+ if (defaultNS) {
1673
+ initOpts.push(` defaultNS: '${ns}',`);
1674
+ }
1675
+ // Strategy-specific init options
1676
+ if (outputTemplate) {
1677
+ if (strategy === 'bundled-resources') {
1678
+ initOpts.push(' resources: {');
1679
+ for (const locale of config.locales) {
1680
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(locale) ? locale : `'${locale}'`;
1681
+ initOpts.push(` ${key}: { ${ns}: ${toResourceVarName(locale, ns)} },`);
1682
+ }
1683
+ initOpts.push(' },');
1684
+ }
1685
+ else if (strategy === 'fs-backend') {
1686
+ const loadPath = buildFsBackendLoadPath(outputTemplate, initDir);
1687
+ initOpts.push(` preload: [${config.locales.map(l => `'${l}'`).join(', ')}],`);
1688
+ initOpts.push(' backend: {');
1689
+ initOpts.push(` loadPath: resolve(__dirname, '${loadPath}'),`);
1690
+ initOpts.push(' },');
1691
+ }
1692
+ }
1693
+ else {
1694
+ // No concrete output path — user needs to configure loading manually
1695
+ initOpts.push(' // resources: { ... } — or use a backend plugin to load translations');
1696
+ }
1697
+ lines.push(initOpts.join('\n'));
1698
+ lines.push(' })');
1699
+ lines.push('');
1700
+ lines.push('export default i18next');
1701
+ lines.push('');
1702
+ return lines.join('\n');
1703
+ }
1704
+ /**
1705
+ * Resolves the import path for a specific locale/namespace resource file
1706
+ * (used by the bundled-resources strategy).
1707
+ */
1708
+ function buildResourceImportPath(outputTemplate, initDir, locale, namespace) {
1709
+ const rel = buildDynamicImportPath(outputTemplate, initDir);
1710
+ return rel
1711
+ .replace(/\{\{language\}\}|\{\{lng\}\}/g, locale)
1712
+ .replace(/\{\{namespace\}\}|\{\{ns\}\}/g, namespace);
1713
+ }
1714
+ /**
1715
+ * Resolves the loadPath for i18next-fs-backend, using i18next's `{{lng}}`
1716
+ * and `{{ns}}` interpolation syntax.
1717
+ */
1718
+ function buildFsBackendLoadPath(outputTemplate, initDir) {
1719
+ const rel = buildDynamicImportPath(outputTemplate, initDir);
1720
+ return rel
1721
+ .replace(/\{\{language\}\}/g, '{{lng}}')
1722
+ .replace(/\{\{namespace\}\}/g, '{{ns}}');
1723
+ }
1724
+ /**
1725
+ * Converts a locale + namespace pair to a valid JS variable name.
1726
+ * E.g. ('en', 'translation') → 'enTranslation', ('zh-CN', 'common') → 'zhCNCommon'
1727
+ */
1728
+ function toResourceVarName(locale, namespace) {
1729
+ const sanitizedLocale = locale.replace(/[^a-zA-Z0-9]/g, '');
1730
+ return sanitizedLocale + namespace.charAt(0).toUpperCase() + namespace.slice(1);
1731
+ }
1608
1732
  /**
1609
1733
  * Common entry-point file names, checked in priority order.
1610
1734
  */
@@ -7,29 +7,37 @@ var inquirer = require('inquirer');
7
7
  var node_path = require('node:path');
8
8
 
9
9
  /**
10
- * Verifies that the locize-cli tool is installed and accessible.
10
+ * Resolves the locize-cli executable to use.
11
11
  *
12
- * @throws Exits the process with error code 1 if locize-cli is not found
12
+ * Tries, in order:
13
+ * 1. A locally / globally installed `locize` binary
14
+ * 2. Falls back to `npx locize-cli` so it can be fetched on demand
13
15
  *
14
- * @example
15
- * ```typescript
16
- * await checkLocizeCliExists()
17
- * // Continues execution if locize-cli is available
18
- * // Otherwise exits with installation instructions
19
- * ```
16
+ * If neither works the process exits with an error.
17
+ *
18
+ * @returns An object with `cmd` (the executable) and `prefixArgs` (extra args
19
+ * to prepend before the locize sub-command, e.g. `['locize-cli']`
20
+ * when running through npx).
20
21
  */
21
- async function checkLocizeCliExists() {
22
+ async function resolveLocizeBin() {
23
+ // 1. Try a locally / globally installed binary
22
24
  try {
23
25
  await execa.execa('locize', ['--version']);
26
+ return { cmd: 'locize', prefixArgs: [] };
24
27
  }
25
- catch (error) {
26
- if (error.code === 'ENOENT') {
27
- console.error(node_util.styleText('red', 'Error: `locize-cli` command not found.'));
28
- console.log(node_util.styleText('yellow', 'Please install it globally to use the Locize integration:'));
29
- console.log(node_util.styleText('cyan', 'npm install -g locize-cli'));
30
- process.exit(1);
31
- }
28
+ catch {
29
+ // not found continue
30
+ }
31
+ // 2. Fall back to npx
32
+ try {
33
+ console.log(node_util.styleText('yellow', '`locize` command not found – trying npx...'));
34
+ await execa.execa('npx', ['locize-cli', '--version']);
35
+ return { cmd: 'npx', prefixArgs: ['locize-cli'] };
32
36
  }
37
+ catch {
38
+ // npx also failed
39
+ }
40
+ return null;
33
41
  }
34
42
  /**
35
43
  * Interactive setup wizard for configuring Locize credentials.
@@ -236,14 +244,23 @@ function buildArgs(command, config, cliOptions) {
236
244
  * ```
237
245
  */
238
246
  async function runLocizeCommand(command, config, cliOptions = {}) {
239
- await checkLocizeCliExists();
247
+ const resolved = await resolveLocizeBin();
248
+ if (!resolved) {
249
+ console.error(node_util.styleText('red', 'Error: `locize-cli` command not found.'));
250
+ console.log(node_util.styleText('yellow', 'Please install it to use the Locize integration:'));
251
+ console.log(node_util.styleText('cyan', ' npm install -g locize-cli'));
252
+ console.log(node_util.styleText('yellow', 'Or make sure npx is available so it can be fetched on demand.'));
253
+ process.exit(1);
254
+ return;
255
+ }
256
+ const { cmd, prefixArgs } = resolved;
240
257
  const spinner = ora(`Running 'locize ${command}'...\n`).start();
241
258
  let effectiveConfig = config;
242
259
  try {
243
260
  // 1. First attempt
244
- const initialArgs = buildArgs(command, effectiveConfig, cliOptions);
245
- console.log(node_util.styleText('cyan', `\nRunning 'locize ${maskArgs(initialArgs).join(' ')}'...`));
246
- const result = await execa.execa('locize', initialArgs, { stdio: 'pipe' });
261
+ const initialArgs = [...prefixArgs, ...buildArgs(command, effectiveConfig, cliOptions)];
262
+ console.log(node_util.styleText('cyan', `\nRunning 'locize ${maskArgs(initialArgs.slice(prefixArgs.length)).join(' ')}'...`));
263
+ const result = await execa.execa(cmd, initialArgs, { stdio: 'pipe' });
247
264
  spinner.succeed(node_util.styleText('green', `'locize ${command}' completed successfully.`));
248
265
  if (result?.stdout)
249
266
  console.log(result.stdout); // Print captured output on success
@@ -259,9 +276,9 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
259
276
  spinner.start('Retrying with new credentials...');
260
277
  try {
261
278
  // 3. Retry attempt, rebuilding args with the NOW-UPDATED currentConfig object
262
- const retryArgs = buildArgs(command, effectiveConfig, cliOptions);
263
- console.log(node_util.styleText('cyan', `\nRunning 'locize ${maskArgs(retryArgs).join(' ')}'...`));
264
- const result = await execa.execa('locize', retryArgs, { stdio: 'pipe' });
279
+ const retryArgs = [...prefixArgs, ...buildArgs(command, effectiveConfig, cliOptions)];
280
+ console.log(node_util.styleText('cyan', `\nRunning 'locize ${maskArgs(retryArgs.slice(prefixArgs.length)).join(' ')}'...`));
281
+ const result = await execa.execa(cmd, retryArgs, { stdio: 'pipe' });
265
282
  spinner.succeed(node_util.styleText('green', 'Retry successful!'));
266
283
  if (result?.stdout)
267
284
  console.log(result.stdout);
package/dist/esm/cli.js CHANGED
@@ -29,7 +29,7 @@ const program = new Command();
29
29
  program
30
30
  .name('i18next-cli')
31
31
  .description('A unified, high-performance i18next CLI.')
32
- .version('1.47.8'); // This string is replaced with the actual version at build time by rollup
32
+ .version('1.47.10'); // This string is replaced with the actual version at build time by rollup
33
33
  // new: global config override option
34
34
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
35
35
  program
@@ -37,6 +37,7 @@ async function runInstrumenter(config, options, logger = new ConsoleLogger()) {
37
37
  let totalTransformed = 0;
38
38
  let totalSkipped = 0;
39
39
  let totalLanguageChanges = 0;
40
+ let usesI18nextT = false;
40
41
  // Detect framework and language
41
42
  const hasReact = await isProjectUsingReact();
42
43
  const hasTypeScript = await isProjectUsingTypeScript();
@@ -155,6 +156,10 @@ async function runInstrumenter(config, options, logger = new ConsoleLogger()) {
155
156
  totalTransformed += transformResult.transformCount;
156
157
  totalLanguageChanges += transformResult.languageChangeCount;
157
158
  totalSkipped += candidates.length - approvedCandidates.length;
159
+ // Track whether any non-component candidate was transformed (i.e. i18next.t() was used)
160
+ if (!usesI18nextT && approvedCandidates.some(c => !c.insideComponent && c.confidence >= 0.7)) {
161
+ usesI18nextT = true;
162
+ }
158
163
  // Log any warnings (e.g. i18next.t() in React files)
159
164
  if (transformResult.warnings?.length) {
160
165
  for (const warning of transformResult.warnings) {
@@ -179,7 +184,7 @@ async function runInstrumenter(config, options, logger = new ConsoleLogger()) {
179
184
  spinner.succeed(styleText('bold', `Scanned complete: ${totalCandidates} candidates, ${totalTransformed} approved${langChangeSuffix}`));
180
185
  // Generate i18n init file if needed and any transformations were made
181
186
  if ((totalTransformed > 0 || totalLanguageChanges > 0) && !options.isDryRun) {
182
- const initFilePath = await ensureI18nInitFile(hasReact, hasTypeScript, config, logger);
187
+ const initFilePath = await ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT);
183
188
  if (initFilePath) {
184
189
  await injectI18nImportIntoEntryFile(initFilePath, logger);
185
190
  }
@@ -1382,6 +1387,70 @@ async function isProjectUsingReact() {
1382
1387
  return false;
1383
1388
  }
1384
1389
  }
1390
+ /** Well-known frontend framework packages (presence → browser environment). */
1391
+ const FRONTEND_FRAMEWORKS = [
1392
+ 'react', 'react-i18next', 'vue', 'vue-i18next',
1393
+ '@angular/core', 'angular-i18next',
1394
+ 'svelte', 'svelte-i18next',
1395
+ 'preact', 'solid-js', 'jquery', 'lit', 'ember-source', 'stimulus',
1396
+ 'next', 'nuxt', 'gatsby', '@remix-run/react', 'astro'
1397
+ ];
1398
+ /** Well-known bundlers whose presence implies a browser build target. */
1399
+ const BUNDLERS = [
1400
+ 'webpack', 'vite', '@vitejs/plugin-react', 'rollup', 'parcel',
1401
+ 'esbuild', 'turbopack', 'snowpack'
1402
+ ];
1403
+ /** Edge/serverless markers (no filesystem access). */
1404
+ const EDGE_MARKERS = [
1405
+ '@cloudflare/workers-types', 'wrangler', '@cloudflare/next-on-pages',
1406
+ '@vercel/edge', '@netlify/edge-functions', '@deno/kv'
1407
+ ];
1408
+ /** Well-known Node.js server frameworks. */
1409
+ const SERVER_FRAMEWORKS = [
1410
+ 'express', 'fastify', 'koa', 'hapi', '@hapi/hapi',
1411
+ '@nestjs/core', 'restify', 'micro', 'polka', 'h3'
1412
+ ];
1413
+ /**
1414
+ * Analyses `package.json` dependencies (and a few project-root files) to
1415
+ * classify the project's runtime environment.
1416
+ *
1417
+ * Priority order:
1418
+ * 1. Edge / serverless markers → `'edge'` (no filesystem)
1419
+ * 2. Frontend framework or bundler → `'browser'`
1420
+ * 3. Node.js server framework → `'node-server'`
1421
+ * 4. Fallback → `'unknown'`
1422
+ */
1423
+ async function detectProjectEnvironment() {
1424
+ try {
1425
+ const packageJsonPath = process.cwd() + '/package.json';
1426
+ const raw = await readFile(packageJsonPath, 'utf-8');
1427
+ const packageJson = JSON.parse(raw);
1428
+ const allDeps = {
1429
+ ...packageJson.dependencies,
1430
+ ...packageJson.devDependencies
1431
+ };
1432
+ const has = (list) => list.some(dep => !!allDeps[dep]);
1433
+ // 1. Edge / serverless (check first — these projects may also list a
1434
+ // bundler or even a framework, but they have no filesystem)
1435
+ if (has(EDGE_MARKERS))
1436
+ return 'edge';
1437
+ // Also check for wrangler.toml / wrangler.json
1438
+ const cwd = process.cwd();
1439
+ if (await fileExists(join(cwd, 'wrangler.toml')) || await fileExists(join(cwd, 'wrangler.json'))) {
1440
+ return 'edge';
1441
+ }
1442
+ // 2. Browser / frontend
1443
+ if (has(FRONTEND_FRAMEWORKS) || has(BUNDLERS))
1444
+ return 'browser';
1445
+ // 3. Node.js server
1446
+ if (has(SERVER_FRAMEWORKS))
1447
+ return 'node-server';
1448
+ return 'unknown';
1449
+ }
1450
+ catch {
1451
+ return 'unknown';
1452
+ }
1453
+ }
1385
1454
  /**
1386
1455
  * Checks if the project uses TypeScript (looks for tsconfig.json).
1387
1456
  */
@@ -1432,14 +1501,12 @@ function buildDynamicImportPath(outputTemplate, initDir) {
1432
1501
  * Ensures that an i18n initialization file exists in the project.
1433
1502
  * If no existing init file is found, generates a sensible default.
1434
1503
  *
1435
- * When extract.output is a string template, the generated file uses
1436
- * i18next-resources-to-backend with a dynamic import derived from the
1437
- * output path making i18next ready to load translations out of the box.
1438
- *
1439
- * For React projects: creates i18n.ts with react-i18next integration.
1440
- * For non-React projects: creates i18n.ts with basic i18next init.
1504
+ * The generated file's backend strategy depends on the project context:
1505
+ * - React app without i18next.t() → `i18next-resources-to-backend` (async dynamic imports)
1506
+ * - React app with i18next.t() → bundled resources (static imports, synchronous)
1507
+ * - Server-side (no React) → `i18next-fs-backend` (filesystem, initImmediate: false + preload)
1441
1508
  */
1442
- async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1509
+ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT) {
1443
1510
  const cwd = process.cwd();
1444
1511
  // Check for existing init files in common locations
1445
1512
  const searchDirs = ['src', '.'];
@@ -1473,125 +1540,17 @@ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1473
1540
  const initDir = srcExists ? join(cwd, 'src') : cwd;
1474
1541
  const initFileExt = hasTypeScript ? '.ts' : '.js';
1475
1542
  const initFilePath = join(initDir, 'i18n' + initFileExt);
1476
- const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1477
- const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1478
- // Build the .init({...}) options block
1479
- const initOptions = [
1480
- ' returnEmptyString: false, // allows empty string as valid translation',
1481
- ` // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user`,
1482
- ` fallbackLng: '${primaryLang}'`,
1483
- ];
1484
- if (defaultNS) {
1485
- initOptions.push(` defaultNS: '${defaultNS}'`);
1486
- }
1487
- const initBlock = initOptions.join(',\n');
1488
- let initContent;
1489
- if (typeof config.extract.output === 'string') {
1490
- // Derive a dynamic import path so the generated init file loads translations automatically
1491
- const dynamicImportPath = buildDynamicImportPath(config.extract.output, initDir);
1492
- const importPathTemplate = dynamicImportPath
1493
- // eslint-disable-next-line no-template-curly-in-string
1494
- .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1495
- // eslint-disable-next-line no-template-curly-in-string
1496
- .replace(/\{\{namespace\}\}/g, '${namespace}');
1497
- const hasNamespace = config.extract.output.includes('{{namespace}}');
1498
- const callbackParams = hasNamespace ? hasTypeScript ? 'language: string, namespace: string' : 'language, namespace' : hasTypeScript ? 'language: string' : 'language';
1499
- const backendUseLine = ' .use(resourcesToBackend((' + callbackParams + ') => import(`' + importPathTemplate + '`)))';
1500
- if (hasReact) {
1501
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1502
- // You may need to install dependencies: npm install i18next react-i18next i18next-resources-to-backend
1503
- //
1504
- // Other translation loading approaches:
1505
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1506
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1507
- // • Manage translations with your team via Locize: https://www.locize.com
1508
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1509
- import i18next from 'i18next'
1510
- import { initReactI18next } from 'react-i18next'
1511
- import resourcesToBackend from 'i18next-resources-to-backend'
1512
-
1513
- i18next
1514
- .use(initReactI18next)
1515
- ${backendUseLine}
1516
- .init({
1517
- ${initBlock}
1518
- })
1519
-
1520
- export default i18next
1521
- `;
1522
- }
1523
- else {
1524
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1525
- // You may need to install the dependency: npm install i18next i18next-resources-to-backend
1526
- //
1527
- // Other translation loading approaches:
1528
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1529
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1530
- // • Manage translations with your team via Locize: https://www.locize.com
1531
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1532
- import i18next from 'i18next'
1533
- import resourcesToBackend from 'i18next-resources-to-backend'
1534
-
1535
- i18next
1536
- ${backendUseLine}
1537
- .init({
1538
- ${initBlock}
1539
- })
1540
-
1541
- export default i18next
1542
- `;
1543
- }
1544
- }
1545
- else {
1546
- // Output is a function — can't derive import path, fall back to comments only
1547
- if (hasReact) {
1548
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1549
- // You may need to install dependencies: npm install i18next react-i18next
1550
- //
1551
- // Loading translations:
1552
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1553
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1554
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1555
- // • Manage translations with your team via Locize: https://www.locize.com
1556
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1557
- import i18next from 'i18next'
1558
- import { initReactI18next } from 'react-i18next'
1559
-
1560
- i18next
1561
- .use(initReactI18next)
1562
- .init({
1563
- returnEmptyString: false, // allows empty string as valid translation
1564
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1565
- fallbackLng: '${primaryLang}'
1566
- // resources: { ... } — or use a backend plugin to load translations
1567
- })
1568
-
1569
- export default i18next
1570
- `;
1571
- }
1572
- else {
1573
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1574
- // You may need to install the dependency: npm install i18next
1575
- //
1576
- // Loading translations:
1577
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1578
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1579
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1580
- // • Manage translations with your team via Locize: https://www.locize.com
1581
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1582
- import i18next from 'i18next'
1583
-
1584
- i18next.init({
1585
- returnEmptyString: false, // allows empty string as valid translation
1586
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1587
- fallbackLng: '${primaryLang}'
1588
- // resources: { ... } — or use a backend plugin to load translations
1589
- })
1590
-
1591
- export default i18next
1592
- `;
1593
- }
1594
- }
1543
+ const environment = await detectProjectEnvironment();
1544
+ const strategy = determineBackendStrategy(environment, usesI18nextT);
1545
+ const outputTemplate = typeof config.extract.output === 'string' ? config.extract.output : null;
1546
+ const initContent = buildInitFileContent({
1547
+ strategy,
1548
+ hasReact,
1549
+ hasTypeScript,
1550
+ config,
1551
+ initDir,
1552
+ outputTemplate
1553
+ });
1595
1554
  try {
1596
1555
  await mkdir(initDir, { recursive: true });
1597
1556
  await writeFile(initFilePath, initContent);
@@ -1603,6 +1562,171 @@ export default i18next
1603
1562
  return null;
1604
1563
  }
1605
1564
  }
1565
+ /**
1566
+ * Determines which backend strategy to use for the i18n init file.
1567
+ *
1568
+ * Decision logic:
1569
+ * 1. Node.js server with filesystem → `fs-backend`
1570
+ * (synchronous with `initImmediate: false` + `preload`)
1571
+ * 2. Browser / edge / unknown with `i18next.t()` outside React components →
1572
+ * `bundled-resources` (static imports so resources are available synchronously)
1573
+ * 3. Otherwise → `resources-to-backend` (async dynamic imports, lazy-loaded)
1574
+ */
1575
+ function determineBackendStrategy(environment, usesI18nextT) {
1576
+ if (environment === 'node-server')
1577
+ return 'fs-backend';
1578
+ if (usesI18nextT)
1579
+ return 'bundled-resources';
1580
+ return 'resources-to-backend';
1581
+ }
1582
+ /**
1583
+ * Builds the full i18n init file content from composable parts,
1584
+ * avoiding repetition across different strategies.
1585
+ */
1586
+ function buildInitFileContent(opts) {
1587
+ const { strategy, hasReact, hasTypeScript, config, initDir, outputTemplate } = opts;
1588
+ const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1589
+ const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1590
+ const ns = defaultNS || 'translation';
1591
+ // ── Dependencies for the install hint ──
1592
+ const deps = ['i18next'];
1593
+ if (hasReact)
1594
+ deps.push('react-i18next');
1595
+ if (strategy === 'resources-to-backend' && outputTemplate)
1596
+ deps.push('i18next-resources-to-backend');
1597
+ if (strategy === 'fs-backend' && outputTemplate)
1598
+ deps.push('i18next-fs-backend');
1599
+ const lines = [];
1600
+ // ── Header comment ──
1601
+ lines.push("// Generated by i18next-cli — review and adapt to your project's needs.", `// You may need to install dependencies: npm install ${deps.join(' ')}`, '//', '// Other translation loading approaches:', '// • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations', '// • Lazy-load from a server: https://github.com/i18next/i18next-http-backend', '// • Manage translations with your team via Locize: https://www.locize.com', '// (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)');
1602
+ // ── Import declarations ──
1603
+ lines.push("import i18next from 'i18next'");
1604
+ if (hasReact)
1605
+ lines.push("import { initReactI18next } from 'react-i18next'");
1606
+ if (outputTemplate) {
1607
+ switch (strategy) {
1608
+ case 'resources-to-backend':
1609
+ lines.push("import resourcesToBackend from 'i18next-resources-to-backend'");
1610
+ break;
1611
+ case 'bundled-resources':
1612
+ for (const locale of config.locales) {
1613
+ const importPath = buildResourceImportPath(outputTemplate, initDir, locale, ns);
1614
+ lines.push(`import ${toResourceVarName(locale, ns)} from '${importPath}'`);
1615
+ }
1616
+ break;
1617
+ case 'fs-backend':
1618
+ lines.push("import Backend from 'i18next-fs-backend'");
1619
+ lines.push("import { resolve, dirname } from 'node:path'");
1620
+ lines.push("import { fileURLToPath } from 'node:url'");
1621
+ break;
1622
+ }
1623
+ }
1624
+ // ── Pre-init statements ──
1625
+ lines.push('');
1626
+ if (strategy === 'fs-backend' && outputTemplate) {
1627
+ lines.push('const __dirname = dirname(fileURLToPath(import.meta.url))');
1628
+ lines.push('');
1629
+ }
1630
+ // ── .use() chain entries ──
1631
+ const useEntries = [];
1632
+ if (hasReact)
1633
+ useEntries.push(' .use(initReactI18next)');
1634
+ if (outputTemplate) {
1635
+ if (strategy === 'resources-to-backend') {
1636
+ const dynamicPath = buildDynamicImportPath(outputTemplate, initDir);
1637
+ const importPathTemplate = dynamicPath
1638
+ // eslint-disable-next-line no-template-curly-in-string
1639
+ .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1640
+ // eslint-disable-next-line no-template-curly-in-string
1641
+ .replace(/\{\{namespace\}\}/g, '${namespace}');
1642
+ const hasNamespace = outputTemplate.includes('{{namespace}}');
1643
+ const cbParams = hasNamespace
1644
+ ? (hasTypeScript ? 'language: string, namespace: string' : 'language, namespace')
1645
+ : (hasTypeScript ? 'language: string' : 'language');
1646
+ useEntries.push(` .use(resourcesToBackend((${cbParams}) => import(\`${importPathTemplate}\`)))`);
1647
+ }
1648
+ else if (strategy === 'fs-backend') {
1649
+ useEntries.push(' .use(Backend)');
1650
+ }
1651
+ }
1652
+ // Emit the i18next chain — use compact form if no .use() calls
1653
+ const awaitPrefix = (strategy === 'fs-backend' && outputTemplate) ? 'await ' : '';
1654
+ if (useEntries.length > 0) {
1655
+ lines.push(`${awaitPrefix}i18next`);
1656
+ lines.push(...useEntries);
1657
+ lines.push(' .init({');
1658
+ }
1659
+ else {
1660
+ lines.push(`${awaitPrefix}i18next.init({`);
1661
+ }
1662
+ // ── .init() options ──
1663
+ const initOpts = [];
1664
+ if (strategy === 'fs-backend' && outputTemplate) {
1665
+ initOpts.push(' initImmediate: false,');
1666
+ }
1667
+ initOpts.push(' returnEmptyString: false, // allows empty string as valid translation');
1668
+ initOpts.push(` // lng: '${config.locales.at(-1)}', // or add a language detector to detect the preferred language of your user`);
1669
+ initOpts.push(` fallbackLng: '${primaryLang}',`);
1670
+ if (defaultNS) {
1671
+ initOpts.push(` defaultNS: '${ns}',`);
1672
+ }
1673
+ // Strategy-specific init options
1674
+ if (outputTemplate) {
1675
+ if (strategy === 'bundled-resources') {
1676
+ initOpts.push(' resources: {');
1677
+ for (const locale of config.locales) {
1678
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(locale) ? locale : `'${locale}'`;
1679
+ initOpts.push(` ${key}: { ${ns}: ${toResourceVarName(locale, ns)} },`);
1680
+ }
1681
+ initOpts.push(' },');
1682
+ }
1683
+ else if (strategy === 'fs-backend') {
1684
+ const loadPath = buildFsBackendLoadPath(outputTemplate, initDir);
1685
+ initOpts.push(` preload: [${config.locales.map(l => `'${l}'`).join(', ')}],`);
1686
+ initOpts.push(' backend: {');
1687
+ initOpts.push(` loadPath: resolve(__dirname, '${loadPath}'),`);
1688
+ initOpts.push(' },');
1689
+ }
1690
+ }
1691
+ else {
1692
+ // No concrete output path — user needs to configure loading manually
1693
+ initOpts.push(' // resources: { ... } — or use a backend plugin to load translations');
1694
+ }
1695
+ lines.push(initOpts.join('\n'));
1696
+ lines.push(' })');
1697
+ lines.push('');
1698
+ lines.push('export default i18next');
1699
+ lines.push('');
1700
+ return lines.join('\n');
1701
+ }
1702
+ /**
1703
+ * Resolves the import path for a specific locale/namespace resource file
1704
+ * (used by the bundled-resources strategy).
1705
+ */
1706
+ function buildResourceImportPath(outputTemplate, initDir, locale, namespace) {
1707
+ const rel = buildDynamicImportPath(outputTemplate, initDir);
1708
+ return rel
1709
+ .replace(/\{\{language\}\}|\{\{lng\}\}/g, locale)
1710
+ .replace(/\{\{namespace\}\}|\{\{ns\}\}/g, namespace);
1711
+ }
1712
+ /**
1713
+ * Resolves the loadPath for i18next-fs-backend, using i18next's `{{lng}}`
1714
+ * and `{{ns}}` interpolation syntax.
1715
+ */
1716
+ function buildFsBackendLoadPath(outputTemplate, initDir) {
1717
+ const rel = buildDynamicImportPath(outputTemplate, initDir);
1718
+ return rel
1719
+ .replace(/\{\{language\}\}/g, '{{lng}}')
1720
+ .replace(/\{\{namespace\}\}/g, '{{ns}}');
1721
+ }
1722
+ /**
1723
+ * Converts a locale + namespace pair to a valid JS variable name.
1724
+ * E.g. ('en', 'translation') → 'enTranslation', ('zh-CN', 'common') → 'zhCNCommon'
1725
+ */
1726
+ function toResourceVarName(locale, namespace) {
1727
+ const sanitizedLocale = locale.replace(/[^a-zA-Z0-9]/g, '');
1728
+ return sanitizedLocale + namespace.charAt(0).toUpperCase() + namespace.slice(1);
1729
+ }
1606
1730
  /**
1607
1731
  * Common entry-point file names, checked in priority order.
1608
1732
  */
@@ -5,29 +5,37 @@ import inquirer from 'inquirer';
5
5
  import { sep, resolve } from 'node:path';
6
6
 
7
7
  /**
8
- * Verifies that the locize-cli tool is installed and accessible.
8
+ * Resolves the locize-cli executable to use.
9
9
  *
10
- * @throws Exits the process with error code 1 if locize-cli is not found
10
+ * Tries, in order:
11
+ * 1. A locally / globally installed `locize` binary
12
+ * 2. Falls back to `npx locize-cli` so it can be fetched on demand
11
13
  *
12
- * @example
13
- * ```typescript
14
- * await checkLocizeCliExists()
15
- * // Continues execution if locize-cli is available
16
- * // Otherwise exits with installation instructions
17
- * ```
14
+ * If neither works the process exits with an error.
15
+ *
16
+ * @returns An object with `cmd` (the executable) and `prefixArgs` (extra args
17
+ * to prepend before the locize sub-command, e.g. `['locize-cli']`
18
+ * when running through npx).
18
19
  */
19
- async function checkLocizeCliExists() {
20
+ async function resolveLocizeBin() {
21
+ // 1. Try a locally / globally installed binary
20
22
  try {
21
23
  await execa('locize', ['--version']);
24
+ return { cmd: 'locize', prefixArgs: [] };
22
25
  }
23
- catch (error) {
24
- if (error.code === 'ENOENT') {
25
- console.error(styleText('red', 'Error: `locize-cli` command not found.'));
26
- console.log(styleText('yellow', 'Please install it globally to use the Locize integration:'));
27
- console.log(styleText('cyan', 'npm install -g locize-cli'));
28
- process.exit(1);
29
- }
26
+ catch {
27
+ // not found continue
28
+ }
29
+ // 2. Fall back to npx
30
+ try {
31
+ console.log(styleText('yellow', '`locize` command not found – trying npx...'));
32
+ await execa('npx', ['locize-cli', '--version']);
33
+ return { cmd: 'npx', prefixArgs: ['locize-cli'] };
30
34
  }
35
+ catch {
36
+ // npx also failed
37
+ }
38
+ return null;
31
39
  }
32
40
  /**
33
41
  * Interactive setup wizard for configuring Locize credentials.
@@ -234,14 +242,23 @@ function buildArgs(command, config, cliOptions) {
234
242
  * ```
235
243
  */
236
244
  async function runLocizeCommand(command, config, cliOptions = {}) {
237
- await checkLocizeCliExists();
245
+ const resolved = await resolveLocizeBin();
246
+ if (!resolved) {
247
+ console.error(styleText('red', 'Error: `locize-cli` command not found.'));
248
+ console.log(styleText('yellow', 'Please install it to use the Locize integration:'));
249
+ console.log(styleText('cyan', ' npm install -g locize-cli'));
250
+ console.log(styleText('yellow', 'Or make sure npx is available so it can be fetched on demand.'));
251
+ process.exit(1);
252
+ return;
253
+ }
254
+ const { cmd, prefixArgs } = resolved;
238
255
  const spinner = ora(`Running 'locize ${command}'...\n`).start();
239
256
  let effectiveConfig = config;
240
257
  try {
241
258
  // 1. First attempt
242
- const initialArgs = buildArgs(command, effectiveConfig, cliOptions);
243
- console.log(styleText('cyan', `\nRunning 'locize ${maskArgs(initialArgs).join(' ')}'...`));
244
- const result = await execa('locize', initialArgs, { stdio: 'pipe' });
259
+ const initialArgs = [...prefixArgs, ...buildArgs(command, effectiveConfig, cliOptions)];
260
+ console.log(styleText('cyan', `\nRunning 'locize ${maskArgs(initialArgs.slice(prefixArgs.length)).join(' ')}'...`));
261
+ const result = await execa(cmd, initialArgs, { stdio: 'pipe' });
245
262
  spinner.succeed(styleText('green', `'locize ${command}' completed successfully.`));
246
263
  if (result?.stdout)
247
264
  console.log(result.stdout); // Print captured output on success
@@ -257,9 +274,9 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
257
274
  spinner.start('Retrying with new credentials...');
258
275
  try {
259
276
  // 3. Retry attempt, rebuilding args with the NOW-UPDATED currentConfig object
260
- const retryArgs = buildArgs(command, effectiveConfig, cliOptions);
261
- console.log(styleText('cyan', `\nRunning 'locize ${maskArgs(retryArgs).join(' ')}'...`));
262
- const result = await execa('locize', retryArgs, { stdio: 'pipe' });
277
+ const retryArgs = [...prefixArgs, ...buildArgs(command, effectiveConfig, cliOptions)];
278
+ console.log(styleText('cyan', `\nRunning 'locize ${maskArgs(retryArgs.slice(prefixArgs.length)).join(' ')}'...`));
279
+ const result = await execa(cmd, retryArgs, { stdio: 'pipe' });
263
280
  spinner.succeed(styleText('green', 'Retry successful!'));
264
281
  if (result?.stdout)
265
282
  console.log(result.stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.47.8",
3
+ "version": "1.47.10",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CA8MjC;AAinDD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,IAAI,CAAC,CAoDf"}
1
+ {"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAoNjC;AA4wDD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,IAAI,CAAC,CAoDf"}
@@ -1 +1 @@
1
- {"version":3,"file":"locize.d.ts","sourceRoot":"","sources":["../src/locize.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AA8RnD,eAAO,MAAM,aAAa,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAiD,CAAA;AAC7H,eAAO,MAAM,iBAAiB,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAqD,CAAA;AACrI,eAAO,MAAM,gBAAgB,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAoD,CAAA"}
1
+ {"version":3,"file":"locize.d.ts","sourceRoot":"","sources":["../src/locize.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAgTnD,eAAO,MAAM,aAAa,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAiD,CAAA;AAC7H,eAAO,MAAM,iBAAiB,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAqD,CAAA;AACrI,eAAO,MAAM,gBAAgB,GAAI,QAAQ,oBAAoB,EAAE,aAAa,GAAG,kBAAoD,CAAA"}