i18next-cli 1.24.13 → 1.24.14

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 (39) hide show
  1. package/dist/cjs/cli.js +1 -1
  2. package/dist/esm/cli.js +1 -1
  3. package/package.json +6 -6
  4. package/types/cli.d.ts +3 -1
  5. package/types/cli.d.ts.map +1 -1
  6. package/CHANGELOG.md +0 -599
  7. package/src/cli.ts +0 -283
  8. package/src/config.ts +0 -215
  9. package/src/extractor/core/ast-visitors.ts +0 -259
  10. package/src/extractor/core/extractor.ts +0 -250
  11. package/src/extractor/core/key-finder.ts +0 -142
  12. package/src/extractor/core/translation-manager.ts +0 -750
  13. package/src/extractor/index.ts +0 -7
  14. package/src/extractor/parsers/ast-utils.ts +0 -87
  15. package/src/extractor/parsers/call-expression-handler.ts +0 -793
  16. package/src/extractor/parsers/comment-parser.ts +0 -424
  17. package/src/extractor/parsers/expression-resolver.ts +0 -391
  18. package/src/extractor/parsers/jsx-handler.ts +0 -488
  19. package/src/extractor/parsers/jsx-parser.ts +0 -1463
  20. package/src/extractor/parsers/scope-manager.ts +0 -445
  21. package/src/extractor/plugin-manager.ts +0 -116
  22. package/src/extractor.ts +0 -15
  23. package/src/heuristic-config.ts +0 -92
  24. package/src/index.ts +0 -22
  25. package/src/init.ts +0 -175
  26. package/src/linter.ts +0 -345
  27. package/src/locize.ts +0 -263
  28. package/src/migrator.ts +0 -208
  29. package/src/rename-key.ts +0 -398
  30. package/src/status.ts +0 -380
  31. package/src/syncer.ts +0 -133
  32. package/src/types-generator.ts +0 -139
  33. package/src/types.ts +0 -577
  34. package/src/utils/default-value.ts +0 -45
  35. package/src/utils/file-utils.ts +0 -167
  36. package/src/utils/funnel-msg-tracker.ts +0 -84
  37. package/src/utils/logger.ts +0 -36
  38. package/src/utils/nested-object.ts +0 -135
  39. package/src/utils/validation.ts +0 -72
