i18next-cli 1.33.5 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +2 -1
  2. package/dist/cjs/cli.js +271 -1
  3. package/dist/cjs/config.js +211 -1
  4. package/dist/cjs/extractor/core/ast-visitors.js +364 -1
  5. package/dist/cjs/extractor/core/extractor.js +245 -1
  6. package/dist/cjs/extractor/core/key-finder.js +132 -1
  7. package/dist/cjs/extractor/core/translation-manager.js +745 -1
  8. package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
  9. package/dist/cjs/extractor/parsers/call-expression-handler.js +941 -1
  10. package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
  11. package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
  12. package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
  13. package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
  14. package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
  15. package/dist/cjs/extractor/plugin-manager.js +106 -1
  16. package/dist/cjs/heuristic-config.js +99 -1
  17. package/dist/cjs/index.js +28 -1
  18. package/dist/cjs/init.js +174 -1
  19. package/dist/cjs/linter.js +431 -1
  20. package/dist/cjs/locize.js +269 -1
  21. package/dist/cjs/migrator.js +196 -1
  22. package/dist/cjs/rename-key.js +354 -1
  23. package/dist/cjs/status.js +336 -1
  24. package/dist/cjs/syncer.js +120 -1
  25. package/dist/cjs/types-generator.js +165 -1
  26. package/dist/cjs/utils/default-value.js +43 -1
  27. package/dist/cjs/utils/file-utils.js +136 -1
  28. package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
  29. package/dist/cjs/utils/logger.js +36 -1
  30. package/dist/cjs/utils/nested-object.js +124 -1
  31. package/dist/cjs/utils/validation.js +71 -1
  32. package/dist/esm/cli.js +269 -1
  33. package/dist/esm/config.js +206 -1
  34. package/dist/esm/extractor/core/ast-visitors.js +362 -1
  35. package/dist/esm/extractor/core/extractor.js +241 -1
  36. package/dist/esm/extractor/core/key-finder.js +130 -1
  37. package/dist/esm/extractor/core/translation-manager.js +743 -1
  38. package/dist/esm/extractor/parsers/ast-utils.js +80 -1
  39. package/dist/esm/extractor/parsers/call-expression-handler.js +939 -1
  40. package/dist/esm/extractor/parsers/comment-parser.js +373 -1
  41. package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
  42. package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
  43. package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
  44. package/dist/esm/extractor/parsers/scope-manager.js +406 -1
  45. package/dist/esm/extractor/plugin-manager.js +103 -1
  46. package/dist/esm/heuristic-config.js +97 -1
  47. package/dist/esm/index.js +11 -1
  48. package/dist/esm/init.js +172 -1
  49. package/dist/esm/linter.js +425 -1
  50. package/dist/esm/locize.js +265 -1
  51. package/dist/esm/migrator.js +194 -1
  52. package/dist/esm/rename-key.js +352 -1
  53. package/dist/esm/status.js +334 -1
  54. package/dist/esm/syncer.js +118 -1
  55. package/dist/esm/types-generator.js +163 -1
  56. package/dist/esm/utils/default-value.js +41 -1
  57. package/dist/esm/utils/file-utils.js +131 -1
  58. package/dist/esm/utils/funnel-msg-tracker.js +72 -1
  59. package/dist/esm/utils/logger.js +34 -1
  60. package/dist/esm/utils/nested-object.js +120 -1
  61. package/dist/esm/utils/validation.js +68 -1
  62. package/package.json +4 -2
  63. package/types/extractor/core/extractor.d.ts.map +1 -1
  64. package/types/extractor/parsers/jsx-parser.d.ts.map +1 -1
  65. package/types/locize.d.ts.map +1 -1
