i18next-cli 0.9.8 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/README.md +69 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/core/extractor.js +1 -1
- package/dist/cjs/extractor/core/translation-manager.js +1 -1
- package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
- package/dist/cjs/extractor/parsers/jsx-parser.js +1 -1
- package/dist/cjs/heuristic-config.js +1 -1
- package/dist/cjs/status.js +1 -1
- package/dist/cjs/syncer.js +1 -1
- package/dist/cjs/utils/file-utils.js +1 -1
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/core/extractor.js +1 -1
- package/dist/esm/extractor/core/translation-manager.js +1 -1
- package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
- package/dist/esm/extractor/parsers/jsx-parser.js +1 -1
- package/dist/esm/heuristic-config.js +1 -1
- package/dist/esm/status.js +1 -1
- package/dist/esm/syncer.js +1 -1
- package/dist/esm/utils/file-utils.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/extractor/core/extractor.ts +13 -7
- package/src/extractor/core/translation-manager.ts +38 -21
- package/src/extractor/parsers/ast-visitors.ts +123 -53
- package/src/extractor/parsers/jsx-parser.ts +11 -1
- package/src/heuristic-config.ts +14 -3
- package/src/status.ts +21 -13
- package/src/syncer.ts +77 -67
- package/src/types.ts +22 -1
- package/src/utils/file-utils.ts +54 -1
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/parsers/ast-visitors.d.ts +33 -15
- package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
- package/types/extractor/parsers/jsx-parser.d.ts.map +1 -1
- package/types/heuristic-config.d.ts.map +1 -1
- package/types/status.d.ts.map +1 -1
- package/types/syncer.d.ts.map +1 -1
- package/types/types.d.ts +19 -1
- package/types/types.d.ts.map +1 -1
- package/types/utils/file-utils.d.ts +16 -1
- package/types/utils/file-utils.d.ts.map +1 -1
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { TranslationResult, ExtractedKey, I18nextToolkitConfig } from '../../types'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
2
|
import { resolve } from 'node:path'
|
|
4
3
|
import { getNestedValue, setNestedValue, getNestedKeys } from '../../utils/nested-object'
|
|
5
|
-
import { getOutputPath } from '../../utils/file-utils'
|
|
4
|
+
import { getOutputPath, loadTranslationFile } from '../../utils/file-utils'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Converts a glob pattern to a regular expression for matching keys
|
|
@@ -51,13 +50,15 @@ export async function getTranslations (
|
|
|
51
50
|
const defaultNS = config.extract.defaultNS ?? 'translation'
|
|
52
51
|
const keySeparator = config.extract.keySeparator ?? '.'
|
|
53
52
|
const patternsToPreserve = [...(config.extract.preservePatterns || [])]
|
|
53
|
+
const mergeNamespaces = config.extract.mergeNamespaces ?? false
|
|
54
54
|
for (const key of objectKeys) {
|
|
55
55
|
// Convert the object key to a glob pattern to preserve all its children
|
|
56
56
|
patternsToPreserve.push(`${key}.*`)
|
|
57
57
|
}
|
|
58
58
|
const preservePatterns = patternsToPreserve.map(globToRegex)
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
60
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
|
|
61
|
+
const primaryLanguage = config.extract.primaryLanguage
|
|
61
62
|
|
|
62
63
|
// Group keys by namespace
|
|
63
64
|
const keysByNS = new Map<string, ExtractedKey[]>()
|
|
@@ -72,21 +73,16 @@ export async function getTranslations (
|
|
|
72
73
|
const results: TranslationResult[] = []
|
|
73
74
|
|
|
74
75
|
for (const locale of config.locales) {
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const mergedTranslations: Record<string, any> = {}
|
|
77
|
+
const mergedExisting: Record<string, any> = {}
|
|
77
78
|
|
|
79
|
+
for (const [ns, nsKeys] of keysByNS.entries()) {
|
|
80
|
+
const outputPath = getOutputPath(config.extract.output, locale, mergeNamespaces ? undefined : ns)
|
|
78
81
|
const fullPath = resolve(process.cwd(), outputPath)
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
let existingTranslations: Record<string, any> = {}
|
|
82
|
-
try {
|
|
83
|
-
oldContent = await readFile(fullPath, 'utf-8')
|
|
84
|
-
existingTranslations = JSON.parse(oldContent)
|
|
85
|
-
} catch (e) { /* File doesn't exist, which is fine */ }
|
|
86
|
-
|
|
83
|
+
const existingTranslations = await loadTranslationFile(fullPath) || {}
|
|
87
84
|
const newTranslations: Record<string, any> = {}
|
|
88
85
|
|
|
89
|
-
// 1. Preserve keys from existing translations that match patterns
|
|
90
86
|
const existingKeys = getNestedKeys(existingTranslations, keySeparator)
|
|
91
87
|
for (const existingKey of existingKeys) {
|
|
92
88
|
if (preservePatterns.some(re => re.test(existingKey))) {
|
|
@@ -95,24 +91,45 @@ export async function getTranslations (
|
|
|
95
91
|
}
|
|
96
92
|
}
|
|
97
93
|
|
|
98
|
-
// 2. Merge in newly found keys for this namespace
|
|
99
94
|
const sortedKeys = (config.extract.sort === false)
|
|
100
95
|
? nsKeys
|
|
101
|
-
: nsKeys.sort((a, b) => a.key.localeCompare(b.key))
|
|
96
|
+
: [...nsKeys].sort((a, b) => a.key.localeCompare(b.key))
|
|
97
|
+
|
|
102
98
|
for (const { key, defaultValue } of sortedKeys) {
|
|
103
99
|
const existingValue = getNestedValue(existingTranslations, key, keySeparator)
|
|
104
|
-
const valueToSet = existingValue ?? (locale ===
|
|
100
|
+
const valueToSet = existingValue ?? (locale === primaryLanguage ? defaultValue : (config.extract.defaultValue ?? ''))
|
|
105
101
|
setNestedValue(newTranslations, key, valueToSet, keySeparator)
|
|
106
102
|
}
|
|
107
103
|
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
if (mergeNamespaces) {
|
|
105
|
+
mergedTranslations[ns] = newTranslations
|
|
106
|
+
if (Object.keys(existingTranslations).length > 0) {
|
|
107
|
+
mergedExisting[ns] = existingTranslations
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
const oldContent = existingTranslations ? JSON.stringify(existingTranslations, null, config.extract.indentation ?? 2) : ''
|
|
111
|
+
const newContent = JSON.stringify(newTranslations, null, config.extract.indentation ?? 2)
|
|
112
|
+
|
|
113
|
+
results.push({
|
|
114
|
+
path: fullPath,
|
|
115
|
+
updated: newContent !== oldContent,
|
|
116
|
+
newTranslations,
|
|
117
|
+
existingTranslations,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (mergeNamespaces) {
|
|
123
|
+
const outputPath = getOutputPath(config.extract.output, locale)
|
|
124
|
+
const fullPath = resolve(process.cwd(), outputPath)
|
|
125
|
+
const oldContent = Object.keys(mergedExisting).length > 0 ? JSON.stringify(mergedExisting, null, config.extract.indentation ?? 2) : ''
|
|
126
|
+
const newContent = JSON.stringify(mergedTranslations, null, config.extract.indentation ?? 2)
|
|
110
127
|
|
|
111
128
|
results.push({
|
|
112
129
|
path: fullPath,
|
|
113
130
|
updated: newContent !== oldContent,
|
|
114
|
-
newTranslations,
|
|
115
|
-
existingTranslations,
|
|
131
|
+
newTranslations: mergedTranslations,
|
|
132
|
+
existingTranslations: mergedExisting,
|
|
116
133
|
})
|
|
117
134
|
}
|
|
118
135
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression } from '@swc/core'
|
|
1
|
+
import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression } from '@swc/core'
|
|
2
2
|
import type { PluginContext, I18nextToolkitConfig, Logger } from '../../types'
|
|
3
3
|
import { extractFromTransComponent } from './jsx-parser'
|
|
4
4
|
|
|
@@ -337,7 +337,7 @@ export class ASTVisitors {
|
|
|
337
337
|
if (callee.type !== 'Identifier') return
|
|
338
338
|
|
|
339
339
|
const scopeInfo = this.getVarFromScope(callee.value)
|
|
340
|
-
const isFunctionToParse = (this.config.extract.functions || []).includes(callee.value) || scopeInfo !== undefined
|
|
340
|
+
const isFunctionToParse = (this.config.extract.functions || ['t']).includes(callee.value) || scopeInfo !== undefined
|
|
341
341
|
if (!isFunctionToParse || node.arguments.length === 0) return
|
|
342
342
|
|
|
343
343
|
const firstArg = node.arguments[0].expression
|
|
@@ -359,8 +359,26 @@ export class ASTVisitors {
|
|
|
359
359
|
|
|
360
360
|
if (keysToProcess.length === 0) return
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
|
|
362
|
+
let defaultValue: string | undefined
|
|
363
|
+
let options: ObjectExpression | undefined
|
|
364
|
+
|
|
365
|
+
if (node.arguments.length > 1) {
|
|
366
|
+
const arg2 = node.arguments[1].expression
|
|
367
|
+
if (arg2.type === 'ObjectExpression') {
|
|
368
|
+
options = arg2
|
|
369
|
+
} else if (arg2.type === 'StringLiteral') {
|
|
370
|
+
defaultValue = arg2.value
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (node.arguments.length > 2) {
|
|
374
|
+
const arg3 = node.arguments[2].expression
|
|
375
|
+
if (arg3.type === 'ObjectExpression') {
|
|
376
|
+
options = arg3
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const defaultValueFromOptions = options ? this.getObjectPropValue(options, 'defaultValue') : undefined
|
|
381
|
+
const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
|
|
364
382
|
|
|
365
383
|
// Loop through each key found (could be one or more) and process it
|
|
366
384
|
for (let i = 0; i < keysToProcess.length; i++) {
|
|
@@ -391,35 +409,50 @@ export class ASTVisitors {
|
|
|
391
409
|
// The explicit defaultValue only applies to the LAST key in the fallback array.
|
|
392
410
|
// For all preceding keys, their own key is their fallback.
|
|
393
411
|
const isLastKey = i === keysToProcess.length - 1
|
|
394
|
-
const dv = isLastKey ?
|
|
412
|
+
const dv = isLastKey ? (finalDefaultValue || key) : key
|
|
395
413
|
|
|
396
414
|
// Handle plurals, context, and returnObjects
|
|
397
|
-
let keyHandled = false
|
|
398
415
|
if (options?.type === 'ObjectExpression') {
|
|
399
|
-
|
|
416
|
+
const contextProp = this.getObjectProperty(options, 'context')
|
|
417
|
+
|
|
418
|
+
// 1. Handle Dynamic Context (Ternary) first
|
|
419
|
+
if (contextProp?.value?.type === 'ConditionalExpression') {
|
|
420
|
+
const contextValues = this.resolvePossibleStringValues(contextProp.value)
|
|
421
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
422
|
+
|
|
423
|
+
if (contextValues.length > 0) {
|
|
424
|
+
contextValues.forEach(context => {
|
|
425
|
+
this.pluginContext.addKey({ key: `${finalKey}${contextSeparator}${context}`, ns, defaultValue: dv })
|
|
426
|
+
})
|
|
427
|
+
// For dynamic context, also add the base key as a fallback
|
|
428
|
+
this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
|
|
429
|
+
continue // This key is fully handled, move to the next in the array
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 2. Handle Static Context
|
|
400
434
|
const contextValue = this.getObjectPropValue(options, 'context')
|
|
401
435
|
if (typeof contextValue === 'string' && contextValue) {
|
|
402
436
|
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
403
437
|
this.pluginContext.addKey({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
|
|
404
|
-
|
|
438
|
+
continue // This key is fully handled
|
|
405
439
|
}
|
|
406
440
|
|
|
407
|
-
// Handle
|
|
408
|
-
if (
|
|
441
|
+
// 3. Handle Plurals
|
|
442
|
+
if (this.getObjectPropValue(options, 'count') !== undefined) {
|
|
409
443
|
this.handlePluralKeys(finalKey, dv, ns)
|
|
410
|
-
|
|
444
|
+
continue // This key is fully handled
|
|
411
445
|
}
|
|
412
446
|
|
|
413
|
-
// Handle returnObjects
|
|
414
|
-
if (
|
|
447
|
+
// 4. Handle returnObjects
|
|
448
|
+
if (this.getObjectPropValue(options, 'returnObjects') === true) {
|
|
415
449
|
this.objectKeys.add(finalKey)
|
|
416
|
-
//
|
|
450
|
+
// Fall through to add the base key itself
|
|
417
451
|
}
|
|
418
452
|
}
|
|
419
453
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
454
|
+
// 5. Default case: Add the simple key
|
|
455
|
+
this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
|
|
423
456
|
}
|
|
424
457
|
}
|
|
425
458
|
|
|
@@ -455,39 +488,6 @@ export class ASTVisitors {
|
|
|
455
488
|
}
|
|
456
489
|
}
|
|
457
490
|
|
|
458
|
-
/**
|
|
459
|
-
* Extracts default value from translation function call arguments.
|
|
460
|
-
*
|
|
461
|
-
* Supports multiple patterns:
|
|
462
|
-
* - String as second argument: `t('key', 'Default')`
|
|
463
|
-
* - Object with defaultValue: `t('key', { defaultValue: 'Default' })`
|
|
464
|
-
* - Falls back to the key itself if no default found
|
|
465
|
-
*
|
|
466
|
-
* @param node - Call expression node
|
|
467
|
-
* @param fallback - Fallback value if no default found
|
|
468
|
-
* @returns Extracted default value
|
|
469
|
-
*
|
|
470
|
-
* @private
|
|
471
|
-
*/
|
|
472
|
-
private getDefaultValue (node: CallExpression, fallback: string): string {
|
|
473
|
-
if (node.arguments.length <= 1) return fallback
|
|
474
|
-
|
|
475
|
-
const secondArg = node.arguments[1].expression
|
|
476
|
-
|
|
477
|
-
if (secondArg.type === 'StringLiteral') {
|
|
478
|
-
return secondArg.value || fallback
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (secondArg.type === 'ObjectExpression') {
|
|
482
|
-
const val = this.getObjectPropValue(secondArg, 'defaultValue')
|
|
483
|
-
if (typeof val === 'string') return val || fallback
|
|
484
|
-
if (typeof val === 'number' || typeof val === 'boolean') return String(val)
|
|
485
|
-
return fallback
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return fallback
|
|
489
|
-
}
|
|
490
|
-
|
|
491
491
|
/**
|
|
492
492
|
* Processes JSX elements to extract translation keys from Trans components.
|
|
493
493
|
*
|
|
@@ -532,11 +532,20 @@ export class ASTVisitors {
|
|
|
532
532
|
extractedKey.ns = this.config.extract.defaultNS
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
if (extractedKey.contextExpression) {
|
|
536
|
+
const contextValues = this.resolvePossibleStringValues(extractedKey.contextExpression)
|
|
537
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
538
|
+
|
|
539
|
+
if (contextValues.length > 0) {
|
|
540
|
+
for (const context of contextValues) {
|
|
541
|
+
this.pluginContext.addKey({ key: `${extractedKey.key}${contextSeparator}${context}`, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
|
|
542
|
+
}
|
|
543
|
+
// Add the base key as well
|
|
544
|
+
this.pluginContext.addKey(extractedKey)
|
|
545
|
+
}
|
|
546
|
+
} else if (extractedKey.hasCount) {
|
|
537
547
|
this.handlePluralKeys(extractedKey.key, extractedKey.defaultValue, extractedKey.ns)
|
|
538
548
|
} else {
|
|
539
|
-
// Otherwise, add the key as-is
|
|
540
549
|
this.pluginContext.addKey(extractedKey)
|
|
541
550
|
}
|
|
542
551
|
// The duplicated addKey call has been removed.
|
|
@@ -671,4 +680,65 @@ export class ASTVisitors {
|
|
|
671
680
|
|
|
672
681
|
return null
|
|
673
682
|
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Resolves an expression to one or more possible string values that can be
|
|
686
|
+
* determined statically from the AST.
|
|
687
|
+
*
|
|
688
|
+
* Supports:
|
|
689
|
+
* - StringLiteral -> single value
|
|
690
|
+
* - ConditionalExpression (ternary) -> union of consequent and alternate resolved values
|
|
691
|
+
* - The identifier `undefined` -> empty array
|
|
692
|
+
*
|
|
693
|
+
* For any other expression types (identifiers, function calls, member expressions,
|
|
694
|
+
* etc.) the value cannot be determined statically and an empty array is returned.
|
|
695
|
+
*
|
|
696
|
+
* @private
|
|
697
|
+
* @param expression - The SWC AST expression node to resolve
|
|
698
|
+
* @returns An array of possible string values that the expression may produce.
|
|
699
|
+
*/
|
|
700
|
+
private resolvePossibleStringValues (expression: Expression): string[] {
|
|
701
|
+
if (expression.type === 'StringLiteral') {
|
|
702
|
+
return [expression.value]
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (expression.type === 'ConditionalExpression') { // This is a ternary operator
|
|
706
|
+
const consequentValues = this.resolvePossibleStringValues(expression.consequent)
|
|
707
|
+
const alternateValues = this.resolvePossibleStringValues(expression.alternate)
|
|
708
|
+
return [...consequentValues, ...alternateValues]
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (expression.type === 'Identifier' && expression.value === 'undefined') {
|
|
712
|
+
return [] // Handle the `undefined` case
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// We can't statically determine the value of other expressions (e.g., variables, function calls)
|
|
716
|
+
return []
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Finds and returns the full property node (KeyValueProperty) for the given
|
|
721
|
+
* property name from an ObjectExpression.
|
|
722
|
+
*
|
|
723
|
+
* Matches both identifier keys (e.g., { ns: 'value' }) and string literal keys
|
|
724
|
+
* (e.g., { 'ns': 'value' }).
|
|
725
|
+
*
|
|
726
|
+
* This helper returns the full property node rather than just its primitive
|
|
727
|
+
* value so callers can inspect expression types (ConditionalExpression, etc.).
|
|
728
|
+
*
|
|
729
|
+
* @private
|
|
730
|
+
* @param object - The SWC ObjectExpression to search
|
|
731
|
+
* @param propName - The property name to locate
|
|
732
|
+
* @returns The matching KeyValueProperty node if found, otherwise undefined.
|
|
733
|
+
*/
|
|
734
|
+
private getObjectProperty (object: ObjectExpression, propName: string): any {
|
|
735
|
+
return (object.properties).find(
|
|
736
|
+
(p) =>
|
|
737
|
+
p.type === 'KeyValueProperty' &&
|
|
738
|
+
(
|
|
739
|
+
(p.key?.type === 'Identifier' && p.key.value === propName) ||
|
|
740
|
+
(p.key?.type === 'StringLiteral' && p.key.value === propName)
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
}
|
|
674
744
|
}
|
|
@@ -55,6 +55,16 @@ export function extractFromTransComponent (node: JSXElement, config: I18nextTool
|
|
|
55
55
|
)
|
|
56
56
|
const hasCount = !!countAttr
|
|
57
57
|
|
|
58
|
+
const contextAttr = node.opening.attributes?.find(
|
|
59
|
+
(attr) =>
|
|
60
|
+
attr.type === 'JSXAttribute' &&
|
|
61
|
+
attr.name.type === 'Identifier' &&
|
|
62
|
+
attr.name.value === 'context'
|
|
63
|
+
)
|
|
64
|
+
const contextExpression = (contextAttr?.type === 'JSXAttribute' && contextAttr.value?.type === 'JSXExpressionContainer')
|
|
65
|
+
? contextAttr.value.expression
|
|
66
|
+
: undefined
|
|
67
|
+
|
|
58
68
|
let key: string
|
|
59
69
|
if (i18nKeyAttr?.type === 'JSXAttribute' && i18nKeyAttr.value?.type === 'StringLiteral') {
|
|
60
70
|
key = i18nKeyAttr.value.value
|
|
@@ -81,7 +91,7 @@ export function extractFromTransComponent (node: JSXElement, config: I18nextTool
|
|
|
81
91
|
defaultValue = serializeJSXChildren(node.children, config)
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
return { key, ns, defaultValue: defaultValue || key, hasCount }
|
|
94
|
+
return { key, ns, defaultValue: defaultValue || key, hasCount, contextExpression }
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
/**
|
package/src/heuristic-config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { glob } from 'glob'
|
|
2
2
|
import { readdir } from 'node:fs/promises'
|
|
3
|
-
import { dirname, join } from 'node:path'
|
|
3
|
+
import { dirname, join, extname } from 'node:path'
|
|
4
4
|
import type { I18nextToolkitConfig } from './types'
|
|
5
5
|
|
|
6
6
|
// A list of common glob patterns for the primary language ('en') or ('dev') translation files.
|
|
@@ -32,10 +32,20 @@ export async function detectConfig (): Promise<Partial<I18nextToolkitConfig> | n
|
|
|
32
32
|
if (files.length > 0) {
|
|
33
33
|
const firstFile = files[0]
|
|
34
34
|
const basePath = dirname(dirname(firstFile))
|
|
35
|
+
const extension = extname(firstFile)
|
|
36
|
+
|
|
37
|
+
// Infer outputFormat from the file extension
|
|
38
|
+
let outputFormat: I18nextToolkitConfig['extract']['outputFormat'] = 'json'
|
|
39
|
+
if (extension === '.ts') {
|
|
40
|
+
outputFormat = 'ts'
|
|
41
|
+
} else if (extension === '.js') {
|
|
42
|
+
// We can't know if it's ESM or CJS, so we default to a safe choice.
|
|
43
|
+
// The tool's file loaders can handle both.
|
|
44
|
+
outputFormat = 'js'
|
|
45
|
+
}
|
|
35
46
|
|
|
36
47
|
try {
|
|
37
48
|
const allDirs = await readdir(basePath)
|
|
38
|
-
// CORRECTED REGEX: Now accepts 'dev' in addition to standard locale codes.
|
|
39
49
|
let locales = allDirs.filter(dir => /^(dev|[a-z]{2}(-[A-Z]{2})?)$/.test(dir))
|
|
40
50
|
|
|
41
51
|
if (locales.length > 0) {
|
|
@@ -57,7 +67,8 @@ export async function detectConfig (): Promise<Partial<I18nextToolkitConfig> | n
|
|
|
57
67
|
'pages/**/*.{js,jsx,ts,tsx}',
|
|
58
68
|
'components/**/*.{js,jsx,ts,tsx}'
|
|
59
69
|
],
|
|
60
|
-
output: join(basePath, '{{language}}',
|
|
70
|
+
output: join(basePath, '{{language}}', `{{namespace}}${extension}`),
|
|
71
|
+
outputFormat,
|
|
61
72
|
primaryLanguage: locales.includes('en') ? 'en' : locales[0],
|
|
62
73
|
},
|
|
63
74
|
}
|
package/src/status.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import ora from 'ora'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
-
import { readFile } from 'node:fs/promises'
|
|
5
4
|
import { findKeys } from './extractor/core/key-finder'
|
|
6
5
|
import { getNestedValue } from './utils/nested-object'
|
|
7
6
|
import type { I18nextToolkitConfig, ExtractedKey } from './types'
|
|
8
|
-
import { getOutputPath } from './utils/file-utils'
|
|
7
|
+
import { getOutputPath, loadTranslationFile } from './utils/file-utils'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Options for configuring the status report display.
|
|
@@ -56,8 +55,8 @@ interface StatusReport {
|
|
|
56
55
|
* @throws {Error} When unable to extract keys or read translation files
|
|
57
56
|
*/
|
|
58
57
|
export async function runStatus (config: I18nextToolkitConfig, options: StatusOptions = {}) {
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
59
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
|
|
61
60
|
const spinner = ora('Analyzing project localization status...\n').start()
|
|
62
61
|
try {
|
|
63
62
|
const report = await generateStatusReport(config)
|
|
@@ -84,9 +83,16 @@ export async function runStatus (config: I18nextToolkitConfig, options: StatusOp
|
|
|
84
83
|
* @throws {Error} When key extraction fails or configuration is invalid
|
|
85
84
|
*/
|
|
86
85
|
async function generateStatusReport (config: I18nextToolkitConfig): Promise<StatusReport> {
|
|
86
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
87
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
|
|
88
|
+
|
|
87
89
|
const { allKeys: allExtractedKeys } = await findKeys(config)
|
|
88
|
-
const {
|
|
89
|
-
|
|
90
|
+
const {
|
|
91
|
+
secondaryLanguages,
|
|
92
|
+
keySeparator = '.',
|
|
93
|
+
defaultNS = 'translation',
|
|
94
|
+
mergeNamespaces = false,
|
|
95
|
+
} = config.extract
|
|
90
96
|
|
|
91
97
|
const keysByNs = new Map<string, ExtractedKey[]>()
|
|
92
98
|
for (const key of allExtractedKeys.values()) {
|
|
@@ -105,17 +111,19 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
|
|
|
105
111
|
let totalTranslatedForLocale = 0
|
|
106
112
|
const namespaces = new Map<string, any>()
|
|
107
113
|
|
|
114
|
+
const mergedTranslations = mergeNamespaces
|
|
115
|
+
? await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale))) || {}
|
|
116
|
+
: null
|
|
117
|
+
|
|
108
118
|
for (const [ns, keysInNs] of keysByNs.entries()) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const content = await readFile(resolve(process.cwd(), langFilePath), 'utf-8')
|
|
113
|
-
translations = JSON.parse(content)
|
|
114
|
-
} catch {}
|
|
119
|
+
const translationsForNs = mergeNamespaces
|
|
120
|
+
? mergedTranslations?.[ns] || {}
|
|
121
|
+
: await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, ns))) || {}
|
|
115
122
|
|
|
116
123
|
let translatedInNs = 0
|
|
117
124
|
const keyDetails = keysInNs.map(({ key }) => {
|
|
118
|
-
|
|
125
|
+
// Search for the key within the correct namespace object
|
|
126
|
+
const value = getNestedValue(translationsForNs, key, keySeparator ?? '.')
|
|
119
127
|
const isTranslated = !!value
|
|
120
128
|
if (isTranslated) translatedInNs++
|
|
121
129
|
return { key, isTranslated }
|
package/src/syncer.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve, dirname } from 'path'
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { resolve, dirname, basename } from 'path'
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
import ora from 'ora'
|
|
5
|
+
import { glob } from 'glob'
|
|
5
6
|
import type { I18nextToolkitConfig } from './types'
|
|
6
7
|
import { getNestedKeys, getNestedValue, setNestedValue } from './utils/nested-object'
|
|
7
|
-
import { getOutputPath } from './utils/file-utils'
|
|
8
|
+
import { getOutputPath, loadTranslationFile, serializeTranslationFile } from './utils/file-utils'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Synchronizes translation files across different locales by ensuring all secondary
|
|
@@ -38,77 +39,86 @@ import { getOutputPath } from './utils/file-utils'
|
|
|
38
39
|
*/
|
|
39
40
|
export async function runSyncer (config: I18nextToolkitConfig) {
|
|
40
41
|
const spinner = ora('Running i18next locale synchronizer...\n').start()
|
|
41
|
-
|
|
42
|
-
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
43
|
-
const { primaryLanguage } = config.extract
|
|
44
|
-
const secondaryLanguages = config.locales.filter(l => l !== primaryLanguage)
|
|
45
|
-
const keySeparator = config.extract.keySeparator ?? '.'
|
|
46
|
-
|
|
47
|
-
const logMessages: string[] = []
|
|
48
|
-
let wasAnythingSynced = false
|
|
49
|
-
|
|
50
|
-
// Assume sync operates on the default namespace for simplicity
|
|
51
|
-
const defaultNS = config.extract.defaultNS ?? 'translation'
|
|
52
|
-
|
|
53
|
-
// 1. Get all keys from the primary language file
|
|
54
|
-
const primaryPath = getOutputPath(config.extract.output, primaryLanguage, defaultNS)
|
|
55
|
-
|
|
56
|
-
const fullPrimaryPath = resolve(process.cwd(), primaryPath)
|
|
57
|
-
|
|
58
|
-
let primaryTranslations: Record<string, any>
|
|
59
42
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const newSecondaryTranslations: Record<string, any> = {}
|
|
43
|
+
const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
|
|
44
|
+
const secondaryLanguages = config.locales.filter((l) => l !== primaryLanguage)
|
|
45
|
+
const {
|
|
46
|
+
output,
|
|
47
|
+
keySeparator = '.',
|
|
48
|
+
outputFormat = 'json',
|
|
49
|
+
indentation = 2,
|
|
50
|
+
defaultValue = '',
|
|
51
|
+
} = config.extract
|
|
52
|
+
|
|
53
|
+
const logMessages: string[] = []
|
|
54
|
+
let wasAnythingSynced = false
|
|
55
|
+
|
|
56
|
+
// 1. Find all namespace files for the primary language
|
|
57
|
+
const primaryNsPattern = getOutputPath(output, primaryLanguage, '*')
|
|
58
|
+
const primaryNsFiles = await glob(primaryNsPattern)
|
|
59
|
+
|
|
60
|
+
if (primaryNsFiles.length === 0) {
|
|
61
|
+
spinner.warn(`No translation files found for primary language "${primaryLanguage}". Nothing to sync.`)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
82
64
|
|
|
83
|
-
//
|
|
84
|
-
for (const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
65
|
+
// 2. Loop through each primary namespace file
|
|
66
|
+
for (const primaryPath of primaryNsFiles) {
|
|
67
|
+
const ns = basename(primaryPath).split('.')[0]
|
|
68
|
+
const primaryTranslations = await loadTranslationFile(primaryPath)
|
|
69
|
+
|
|
70
|
+
if (!primaryTranslations) {
|
|
71
|
+
logMessages.push(` ${chalk.yellow('-')} Could not read primary file: ${primaryPath}`)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const primaryKeys = getNestedKeys(primaryTranslations, keySeparator ?? '.')
|
|
76
|
+
|
|
77
|
+
// 3. For each secondary language, sync the current namespace
|
|
78
|
+
for (const lang of secondaryLanguages) {
|
|
79
|
+
const secondaryPath = getOutputPath(output, lang, ns)
|
|
80
|
+
const fullSecondaryPath = resolve(process.cwd(), secondaryPath)
|
|
81
|
+
const existingSecondaryTranslations = await loadTranslationFile(fullSecondaryPath) || {}
|
|
82
|
+
const newSecondaryTranslations: Record<string, any> = {}
|
|
83
|
+
|
|
84
|
+
for (const key of primaryKeys) {
|
|
85
|
+
const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.')
|
|
86
|
+
const valueToSet = existingValue ?? defaultValue
|
|
87
|
+
setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Use JSON.stringify for a reliable object comparison, regardless of format
|
|
91
|
+
const oldContent = JSON.stringify(existingSecondaryTranslations)
|
|
92
|
+
const newContent = JSON.stringify(newSecondaryTranslations)
|
|
93
|
+
|
|
94
|
+
if (newContent !== oldContent) {
|
|
95
|
+
wasAnythingSynced = true
|
|
96
|
+
const serializedContent = serializeTranslationFile(newSecondaryTranslations, outputFormat, indentation)
|
|
97
|
+
await mkdir(dirname(fullSecondaryPath), { recursive: true })
|
|
98
|
+
await writeFile(fullSecondaryPath, serializedContent)
|
|
99
|
+
logMessages.push(` ${chalk.green('✓')} Synchronized: ${secondaryPath}`)
|
|
100
|
+
} else {
|
|
101
|
+
logMessages.push(` ${chalk.gray('-')} Already in sync: ${secondaryPath}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
spinner.succeed(chalk.bold('Synchronization complete!'))
|
|
107
|
+
logMessages.forEach(msg => console.log(msg))
|
|
93
108
|
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
await mkdir(dirname(fullSecondaryPath), { recursive: true })
|
|
97
|
-
await writeFile(fullSecondaryPath, newContent)
|
|
98
|
-
logMessages.push(` ${chalk.green('✓')} Synchronized: ${secondaryPath}`)
|
|
109
|
+
if (wasAnythingSynced) {
|
|
110
|
+
printLocizeFunnel()
|
|
99
111
|
} else {
|
|
100
|
-
|
|
112
|
+
console.log(chalk.green.bold('\n✅ All locales are already in sync.'))
|
|
101
113
|
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
spinner.fail(chalk.red('Synchronization failed.'))
|
|
116
|
+
console.error(error)
|
|
102
117
|
}
|
|
118
|
+
}
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
console.log(chalk.green.bold('\n✅ Sync complete.'))
|
|
109
|
-
console.log(chalk.yellow('🚀 Ready to collaborate with translators? Move your files to the cloud.'))
|
|
110
|
-
console.log(` Get started with the official TMS for i18next: ${chalk.cyan('npx i18next-cli locize-migrate')}`)
|
|
111
|
-
} else {
|
|
112
|
-
console.log(chalk.green.bold('\n✅ All locales are already in sync.'))
|
|
113
|
-
}
|
|
120
|
+
function printLocizeFunnel () {
|
|
121
|
+
console.log(chalk.green.bold('\n✅ Sync complete.'))
|
|
122
|
+
console.log(chalk.yellow('🚀 Ready to collaborate with translators? Move your files to the cloud.'))
|
|
123
|
+
console.log(` Get started with the official TMS for i18next: ${chalk.cyan('npx i18next-cli locize-migrate')}`)
|
|
114
124
|
}
|