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