i18next-cli 0.9.9 → 0.9.11
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 +25 -10
- package/README.md +75 -3
- 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/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/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 +71 -16
- package/src/heuristic-config.ts +14 -3
- package/src/status.ts +21 -13
- package/src/syncer.ts +77 -67
- package/src/types.ts +18 -0
- 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 +5 -1
- package/types/extractor/parsers/ast-visitors.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 +16 -0
- 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
|
@@ -11,6 +11,7 @@ import { createPluginContext } from '../plugin-manager'
|
|
|
11
11
|
import { extractKeysFromComments } from '../parsers/comment-parser'
|
|
12
12
|
import { ASTVisitors } from '../parsers/ast-visitors'
|
|
13
13
|
import { ConsoleLogger } from '../../utils/logger'
|
|
14
|
+
import { serializeTranslationFile } from '../../utils/file-utils'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Main extractor function that runs the complete key extraction and file generation process.
|
|
@@ -42,8 +43,8 @@ export async function runExtractor (
|
|
|
42
43
|
config: I18nextToolkitConfig,
|
|
43
44
|
logger: Logger = new ConsoleLogger()
|
|
44
45
|
): Promise<boolean> {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
47
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
|
|
47
48
|
|
|
48
49
|
validateExtractorConfig(config)
|
|
49
50
|
|
|
@@ -59,8 +60,13 @@ export async function runExtractor (
|
|
|
59
60
|
for (const result of results) {
|
|
60
61
|
if (result.updated) {
|
|
61
62
|
anyFileUpdated = true
|
|
63
|
+
const fileContent = serializeTranslationFile(
|
|
64
|
+
result.newTranslations,
|
|
65
|
+
config.extract.outputFormat,
|
|
66
|
+
config.extract.indentation
|
|
67
|
+
)
|
|
62
68
|
await mkdir(dirname(result.path), { recursive: true })
|
|
63
|
-
await writeFile(result.path,
|
|
69
|
+
await writeFile(result.path, fileContent)
|
|
64
70
|
logger.info(chalk.green(`Updated: ${result.path}`))
|
|
65
71
|
}
|
|
66
72
|
}
|
|
@@ -180,10 +186,10 @@ function traverseEveryNode (node: any, plugins: any[], pluginContext: PluginCont
|
|
|
180
186
|
* ```
|
|
181
187
|
*/
|
|
182
188
|
export async function extract (config: I18nextToolkitConfig) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en'
|
|
190
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
|
|
191
|
+
config.extract.functions ||= ['t']
|
|
192
|
+
config.extract.transComponents ||= ['Trans']
|
|
187
193
|
const { allKeys, objectKeys } = await findKeys(config)
|
|
188
194
|
return getTranslations(allKeys, objectKeys, config)
|
|
189
195
|
}
|
|
@@ -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
|
}
|
|
@@ -118,7 +118,10 @@ export class ASTVisitors {
|
|
|
118
118
|
const child = node[key]
|
|
119
119
|
if (Array.isArray(child)) {
|
|
120
120
|
for (const item of child) {
|
|
121
|
-
|
|
121
|
+
// Be less strict: if it's a non-null object, walk it.
|
|
122
|
+
// This allows traversal into nodes that might not have a `.type` property
|
|
123
|
+
// but still contain other valid AST nodes.
|
|
124
|
+
if (item && typeof item === 'object') {
|
|
122
125
|
this.walk(item)
|
|
123
126
|
}
|
|
124
127
|
}
|
|
@@ -337,7 +340,7 @@ export class ASTVisitors {
|
|
|
337
340
|
if (callee.type !== 'Identifier') return
|
|
338
341
|
|
|
339
342
|
const scopeInfo = this.getVarFromScope(callee.value)
|
|
340
|
-
const isFunctionToParse = (this.config.extract.functions || []).includes(callee.value) || scopeInfo !== undefined
|
|
343
|
+
const isFunctionToParse = (this.config.extract.functions || ['t']).includes(callee.value) || scopeInfo !== undefined
|
|
341
344
|
if (!isFunctionToParse || node.arguments.length === 0) return
|
|
342
345
|
|
|
343
346
|
const firstArg = node.arguments[0].expression
|
|
@@ -376,7 +379,6 @@ export class ASTVisitors {
|
|
|
376
379
|
options = arg3
|
|
377
380
|
}
|
|
378
381
|
}
|
|
379
|
-
|
|
380
382
|
const defaultValueFromOptions = options ? this.getObjectPropValue(options, 'defaultValue') : undefined
|
|
381
383
|
const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
|
|
382
384
|
|
|
@@ -386,7 +388,7 @@ export class ASTVisitors {
|
|
|
386
388
|
let ns: string | undefined
|
|
387
389
|
|
|
388
390
|
// Determine namespace (explicit ns > scope ns > ns:key > default)
|
|
389
|
-
if (options
|
|
391
|
+
if (options) {
|
|
390
392
|
const nsVal = this.getObjectPropValue(options, 'ns')
|
|
391
393
|
if (typeof nsVal === 'string') ns = nsVal
|
|
392
394
|
}
|
|
@@ -406,13 +408,11 @@ export class ASTVisitors {
|
|
|
406
408
|
finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
|
|
407
409
|
}
|
|
408
410
|
|
|
409
|
-
// The explicit defaultValue only applies to the LAST key in the fallback array.
|
|
410
|
-
// For all preceding keys, their own key is their fallback.
|
|
411
411
|
const isLastKey = i === keysToProcess.length - 1
|
|
412
412
|
const dv = isLastKey ? (finalDefaultValue || key) : key
|
|
413
413
|
|
|
414
414
|
// Handle plurals, context, and returnObjects
|
|
415
|
-
if (options
|
|
415
|
+
if (options) {
|
|
416
416
|
const contextProp = this.getObjectProperty(options, 'context')
|
|
417
417
|
|
|
418
418
|
// 1. Handle Dynamic Context (Ternary) first
|
|
@@ -440,8 +440,8 @@ export class ASTVisitors {
|
|
|
440
440
|
|
|
441
441
|
// 3. Handle Plurals
|
|
442
442
|
if (this.getObjectPropValue(options, 'count') !== undefined) {
|
|
443
|
-
this.handlePluralKeys(finalKey,
|
|
444
|
-
continue
|
|
443
|
+
this.handlePluralKeys(finalKey, ns, options)
|
|
444
|
+
continue
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
// 4. Handle returnObjects
|
|
@@ -464,27 +464,82 @@ export class ASTVisitors {
|
|
|
464
464
|
* for each category (e.g., 'item_one', 'item_other').
|
|
465
465
|
*
|
|
466
466
|
* @param key - Base key name for pluralization
|
|
467
|
-
* @param defaultValue - Default value to use for all plural forms
|
|
468
467
|
* @param ns - Namespace for the keys
|
|
468
|
+
* @param options - object expression options
|
|
469
469
|
*
|
|
470
470
|
* @private
|
|
471
471
|
*/
|
|
472
|
-
private handlePluralKeys (key: string,
|
|
472
|
+
private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression): void {
|
|
473
473
|
try {
|
|
474
|
-
const
|
|
474
|
+
const isOrdinal = this.getObjectPropValue(options, 'ordinal') === true
|
|
475
|
+
const type = isOrdinal ? 'ordinal' : 'cardinal'
|
|
476
|
+
|
|
477
|
+
const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
|
|
475
478
|
const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
|
|
476
479
|
|
|
480
|
+
const defaultValue = this.getObjectPropValue(options, 'defaultValue')
|
|
481
|
+
const defaultValueOther = this.getObjectPropValue(options, 'defaultValue_other')
|
|
482
|
+
|
|
483
|
+
for (const category of pluralCategories) {
|
|
484
|
+
// Construct the specific defaultValue key, e.g., `defaultValue_ordinal_one` or `defaultValue_one`
|
|
485
|
+
const specificDefaultKey = isOrdinal ? `defaultValue_ordinal_${category}` : `defaultValue_${category}`
|
|
486
|
+
const specificDefault = this.getObjectPropValue(options, specificDefaultKey)
|
|
487
|
+
|
|
488
|
+
let finalDefaultValue: string | undefined
|
|
489
|
+
|
|
490
|
+
if (typeof specificDefault === 'string') {
|
|
491
|
+
finalDefaultValue = specificDefault
|
|
492
|
+
} else if (category === 'one' && typeof defaultValue === 'string') {
|
|
493
|
+
// 'one' specifically falls back to the main `defaultValue` prop
|
|
494
|
+
finalDefaultValue = defaultValue
|
|
495
|
+
} else if (typeof defaultValueOther === 'string') {
|
|
496
|
+
// All other categories fall back to `defaultValue_other`
|
|
497
|
+
finalDefaultValue = defaultValueOther
|
|
498
|
+
} else if (typeof defaultValue === 'string') {
|
|
499
|
+
// If all else fails, use the main `defaultValue` as a last resort
|
|
500
|
+
finalDefaultValue = defaultValue
|
|
501
|
+
} else {
|
|
502
|
+
finalDefaultValue = key // Fallback to the key itself
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Construct the final key, e.g., `key_ordinal_one` or `key_one`
|
|
506
|
+
const finalKey = isOrdinal
|
|
507
|
+
? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
|
|
508
|
+
: `${key}${pluralSeparator}${category}`
|
|
509
|
+
|
|
510
|
+
this.pluginContext.addKey({
|
|
511
|
+
key: finalKey,
|
|
512
|
+
ns,
|
|
513
|
+
defaultValue: finalDefaultValue,
|
|
514
|
+
hasCount: true,
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
} catch (e) {
|
|
518
|
+
this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
|
|
519
|
+
// Fallback to a simple key if Intl API fails
|
|
520
|
+
const defaultValue = this.getObjectPropValue(options, 'defaultValue')
|
|
521
|
+
this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Generates simple plural keys, typically for a <Trans> component.
|
|
527
|
+
*/
|
|
528
|
+
private handleSimplePluralKeys (key: string, defaultValue: string | undefined, ns: string | undefined): void {
|
|
529
|
+
try {
|
|
530
|
+
const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage).resolvedOptions().pluralCategories
|
|
531
|
+
const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
|
|
477
532
|
for (const category of pluralCategories) {
|
|
478
533
|
this.pluginContext.addKey({
|
|
479
534
|
key: `${key}${pluralSeparator}${category}`,
|
|
480
535
|
ns,
|
|
481
536
|
defaultValue,
|
|
482
|
-
hasCount: true
|
|
537
|
+
hasCount: true,
|
|
483
538
|
})
|
|
484
539
|
}
|
|
485
540
|
} catch (e) {
|
|
486
|
-
this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}"
|
|
487
|
-
this.pluginContext.addKey({ key,
|
|
541
|
+
this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}".`)
|
|
542
|
+
this.pluginContext.addKey({ key, ns, defaultValue })
|
|
488
543
|
}
|
|
489
544
|
}
|
|
490
545
|
|
|
@@ -544,7 +599,7 @@ export class ASTVisitors {
|
|
|
544
599
|
this.pluginContext.addKey(extractedKey)
|
|
545
600
|
}
|
|
546
601
|
} else if (extractedKey.hasCount) {
|
|
547
|
-
this.
|
|
602
|
+
this.handleSimplePluralKeys(extractedKey.key, extractedKey.defaultValue, extractedKey.ns)
|
|
548
603
|
} else {
|
|
549
604
|
this.pluginContext.addKey(extractedKey)
|
|
550
605
|
}
|
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
|
}
|
package/src/types.ts
CHANGED
|
@@ -83,6 +83,24 @@ export interface I18nextToolkitConfig {
|
|
|
83
83
|
|
|
84
84
|
/** Secondary languages that get empty values initially */
|
|
85
85
|
secondaryLanguages?: string[];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The format of the output translation files.
|
|
89
|
+
* 'json': Standard JSON file (default)
|
|
90
|
+
* 'js': JavaScript file with ES Module syntax (export default)
|
|
91
|
+
* 'js-esm': JavaScript file with ES Module syntax (export default)
|
|
92
|
+
* 'js-cjs': JavaScript file with CommonJS syntax (module.exports)
|
|
93
|
+
* 'ts': TypeScript file with ES Module syntax and `as const` for type safety
|
|
94
|
+
*/
|
|
95
|
+
outputFormat?: 'json' | 'js' | 'js-esm' | 'js-esm' | 'js-cjs' | 'ts';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* If true, all namespaces will be merged into a single file per language.
|
|
99
|
+
* The `output` path should not contain the `{{namespace}}` placeholder.
|
|
100
|
+
* Example output: `locales/en.js`
|
|
101
|
+
* (default: false)
|
|
102
|
+
*/
|
|
103
|
+
mergeNamespaces?: boolean;
|
|
86
104
|
};
|
|
87
105
|
|
|
88
106
|
/** Configuration options for TypeScript type generation */
|