@@ -1,750 +0,0 @@
1
- import { TranslationResult, ExtractedKey, I18nextToolkitConfig } from '../../types'
2
- import { resolve, basename, extname } from 'node:path'
3
- import { glob } from 'glob'
4
- import { getNestedValue, setNestedValue, getNestedKeys } from '../../utils/nested-object'
5
- import { getOutputPath, loadTranslationFile } from '../../utils/file-utils'
6
- import { resolveDefaultValue } from '../../utils/default-value'
7
-
8
- /**
9
- * Converts a glob pattern to a regular expression for matching keys
10
- * @param glob - The glob pattern to convert
11
- * @returns A RegExp object that matches the glob pattern
12
- */
13
- function globToRegex (glob: string): RegExp {
14
- const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
15
- const regexString = `^${escaped.replace(/\*/g, '.*')}$`
16
- return new RegExp(regexString)
17
- }
18
-
19
- /**
20
- * Checks if an existing key is a context variant of a base key that accepts context.
21
- * This function handles complex cases where:
22
- * - The key might have plural suffixes (_one, _other, etc.)
23
- * - The context value itself might contain the separator (e.g., mc_laren)
24
- *
25
- * @param existingKey - The key from the translation file to check
26
- * @param keysAcceptingContext - Set of base keys that were used with context in source code
27
- * @param pluralSeparator - The separator used for plural forms (default: '_')
28
- * @param contextSeparator - The separator used for context variants (default: '_')
29
- * @returns true if the existing key is a context variant of a key accepting context
30
- */
31
- function isContextVariantOfAcceptingKey (
32
- existingKey: string,
33
- keysAcceptingContext: ReadonlySet<string>,
34
- pluralSeparator: string,
35
- contextSeparator: string
36
- ): boolean {
37
- if (keysAcceptingContext.size === 0) {
38
- return false
39
- }
40
-
41
- // Try to extract the base key from this existing key by removing context and/or plural suffixes
42
- let potentialBaseKey = existingKey
43
-
44
- // First, try removing plural suffixes if present
45
- const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other']
46
- for (const form of pluralForms) {
47
- if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
48
- potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length))
49
- break
50
- }
51
- if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
52
- potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length))
53
- break
54
- }
55
- }
56
-
57
- // Then, try removing the context suffix to get the base key
58
- // We need to check all possible base keys since the context value itself might contain separators
59
- // For example: 'formula_one_mc_laren' could be:
60
- // - base: 'formula_one_mc', context: 'laren'
61
- // - base: 'formula_one', context: 'mc_laren' ← correct
62
- // - base: 'formula', context: 'one_mc_laren'
63
- const parts = potentialBaseKey.split(contextSeparator)
64
- if (parts.length > 1) {
65
- // Try removing 1, 2, 3... parts from the end to find a matching base key
66
- for (let i = 1; i < parts.length; i++) {
67
- const baseWithoutContext = parts.slice(0, -i).join(contextSeparator)
68
- if (keysAcceptingContext.has(baseWithoutContext)) {
69
- return true
70
- }
71
- }
72
- }
73
-
74
- // Also check if the key itself (after removing plural suffix) accepts context
75
- // This handles cases like 'friend_other' where 'friend' accepts context
76
- if (keysAcceptingContext.has(potentialBaseKey)) {
77
- return true
78
- }
79
-
80
- return false
81
- }
82
-
83
- /**
84
- * Recursively sorts the keys of an object.
85
- */
86
- function sortObject (obj: any, config?: I18nextToolkitConfig, customSort?: (a: string, b: string) => number): any {
87
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
88
- return obj
89
- }
90
-
91
- const sortedObj: Record<string, any> = {}
92
- const pluralSeparator = config?.extract?.pluralSeparator ?? '_'
93
-
94
- // Define the canonical order for plural forms
95
- const pluralOrder = ['zero', 'one', 'two', 'few', 'many', 'other']
96
- const ordinalPluralOrder = pluralOrder.map(form => `ordinal${pluralSeparator}${form}`)
97
-
98
- const keys = Object.keys(obj).sort((a, b) => {
99
- // Helper function to extract base key and form info
100
- const getKeyInfo = (key: string) => {
101
- // Handle ordinal plurals: key_ordinal_form or key_context_ordinal_form
102
- for (const form of ordinalPluralOrder) {
103
- if (key.endsWith(`${pluralSeparator}${form}`)) {
104
- const base = key.slice(0, -(pluralSeparator.length + form.length))
105
- return { base, form, isOrdinal: true, isPlural: true, fullKey: key }
106
- }
107
- }
108
- // Handle cardinal plurals: key_form or key_context_form
109
- for (const form of pluralOrder) {
110
- if (key.endsWith(`${pluralSeparator}${form}`)) {
111
- const base = key.slice(0, -(pluralSeparator.length + form.length))
112
- return { base, form, isOrdinal: false, isPlural: true, fullKey: key }
113
- }
114
- }
115
- return { base: key, form: '', isOrdinal: false, isPlural: false, fullKey: key }
116
- }
117
-
118
- const aInfo = getKeyInfo(a)
119
- const bInfo = getKeyInfo(b)
120
-
121
- // If both are plural forms
122
- if (aInfo.isPlural && bInfo.isPlural) {
123
- // First compare by base key
124
- const baseComparison = customSort
125
- ? customSort(aInfo.base, bInfo.base)
126
- : aInfo.base.localeCompare(bInfo.base, undefined, { sensitivity: 'base' })
127
- if (baseComparison !== 0) {
128
- return baseComparison
129
- }
130
-
131
- // Same base key - now sort by plural form order
132
- // Ordinal forms come after cardinal forms
133
- if (aInfo.isOrdinal !== bInfo.isOrdinal) {
134
- return aInfo.isOrdinal ? 1 : -1
135
- }
136
-
137
- // Both same type (cardinal or ordinal), sort by canonical order
138
- const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralOrder
139
- const aIndex = orderArray.indexOf(aInfo.form)
140
- const bIndex = orderArray.indexOf(bInfo.form)
141
-
142
- if (aIndex !== -1 && bIndex !== -1) {
143
- return aIndex - bIndex
144
- }
145
-
146
- // Fallback to alphabetical if forms not found in order array
147
- return aInfo.form.localeCompare(bInfo.form)
148
- }
149
-
150
- // Use custom sort if provided, otherwise default sorting
151
- if (customSort) {
152
- return customSort(a, b)
153
- }
154
-
155
- // Default: case-insensitive, then by case
156
- const caseInsensitiveComparison = a.localeCompare(b, undefined, { sensitivity: 'base' })
157
- if (caseInsensitiveComparison === 0) {
158
- return a.localeCompare(b, undefined, { sensitivity: 'case' })
159
- }
160
- return caseInsensitiveComparison
161
- })
162
-
163
- for (const key of keys) {
164
- sortedObj[key] = sortObject(obj[key], config, customSort)
165
- }
166
-
167
- return sortedObj
168
- }
169
-
170
- /**
171
- * A helper function to build a new translation object for a single namespace.
172
- * This centralizes the core logic of merging keys.
173
- */
174
- function buildNewTranslationsForNs (
175
- nsKeys: ExtractedKey[],
176
- existingTranslations: Record<string, any>,
177
- config: I18nextToolkitConfig,
178
- locale: string,
179
- namespace?: string,
180
- preservePatterns: RegExp[] = [],
181
- objectKeys: Set<string> = new Set(),
182
- syncPrimaryWithDefaults: boolean = false
183
- ): Record<string, any> {
184
- const {
185
- keySeparator = '.',
186
- sort = true,
187
- removeUnusedKeys = true,
188
- primaryLanguage,
189
- defaultValue: emptyDefaultValue = '',
190
- pluralSeparator = '_',
191
- contextSeparator = '_',
192
- preserveContextVariants = false,
193
- } = config.extract
194
-
195
- // Build a set of base keys that accept context (only if preserveContextVariants is enabled)
196
- // These are keys that were called with a context parameter in the source code
197
- const keysAcceptingContext = new Set<string>()
198
- if (preserveContextVariants) {
199
- for (const { keyAcceptingContext } of nsKeys) {
200
- if (keyAcceptingContext) {
201
- keysAcceptingContext.add(keyAcceptingContext)
202
- }
203
- }
204
- }
205
-
206
- // Get the plural categories for the target language
207
- const targetLanguagePluralCategories = new Set<string>()
208
- // Track cardinal plural categories separately so we can special-case single-"other" languages
209
- let cardinalCategories: string[] = []
210
- let ordinalCategories: string[] = []
211
- try {
212
- const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' })
213
- const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' })
214
-
215
- cardinalCategories = cardinalRules.resolvedOptions().pluralCategories
216
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories
217
- cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
218
- ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
219
- } catch (e) {
220
- // Fallback to primaryLanguage (or English) if locale is invalid
221
- const fallbackLang = primaryLanguage || 'en'
222
- const cardinalRules = new Intl.PluralRules(fallbackLang, { type: 'cardinal' })
223
- const ordinalRules = new Intl.PluralRules(fallbackLang, { type: 'ordinal' })
224
-
225
- cardinalCategories = cardinalRules.resolvedOptions().pluralCategories
226
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories
227
- cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
228
- ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
229
- }
230
-
231
- // Prepare namespace pattern checking helpers
232
- const rawPreserve = config.extract.preservePatterns || []
233
- const nsSep = typeof config.extract.nsSeparator === 'string' ? config.extract.nsSeparator : ':'
234
-
235
- // Helper to check if a key should be filtered out during extraction
236
- const shouldFilterKey = (key: string): boolean => {
237
- // 1) regex based patterns (existing behavior)
238
- if (preservePatterns.some(re => re.test(key))) {
239
- return true
240
- }
241
- // 2) namespace:* style patterns (respect nsSeparator)
242
- for (const rp of rawPreserve) {
243
- if (typeof rp !== 'string') continue
244
- if (rp.endsWith(`${nsSep}*`)) {
245
- const nsPrefix = rp.slice(0, -(nsSep.length + 1))
246
- // If namespace is provided to this builder, and pattern targets this namespace, skip keys from this ns
247
- // Support wildcard namespace '*' to match any namespace
248
- if (nsPrefix === '*' || (namespace && nsPrefix === namespace)) {
249
- return true
250
- }
251
- }
252
- }
253
- return false
254
- }
255
-
256
- // Helper to check if an existing key should be preserved
257
- const shouldPreserveExistingKey = (key: string): boolean => {
258
- // 1) regex-style patterns
259
- if (preservePatterns.some(re => re.test(key))) {
260
- return true
261
- }
262
- // 2) namespace:key patterns - check if pattern matches this namespace:key combination
263
- for (const rp of rawPreserve) {
264
- if (typeof rp !== 'string') continue
265
-
266
- // Handle namespace:* patterns
267
- if (rp.endsWith(`${nsSep}*`)) {
268
- const nsPrefix = rp.slice(0, -(nsSep.length + 1))
269
- if (nsPrefix === '*' || (namespace && nsPrefix === namespace)) {
270
- return true
271
- }
272
- }
273
-
274
- // Handle namespace:specificKey patterns (e.g., 'other:okey', 'other:second*')
275
- if (rp.includes(nsSep) && namespace) {
276
- const [patternNs, patternKey] = rp.split(nsSep)
277
- if (patternNs === namespace) {
278
- // Convert the key part to regex (handle wildcards)
279
- const keyRegex = globToRegex(patternKey)
280
- if (keyRegex.test(key)) {
281
- return true
282
- }
283
- }
284
- }
285
- }
286
- return false
287
- }
288
-
289
- // Filter nsKeys to only include keys relevant to this language
290
- const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
291
- // FIRST: Check if key matches preservePatterns and should be excluded
292
- if (shouldFilterKey(key)) {
293
- return false
294
- }
295
-
296
- if (!hasCount) {
297
- // Non-plural keys are always included
298
- return true
299
- }
300
-
301
- // For plural keys, check if this specific plural form is needed for the target language
302
- const keyParts = key.split(pluralSeparator)
303
-
304
- // If this is a base plural key (no plural suffix), keep it so that the
305
- // builder can expand it to the target locale's plural forms.
306
- if (hasCount && keyParts.length === 1) {
307
- return true
308
- }
309
-
310
- // Special-case single-cardinal-"other" languages (ja/zh/ko etc.):
311
- // when the target language's cardinal categories are exactly ['other'],
312
- // the extractor may have emitted the base key (no "_other" suffix).
313
- // Accept the base key in that situation, while still accepting explicit *_other variants.
314
- if (cardinalCategories.length === 1 && cardinalCategories[0] === 'other') {
315
- // If this is a plain/base key (no plural suffix), include it.
316
- if (keyParts.length === 1) return true
317
- // Otherwise fall through and check the explicit suffix as before.
318
- }
319
-
320
- if (isOrdinal && keyParts.includes('ordinal')) {
321
- // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
322
- const lastPart = keyParts[keyParts.length - 1]
323
- return targetLanguagePluralCategories.has(`ordinal_${lastPart}`)
324
- } else if (hasCount) {
325
- // For cardinal plurals: key_context_category or key_category
326
- const lastPart = keyParts[keyParts.length - 1]
327
- return targetLanguagePluralCategories.has(lastPart)
328
- }
329
-
330
- return true
331
- })
332
-
333
- // NEW: detect bases that already have expanded plural variants extracted.
334
- // If a base has explicit expanded variants (e.g. key_one, key_other or key_ordinal_one),
335
- // we should avoid generating/expanding the base plural key for that base to prevent
336
- // double-generation / duplicate counting.
337
- const expandedBases = new Set<string>()
338
- for (const ek of filteredKeys) {
339
- if (ek.isExpandedPlural) {
340
- const parts = String(ek.key).split(pluralSeparator)
341
- // If ordinal form like "key_ordinal_one" -> base should strip "_ordinal_<cat>"
342
- if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
343
- expandedBases.add(parts.slice(0, -2).join(pluralSeparator))
344
- } else {
345
- // strip single trailing category
346
- expandedBases.add(parts.slice(0, -1).join(pluralSeparator))
347
- }
348
- }
349
- }
350
-
351
- // If `removeUnusedKeys` is true, start with an empty object. Otherwise, start with a clone of the existing translations.
352
- let newTranslations: Record<string, any> = removeUnusedKeys
353
- ? {}
354
- : JSON.parse(JSON.stringify(existingTranslations))
355
-
356
- // Preserve keys that match the configured patterns OR are context variants of keys accepting context
357
- const existingKeys = getNestedKeys(existingTranslations, keySeparator ?? '.')
358
- for (const existingKey of existingKeys) {
359
- const shouldPreserve = shouldPreserveExistingKey(existingKey)
360
- const isContextVariant = !shouldPreserve && isContextVariantOfAcceptingKey(
361
- existingKey,
362
- keysAcceptingContext,
363
- pluralSeparator,
364
- contextSeparator
365
- )
366
-
367
- if (shouldPreserve || (preserveContextVariants && isContextVariant)) {
368
- const value = getNestedValue(existingTranslations, existingKey, keySeparator ?? '.')
369
- setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.')
370
- }
371
- }
372
-
373
- // SPECIAL HANDLING: Preserve existing _zero forms even if not in extracted keys
374
- // This ensures that optional _zero forms are not removed when they exist
375
- if (removeUnusedKeys) {
376
- const existingKeys = getNestedKeys(existingTranslations, keySeparator ?? '.')
377
- for (const existingKey of existingKeys) {
378
- // Check if this is a _zero form that should be preserved
379
- const keyParts = existingKey.split(pluralSeparator)
380
- const lastPart = keyParts[keyParts.length - 1]
381
-
382
- if (lastPart === 'zero') {
383
- // Check if the base plural key exists in our extracted keys
384
- const baseKey = keyParts.slice(0, -1).join(pluralSeparator)
385
- const hasBaseInExtracted = filteredKeys.some(({ key }) => {
386
- const extractedParts = key.split(pluralSeparator)
387
- const extractedBase = extractedParts.slice(0, -1).join(pluralSeparator)
388
- return extractedBase === baseKey
389
- })
390
-
391
- if (hasBaseInExtracted) {
392
- // Preserve the existing _zero form
393
- const value = getNestedValue(existingTranslations, existingKey, keySeparator ?? '.')
394
- setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.')
395
- }
396
- }
397
- }
398
- }
399
-
400
- // 1. Build the object first, without any sorting.
401
- for (const { key, defaultValue, explicitDefault, hasCount, isExpandedPlural, isOrdinal } of filteredKeys) {
402
- // If this is a base plural key (hasCount true but not an already-expanded variant)
403
- // and we detected explicit expanded variants for this base, skip expanding the base.
404
- if (hasCount && !isExpandedPlural) {
405
- const parts = String(key).split(pluralSeparator)
406
- let base = key
407
- if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
408
- base = parts.slice(0, -2).join(pluralSeparator)
409
- } else if (parts.length >= 2) {
410
- base = parts.slice(0, -1).join(pluralSeparator)
411
- }
412
- if (expandedBases.has(base)) {
413
- // Skip generating/expanding this base key because explicit expanded forms exist.
414
- continue
415
- }
416
- }
417
-
418
- // If this is a base plural key (no explicit suffix) and the locale is NOT the primary,
419
- // expand it into locale-specific plural variants (e.g. key_one, key_other).
420
- // Use the extracted defaultValue (fallback to base) for variant values.
421
- if (hasCount && !isExpandedPlural) {
422
- const parts = String(key).split(pluralSeparator)
423
- const isBaseKey = parts.length === 1
424
- if (isBaseKey && locale !== primaryLanguage) {
425
- // If explicit expanded variants exist, do not expand the base.
426
- const base = key
427
- if (expandedBases.has(base)) {
428
- // Skip expansion when explicit variants were provided
429
- } else {
430
- // choose categories based on ordinal flag
431
- const categories = isOrdinal ? ordinalCategories : cardinalCategories
432
- for (const category of categories) {
433
- const finalKey = isOrdinal
434
- ? `${base}${pluralSeparator}ordinal${pluralSeparator}${category}`
435
- : `${base}${pluralSeparator}${category}`
436
-
437
- // Preserve existing translation if present; otherwise set a sensible default
438
- const existingVariantValue = getNestedValue(existingTranslations, finalKey, keySeparator ?? '.')
439
- if (existingVariantValue === undefined) {
440
- // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
441
- // (resolved via resolveDefaultValue which handles functions or strings and accepts the full parameter set).
442
- let resolvedValue: string
443
- if (typeof defaultValue === 'string') {
444
- resolvedValue = defaultValue
445
- } else {
446
- // Use resolveDefaultValue to compute a sensible default, providing namespace and locale context.
447
- resolvedValue = resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue)
448
- }
449
- setNestedValue(newTranslations, finalKey, resolvedValue, keySeparator ?? '.')
450
- } else {
451
- // Keep existing translation
452
- setNestedValue(newTranslations, finalKey, existingVariantValue, keySeparator ?? '.')
453
- }
454
- }
455
- }
456
- // We've expanded variants for this base key; skip the normal single-key handling.
457
- continue
458
- }
459
- }
460
-
461
- const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
462
- // When keySeparator === false we are working with flat keys (no nesting).
463
- // Avoid concatenating false into strings (``${key}${false}`` => "keyfalse") which breaks the startsWith check.
464
- // For flat keys there cannot be nested children, so treat them as leaves.
465
- const isLeafInNewKeys = keySeparator === false
466
- ? true
467
- : !filteredKeys.some(otherKey => otherKey.key !== key && otherKey.key.startsWith(`${key}${keySeparator}`))
468
-
469
- // Determine if we should preserve an existing object
470
- const shouldPreserveObject = typeof existingValue === 'object' && existingValue !== null && (
471
- objectKeys.has(key) || // Explicit returnObjects
472
- !defaultValue || defaultValue === key // No explicit default or default equals key
473
- )
474
-
475
- const isStaleObject = typeof existingValue === 'object' && existingValue !== null && isLeafInNewKeys && !objectKeys.has(key) && !shouldPreserveObject
476
-
477
- // Special handling for existing objects that should be preserved
478
- if (shouldPreserveObject) {
479
- setNestedValue(newTranslations, key, existingValue, keySeparator ?? '.')
480
- continue
481
- }
482
-
483
- let valueToSet: string
484
-
485
- if (existingValue === undefined || isStaleObject) {
486
- // New key or stale object - determine what value to use
487
- if (locale === primaryLanguage) {
488
- if (syncPrimaryWithDefaults) {
489
- // When syncPrimaryWithDefaults is true:
490
- // - Use defaultValue if it exists and is meaningful (not derived from key pattern)
491
- // - Otherwise use empty string for new keys
492
- const isDerivedDefault = defaultValue && (
493
- defaultValue === key || // Exact match with the key itself
494
- // Check if defaultValue matches the namespaced key format (namespace:key)
495
- (nsSep && namespace && defaultValue === `${namespace}${nsSep}${key}`) ||
496
- // For variant keys (plural/context), check if defaultValue is the base
497
- (key !== defaultValue &&
498
- (key.startsWith(defaultValue + pluralSeparator) ||
499
- key.startsWith(defaultValue + contextSeparator)))
500
- )
501
-
502
- valueToSet = (defaultValue && !isDerivedDefault) ? defaultValue : resolveDefaultValue(emptyDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue)
503
- } else {
504
- // syncPrimaryWithDefaults is false - use original behavior
505
- valueToSet = defaultValue || key
506
- }
507
- } else {
508
- // For secondary languages, always use empty string
509
- valueToSet = resolveDefaultValue(emptyDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue)
510
- }
511
- } else {
512
- // Existing value exists - decide whether to preserve or sync
513
- if (locale === primaryLanguage && syncPrimaryWithDefaults) {
514
- // Only update when we have a meaningful defaultValue that's not derived from the key pattern.
515
- const isDerivedDefault = defaultValue && (
516
- defaultValue === key || // Exact match with the key itself
517
- // Check if defaultValue matches the namespaced key format (namespace:key)
518
- (nsSep && namespace && defaultValue === `${namespace}${nsSep}${key}`) ||
519
- // For variant keys (plural/context), check if defaultValue is the base
520
- (key !== defaultValue &&
521
- (key.startsWith(defaultValue + pluralSeparator) ||
522
- key.startsWith(defaultValue + contextSeparator)))
523
- )
524
-
525
- // If this key looks like a plural/context variant and the default
526
- // wasn't explicitly provided in source code, preserve the existing value.
527
- const isVariantKey = key.includes(pluralSeparator) || key.includes(contextSeparator)
528
- if (isVariantKey && !explicitDefault) {
529
- valueToSet = existingValue
530
- } else if (defaultValue && !isDerivedDefault) {
531
- // Otherwise, if we have a meaningful (non-derived) default, apply it.
532
- valueToSet = defaultValue
533
- } else {
534
- // Fallback: preserve existing translation.
535
- valueToSet = existingValue
536
- }
537
- } else {
538
- // Not primary language or not syncing - always preserve existing
539
- valueToSet = existingValue
540
- }
541
- }
542
-
543
- setNestedValue(newTranslations, key, valueToSet, keySeparator ?? '.')
544
- }
545
-
546
- // 2. If sorting is enabled, recursively sort the entire object.
547
- // This correctly handles both top-level and nested keys.
548
- if (sort === true) {
549
- return sortObject(newTranslations, config)
550
- }
551
- // Custom sort function logic remains as a future enhancement if needed,
552
- // but for now, this robustly handles the most common `sort: true` case.
553
- if (typeof sort === 'function') {
554
- const sortedObject: Record<string, any> = {}
555
- const topLevelKeys = Object.keys(newTranslations)
556
-
557
- // Create a map from key string to ExtractedKey for lookup
558
- const keyMap = new Map<string, ExtractedKey>()
559
- for (const extractedKey of nsKeys) {
560
- // Store the full key path
561
- keyMap.set(String(extractedKey.key), extractedKey)
562
-
563
- // For nested keys, also store the top-level part
564
- if (keySeparator) {
565
- const topLevelKey = String(extractedKey.key).split(keySeparator)[0]
566
- if (!keyMap.has(topLevelKey)) {
567
- keyMap.set(topLevelKey, extractedKey)
568
- }
569
- }
570
- }
571
-
572
- // Create a string comparator that applies the same logic as the custom sort function
573
- // by extracting the actual comparison behavior
574
- const stringSort = (a: string, b: string) => {
575
- // Try to find ExtractedKey objects to use the custom comparator
576
- const keyA = keyMap.get(a)
577
- const keyB = keyMap.get(b)
578
-
579
- if (keyA && keyB) {
580
- return sort(keyA, keyB)
581
- }
582
-
583
- // If we don't have ExtractedKey objects, we need to apply the same sorting logic
584
- // Create mock ExtractedKey objects with just the key property
585
- const mockKeyA = { key: a } as ExtractedKey
586
- const mockKeyB = { key: b } as ExtractedKey
587
-
588
- return sort(mockKeyA, mockKeyB)
589
- }
590
-
591
- // Sort top-level keys
592
- topLevelKeys.sort(stringSort)
593
-
594
- // Pass the same string comparator to sortObject for nested keys
595
- for (const key of topLevelKeys) {
596
- sortedObject[key] = sortObject(newTranslations[key], config, stringSort)
597
- }
598
- newTranslations = sortedObject
599
- }
600
-
601
- return newTranslations
602
- }
603
-
604
- /**
605
- * Processes extracted translation keys and generates translation files for all configured locales.
606
- *
607
- * This function:
608
- * 1. Groups keys by namespace
609
- * 2. For each locale and namespace combination:
610
- * - Reads existing translation files
611
- * - Preserves keys matching `preservePatterns` and those from `objectKeys`
612
- * - Merges in newly extracted keys
613
- * - Uses primary language defaults or empty strings for secondary languages
614
- * - Maintains key sorting based on configuration
615
- * 3. Determines if files need updating by comparing content
616
- *
617
- * @param keys - Map of extracted translation keys with metadata.
618
- * @param objectKeys - A set of base keys that were called with the `returnObjects: true` option.
619
- * @param config - The i18next toolkit configuration object.
620
- * @returns Promise resolving to array of translation results with update status.
621
- *
622
- * @example
623
- * ```typescript
624
- * const keys = new Map([
625
- * ['translation:welcome', { key: 'welcome', defaultValue: 'Welcome!', ns: 'translation' }],
626
- * ]);
627
- * const objectKeys = new Set(['countries']);
628
- *
629
- * const results = await getTranslations(keys, objectKeys, config);
630
- * // Results contain update status and new/existing translations for each locale.
631
- * ```
632
- */
633
- export async function getTranslations (
634
- keys: Map<string, ExtractedKey>,
635
- objectKeys: Set<string>,
636
- config: I18nextToolkitConfig,
637
- { syncPrimaryWithDefaults = false }: { syncPrimaryWithDefaults?: boolean } = {}
638
- ): Promise<TranslationResult[]> {
639
- config.extract.primaryLanguage ||= config.locales[0] || 'en'
640
- config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
641
- const patternsToPreserve = [...(config.extract.preservePatterns || [])]
642
- const indentation = config.extract.indentation ?? 2
643
-
644
- for (const key of objectKeys) {
645
- // Convert the object key to a glob pattern to preserve all its children
646
- patternsToPreserve.push(`${key}.*`)
647
- }
648
- const preservePatterns = patternsToPreserve.map(globToRegex)
649
-
650
- // Group keys by namespace. If the plugin recorded the namespace as implicit
651
- // (nsIsImplicit) AND the user set defaultNS === false we treat those keys
652
- // as "no namespace" (will be merged at top-level). Otherwise use the stored
653
- // namespace (internally we keep implicit keys as 'translation').
654
- const NO_NS_TOKEN = '__no_namespace__'
655
- const keysByNS = new Map<string, ExtractedKey[]>()
656
- for (const k of keys.values()) {
657
- const nsKey = (k.nsIsImplicit && config.extract.defaultNS === false)
658
- ? NO_NS_TOKEN
659
- : String(k.ns ?? (config.extract.defaultNS ?? 'translation'))
660
- if (!keysByNS.has(nsKey)) keysByNS.set(nsKey, [])
661
- keysByNS.get(nsKey)!.push(k)
662
- }
663
-
664
- const results: TranslationResult[] = []
665
- const userIgnore = Array.isArray(config.extract.ignore)
666
- ? config.extract.ignore
667
- : config.extract.ignore ? [config.extract.ignore] : []
668
-
669
- // Process each locale one by one
670
- for (const locale of config.locales) {
671
- // If output is a string we can detect the presence of the namespace placeholder.
672
- // If it's a function we cannot reliably detect that here — default to not merged
673
- // unless mergeNamespaces is explicitly true.
674
- const shouldMerge = config.extract.mergeNamespaces || (typeof config.extract.output === 'string' ? !config.extract.output.includes('{{namespace}}') : false)
675
-
676
- // LOGIC PATH 1: Merged Namespaces
677
- if (shouldMerge) {
678
- const newMergedTranslations: Record<string, any> = {}
679
- const outputPath = getOutputPath(config.extract.output, locale)
680
- const fullPath = resolve(process.cwd(), outputPath)
681
- const existingMergedFile = await loadTranslationFile(fullPath) || {}
682
-
683
- // Determine whether the existing merged file already uses namespace objects
684
- // or is a flat mapping of translation keys -> values.
685
- // If it's flat (values are primitives), we must NOT treat each translation key as a namespace.
686
- const existingKeys = Object.keys(existingMergedFile)
687
- // Treat the file as namespaced only when the user is using namespaces.
688
- // If defaultNS === false the project stores translations at the top-level
689
- // (possibly as nested objects when keySeparator is '.'), which should NOT
690
- // be interpreted as "namespaced files". This avoids splitting a single
691
- // merged translations file into artificial namespace buckets on re-extract.
692
- const existingIsNamespaced = (config.extract.defaultNS !== false) && existingKeys.some(k => {
693
- const v = (existingMergedFile as any)[k]
694
- return typeof v === 'object' && v !== null && !Array.isArray(v)
695
- })
696
-
697
- // The namespaces to process:
698
- // - If existing file is namespaced, combine keysByNS with existingMergedFile namespaces.
699
- // - If existing file is flat (top-level translations), ensure NO_NS_TOKEN is processed.
700
- const namespacesToProcess = existingIsNamespaced
701
- ? new Set<string>([...keysByNS.keys(), ...existingKeys])
702
- : new Set<string>([...keysByNS.keys(), NO_NS_TOKEN])
703
-
704
- for (const nsKey of namespacesToProcess) {
705
- const nsKeys = keysByNS.get(nsKey) || []
706
- if (nsKey === NO_NS_TOKEN) {
707
- // keys without namespace -> merged into top-level of the merged file
708
- const built = buildNewTranslationsForNs(nsKeys, existingMergedFile, config, locale, undefined, preservePatterns, objectKeys, syncPrimaryWithDefaults)
709
- Object.assign(newMergedTranslations, built)
710
- } else {
711
- const existingTranslations = existingMergedFile[nsKey] || {}
712
- newMergedTranslations[nsKey] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, nsKey, preservePatterns, objectKeys, syncPrimaryWithDefaults)
713
- }
714
- }
715
-
716
- const oldContent = JSON.stringify(existingMergedFile, null, indentation)
717
- const newContent = JSON.stringify(newMergedTranslations, null, indentation)
718
- // Push a single result for the merged file
719
- results.push({ path: fullPath, updated: newContent !== oldContent, newTranslations: newMergedTranslations, existingTranslations: existingMergedFile })
720
-
721
- // LOGIC PATH 2: Separate Namespace Files
722
- } else {
723
- // Find all namespaces that exist on disk for this locale
724
- const namespacesToProcess = new Set(keysByNS.keys())
725
- const existingNsPattern = getOutputPath(config.extract.output, locale, '*')
726
- // Ensure glob receives POSIX-style separators so pattern matching works cross-platform (Windows -> backslashes)
727
- const existingNsGlobPattern = existingNsPattern.replace(/\\/g, '/')
728
- const existingNsFiles = await glob(existingNsGlobPattern, { ignore: userIgnore })
729
- for (const file of existingNsFiles) {
730
- namespacesToProcess.add(basename(file, extname(file)))
731
- }
732
-
733
- // Process each namespace individually and create a result for each one
734
- for (const ns of namespacesToProcess) {
735
- const nsKeys = keysByNS.get(ns) || []
736
- const outputPath = getOutputPath(config.extract.output, locale, ns)
737
- const fullPath = resolve(process.cwd(), outputPath)
738
- const existingTranslations = await loadTranslationFile(fullPath) || {}
739
- const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys, syncPrimaryWithDefaults)
740
-
741
- const oldContent = JSON.stringify(existingTranslations, null, indentation)
742
- const newContent = JSON.stringify(newTranslations, null, indentation)
743
- // Push one result per namespace file
744
- results.push({ path: fullPath, updated: newContent !== oldContent, newTranslations, existingTranslations })
745
- }
746
- }
747
- }
748
-
749
- return results
750
- }