@@ -1 +1,336 @@
1
- "use strict";var e=require("chalk"),t=require("ora"),a=require("node:path"),o=require("./extractor/core/key-finder.js"),s=require("./utils/nested-object.js"),n=require("./utils/file-utils.js"),l=require("./utils/funnel-msg-tracker.js");function r(t,a,o){const s=o>0?Math.round(a/o*100):100,n=c(s);console.log(`${e.bold(t)}: ${n} ${s}% (${a}/${o})`)}function c(t){const a=Math.floor(t/100*20),o=20-a;return`[${e.green("".padStart(a,"■"))}${"".padStart(o,"□")}]`}async function i(){if(await l.shouldShowFunnel("status"))return console.log(e.yellow.bold("\n✨ Take your localization to the next level!")),console.log("Manage translations with your team in the cloud with locize => https://www.locize.com/docs/getting-started"),console.log(`Run ${e.cyan("npx i18next-cli locize-migrate")} to get started.`),l.recordFunnelShown("status")}exports.runStatus=async function(l,u={}){l.extract.primaryLanguage||=l.locales[0]||"en",l.extract.secondaryLanguages||=l.locales.filter(e=>e!==l?.extract?.primaryLanguage);const d=t("Analyzing project localization status...\n").start();try{const t=await async function(e){e.extract.primaryLanguage||=e.locales[0]||"en",e.extract.secondaryLanguages||=e.locales.filter(t=>t!==e?.extract?.primaryLanguage);const{allKeys:t}=await o.findKeys(e),{secondaryLanguages:l,keySeparator:r=".",defaultNS:c="translation",mergeNamespaces:i=!1,pluralSeparator:u="_"}=e.extract,d=new Map;for(const e of t.values()){const t=e.ns||c||"translation";d.has(t)||d.set(t,[]),d.get(t).push(e)}const y={totalBaseKeys:t.size,keysByNs:d,locales:new Map};for(const t of l){let o=0,l=0;const g=new Map,f=i?await n.loadTranslationFile(a.resolve(process.cwd(),n.getOutputPath(e.extract.output,t,!1===c?"translation":c||"translation")))||{}:null;for(const[c,y]of d.entries()){const d=i?f?.[c]??f??{}:await n.loadTranslationFile(a.resolve(process.cwd(),n.getOutputPath(e.extract.output,t,c)))||{};let p=0,$=0;const m=[],h=(e,t)=>{try{const a=t?"ordinal":"cardinal";return new Intl.PluralRules(e,{type:a}).resolvedOptions().pluralCategories}catch(e){return new Intl.PluralRules("en",{type:t?"ordinal":"cardinal"}).resolvedOptions().pluralCategories}};for(const{key:e,hasCount:a,isOrdinal:o,isExpandedPlural:n}of y)if(a)if(n){const a=e.split(u),o=a[a.length-1],n=a.length>=2&&"ordinal"===a[a.length-2],l=n?a[a.length-1]:o;if(h(t,n).includes(l)){$++;const t=!!s.getNestedValue(d,e,r??".");t&&p++,m.push({key:e,isTranslated:t})}}else{const a=h(t,o||!1);for(const t of a){$++;const a=o?`${e}${u}ordinal${u}${t}`:`${e}${u}${t}`,n=!!s.getNestedValue(d,a,r??".");n&&p++,m.push({key:a,isTranslated:n})}}else{$++;const t=!!s.getNestedValue(d,e,r??".");t&&p++,m.push({key:e,isTranslated:t})}g.set(c,{totalKeys:$,translatedKeys:p,keyDetails:m}),o+=p,l+=$}y.locales.set(t,{totalKeys:l,totalTranslated:o,namespaces:g})}return y}(l);d.succeed("Analysis complete."),await async function(t,a,o){o.detail?await async function(t,a,o,s){if(o===a.extract.primaryLanguage)return void console.log(e.yellow(`Locale "${o}" is the primary language. All keys are considered present.`));if(!a.locales.includes(o))return void console.error(e.red(`Error: Locale "${o}" is not defined in your configuration.`));const n=t.locales.get(o);if(!n)return void console.error(e.red(`Error: Locale "${o}" is not a valid secondary language.`));console.log(e.bold(`\nKey Status for "${e.cyan(o)}":`));const l=n.totalKeys;r("Overall",n.totalTranslated,l);const c=s?[s]:Array.from(n.namespaces.keys()).sort();for(const t of c){const a=n.namespaces.get(t);a&&(console.log(e.cyan.bold(`\nNamespace: ${t}`)),r("Namespace Progress",a.translatedKeys,a.totalKeys),a.keyDetails.forEach(({key:t,isTranslated:a})=>{const o=a?e.green("✓"):e.red("✗");console.log(` ${o} ${t}`)}))}const u=l-n.totalTranslated;u>0?console.log(e.yellow.bold(`\nSummary: Found ${u} missing translations for "${o}".`)):console.log(e.green.bold(`\nSummary: 🎉 All keys are translated for "${o}".`));await i()}(t,a,o.detail,o.namespace):o.namespace?await async function(t,a,o){const s=t.keysByNs.get(o);if(!s)return void console.error(e.red(`Error: Namespace "${o}" was not found in your source code.`));console.log(e.cyan.bold(`\nStatus for Namespace: "${o}"`)),console.log("------------------------");for(const[e,a]of t.locales.entries()){const t=a.namespaces.get(o);if(t){const a=t.totalKeys>0?Math.round(t.translatedKeys/t.totalKeys*100):100,o=c(a);console.log(`- ${e}: ${o} ${a}% (${t.translatedKeys}/${t.totalKeys} keys)`)}}await i()}(t,0,o.namespace):await async function(t,a){const{primaryLanguage:o}=a.extract;console.log(e.cyan.bold("\ni18next Project Status")),console.log("------------------------"),console.log(`🔑 Keys Found: ${e.bold(t.totalBaseKeys)}`),console.log(`📚 Namespaces Found: ${e.bold(t.keysByNs.size)}`),console.log(`🌍 Locales: ${e.bold(a.locales.join(", "))}`),console.log(`✅ Primary Language: ${e.bold(o)}`),console.log("\nTranslation Progress:");for(const[e,a]of t.locales.entries()){const t=a.totalKeys>0?Math.round(a.totalTranslated/a.totalKeys*100):100,o=c(t);console.log(`- ${e}: ${o} ${t}% (${a.totalTranslated}/${a.totalKeys} keys)`)}await i()}(t,a)}(t,l,u);let y=!1;for(const[,e]of t.locales.entries())if(e.totalTranslated<e.totalKeys){y=!0;break}y&&(d.fail("Error: Missing translations detected."),process.exit(1))}catch(e){d.fail("Failed to generate status report."),console.error(e)}};
1
+ 'use strict';
2
+
3
+ var chalk = require('chalk');
4
+ var ora = require('ora');
5
+ var node_path = require('node:path');
6
+ var keyFinder = require('./extractor/core/key-finder.js');
7
+ var nestedObject = require('./utils/nested-object.js');
8
+ var fileUtils = require('./utils/file-utils.js');
9
+ var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
10
+
11
+ /**
12
+ * Runs a health check on the project's i18next translations and displays a status report.
13
+ *
14
+ * This command provides a high-level overview of the localization status by:
15
+ * 1. Extracting all keys from the source code using the core extractor.
16
+ * 2. Reading all existing translation files for each locale.
17
+ * 3. Calculating the translation completeness for each secondary language against the primary.
18
+ * 4. Displaying a formatted report with key counts, locales, and progress bars.
19
+ * 5. Serving as a value-driven funnel to introduce the locize commercial service.
20
+ *
21
+ * @param config - The i18next toolkit configuration object.
22
+ * @param options - Options object, may contain a `detail` property with a locale string.
23
+ * @throws {Error} When unable to extract keys or read translation files
24
+ */
25
+ async function runStatus(config, options = {}) {
26
+ config.extract.primaryLanguage ||= config.locales[0] || 'en';
27
+ config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
28
+ const spinner = ora('Analyzing project localization status...\n').start();
29
+ try {
30
+ const report = await generateStatusReport(config);
31
+ spinner.succeed('Analysis complete.');
32
+ await displayStatusReport(report, config, options);
33
+ let hasMissing = false;
34
+ for (const [, localeData] of report.locales.entries()) {
35
+ if (localeData.totalTranslated < localeData.totalKeys) {
36
+ hasMissing = true;
37
+ break;
38
+ }
39
+ }
40
+ if (hasMissing) {
41
+ spinner.fail('Error: Missing translations detected.');
42
+ process.exit(1);
43
+ }
44
+ }
45
+ catch (error) {
46
+ spinner.fail('Failed to generate status report.');
47
+ console.error(error);
48
+ }
49
+ }
50
+ /**
51
+ * Gathers all translation data and compiles it into a structured report.
52
+ *
53
+ * This function:
54
+ * - Extracts all keys from source code using the configured extractor
55
+ * - Groups keys by namespace
56
+ * - Reads translation files for each secondary language
57
+ * - Compares extracted keys against existing translations
58
+ * - Compiles translation statistics for each locale and namespace
59
+ *
60
+ * @param config - The i18next toolkit configuration object
61
+ * @returns Promise that resolves to a complete status report
62
+ * @throws {Error} When key extraction fails or configuration is invalid
63
+ */
64
+ async function generateStatusReport(config) {
65
+ config.extract.primaryLanguage ||= config.locales[0] || 'en';
66
+ config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
67
+ const { allKeys: allExtractedKeys } = await keyFinder.findKeys(config);
68
+ const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_' } = config.extract;
69
+ const keysByNs = new Map();
70
+ for (const key of allExtractedKeys.values()) {
71
+ const ns = key.ns || defaultNS || 'translation';
72
+ if (!keysByNs.has(ns))
73
+ keysByNs.set(ns, []);
74
+ keysByNs.get(ns).push(key);
75
+ }
76
+ const report = {
77
+ totalBaseKeys: allExtractedKeys.size,
78
+ keysByNs,
79
+ locales: new Map(),
80
+ };
81
+ for (const locale of secondaryLanguages) {
82
+ let totalTranslatedForLocale = 0;
83
+ let totalKeysForLocale = 0;
84
+ const namespaces = new Map();
85
+ const mergedTranslations = mergeNamespaces
86
+ // When merging namespaces we need to load the combined translation file.
87
+ // The combined file lives under the regular output pattern and must include a namespace.
88
+ // If defaultNS is explicitly false, fall back to the conventional "translation" file name.
89
+ ? await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, (defaultNS === false ? 'translation' : (defaultNS || 'translation'))))) || {}
90
+ : null;
91
+ for (const [ns, keysInNs] of keysByNs.entries()) {
92
+ const translationsForNs = mergeNamespaces
93
+ // If mergedTranslations is a flat object (no nested namespace) prefer the root object
94
+ // when mergedTranslations[ns] is missing.
95
+ ? (mergedTranslations?.[ns] ?? mergedTranslations ?? {})
96
+ : await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, ns))) || {};
97
+ let translatedInNs = 0;
98
+ let totalInNs = 0;
99
+ const keyDetails = [];
100
+ // Get the plural categories for THIS specific locale
101
+ const getLocalePluralCategories = (locale, isOrdinal) => {
102
+ try {
103
+ const type = isOrdinal ? 'ordinal' : 'cardinal';
104
+ const pluralRules = new Intl.PluralRules(locale, { type });
105
+ return pluralRules.resolvedOptions().pluralCategories;
106
+ }
107
+ catch (e) {
108
+ // Fallback to English if locale is invalid
109
+ const fallbackRules = new Intl.PluralRules('en', { type: isOrdinal ? 'ordinal' : 'cardinal' });
110
+ return fallbackRules.resolvedOptions().pluralCategories;
111
+ }
112
+ };
113
+ for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
114
+ if (hasCount) {
115
+ if (isExpandedPlural) {
116
+ // This is an already-expanded plural variant key (e.g., key_one, key_other)
117
+ // Check if this specific variant is needed for the target locale
118
+ const keyParts = baseKey.split(pluralSeparator);
119
+ const lastPart = keyParts[keyParts.length - 1];
120
+ // Determine if this is an ordinal or cardinal plural
121
+ const isOrdinalVariant = keyParts.length >= 2 && keyParts[keyParts.length - 2] === 'ordinal';
122
+ const category = isOrdinalVariant ? keyParts[keyParts.length - 1] : lastPart;
123
+ // Get the plural categories for this locale
124
+ const localePluralCategories = getLocalePluralCategories(locale, isOrdinalVariant);
125
+ // Only count this key if it's a plural form used by this locale
126
+ if (localePluralCategories.includes(category)) {
127
+ totalInNs++;
128
+ const value = nestedObject.getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
129
+ const isTranslated = !!value;
130
+ if (isTranslated)
131
+ translatedInNs++;
132
+ keyDetails.push({ key: baseKey, isTranslated });
133
+ }
134
+ }
135
+ else {
136
+ // This is a base plural key without expanded variants
137
+ // Expand it according to THIS locale's plural rules
138
+ const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
139
+ for (const category of localePluralCategories) {
140
+ totalInNs++;
141
+ const pluralKey = isOrdinal
142
+ ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
143
+ : `${baseKey}${pluralSeparator}${category}`;
144
+ const value = nestedObject.getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.');
145
+ const isTranslated = !!value;
146
+ if (isTranslated)
147
+ translatedInNs++;
148
+ keyDetails.push({ key: pluralKey, isTranslated });
149
+ }
150
+ }
151
+ }
152
+ else {
153
+ // It's a simple key
154
+ totalInNs++;
155
+ const value = nestedObject.getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
156
+ const isTranslated = !!value;
157
+ if (isTranslated)
158
+ translatedInNs++;
159
+ keyDetails.push({ key: baseKey, isTranslated });
160
+ }
161
+ }
162
+ namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, keyDetails });
163
+ totalTranslatedForLocale += translatedInNs;
164
+ totalKeysForLocale += totalInNs;
165
+ }
166
+ report.locales.set(locale, { totalKeys: totalKeysForLocale, totalTranslated: totalTranslatedForLocale, namespaces });
167
+ }
168
+ return report;
169
+ }
170
+ /**
171
+ * Main display router that calls the appropriate display function based on options.
172
+ *
173
+ * Routes to one of three display modes:
174
+ * - Detailed locale report: Shows per-key status for a specific locale
175
+ * - Namespace summary: Shows translation progress for all locales in a specific namespace
176
+ * - Overall summary: Shows high-level statistics across all locales and namespaces
177
+ *
178
+ * @param report - The generated status report data
179
+ * @param config - The i18next toolkit configuration object
180
+ * @param options - Display options determining which report type to show
181
+ */
182
+ async function displayStatusReport(report, config, options) {
183
+ if (options.detail) {
184
+ await displayDetailedLocaleReport(report, config, options.detail, options.namespace);
185
+ }
186
+ else if (options.namespace) {
187
+ await displayNamespaceSummaryReport(report, config, options.namespace);
188
+ }
189
+ else {
190
+ await displayOverallSummaryReport(report, config);
191
+ }
192
+ }
193
+ /**
194
+ * Displays the detailed, grouped report for a single locale.
195
+ *
196
+ * Shows:
197
+ * - Overall progress for the locale
198
+ * - Progress for each namespace (or filtered namespace)
199
+ * - Individual key status (translated/missing) with visual indicators
200
+ * - Summary message with total missing translations
201
+ *
202
+ * @param report - The generated status report data
203
+ * @param config - The i18next toolkit configuration object
204
+ * @param locale - The locale code to display details for
205
+ * @param namespaceFilter - Optional namespace to filter the display
206
+ */
207
+ async function displayDetailedLocaleReport(report, config, locale, namespaceFilter) {
208
+ if (locale === config.extract.primaryLanguage) {
209
+ console.log(chalk.yellow(`Locale "${locale}" is the primary language. All keys are considered present.`));
210
+ return;
211
+ }
212
+ if (!config.locales.includes(locale)) {
213
+ console.error(chalk.red(`Error: Locale "${locale}" is not defined in your configuration.`));
214
+ return;
215
+ }
216
+ const localeData = report.locales.get(locale);
217
+ if (!localeData) {
218
+ console.error(chalk.red(`Error: Locale "${locale}" is not a valid secondary language.`));
219
+ return;
220
+ }
221
+ console.log(chalk.bold(`\nKey Status for "${chalk.cyan(locale)}":`));
222
+ const totalKeysForLocale = localeData.totalKeys;
223
+ printProgressBar('Overall', localeData.totalTranslated, totalKeysForLocale);
224
+ const namespacesToDisplay = namespaceFilter ? [namespaceFilter] : Array.from(localeData.namespaces.keys()).sort();
225
+ for (const ns of namespacesToDisplay) {
226
+ const nsData = localeData.namespaces.get(ns);
227
+ if (!nsData)
228
+ continue;
229
+ console.log(chalk.cyan.bold(`\nNamespace: ${ns}`));
230
+ printProgressBar('Namespace Progress', nsData.translatedKeys, nsData.totalKeys);
231
+ nsData.keyDetails.forEach(({ key, isTranslated }) => {
232
+ const icon = isTranslated ? chalk.green('✓') : chalk.red('✗');
233
+ console.log(` ${icon} ${key}`);
234
+ });
235
+ }
236
+ const missingCount = totalKeysForLocale - localeData.totalTranslated;
237
+ if (missingCount > 0) {
238
+ console.log(chalk.yellow.bold(`\nSummary: Found ${missingCount} missing translations for "${locale}".`));
239
+ }
240
+ else {
241
+ console.log(chalk.green.bold(`\nSummary: 🎉 All keys are translated for "${locale}".`));
242
+ }
243
+ await printLocizeFunnel();
244
+ }
245
+ /**
246
+ * Displays a summary report filtered by a single namespace.
247
+ *
248
+ * Shows translation progress for the specified namespace across all secondary locales,
249
+ * including percentage completion and translated/total key counts.
250
+ *
251
+ * @param report - The generated status report data
252
+ * @param config - The i18next toolkit configuration object
253
+ * @param namespace - The namespace to display summary for
254
+ */
255
+ async function displayNamespaceSummaryReport(report, config, namespace) {
256
+ const nsData = report.keysByNs.get(namespace);
257
+ if (!nsData) {
258
+ console.error(chalk.red(`Error: Namespace "${namespace}" was not found in your source code.`));
259
+ return;
260
+ }
261
+ console.log(chalk.cyan.bold(`\nStatus for Namespace: "${namespace}"`));
262
+ console.log('------------------------');
263
+ for (const [locale, localeData] of report.locales.entries()) {
264
+ const nsLocaleData = localeData.namespaces.get(namespace);
265
+ if (nsLocaleData) {
266
+ const percentage = nsLocaleData.totalKeys > 0 ? Math.round((nsLocaleData.translatedKeys / nsLocaleData.totalKeys) * 100) : 100;
267
+ const bar = generateProgressBarText(percentage);
268
+ console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)`);
269
+ }
270
+ }
271
+ await printLocizeFunnel();
272
+ }
273
+ /**
274
+ * Displays the default, high-level summary report for all locales.
275
+ *
276
+ * Shows:
277
+ * - Project overview (total keys, locales, primary language)
278
+ * - Translation progress for each secondary locale with progress bars
279
+ * - Promotional message for locize service
280
+ *
281
+ * @param report - The generated status report data
282
+ * @param config - The i18next toolkit configuration object
283
+ */
284
+ async function displayOverallSummaryReport(report, config) {
285
+ const { primaryLanguage } = config.extract;
286
+ console.log(chalk.cyan.bold('\ni18next Project Status'));
287
+ console.log('------------------------');
288
+ console.log(`🔑 Keys Found: ${chalk.bold(report.totalBaseKeys)}`);
289
+ console.log(`📚 Namespaces Found: ${chalk.bold(report.keysByNs.size)}`);
290
+ console.log(`🌍 Locales: ${chalk.bold(config.locales.join(', '))}`);
291
+ console.log(`✅ Primary Language: ${chalk.bold(primaryLanguage)}`);
292
+ console.log('\nTranslation Progress:');
293
+ for (const [locale, localeData] of report.locales.entries()) {
294
+ const percentage = localeData.totalKeys > 0 ? Math.round((localeData.totalTranslated / localeData.totalKeys) * 100) : 100;
295
+ const bar = generateProgressBarText(percentage);
296
+ console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)`);
297
+ }
298
+ await printLocizeFunnel();
299
+ }
300
+ /**
301
+ * Prints a formatted progress bar with label, percentage, and counts.
302
+ *
303
+ * @param label - The label to display before the progress bar
304
+ * @param current - The current count (translated keys)
305
+ * @param total - The total count (all keys)
306
+ */
307
+ function printProgressBar(label, current, total) {
308
+ const percentage = total > 0 ? Math.round((current / total) * 100) : 100;
309
+ const bar = generateProgressBarText(percentage);
310
+ console.log(`${chalk.bold(label)}: ${bar} ${percentage}% (${current}/${total})`);
311
+ }
312
+ /**
313
+ * Generates a visual progress bar string based on percentage completion.
314
+ *
315
+ * Creates a 20-character progress bar using filled (■) and empty (□) squares,
316
+ * with the filled portion colored green.
317
+ *
318
+ * @param percentage - The completion percentage (0-100)
319
+ * @returns A formatted progress bar string with colors
320
+ */
321
+ function generateProgressBarText(percentage) {
322
+ const totalBars = 20;
323
+ const filledBars = Math.floor((percentage / 100) * totalBars);
324
+ const emptyBars = totalBars - filledBars;
325
+ return `[${chalk.green(''.padStart(filledBars, '■'))}${''.padStart(emptyBars, '□')}]`;
326
+ }
327
+ async function printLocizeFunnel() {
328
+ if (!(await funnelMsgTracker.shouldShowFunnel('status')))
329
+ return;
330
+ console.log(chalk.yellow.bold('\n✨ Take your localization to the next level!'));
331
+ console.log('Manage translations with your team in the cloud with locize => https://www.locize.com/docs/getting-started');
332
+ console.log(`Run ${chalk.cyan('npx i18next-cli locize-migrate')} to get started.`);
333
+ return funnelMsgTracker.recordFunnelShown('status');
334
+ }
335
+
336
+ exports.runStatus = runStatus;
@@ -1 +1,120 @@
1
- "use strict";var e=require("chalk"),t=require("glob"),o=require("node:fs/promises"),n=require("node:path"),r=require("ora"),a=require("./utils/default-value.js"),l=require("./utils/file-utils.js"),i=require("./utils/funnel-msg-tracker.js"),s=require("./utils/nested-object.js");exports.runSyncer=async function(c){const u=r("Running i18next locale synchronizer...\n").start();try{const r=c.extract.primaryLanguage||c.locales[0]||"en",d=c.locales.filter(e=>e!==r),{output:f,keySeparator:y=".",outputFormat:g="json",indentation:h=2,defaultValue:p=""}=c.extract,w=[];let m=!1;const S=l.getOutputPath(f,r,"*"),j=await t.glob(S);if(0===j.length)return void u.warn(`No translation files found for primary language "${r}". Nothing to sync.`);for(const t of j){const r=n.basename(t).split(".")[0],i=await l.loadTranslationFile(t);if(!i){w.push(` ${e.yellow("-")} Could not read primary file: ${t}`);continue}const u=s.getNestedKeys(i,y??".");for(const t of d){const d=l.getOutputPath(f,t,r),S=n.resolve(process.cwd(),d),j=await l.loadTranslationFile(S)||{},q={};for(const e of u){const o=s.getNestedValue(i,e,y??"."),n=s.getNestedValue(j,e,y??".")??a.resolveDefaultValue(p,e,r,t,o);s.setNestedValue(q,e,n,y??".")}const v=JSON.stringify(j);if(JSON.stringify(q)!==v){m=!0;const t=c.extract.outputFormat??(S.endsWith(".json5")?"json5":g),r="json5"===t?await l.loadRawJson5Content(S)??void 0:void 0,a=l.serializeTranslationFile(q,t,h,r);await o.mkdir(n.dirname(S),{recursive:!0}),await o.writeFile(S,a),w.push(` ${e.green("✓")} Synchronized: ${d}`)}else w.push(` ${e.gray("-")} Already in sync: ${d}`)}}u.succeed(e.bold("Synchronization complete!")),w.forEach(e=>console.log(e)),m?await async function(){if(!await i.shouldShowFunnel("syncer"))return;return console.log(e.green.bold("\n✅ Sync complete.")),console.log(e.yellow("🚀 Ready to collaborate with translators? Move your files to the cloud.")),console.log(` Get started with the official TMS for i18next: ${e.cyan("npx i18next-cli locize-migrate")}`),i.recordFunnelShown("syncer")}():console.log(e.green.bold("\n✅ All locales are already in sync."))}catch(t){u.fail(e.red("Synchronization failed.")),console.error(t)}};
1
+ 'use strict';
2
+
3
+ var chalk = require('chalk');
4
+ var glob = require('glob');
5
+ var promises = require('node:fs/promises');
6
+ var node_path = require('node:path');
7
+ var ora = require('ora');
8
+ var defaultValue = require('./utils/default-value.js');
9
+ var fileUtils = require('./utils/file-utils.js');
10
+ var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
11
+ var nestedObject = require('./utils/nested-object.js');
12
+
13
+ /**
14
+ * Synchronizes translation files across different locales by ensuring all secondary
15
+ * language files contain the same keys as the primary language file.
16
+ *
17
+ * This function:
18
+ * 1. Reads the primary language translation file
19
+ * 2. Extracts all translation keys from the primary file
20
+ * 3. For each secondary language:
21
+ * - Preserves existing translations
22
+ * - Adds missing keys with empty values or configured default
23
+ * - Removes keys that no longer exist in primary
24
+ * 4. Only writes files that have changes
25
+ *
26
+ * @param config - The i18next toolkit configuration object
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * // Configuration
31
+ * const config = {
32
+ * locales: ['en', 'de', 'fr'],
33
+ * extract: {
34
+ * output: 'locales/{{language}}/{{namespace}}.json',
35
+ * defaultNS: 'translation'
36
+ * defaultValue: '[MISSING]'
37
+ * }
38
+ * }
39
+ *
40
+ * await runSyncer(config)
41
+ * ```
42
+ */
43
+ async function runSyncer(config) {
44
+ const spinner = ora('Running i18next locale synchronizer...\n').start();
45
+ try {
46
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en';
47
+ const secondaryLanguages = config.locales.filter((l) => l !== primaryLanguage);
48
+ const { output, keySeparator = '.', outputFormat = 'json', indentation = 2, defaultValue: defaultValue$1 = '', } = config.extract;
49
+ const logMessages = [];
50
+ let wasAnythingSynced = false;
51
+ // 1. Find all namespace files for the primary language
52
+ const primaryNsPattern = fileUtils.getOutputPath(output, primaryLanguage, '*');
53
+ const primaryNsFiles = await glob.glob(primaryNsPattern);
54
+ if (primaryNsFiles.length === 0) {
55
+ spinner.warn(`No translation files found for primary language "${primaryLanguage}". Nothing to sync.`);
56
+ return;
57
+ }
58
+ // 2. Loop through each primary namespace file
59
+ for (const primaryPath of primaryNsFiles) {
60
+ const ns = node_path.basename(primaryPath).split('.')[0];
61
+ const primaryTranslations = await fileUtils.loadTranslationFile(primaryPath);
62
+ if (!primaryTranslations) {
63
+ logMessages.push(` ${chalk.yellow('-')} Could not read primary file: ${primaryPath}`);
64
+ continue;
65
+ }
66
+ const primaryKeys = nestedObject.getNestedKeys(primaryTranslations, keySeparator ?? '.');
67
+ // 3. For each secondary language, sync the current namespace
68
+ for (const lang of secondaryLanguages) {
69
+ const secondaryPath = fileUtils.getOutputPath(output, lang, ns);
70
+ const fullSecondaryPath = node_path.resolve(process.cwd(), secondaryPath);
71
+ const existingSecondaryTranslations = await fileUtils.loadTranslationFile(fullSecondaryPath) || {};
72
+ const newSecondaryTranslations = {};
73
+ for (const key of primaryKeys) {
74
+ const primaryValue = nestedObject.getNestedValue(primaryTranslations, key, keySeparator ?? '.');
75
+ const existingValue = nestedObject.getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
76
+ // Use the resolved default value if no existing value
77
+ const valueToSet = existingValue ?? defaultValue.resolveDefaultValue(defaultValue$1, key, ns, lang, primaryValue);
78
+ nestedObject.setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
79
+ }
80
+ // Use JSON.stringify for a reliable object comparison, regardless of format
81
+ const oldContent = JSON.stringify(existingSecondaryTranslations);
82
+ const newContent = JSON.stringify(newSecondaryTranslations);
83
+ if (newContent !== oldContent) {
84
+ wasAnythingSynced = true;
85
+ const perFileFormat = config.extract.outputFormat ?? (fullSecondaryPath.endsWith('.json5') ? 'json5' : outputFormat);
86
+ const raw = perFileFormat === 'json5' ? (await fileUtils.loadRawJson5Content(fullSecondaryPath)) ?? undefined : undefined;
87
+ const serializedContent = fileUtils.serializeTranslationFile(newSecondaryTranslations, perFileFormat, indentation, raw);
88
+ await promises.mkdir(node_path.dirname(fullSecondaryPath), { recursive: true });
89
+ await promises.writeFile(fullSecondaryPath, serializedContent);
90
+ logMessages.push(` ${chalk.green('✓')} Synchronized: ${secondaryPath}`);
91
+ }
92
+ else {
93
+ logMessages.push(` ${chalk.gray('-')} Already in sync: ${secondaryPath}`);
94
+ }
95
+ }
96
+ }
97
+ spinner.succeed(chalk.bold('Synchronization complete!'));
98
+ logMessages.forEach(msg => console.log(msg));
99
+ if (wasAnythingSynced) {
100
+ await printLocizeFunnel();
101
+ }
102
+ else {
103
+ console.log(chalk.green.bold('\n✅ All locales are already in sync.'));
104
+ }
105
+ }
106
+ catch (error) {
107
+ spinner.fail(chalk.red('Synchronization failed.'));
108
+ console.error(error);
109
+ }
110
+ }
111
+ async function printLocizeFunnel() {
112
+ if (!(await funnelMsgTracker.shouldShowFunnel('syncer')))
113
+ return;
114
+ console.log(chalk.green.bold('\n✅ Sync complete.'));
115
+ console.log(chalk.yellow('🚀 Ready to collaborate with translators? Move your files to the cloud.'));
116
+ console.log(` Get started with the official TMS for i18next: ${chalk.cyan('npx i18next-cli locize-migrate')}`);
117
+ return funnelMsgTracker.recordFunnelShown('syncer');
118
+ }
119
+
120
+ exports.runSyncer = runSyncer;
@@ -1 +1,165 @@
1
- "use strict";var e=require("i18next-resources-for-ts"),t=require("glob"),s=require("ora"),r=require("chalk"),n=require("node:fs/promises"),o=require("node:path"),i=require("@swc/core"),a=require("./utils/file-utils.js"),c=require("node:vm");async function p(e){const t=o.extname(e);if([".ts",".mts",".cts",".js",".mjs",".cjs"].includes(t)){const t=await n.readFile(e,"utf-8"),{code:s}=await i.transform(t,{filename:e,jsc:{parser:{syntax:"typescript"},target:"es2018"},module:{type:"commonjs"}}),r={},o={exports:r},a=c.createContext({exports:r,module:o,require:e=>require(e),console:console,process:process});new c.Script(s,{filename:e}).runInContext(a);return a.module.exports?.default||a.module.exports}const s=await n.readFile(e,"utf-8");return JSON.parse(s)}exports.runTypesGenerator=async function(i){const c=s("Generating TypeScript types for translations...\n").start();try{i.extract.primaryLanguage||=i.locales[0]||"en";let s=i.extract.output||`locales/${i.extract.primaryLanguage}/*.json`;if(s=a.getOutputPath(s,i.extract.primaryLanguage||"en","*"),i.types||(i.types={input:s,output:"src/@types/i18next.d.ts"}),void 0===i.types.input&&(i.types.input=s),i.types.output||(i.types.output="src/@types/i18next.d.ts"),i.types.resourcesFile||(i.types.resourcesFile=o.join(o.dirname(i.types?.output),"resources.d.ts")),!i.types?.input||i.types?.input.length<0)return void console.log("No input defined!");const u=await t.glob(i.types?.input||[],{cwd:process.cwd()}),l=[];for(const e of u){const t=o.basename(e,o.extname(e)),s=await p(e);if(i.extract?.mergeNamespaces&&s&&"object"==typeof s){const t=Object.keys(s),n=t.filter(e=>s[e]&&"object"==typeof s[e]);if(n.length>0){for(const e of n)l.push({name:e,resources:s[e]});const o=t.filter(e=>!s[e]||"object"!=typeof s[e]);o.length>0&&console.warn(r.yellow(`Warning: The file ${e} contains top-level keys that are not objects (${o.join(", ")}). When 'mergeNamespaces' is enabled, top-level keys are treated as namespaces. These keys will be ignored.`));continue}}l.push({name:t,resources:s})}const d=[],y=i.types?.enableSelector||!1,f=i.types?.indentation??i.extract.indentation??2,m=`// This file is automatically generated by i18next-cli. Do not edit manually.\n${e.mergeResourcesAsInterface(l,{optimize:!!y,indentation:f})}`,g=o.resolve(process.cwd(),i.types?.output||""),w=o.resolve(process.cwd(),i.types.resourcesFile);let x;await n.mkdir(o.dirname(w),{recursive:!0}),await n.writeFile(w,m),d.push(` ${r.green("✓")} Resources interface written to ${i.types.resourcesFile}`);try{await n.access(g),x=!0}catch(e){x=!1}if(!x){const e=o.relative(o.dirname(g),w).replace(/\\/g,"/").replace(/\.d\.ts$/,""),t=`// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options\nimport Resources from './${e}';\n\ndeclare module 'i18next' {\n interface CustomTypeOptions {\n enableSelector: ${"string"==typeof y?`"${y}"`:y};\n defaultNS: ${!1===i.extract.defaultNS?"false":`'${i.extract.defaultNS||"translation"}'`};\n resources: Resources;\n }\n}`;await n.mkdir(o.dirname(g),{recursive:!0}),await n.writeFile(g,t),d.push(` ${r.green("✓")} TypeScript definitions written to ${i.types.output||""}`)}c.succeed(r.bold("TypeScript definitions generated successfully.")),d.forEach(e=>console.log(e))}catch(e){c.fail(r.red("Failed to generate TypeScript definitions.")),console.error(e)}};
1
+ 'use strict';
2
+
3
+ var i18nextResourcesForTs = require('i18next-resources-for-ts');
4
+ var glob = require('glob');
5
+ var ora = require('ora');
6
+ var chalk = require('chalk');
7
+ var promises = require('node:fs/promises');
8
+ var node_path = require('node:path');
9
+ var core = require('@swc/core');
10
+ var fileUtils = require('./utils/file-utils.js');
11
+ var vm = require('node:vm');
12
+
13
+ async function loadFile(file) {
14
+ const ext = node_path.extname(file);
15
+ if (['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].includes(ext)) {
16
+ const content = await promises.readFile(file, 'utf-8');
17
+ const { code } = await core.transform(content, {
18
+ filename: file,
19
+ jsc: {
20
+ parser: {
21
+ syntax: 'typescript',
22
+ },
23
+ target: 'es2018'
24
+ },
25
+ module: {
26
+ type: 'commonjs'
27
+ }
28
+ });
29
+ const exports = {};
30
+ const module = { exports };
31
+ const context = vm.createContext({
32
+ exports,
33
+ module,
34
+ require: (id) => require(id),
35
+ console,
36
+ process
37
+ });
38
+ const script = new vm.Script(code, { filename: file });
39
+ script.runInContext(context);
40
+ // @ts-ignore
41
+ const exported = context.module.exports?.default || context.module.exports;
42
+ return exported;
43
+ }
44
+ const content = await promises.readFile(file, 'utf-8');
45
+ return JSON.parse(content);
46
+ }
47
+ /**
48
+ * Generates TypeScript type definitions for i18next translations.
49
+ *
50
+ * This function:
51
+ * 1. Reads translation files based on the input glob patterns
52
+ * 2. Generates TypeScript interfaces using i18next-resources-for-ts
53
+ * 3. Creates separate resources.d.ts and main i18next.d.ts files
54
+ * 4. Handles namespace detection from filenames
55
+ * 5. Supports type-safe selector API when enabled
56
+ *
57
+ * @param config - The i18next toolkit configuration object
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * // Configuration
62
+ * const config = {
63
+ * types: {
64
+ * input: ['locales/en/*.json'],
65
+ * output: 'src/types/i18next.d.ts',
66
+ * enableSelector: true
67
+ * }
68
+ * }
69
+ *
70
+ * await runTypesGenerator(config)
71
+ * ```
72
+ */
73
+ async function runTypesGenerator(config) {
74
+ const spinner = ora('Generating TypeScript types for translations...\n').start();
75
+ try {
76
+ config.extract.primaryLanguage ||= config.locales[0] || 'en';
77
+ let defaultTypesInputPath = config.extract.output || `locales/${config.extract.primaryLanguage}/*.json`;
78
+ defaultTypesInputPath = fileUtils.getOutputPath(defaultTypesInputPath, config.extract.primaryLanguage || 'en', '*');
79
+ if (!config.types)
80
+ config.types = { input: defaultTypesInputPath, output: 'src/@types/i18next.d.ts' };
81
+ if (config.types.input === undefined)
82
+ config.types.input = defaultTypesInputPath;
83
+ if (!config.types.output)
84
+ config.types.output = 'src/@types/i18next.d.ts';
85
+ if (!config.types.resourcesFile)
86
+ config.types.resourcesFile = node_path.join(node_path.dirname(config.types?.output), 'resources.d.ts');
87
+ if (!config.types?.input || config.types?.input.length < 0) {
88
+ console.log('No input defined!');
89
+ return;
90
+ }
91
+ const resourceFiles = await glob.glob(config.types?.input || [], {
92
+ cwd: process.cwd(),
93
+ });
94
+ const resources = [];
95
+ for (const file of resourceFiles) {
96
+ const namespace = node_path.basename(file, node_path.extname(file));
97
+ const parsedContent = await loadFile(file);
98
+ // If mergeNamespaces is used, a single file can contain multiple namespaces
99
+ // (e.g. { "translation": { ... }, "common": { ... } } in a per-language file).
100
+ // In that case, expose each top-level key as a namespace entry so the type
101
+ // generator will produce top-level namespace interfaces (not a language wrapper).
102
+ if (config.extract?.mergeNamespaces && parsedContent && typeof parsedContent === 'object') {
103
+ const keys = Object.keys(parsedContent);
104
+ const objectKeys = keys.filter(k => parsedContent[k] && typeof parsedContent[k] === 'object');
105
+ // If we have at least one object and we are in mergeNamespaces mode, assume it's a merged file
106
+ if (objectKeys.length > 0) {
107
+ for (const nsName of objectKeys) {
108
+ resources.push({ name: nsName, resources: parsedContent[nsName] });
109
+ }
110
+ const nonObjectKeys = keys.filter(k => !parsedContent[k] || typeof parsedContent[k] !== 'object');
111
+ if (nonObjectKeys.length > 0) {
112
+ console.warn(chalk.yellow(`Warning: The file ${file} contains top-level keys that are not objects (${nonObjectKeys.join(', ')}). When 'mergeNamespaces' is enabled, top-level keys are treated as namespaces. These keys will be ignored.`));
113
+ }
114
+ continue;
115
+ }
116
+ }
117
+ resources.push({ name: namespace, resources: parsedContent });
118
+ }
119
+ const logMessages = [];
120
+ const enableSelector = config.types?.enableSelector || false;
121
+ const indentation = config.types?.indentation ?? config.extract.indentation ?? 2;
122
+ const interfaceDefinition = `// This file is automatically generated by i18next-cli. Do not edit manually.
123
+ ${i18nextResourcesForTs.mergeResourcesAsInterface(resources, { optimize: !!enableSelector, indentation })}`;
124
+ const outputPath = node_path.resolve(process.cwd(), config.types?.output || '');
125
+ const resourcesOutputPath = node_path.resolve(process.cwd(), config.types.resourcesFile);
126
+ await promises.mkdir(node_path.dirname(resourcesOutputPath), { recursive: true });
127
+ await promises.writeFile(resourcesOutputPath, interfaceDefinition);
128
+ logMessages.push(` ${chalk.green('✓')} Resources interface written to ${config.types.resourcesFile}`);
129
+ let outputPathExists;
130
+ try {
131
+ await promises.access(outputPath);
132
+ outputPathExists = true;
133
+ }
134
+ catch (e) {
135
+ outputPathExists = false;
136
+ }
137
+ if (!outputPathExists) {
138
+ // The main output file will now import from the resources file
139
+ const importPath = node_path.relative(node_path.dirname(outputPath), resourcesOutputPath)
140
+ .replace(/\\/g, '/').replace(/\.d\.ts$/, ''); // Make it a valid module path
141
+ const defaultNS = config.extract.defaultNS === false ? 'false' : `'${config.extract.defaultNS || 'translation'}'`;
142
+ const fileContent = `// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options
143
+ import Resources from './${importPath}';
144
+
145
+ declare module 'i18next' {
146
+ interface CustomTypeOptions {
147
+ enableSelector: ${typeof enableSelector === 'string' ? `"${enableSelector}"` : enableSelector};
148
+ defaultNS: ${defaultNS};
149
+ resources: Resources;
150
+ }
151
+ }`;
152
+ await promises.mkdir(node_path.dirname(outputPath), { recursive: true });
153
+ await promises.writeFile(outputPath, fileContent);
154
+ logMessages.push(` ${chalk.green('✓')} TypeScript definitions written to ${config.types.output || ''}`);
155
+ }
156
+ spinner.succeed(chalk.bold('TypeScript definitions generated successfully.'));
157
+ logMessages.forEach(msg => console.log(msg));
158
+ }
159
+ catch (error) {
160
+ spinner.fail(chalk.red('Failed to generate TypeScript definitions.'));
161
+ console.error(error);
162
+ }
163
+ }
164
+
165
+ exports.runTypesGenerator = runTypesGenerator;