i18next-cli 1.72.2 → 1.72.4

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
@@ -609,6 +609,8 @@ The command walks through six steps:
609
609
  npx skills add i18next/i18next-cli
610
610
  ```
611
611
 
612
+ [![skills.sh installs](https://www.skills.sh/b/i18next/i18next-cli)](https://www.skills.sh/i18next/i18next-cli)
613
+
612
614
  Then just ask it to *"add i18n to this project"*. See [`skills/i18next-localization`](./skills/i18next-localization/SKILL.md) — it ships in this repo, so it stays version-matched to the commands it drives.
613
615
 
614
616
  **Agent prompt:** the same flow is also available as a copy-paste prompt for AI coding agents (Claude Code, Cursor, …):
package/dist/cjs/cli.js CHANGED
@@ -37,7 +37,8 @@ const program = new commander.Command();
37
37
  program
38
38
  .name('i18next-cli')
39
39
  .description('A unified, high-performance i18next CLI.')
40
- .version('1.72.2'); // This string is replaced with the actual version at build time by rollup
40
+ .version('1.72.4') // This string is replaced with the actual version at build time by rollup
41
+ .addHelpText('after', '\nUsing an AI coding agent? Install the i18next-localization skill: npx skills add i18next/i18next-cli\n');
41
42
  // new: global config override option
42
43
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
43
44
  program
@@ -13,8 +13,10 @@ var commentParser = require('../parsers/comment-parser.js');
13
13
  var astUtils = require('../parsers/ast-utils.js');
14
14
  var logger = require('../../utils/logger.js');
15
15
  var fileUtils = require('../../utils/file-utils.js');
16
- var funnelMsgTracker = require('../../utils/funnel-msg-tracker.js');
16
+ var locizeFunnel = require('../../utils/locize-funnel.js');
17
+ var nestedObject = require('../../utils/nested-object.js');
17
18
 
19
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__extract';
18
20
  /**
19
21
  * Main extractor function that runs the complete key extraction and file generation process.
20
22
  *
@@ -91,8 +93,16 @@ async function runExtractor(config, options = {}) {
91
93
  : node_util.styleText('bold', 'Extraction complete!');
92
94
  spinner.succeed(completionMessage);
93
95
  // Show the funnel message only if files were actually changed.
94
- if (anyFileUpdated && !options.isDryRun && !options.quiet)
95
- await printLocizeFunnel(options.logger);
96
+ if (anyFileUpdated && !options.isDryRun && !options.quiet) {
97
+ const keySeparator = config.extract.keySeparator ?? '.';
98
+ const countEmpty = (obj) => nestedObject.getNestedKeys(obj, keySeparator).filter(key => nestedObject.getNestedValue(obj, key, keySeparator) === '').length;
99
+ const gaps = (config.extract.secondaryLanguages ?? []).map(locale => ({
100
+ locale,
101
+ untranslated: results.filter(r => r.locale === locale).reduce((sum, r) => sum + countEmpty(r.newTranslations), 0)
102
+ }));
103
+ const log = typeof internalLogger.info === 'function' ? (msg) => internalLogger.info(msg) : undefined;
104
+ await locizeFunnel.printUntranslatedFunnel('extract', gaps, LOCIZE_SIGNUP_URL, log);
105
+ }
96
106
  return { anyFileUpdated, hasErrors: fileErrors.length > 0, results };
97
107
  }
98
108
  catch (error) {
@@ -383,33 +393,6 @@ async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
383
393
  const { allKeys, objectKeys } = await keyFinder.findKeys(config);
384
394
  return translationManager.getTranslations(allKeys, objectKeys, config, { syncPrimaryWithDefaults });
385
395
  }
386
- /**
387
- * Prints a promotional message for the locize saveMissing workflow.
388
- * This message is shown after a successful extraction that resulted in changes.
389
- */
390
- async function printLocizeFunnel(logger$1) {
391
- if (!(await funnelMsgTracker.shouldShowFunnel('extract')))
392
- return;
393
- const internalLogger = logger$1 ?? new logger.ConsoleLogger();
394
- const lines = [
395
- node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'),
396
- ' Discover a real-time "push" workflow with `saveMissing` and Locize AI/MT,',
397
- ' where keys are created and translated automatically as you code.',
398
- ` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`,
399
- ` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`,
400
- '',
401
- ' You can also sync your extracted translations to Locize:',
402
- ` ${node_util.styleText('cyan', 'npx i18next-cli locize-sync')} – upload/sync translations to Locize`,
403
- ` ${node_util.styleText('cyan', 'npx i18next-cli locize-migrate')} – migrate local translations to Locize`,
404
- ' Or import them manually via the Locize UI, API, or locize-cli.',
405
- ];
406
- const log = typeof internalLogger.info === 'function'
407
- ? (msg) => internalLogger.info(msg)
408
- : (msg) => console.log(msg);
409
- for (const line of lines)
410
- log(line);
411
- return funnelMsgTracker.recordFunnelShown('extract');
412
- }
413
396
 
414
397
  exports.extract = extract;
415
398
  exports.preScanFile = preScanFile;
@@ -13,11 +13,13 @@ var nesting = require('./utils/nesting.js');
13
13
  var contextVariants = require('./utils/context-variants.js');
14
14
  require('node:module');
15
15
  var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
16
+ var locizeFunnel = require('./utils/locize-funnel.js');
16
17
 
17
18
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
18
19
 
19
20
  var ora__default = /*#__PURE__*/_interopDefault(ora);
20
21
 
22
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__status';
21
23
  function globToRegex(glob) {
22
24
  const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
23
25
  return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
@@ -693,7 +695,11 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
693
695
  else {
694
696
  console.log(node_util.styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
695
697
  }
696
- await printLocizeFunnel();
698
+ // Untranslated keys in a secondary locale are the gap Locize fills; absent
699
+ // keys in the primary language are an `extract` problem (handled above).
700
+ const nsData = namespaceFilter ? localeData.namespaces.get(namespaceFilter) : undefined;
701
+ const untranslated = nsData ? nsData.totalKeys - nsData.translatedKeys : missingCount;
702
+ await locizeFunnel.printUntranslatedFunnel('status', isPrimary ? [] : [{ locale, untranslated }], LOCIZE_SIGNUP_URL);
697
703
  }
698
704
  /**
699
705
  * Displays a summary report filtered by a single namespace.
@@ -728,7 +734,11 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
728
734
  const { primaryLanguage } = config.extract;
729
735
  console.log(node_util.styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${primaryNsData.absentKeys} key(s) that are used in code.`));
