i18next-cli 1.47.7 → 1.47.9

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/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.7'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.47.9'); // 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
  }
@@ -454,12 +459,41 @@ function addComponentFromFunctionNode(name, fnNode, content, components) {
454
459
  // Non-translatable JSX attributes are defined in utils/jsx-attributes.ts
455
460
  // and shared with the linter. The instrumenter uses `ignoredAttributeSet`
456
461
  // to skip recursing into non-translatable attribute values.
462
+ /**
463
+ * Returns true when the AST node is a `t(...)` or `i18next.t(...)` call
464
+ * expression — i.e. code that was already instrumented.
465
+ */
466
+ function isTranslationCall(node) {
467
+ const callee = node.callee;
468
+ if (!callee)
469
+ return false;
470
+ // t(...)
471
+ if (callee.type === 'Identifier' && callee.value === 't')
472
+ return true;
473
+ // i18next.t(...)
474
+ if (callee.type === 'MemberExpression' &&
475
+ !callee.computed &&
476
+ callee.property?.type === 'Identifier' &&
477
+ callee.property.value === 't' &&
478
+ callee.object?.type === 'Identifier' &&
479
+ callee.object.value === 'i18next')
480
+ return true;
481
+ return false;
482
+ }
457
483
  /**
458
484
  * Recursively visits AST nodes to find string literals.
459
485
  */
460
486
  function visitNodeForStrings(node, content, file, config, candidates) {
461
487
  if (!node)
462
488
  return;
489
+ // Skip already-instrumented t() / i18next.t() calls entirely so that
490
+ // strings inside the options object (defaultValue_one, etc.) are not
491
+ // picked up as new candidates on a second run.
492
+ if (node.type === 'CallExpression' && isTranslationCall(node))
493
+ return;
494
+ // Skip <Trans> elements (already instrumented)
495
+ if (node.type === 'JSXElement' && isTransComponent(node))
496
+ return;
463
497
  // Skip non-translatable JSX attributes entirely (e.g. className={...})
464
498
  if (node.type === 'JSXAttribute') {
465
499
  const nameNode = node.name;
@@ -633,6 +667,9 @@ function resolveExpressionName(expr, content, usedNames) {
633
667
  function detectJSXInterpolation(node, content, file, config, candidates) {
634
668
  if (!node)
635
669
  return;
670
+ // Skip <Trans> elements (already instrumented)
671
+ if (node.type === 'JSXElement' && isTransComponent(node))
672
+ return;
636
673
  const children = (node.type === 'JSXElement' || node.type === 'JSXFragment') ? node.children : null;
637
674
  if (children?.length > 1) {
638
675
  // Build "runs" of consecutive JSXText + simple-expression containers
@@ -651,6 +688,10 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
651
688
  // Plural ternary expression — include in the run for merged handling
652
689
  currentRun.push(child);
653
690
  }
691
+ else if (child.type === 'JSXElement' && isSimpleJSXElement(child)) {
692
+ // Simple HTML element — include in the run for Trans detection
693
+ currentRun.push(child);
694
+ }
654
695
  else {
655
696
  // JSXElement, complex expression, etc. — break the run
656
697
  if (currentRun.length > 0) {
@@ -665,7 +706,11 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
665
706
  for (const run of runs) {
666
707
  const hasText = run.some(c => c.type === 'JSXText' && c.value?.trim());
667
708
  const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
668
- if (!hasText || !hasExpr || run.length < 2)
709
+ const hasElement = run.some(c => c.type === 'JSXElement');
710
+ // Require at least one text node plus either an expression or element
711
+ if (!hasText || run.length < 2)
712
+ continue;
713
+ if (!hasExpr && !hasElement)
669
714
  continue;
670
715
  // Check if any expression container in this run is a plural ternary
671
716
  let pluralChild = null;
@@ -681,7 +726,77 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
681
726
  }
682
727
  }
683
728
  }
684
- if (pluralChild && pluralData) {
729
+ if (hasElement) {
730
+ // ── JSX sibling run with nested HTML elements → <Trans> ──
731
+ const spanStart = run[0].span.start;
732
+ const spanEnd = run[run.length - 1].span.end;
733
+ // Build the translation string (with indexed tags) and text-only version (for scoring)
734
+ const usedNames = new Set();
735
+ const interpolations = [];
736
+ let transValue = '';
737
+ let textOnly = '';
738
+ let transContent = '';
739
+ let childIndex = 0;
740
+ let valid = true;
741
+ for (const child of run) {
742
+ if (child.type === 'JSXText') {
743
+ const raw = content.slice(child.span.start, child.span.end);
744
+ transValue += raw;
745
+ textOnly += raw;
746
+ transContent += raw;
747
+ childIndex++;
748
+ }
749
+ else if (child.type === 'JSXExpressionContainer') {
750
+ const info = resolveExpressionName(child.expression, content, usedNames);
751
+ if (!info) {
752
+ valid = false;
753
+ break;
754
+ }
755
+ transValue += `{{${info.name}}}`;
756
+ textOnly += info.name;
757
+ // In <Trans> children, simple expressions become {{ obj }} syntax
758
+ const objExpr = info.name === info.expression ? info.name : `${info.name}: ${info.expression}`;
759
+ transContent += `{{ ${objExpr} }}`;
760
+ interpolations.push(info);
761
+ childIndex++;
762
+ }
763
+ else if (child.type === 'JSXElement') {
764
+ const innerText = getJSXElementTextContent(child, content);
765
+ transValue += `<${childIndex}>${innerText}</${childIndex}>`;
766
+ textOnly += innerText;
767
+ // Keep the original JSX element source for the <Trans> children
768
+ transContent += content.slice(child.span.start, child.span.end);
769
+ childIndex++;
770
+ }
771
+ }
772
+ if (!valid)
773
+ continue;
774
+ const trimmedText = textOnly.trim();
775
+ const trimmedTransValue = transValue.trim();
776
+ if (!trimmedText || !trimmedTransValue)
777
+ continue;
778
+ const candidate = stringDetector.detectCandidate(trimmedText, spanStart, spanEnd, file, content, config);
779
+ if (candidate) {
780
+ candidate.type = 'jsx-mixed';
781
+ candidate.content = transContent.trim();
782
+ candidate.transValue = trimmedTransValue;
783
+ if (interpolations.length > 0) {
784
+ candidate.interpolations = interpolations;
785
+ }
786
+ // Mixed text + elements in JSX is almost always user-facing
787
+ candidate.confidence = Math.min(1, candidate.confidence + 0.25);
788
+ if (candidate.confidence >= 0.7) {
789
+ // Remove individual candidates that overlap with the merged span
790
+ for (let i = candidates.length - 1; i >= 0; i--) {
791
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
792
+ candidates.splice(i, 1);
793
+ }
794
+ }
795
+ candidates.push(candidate);
796
+ }
797
+ }
798
+ }
799
+ else if (pluralChild && pluralData) {
685
800
  // ── JSX sibling run with embedded plural ternary ──
686
801
  const countExpr = pluralData.countExpression;
687
802
  // Resolve names for non-count, non-plural expressions
@@ -768,7 +883,7 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
768
883
  }
769
884
  }
770
885
  else {
771
- // ── Original JSX sibling merging (no plural) ──
886
+ // ── Original JSX sibling merging (text + expressions, no elements) ──
772
887
  // Build the interpolated text from the run
773
888
  const usedNames = new Set();
774
889
  const interpolations = [];
@@ -841,6 +956,59 @@ function isSimpleJSXExpression(expr) {
841
956
  return true;
842
957
  return false;
843
958
  }
959
+ /**
960
+ * Returns true when a JSXElement is "simple" enough to be included in a
961
+ * `<Trans>` JSX sibling run. Accepts:
962
+ * - Self-closing elements (`<br />`, `<img />`)
963
+ * - Elements whose only children are `JSXText` nodes
964
+ * Only HTML-like elements (lowercase tag name) are accepted; React
965
+ * components (uppercase, e.g. `<Button />`) break the run.
966
+ */
967
+ function isSimpleJSXElement(node) {
968
+ if (node.type !== 'JSXElement')
969
+ return false;
970
+ const namePart = node.opening?.name;
971
+ if (!namePart)
972
+ return false;
973
+ // Only include HTML-like elements (lowercase first char)
974
+ let tagName = null;
975
+ if (namePart.type === 'Identifier') {
976
+ tagName = namePart.value;
977
+ }
978
+ if (!tagName || tagName[0] !== tagName[0].toLowerCase())
979
+ return false;
980
+ // Self-closing elements are simple
981
+ if (node.opening?.selfClosing)
982
+ return true;
983
+ // Elements with only text children (or empty) are simple
984
+ const children = node.children || [];
985
+ return children.length === 0 || children.every((c) => c.type === 'JSXText');
986
+ }
987
+ /**
988
+ * Returns the text content of a simple JSXElement's children.
989
+ */
990
+ function getJSXElementTextContent(node, content) {
991
+ const children = node.children || [];
992
+ return children
993
+ .filter((c) => c.type === 'JSXText')
994
+ .map((c) => content.slice(c.span.start, c.span.end))
995
+ .join('');
996
+ }
997
+ /**
998
+ * Returns true when a JSXElement is a `<Trans>` component
999
+ * (already instrumented content).
1000
+ */
1001
+ function isTransComponent(node) {
1002
+ const opening = node.opening;
1003
+ if (!opening)
1004
+ return false;
1005
+ const name = opening.name;
1006
+ if (name?.type === 'Identifier' && name.value === 'Trans')
1007
+ return true;
1008
+ if (name?.type === 'JSXMemberExpression' && name.property?.type === 'Identifier' && name.property.value === 'Trans')
1009
+ return true;
1010
+ return false;
1011
+ }
844
1012
  // ─── Plural conditional pattern detection ────────────────────────────────────
845
1013
  /**
846
1014
  * Extracts the text value from a string literal or static template literal node.
@@ -1221,6 +1389,70 @@ async function isProjectUsingReact() {
1221
1389
  return false;
1222
1390
  }
1223
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
+ }
1224
1456
  /**
1225
1457
  * Checks if the project uses TypeScript (looks for tsconfig.json).
1226
1458
  */
@@ -1271,14 +1503,12 @@ function buildDynamicImportPath(outputTemplate, initDir) {
1271
1503
  * Ensures that an i18n initialization file exists in the project.
1272
1504
  * If no existing init file is found, generates a sensible default.
1273
1505
  *
1274
- * When extract.output is a string template, the generated file uses
1275
- * i18next-resources-to-backend with a dynamic import derived from the
1276
- * output path making i18next ready to load translations out of the box.
1277
- *
1278
- * For React projects: creates i18n.ts with react-i18next integration.
1279
- * 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)
1280
1510
  */
1281
- async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1511
+ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger, usesI18nextT) {
1282
1512
  const cwd = process.cwd();
1283
1513
  // Check for existing init files in common locations
1284
1514
  const searchDirs = ['src', '.'];
@@ -1312,125 +1542,17 @@ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1312
1542
  const initDir = srcExists ? node_path.join(cwd, 'src') : cwd;
1313
1543
  const initFileExt = hasTypeScript ? '.ts' : '.js';
1314
1544
  const initFilePath = node_path.join(initDir, 'i18n' + initFileExt);
1315
- const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1316
- const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1317
- // Build the .init({...}) options block
1318
- const initOptions = [
1319
- ' returnEmptyString: false, // allows empty string as valid translation',
1320
- ` // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user`,
1321
- ` fallbackLng: '${primaryLang}'`,
1322
- ];
1323
- if (defaultNS) {
1324
- initOptions.push(` defaultNS: '${defaultNS}'`);
1325
- }
1326
- const initBlock = initOptions.join(',\n');
1327
- let initContent;
1328
- if (typeof config.extract.output === 'string') {
1329
- // Derive a dynamic import path so the generated init file loads translations automatically
1330
- const dynamicImportPath = buildDynamicImportPath(config.extract.output, initDir);
1331
- const importPathTemplate = dynamicImportPath
1332
- // eslint-disable-next-line no-template-curly-in-string
1333
- .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1334
- // eslint-disable-next-line no-template-curly-in-string
1335
- .replace(/\{\{namespace\}\}/g, '${namespace}');
1336
- const hasNamespace = config.extract.output.includes('{{namespace}}');
1337
- const callbackParams = hasNamespace ? hasTypeScript ? 'language: string, namespace: string' : 'language, namespace' : hasTypeScript ? 'language: string' : 'language';
1338
- const backendUseLine = ' .use(resourcesToBackend((' + callbackParams + ') => import(`' + importPathTemplate + '`)))';
1339
- if (hasReact) {
1340
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1341
- // You may need to install dependencies: npm install i18next react-i18next i18next-resources-to-backend
1342
- //
1343
- // Other translation loading approaches:
1344
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1345
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1346
- // • Manage translations with your team via Locize: https://www.locize.com
1347
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1348
- import i18next from 'i18next'
1349
- import { initReactI18next } from 'react-i18next'
1350
- import resourcesToBackend from 'i18next-resources-to-backend'
1351
-
1352
- i18next
1353
- .use(initReactI18next)
1354
- ${backendUseLine}
1355
- .init({
1356
- ${initBlock}
1357
- })
1358
-
1359
- export default i18next
1360
- `;
1361
- }
1362
- else {
1363
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1364
- // You may need to install the dependency: npm install i18next i18next-resources-to-backend
1365
- //
1366
- // Other translation loading approaches:
1367
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1368
- // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1369
- // • Manage translations with your team via Locize: https://www.locize.com
1370
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1371
- import i18next from 'i18next'
1372
- import resourcesToBackend from 'i18next-resources-to-backend'
1373
-
1374
- i18next
1375
- ${backendUseLine}
1376
- .init({
1377
- ${initBlock}
1378
- })
1379
-
1380
- export default i18next
1381
- `;
1382
- }
1383
- }
1384
- else {
1385
- // Output is a function — can't derive import path, fall back to comments only
1386
- if (hasReact) {
1387
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1388
- // You may need to install dependencies: npm install i18next react-i18next
1389
- //
1390
- // Loading translations:
1391
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1392
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1393
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1394
- // • Manage translations with your team via Locize: https://www.locize.com
1395
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1396
- import i18next from 'i18next'
1397
- import { initReactI18next } from 'react-i18next'
1398
-
1399
- i18next
1400
- .use(initReactI18next)
1401
- .init({
1402
- returnEmptyString: false, // allows empty string as valid translation
1403
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1404
- fallbackLng: '${primaryLang}'
1405
- // resources: { ... } — or use a backend plugin to load translations
1406
- })
1407
-
1408
- export default i18next
1409
- `;
1410
- }
1411
- else {
1412
- initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1413
- // You may need to install the dependency: npm install i18next
1414
- //
1415
- // Loading translations:
1416
- // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1417
- // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1418
- // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1419
- // • Manage translations with your team via Locize: https://www.locize.com
1420
- // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1421
- import i18next from 'i18next'
1422
-
1423
- i18next.init({
1424
- returnEmptyString: false, // allows empty string as valid translation
1425
- // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1426
- fallbackLng: '${primaryLang}'
1427
- // resources: { ... } — or use a backend plugin to load translations
1428
- })
1429
-
1430
- export default i18next
1431
- `;
1432
- }
1433
- }
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
+ });
1434
1556
  try {
1435
1557
  await promises.mkdir(initDir, { recursive: true });
1436
1558
  await promises.writeFile(initFilePath, initContent);
@@ -1442,6 +1564,171 @@ export default i18next
1442
1564
  return null;
1443
1565
  }
1444
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
+ }
1445
1732
  /**
1446
1733
  * Common entry-point file names, checked in priority order.
1447
1734
  */
@@ -1565,7 +1852,7 @@ async function writeExtractedKeys(candidates, config, namespace, logger$1 = new
1565
1852
  translations[`${candidate.key}_other`] = pf.other;
1566
1853
  }
1567
1854
  else {
1568
- translations[candidate.key] = candidate.content;
1855
+ translations[candidate.key] = candidate.transValue ?? candidate.content;
1569
1856
  }
1570
1857
  }
1571
1858
  }