730
736
  }
731
- await printLocizeFunnel();
737
+ const gaps = Array.from(report.locales, ([locale, localeData]) => {
738
+ const nsLocaleData = localeData.namespaces.get(namespace);
739
+ return { locale, untranslated: nsLocaleData ? nsLocaleData.totalKeys - nsLocaleData.translatedKeys : 0 };
740
+ });
741
+ await locizeFunnel.printUntranslatedFunnel('status', gaps, LOCIZE_SIGNUP_URL);
732
742
  }
733
743
  /**
734
744
  * Displays the default, high-level summary report for all locales.
@@ -762,7 +772,8 @@ async function displayOverallSummaryReport(report, config) {
762
772
  console.log(node_util.styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${report.primary.totalAbsent} key(s) that are used in code.`));
763
773
  console.log(node_util.styleText('red', ` Run "i18next-cli status ${primaryLanguage}" for details, or "i18next-cli extract" to add them.`));
764
774
  }
765
- await printLocizeFunnel();
775
+ const gaps = Array.from(report.locales, ([locale, localeData]) => ({ locale, untranslated: localeData.totalKeys - localeData.totalTranslated }));
776
+ await locizeFunnel.printUntranslatedFunnel('status', gaps, LOCIZE_SIGNUP_URL);
766
777
  }
767
778
  /**
768
779
  * Prints a formatted progress bar with label, percentage, and counts.
@@ -872,14 +883,6 @@ async function runUnusedReport(config, options = {}) {
872
883
  process.exit(1);
873
884
  }
874
885
  }
875
- async function printLocizeFunnel() {
876
- if (!(await funnelMsgTracker.shouldShowFunnel('status')))
877
- return;
878
- console.log(node_util.styleText(['yellow', 'bold'], '\n✨ Take your localization to the next level!'));
879
- console.log('Manage translations with your team in the cloud with Locize => https://www.locize.com/docs/getting-started');
880
- console.log(`Run ${node_util.styleText('cyan', 'npx i18next-cli locize-migrate')} to get started.`);
881
- return funnelMsgTracker.recordFunnelShown('status');
882
- }
883
886
 
884
887
  exports.runStatus = runStatus;
885
888
  exports.runUnusedReport = runUnusedReport;
@@ -8,11 +8,12 @@ var wrapOra = require('./utils/wrap-ora.js');
8
8
  var logger = require('./utils/logger.js');
9
9
  var defaultValue = require('./utils/default-value.js');
10
10
  var fileUtils = require('./utils/file-utils.js');
11
- var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
11
+ var locizeFunnel = require('./utils/locize-funnel.js');
12
12
  var nestedObject = require('./utils/nested-object.js');
13
13
  var pluralRules = require('./utils/plural-rules.js');
14
14
  var gitChangedKeys = require('./utils/git-changed-keys.js');
15
15
 
16
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__sync';
16
17
  /**
17
18
  * Synchronizes translation files across different locales by ensuring all secondary
18
19
  * language files contain the same keys as the primary language file.
@@ -56,6 +57,8 @@ async function runSyncer(config, options = {}) {
56
57
  const { output, keySeparator = '.', outputFormat = 'json', indentation = 2, defaultValue: defaultValue$1 = '', } = config.extract;
57
58
  const logMessages = [];
58
59
  let wasAnythingSynced = false;
60
+ // Keys still empty after the sync, per secondary locale (the funnel line's gap)
61
+ const untranslatedByLocale = {};
59
62
  // 1. Find all namespace files for the primary language
60
63
  const primaryNsPatternRaw = fileUtils.getOutputPath(output, primaryLanguage, '*');
61
64
  // Ensure glob receives POSIX-style separators so pattern matching works cross-platform (Windows -> backslashes)
@@ -216,6 +219,8 @@ async function runSyncer(config, options = {}) {
216
219
  nestedObject.setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
217
220
  }
218
221
  }
222
+ untranslatedByLocale[lang] = (untranslatedByLocale[lang] ?? 0) + nestedObject.getNestedKeys(newSecondaryTranslations, keySeparator ?? '.')
223
+ .filter(key => nestedObject.getNestedValue(newSecondaryTranslations, key, keySeparator ?? '.') === '').length;
219
224
  // Use JSON.stringify for a reliable object comparison, regardless of format
220
225
  const oldContent = JSON.stringify(existingSecondaryTranslations);
221
226
  const newContent = JSON.stringify(newSecondaryTranslations);
@@ -236,7 +241,8 @@ async function runSyncer(config, options = {}) {
236
241
  spinner.succeed(node_util.styleText('bold', 'Synchronization complete!'));
237
242
  logMessages.forEach(msg => internalLogger.info ? internalLogger.info(msg) : console.log(msg));
238
243
  if (wasAnythingSynced && !options.quiet) {
239
- await printLocizeFunnel();
244
+ const gaps = Object.entries(untranslatedByLocale).map(([locale, untranslated]) => ({ locale, untranslated }));
245
+ await locizeFunnel.printUntranslatedFunnel('syncer', gaps, LOCIZE_SIGNUP_URL);
240
246
  }
241
247
  else if (!wasAnythingSynced) {
242
248
  if (typeof internalLogger.info === 'function')
@@ -253,13 +259,5 @@ async function runSyncer(config, options = {}) {
253
259
  console.error(error);
254
260
  }
255
261
  }
256
- async function printLocizeFunnel() {
257
- if (!(await funnelMsgTracker.shouldShowFunnel('syncer')))
258
- return;
259
- console.log(node_util.styleText(['green', 'bold'], '\n✅ Sync complete.'));
260
- console.log(node_util.styleText('yellow', '🚀 Ready to collaborate with translators? Move your files to the cloud.'));
261
- console.log(` Get started with the official TMS for i18next: ${node_util.styleText('cyan', 'npx i18next-cli locize-migrate')}`);
262
- return funnelMsgTracker.recordFunnelShown('syncer');
263
- }
264
262
 
265
263
  exports.runSyncer = runSyncer;
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ var node_util = require('node:util');
4
+ var funnelMsgTracker = require('./funnel-msg-tracker.js');
5
+
6
+ /**
7
+ * Prints the one-line Locize funnel for commands that have just reported
8
+ * untranslated keys: the gap, the one command that fills it, and the
9
+ * register link tagged `?from=i18next_cli__<command>` so registrations stay
10
+ * attributable per command.
11
+ *
12
+ * Prints nothing when there is no gap, and is gated like every other funnel
13
+ * message (never in CI/non-TTY, once per 24h per `funnelKey`).
14
+ */
15
+ async function printUntranslatedFunnel(funnelKey, gaps, signupUrl, log = console.log) {
16
+ const open = gaps.filter(g => g.untranslated > 0);
17
+ const total = open.reduce((sum, g) => sum + g.untranslated, 0);
18
+ if (total === 0)
19
+ return;
20
+ if (!(await funnelMsgTracker.shouldShowFunnel(funnelKey)))
21
+ return;
22
+ const where = open.length === 1
23
+ ? `in ${open[0].locale}`
24
+ : `(${open.map(g => `${g.locale} ${g.untranslated}`).join(', ')})`;
25
+ log(`\n💡 ${total} untranslated key${total === 1 ? '' : 's'} ${where}. Translate them with AI in one command: ${node_util.styleText('cyan', 'npx i18next-cli localize')} (sign up: ${signupUrl})`);
26
+ return funnelMsgTracker.recordFunnelShown(funnelKey);
27
+ }
28
+
29
+ exports.printUntranslatedFunnel = printUntranslatedFunnel;
package/dist/esm/cli.js CHANGED
@@ -31,7 +31,8 @@ const program = new Command();
31
31
  program
32
32
  .name('i18next-cli')
33
33
  .description('A unified, high-performance i18next CLI.')
34
- .version('1.72.2'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.72.4') // This string is replaced with the actual version at build time by rollup
35
+ .addHelpText('after', '\nUsing an AI coding agent? Install the i18next-localization skill: npx skills add i18next/i18next-cli\n');
35
36
  // new: global config override option
36
37
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
37
38
  program
@@ -11,8 +11,10 @@ import { extractKeysFromComments } from '../parsers/comment-parser.js';
11
11
  import { normalizeSpansToCharIndices } from '../parsers/ast-utils.js';
12
12
  import { ConsoleLogger } from '../../utils/logger.js';
13
13
  import { inferFormatFromPath, loadRawJson5Content, serializeTranslationFile } from '../../utils/file-utils.js';
14
- import { shouldShowFunnel, recordFunnelShown } from '../../utils/funnel-msg-tracker.js';
14
+ import { printUntranslatedFunnel } from '../../utils/locize-funnel.js';
15
+ import { getNestedKeys, getNestedValue } from '../../utils/nested-object.js';
15
16
 
17
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__extract';
16
18
  /**
17
19
  * Main extractor function that runs the complete key extraction and file generation process.
18
20
  *
@@ -89,8 +91,16 @@ async function runExtractor(config, options = {}) {
89
91
  : styleText('bold', 'Extraction complete!');
90
92
  spinner.succeed(completionMessage);
91
93
  // Show the funnel message only if files were actually changed.
92
- if (anyFileUpdated && !options.isDryRun && !options.quiet)
93
- await printLocizeFunnel(options.logger);
94
+ if (anyFileUpdated && !options.isDryRun && !options.quiet) {
95
+ const keySeparator = config.extract.keySeparator ?? '.';
96
+ const countEmpty = (obj) => getNestedKeys(obj, keySeparator).filter(key => getNestedValue(obj, key, keySeparator) === '').length;
97
+ const gaps = (config.extract.secondaryLanguages ?? []).map(locale => ({
98
+ locale,
99
+ untranslated: results.filter(r => r.locale === locale).reduce((sum, r) => sum + countEmpty(r.newTranslations), 0)
100
+ }));
101
+ const log = typeof internalLogger.info === 'function' ? (msg) => internalLogger.info(msg) : undefined;
102
+ await printUntranslatedFunnel('extract', gaps, LOCIZE_SIGNUP_URL, log);
103
+ }
94
104
  return { anyFileUpdated, hasErrors: fileErrors.length > 0, results };
95
105
  }
96
106
  catch (error) {
@@ -381,32 +391,5 @@ async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
381
391
  const { allKeys, objectKeys } = await findKeys(config);
382
392
  return getTranslations(allKeys, objectKeys, config, { syncPrimaryWithDefaults });
383
393
  }
384
- /**
385
- * Prints a promotional message for the locize saveMissing workflow.
386
- * This message is shown after a successful extraction that resulted in changes.
387
- */
388
- async function printLocizeFunnel(logger) {
389
- if (!(await shouldShowFunnel('extract')))
390
- return;
391
- const internalLogger = logger ?? new ConsoleLogger();
392
- const lines = [
393
- styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'),
394
- ' Discover a real-time "push" workflow with `saveMissing` and Locize AI/MT,',
395
- ' where keys are created and translated automatically as you code.',
396
- ` Learn more: ${styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`,
397
- ` Watch the video: ${styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`,
398
- '',
399
- ' You can also sync your extracted translations to Locize:',
400
- ` ${styleText('cyan', 'npx i18next-cli locize-sync')} – upload/sync translations to Locize`,
401
- ` ${styleText('cyan', 'npx i18next-cli locize-migrate')} – migrate local translations to Locize`,
402
- ' Or import them manually via the Locize UI, API, or locize-cli.',
403
- ];
404
- const log = typeof internalLogger.info === 'function'
405
- ? (msg) => internalLogger.info(msg)
406
- : (msg) => console.log(msg);
407
- for (const line of lines)
408
- log(line);
409
- return recordFunnelShown('extract');
410
- }
411
394
 
412
395
  export { extract, preScanFile, processFile, runExtractor };
@@ -11,7 +11,9 @@ import { parseNestedReferences } from './utils/nesting.js';
11
11
  import { isContextVariantOfAcceptingKey } from './utils/context-variants.js';
12
12
  import 'node:module';
13
13
  import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
14
+ import { printUntranslatedFunnel } from './utils/locize-funnel.js';
14
15
 
16
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__status';
15
17
  function globToRegex(glob) {
16
18
  const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
17
19
  return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
@@ -687,7 +689,11 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
687
689
  else {
688
690
  console.log(styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
689
691
  }
690
- await printLocizeFunnel();
692
+ // Untranslated keys in a secondary locale are the gap Locize fills; absent
693
+ // keys in the primary language are an `extract` problem (handled above).
694
+ const nsData = namespaceFilter ? localeData.namespaces.get(namespaceFilter) : undefined;
695
+ const untranslated = nsData ? nsData.totalKeys - nsData.translatedKeys : missingCount;
696
+ await printUntranslatedFunnel('status', isPrimary ? [] : [{ locale, untranslated }], LOCIZE_SIGNUP_URL);
691
697
  }
692
698
  /**
693
699
  * Displays a summary report filtered by a single namespace.
@@ -722,7 +728,11 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
722
728
  const { primaryLanguage } = config.extract;
723
729
  console.log(styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${primaryNsData.absentKeys} key(s) that are used in code.`));
724
730
  }
725
- await printLocizeFunnel();
731
+ const gaps = Array.from(report.locales, ([locale, localeData]) => {
732
+ const nsLocaleData = localeData.namespaces.get(namespace);
733
+ return { locale, untranslated: nsLocaleData ? nsLocaleData.totalKeys - nsLocaleData.translatedKeys : 0 };
734
+ });
735
+ await printUntranslatedFunnel('status', gaps, LOCIZE_SIGNUP_URL);
726
736
  }
727
737
  /**
728
738
  * Displays the default, high-level summary report for all locales.
@@ -756,7 +766,8 @@ async function displayOverallSummaryReport(report, config) {
756
766
  console.log(styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${report.primary.totalAbsent} key(s) that are used in code.`));
757
767
  console.log(styleText('red', ` Run "i18next-cli status ${primaryLanguage}" for details, or "i18next-cli extract" to add them.`));
758
768
  }
759
- await printLocizeFunnel();
769
+ const gaps = Array.from(report.locales, ([locale, localeData]) => ({ locale, untranslated: localeData.totalKeys - localeData.totalTranslated }));
770
+ await printUntranslatedFunnel('status', gaps, LOCIZE_SIGNUP_URL);
760
771
  }
761
772
  /**
762
773
  * Prints a formatted progress bar with label, percentage, and counts.
@@ -866,13 +877,5 @@ async function runUnusedReport(config, options = {}) {
866
877
  process.exit(1);
867
878
  }
868
879
  }
869
- async function printLocizeFunnel() {
870
- if (!(await shouldShowFunnel('status')))
871
- return;
872
- console.log(styleText(['yellow', 'bold'], '\n✨ Take your localization to the next level!'));
873
- console.log('Manage translations with your team in the cloud with Locize => https://www.locize.com/docs/getting-started');
874
- console.log(`Run ${styleText('cyan', 'npx i18next-cli locize-migrate')} to get started.`);
875
- return recordFunnelShown('status');
876
- }
877
880
 
878
881
  export { runStatus, runUnusedReport };
@@ -6,11 +6,12 @@ import { createSpinnerLike } from './utils/wrap-ora.js';
6
6
  import { ConsoleLogger } from './utils/logger.js';
7
7
  import { resolveDefaultValue } from './utils/default-value.js';
8
8
  import { getOutputPath, loadTranslationFile, inferFormatFromPath, loadRawJson5Content, serializeTranslationFile } from './utils/file-utils.js';
9
- import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
9
+ import { printUntranslatedFunnel } from './utils/locize-funnel.js';
10
10
  import { getNestedKeys, getNestedValue, setNestedValue } from './utils/nested-object.js';
11
11
  import { safePluralRules } from './utils/plural-rules.js';
12
12
  import { resolveGitCompareRef, readFileAtRef, parseTranslationContent, makeChangedKeyMatcher } from './utils/git-changed-keys.js';
13
13
 
14
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__sync';
14
15
  /**
15
16
  * Synchronizes translation files across different locales by ensuring all secondary
16
17
  * language files contain the same keys as the primary language file.
@@ -54,6 +55,8 @@ async function runSyncer(config, options = {}) {
54
55
  const { output, keySeparator = '.', outputFormat = 'json', indentation = 2, defaultValue = '', } = config.extract;
55
56
  const logMessages = [];
56
57
  let wasAnythingSynced = false;
58
+ // Keys still empty after the sync, per secondary locale (the funnel line's gap)
59
+ const untranslatedByLocale = {};
57
60
  // 1. Find all namespace files for the primary language
58
61
  const primaryNsPatternRaw = getOutputPath(output, primaryLanguage, '*');
59
62
  // Ensure glob receives POSIX-style separators so pattern matching works cross-platform (Windows -> backslashes)
@@ -214,6 +217,8 @@ async function runSyncer(config, options = {}) {
214
217
  setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
215
218
  }
216
219
  }
220
+ untranslatedByLocale[lang] = (untranslatedByLocale[lang] ?? 0) + getNestedKeys(newSecondaryTranslations, keySeparator ?? '.')
221
+ .filter(key => getNestedValue(newSecondaryTranslations, key, keySeparator ?? '.') === '').length;
217
222
  // Use JSON.stringify for a reliable object comparison, regardless of format
218
223
  const oldContent = JSON.stringify(existingSecondaryTranslations);
219
224
  const newContent = JSON.stringify(newSecondaryTranslations);
@@ -234,7 +239,8 @@ async function runSyncer(config, options = {}) {
234
239
  spinner.succeed(styleText('bold', 'Synchronization complete!'));
235
240
  logMessages.forEach(msg => internalLogger.info ? internalLogger.info(msg) : console.log(msg));
236
241
  if (wasAnythingSynced && !options.quiet) {
237
- await printLocizeFunnel();
242
+ const gaps = Object.entries(untranslatedByLocale).map(([locale, untranslated]) => ({ locale, untranslated }));
243
+ await printUntranslatedFunnel('syncer', gaps, LOCIZE_SIGNUP_URL);
238
244
  }
239
245
  else if (!wasAnythingSynced) {
240
246
  if (typeof internalLogger.info === 'function')
@@ -251,13 +257,5 @@ async function runSyncer(config, options = {}) {
251
257
  console.error(error);
252
258
  }
253
259
  }
254
- async function printLocizeFunnel() {
255
- if (!(await shouldShowFunnel('syncer')))
256
- return;
257
- console.log(styleText(['green', 'bold'], '\n✅ Sync complete.'));
258
- console.log(styleText('yellow', '🚀 Ready to collaborate with translators? Move your files to the cloud.'));
259
- console.log(` Get started with the official TMS for i18next: ${styleText('cyan', 'npx i18next-cli locize-migrate')}`);
260
- return recordFunnelShown('syncer');
261
- }
262
260
 
263
261
  export { runSyncer };
@@ -0,0 +1,27 @@
1
+ import { styleText } from 'node:util';
2
+ import { shouldShowFunnel, recordFunnelShown } from './funnel-msg-tracker.js';
3
+
4
+ /**
5
+ * Prints the one-line Locize funnel for commands that have just reported
6
+ * untranslated keys: the gap, the one command that fills it, and the
7
+ * register link tagged `?from=i18next_cli__<command>` so registrations stay
8
+ * attributable per command.
9
+ *
10
+ * Prints nothing when there is no gap, and is gated like every other funnel
11
+ * message (never in CI/non-TTY, once per 24h per `funnelKey`).
12
+ */
13
+ async function printUntranslatedFunnel(funnelKey, gaps, signupUrl, log = console.log) {
14
+ const open = gaps.filter(g => g.untranslated > 0);
15
+ const total = open.reduce((sum, g) => sum + g.untranslated, 0);
16
+ if (total === 0)
17
+ return;
18
+ if (!(await shouldShowFunnel(funnelKey)))
19
+ return;
20
+ const where = open.length === 1
21
+ ? `in ${open[0].locale}`
22
+ : `(${open.map(g => `${g.locale} ${g.untranslated}`).join(', ')})`;
23
+ log(`\n💡 ${total} untranslated key${total === 1 ? '' : 's'} ${where}. Translate them with AI in one command: ${styleText('cyan', 'npx i18next-cli localize')} (sign up: ${signupUrl})`);
24
+ return recordFunnelShown(funnelKey);
25
+ }
26
+
27
+ export { printUntranslatedFunnel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.72.2",
3
+ "version": "1.72.4",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAqBnC,QAAA,MAAM,OAAO,SAAgB,CAAA;AA0d7B,OAAO,EAAE,OAAO,EAAE,CAAA"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAqBnC,QAAA,MAAM,OAAO,SAAgB,CAAA;AA2d7B,OAAO,EAAE,OAAO,EAAE,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"extractor.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/extractor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAO5G,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAK/C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IACP,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;CACX,GACL,OAAO,CAAC;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,iBAAiB,EAAE,CAAA;CAAE,CAAC,CAwExF;AAwBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EAAE,EACjB,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,MAAM,GAAE,MAA4B,EACpC,UAAU,CAAC,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CAiJf;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,WAAW,EACxB,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,MAAM,GAAE,MAA4B,EACpC,UAAU,CAAC,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CA4Df;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,OAAO,CAAE,MAAM,EAAE,oBAAoB,EAAE,EAAE,uBAA+B,EAAE,GAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAO1K"}
1
+ {"version":3,"file":"extractor.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/extractor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAO5G,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAQ/C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IACP,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;CACX,GACL,OAAO,CAAC;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,iBAAiB,EAAE,CAAA;CAAE,CAAC,CAiFxF;AAwBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EAAE,EACjB,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,MAAM,GAAE,MAA4B,EACpC,UAAU,CAAC,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CAiJf;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,WAAW,EACxB,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,MAAM,GAAE,MAA4B,EACpC,UAAU,CAAC,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CA4Df;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,OAAO,CAAE,MAAM,EAAE,oBAAoB,EAAE,EAAE,uBAA+B,EAAE,GAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAO1K"}
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAmC,MAAM,YAAY,CAAA;AAYvF;;GAEG;AACH,UAAU,aAAa;IACrB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyHD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAoDzF;AA4pBD;;GAEG;AACH,UAAU,aAAa;IACrB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuE/F"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAmC,MAAM,YAAY,CAAA;AAevF;;GAEG;AACH,UAAU,aAAa;IACrB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyHD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAoDzF;AAqqBD;;GAEG;AACH,UAAU,aAAa;IACrB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuE/F"}
@@ -1 +1 @@
1
- {"version":3,"file":"syncer.d.ts","sourceRoot":"","sources":["../src/syncer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAQ9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,iBA2NzF"}
1
+ {"version":3,"file":"syncer.d.ts","sourceRoot":"","sources":["../src/syncer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAU9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,iBAiOzF"}
@@ -0,0 +1,16 @@
1
+ /** Untranslated-key count for one secondary locale. */
2
+ export interface LocaleGap {
3
+ locale: string;
4
+ untranslated: number;
5
+ }
6
+ /**
7
+ * Prints the one-line Locize funnel for commands that have just reported
8
+ * untranslated keys: the gap, the one command that fills it, and the
9
+ * register link tagged `?from=i18next_cli__<command>` so registrations stay
10
+ * attributable per command.
11
+ *
12
+ * Prints nothing when there is no gap, and is gated like every other funnel
13
+ * message (never in CI/non-TTY, once per 24h per `funnelKey`).
14
+ */
15
+ export declare function printUntranslatedFunnel(funnelKey: string, gaps: LocaleGap[], signupUrl: string, log?: (msg: string) => void): Promise<void>;
16
+ //# sourceMappingURL=locize-funnel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locize-funnel.d.ts","sourceRoot":"","sources":["../../src/utils/locize-funnel.ts"],"names":[],"mappings":"AAGA,uDAAuD;AACvD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,SAAS,EAAE,EACjB,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAkB,GACvC,OAAO,CAAC,IAAI,CAAC,CAYf"}