i18next-cli 0.9.0

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 (127) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +489 -0
  4. package/dist/cjs/cli.js +2 -0
  5. package/dist/cjs/config.js +1 -0
  6. package/dist/cjs/extractor/core/extractor.js +1 -0
  7. package/dist/cjs/extractor/core/key-finder.js +1 -0
  8. package/dist/cjs/extractor/core/translation-manager.js +1 -0
  9. package/dist/cjs/extractor/parsers/ast-visitors.js +1 -0
  10. package/dist/cjs/extractor/parsers/comment-parser.js +1 -0
  11. package/dist/cjs/extractor/parsers/jsx-parser.js +1 -0
  12. package/dist/cjs/extractor/plugin-manager.js +1 -0
  13. package/dist/cjs/heuristic-config.js +1 -0
  14. package/dist/cjs/index.js +1 -0
  15. package/dist/cjs/init.js +1 -0
  16. package/dist/cjs/linter.js +1 -0
  17. package/dist/cjs/locize.js +1 -0
  18. package/dist/cjs/migrator.js +1 -0
  19. package/dist/cjs/package.json +1 -0
  20. package/dist/cjs/status.js +1 -0
  21. package/dist/cjs/syncer.js +1 -0
  22. package/dist/cjs/types-generator.js +1 -0
  23. package/dist/cjs/utils/file-utils.js +1 -0
  24. package/dist/cjs/utils/logger.js +1 -0
  25. package/dist/cjs/utils/nested-object.js +1 -0
  26. package/dist/cjs/utils/validation.js +1 -0
  27. package/dist/esm/cli.js +2 -0
  28. package/dist/esm/config.js +1 -0
  29. package/dist/esm/extractor/core/extractor.js +1 -0
  30. package/dist/esm/extractor/core/key-finder.js +1 -0
  31. package/dist/esm/extractor/core/translation-manager.js +1 -0
  32. package/dist/esm/extractor/parsers/ast-visitors.js +1 -0
  33. package/dist/esm/extractor/parsers/comment-parser.js +1 -0
  34. package/dist/esm/extractor/parsers/jsx-parser.js +1 -0
  35. package/dist/esm/extractor/plugin-manager.js +1 -0
  36. package/dist/esm/heuristic-config.js +1 -0
  37. package/dist/esm/index.js +1 -0
  38. package/dist/esm/init.js +1 -0
  39. package/dist/esm/linter.js +1 -0
  40. package/dist/esm/locize.js +1 -0
  41. package/dist/esm/migrator.js +1 -0
  42. package/dist/esm/status.js +1 -0
  43. package/dist/esm/syncer.js +1 -0
  44. package/dist/esm/types-generator.js +1 -0
  45. package/dist/esm/utils/file-utils.js +1 -0
  46. package/dist/esm/utils/logger.js +1 -0
  47. package/dist/esm/utils/nested-object.js +1 -0
  48. package/dist/esm/utils/validation.js +1 -0
  49. package/package.json +81 -0
  50. package/src/cli.ts +166 -0
  51. package/src/config.ts +158 -0
  52. package/src/extractor/core/extractor.ts +195 -0
  53. package/src/extractor/core/key-finder.ts +70 -0
  54. package/src/extractor/core/translation-manager.ts +115 -0
  55. package/src/extractor/index.ts +7 -0
  56. package/src/extractor/parsers/ast-visitors.ts +637 -0
  57. package/src/extractor/parsers/comment-parser.ts +125 -0
  58. package/src/extractor/parsers/jsx-parser.ts +166 -0
  59. package/src/extractor/plugin-manager.ts +54 -0
  60. package/src/extractor.ts +15 -0
  61. package/src/heuristic-config.ts +64 -0
  62. package/src/index.ts +12 -0
  63. package/src/init.ts +156 -0
  64. package/src/linter.ts +191 -0
  65. package/src/locize.ts +251 -0
  66. package/src/migrator.ts +139 -0
  67. package/src/status.ts +192 -0
  68. package/src/syncer.ts +114 -0
  69. package/src/types-generator.ts +116 -0
  70. package/src/types.ts +312 -0
  71. package/src/utils/file-utils.ts +81 -0
  72. package/src/utils/logger.ts +36 -0
  73. package/src/utils/nested-object.ts +113 -0
  74. package/src/utils/validation.ts +69 -0
  75. package/tryme.js +8 -0
  76. package/tsconfig.json +71 -0
  77. package/types/cli.d.ts +3 -0
  78. package/types/cli.d.ts.map +1 -0
  79. package/types/config.d.ts +50 -0
  80. package/types/config.d.ts.map +1 -0
  81. package/types/extractor/core/extractor.d.ts +66 -0
  82. package/types/extractor/core/extractor.d.ts.map +1 -0
  83. package/types/extractor/core/key-finder.d.ts +31 -0
  84. package/types/extractor/core/key-finder.d.ts.map +1 -0
  85. package/types/extractor/core/translation-manager.d.ts +31 -0
  86. package/types/extractor/core/translation-manager.d.ts.map +1 -0
  87. package/types/extractor/index.d.ts +8 -0
  88. package/types/extractor/index.d.ts.map +1 -0
  89. package/types/extractor/parsers/ast-visitors.d.ts +235 -0
  90. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -0
  91. package/types/extractor/parsers/comment-parser.d.ts +24 -0
  92. package/types/extractor/parsers/comment-parser.d.ts.map +1 -0
  93. package/types/extractor/parsers/jsx-parser.d.ts +35 -0
  94. package/types/extractor/parsers/jsx-parser.d.ts.map +1 -0
  95. package/types/extractor/plugin-manager.d.ts +37 -0
  96. package/types/extractor/plugin-manager.d.ts.map +1 -0
  97. package/types/extractor.d.ts +7 -0
  98. package/types/extractor.d.ts.map +1 -0
  99. package/types/heuristic-config.d.ts +10 -0
  100. package/types/heuristic-config.d.ts.map +1 -0
  101. package/types/index.d.ts +4 -0
  102. package/types/index.d.ts.map +1 -0
  103. package/types/init.d.ts +29 -0
  104. package/types/init.d.ts.map +1 -0
  105. package/types/linter.d.ts +33 -0
  106. package/types/linter.d.ts.map +1 -0
  107. package/types/locize.d.ts +5 -0
  108. package/types/locize.d.ts.map +1 -0
  109. package/types/migrator.d.ts +37 -0
  110. package/types/migrator.d.ts.map +1 -0
  111. package/types/status.d.ts +20 -0
  112. package/types/status.d.ts.map +1 -0
  113. package/types/syncer.d.ts +33 -0
  114. package/types/syncer.d.ts.map +1 -0
  115. package/types/types-generator.d.ts +29 -0
  116. package/types/types-generator.d.ts.map +1 -0
  117. package/types/types.d.ts +268 -0
  118. package/types/types.d.ts.map +1 -0
  119. package/types/utils/file-utils.d.ts +61 -0
  120. package/types/utils/file-utils.d.ts.map +1 -0
  121. package/types/utils/logger.d.ts +34 -0
  122. package/types/utils/logger.d.ts.map +1 -0
  123. package/types/utils/nested-object.d.ts +71 -0
  124. package/types/utils/nested-object.d.ts.map +1 -0
  125. package/types/utils/validation.d.ts +47 -0
  126. package/types/utils/validation.d.ts.map +1 -0
  127. package/vitest.config.ts +13 -0
@@ -0,0 +1,195 @@
1
+ import ora from 'ora'
2
+ import chalk from 'chalk'
3
+ import { parse } from '@swc/core'
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
5
+ import { dirname } from 'node:path'
6
+ import type { Logger, ExtractedKey, PluginContext, I18nextToolkitConfig } from '../../types'
7
+ import { findKeys } from './key-finder'
8
+ import { getTranslations } from './translation-manager'
9
+ import { validateExtractorConfig, ExtractorError } from '../../utils/validation'
10
+ import { createPluginContext } from '../plugin-manager'
11
+ import { extractKeysFromComments } from '../parsers/comment-parser'
12
+ import { ASTVisitors } from '../parsers/ast-visitors'
13
+ import { ConsoleLogger } from '../../utils/logger'
14
+
15
+ /**
16
+ * Main extractor function that runs the complete key extraction and file generation process.
17
+ *
18
+ * This is the primary entry point that:
19
+ * 1. Validates configuration
20
+ * 2. Sets up default sync options
21
+ * 3. Finds all translation keys across source files
22
+ * 4. Generates/updates translation files for all locales
23
+ * 5. Provides progress feedback via spinner
24
+ * 6. Returns whether any files were updated
25
+ *
26
+ * @param config - The i18next toolkit configuration object
27
+ * @param logger - Logger instance for output (defaults to ConsoleLogger)
28
+ * @returns Promise resolving to boolean indicating if any files were updated
29
+ *
30
+ * @throws {ExtractorError} When configuration validation fails or extraction process encounters errors
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const config = await loadConfig()
35
+ * const updated = await runExtractor(config)
36
+ * if (updated) {
37
+ * console.log('Translation files were updated')
38
+ * }
39
+ * ```
40
+ */
41
+ export async function runExtractor (
42
+ config: I18nextToolkitConfig,
43
+ logger: Logger = new ConsoleLogger()
44
+ ): Promise<boolean> {
45
+ if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0] || 'en'
46
+ if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
47
+
48
+ validateExtractorConfig(config)
49
+
50
+ const spinner = ora('Running i18next key extractor...\n').start()
51
+
52
+ try {
53
+ const allKeys = await findKeys(config, logger)
54
+ spinner.text = `Found ${allKeys.size} unique keys. Updating translation files...`
55
+
56
+ const results = await getTranslations(allKeys, config)
57
+
58
+ let anyFileUpdated = false
59
+ for (const result of results) {
60
+ if (result.updated) {
61
+ anyFileUpdated = true
62
+ await mkdir(dirname(result.path), { recursive: true })
63
+ await writeFile(result.path, JSON.stringify(result.newTranslations, null, 2))
64
+ logger.info(chalk.green(`Updated: ${result.path}`))
65
+ }
66
+ }
67
+
68
+ spinner.succeed(chalk.bold('Extraction complete!'))
69
+ return anyFileUpdated
70
+ } catch (error) {
71
+ spinner.fail(chalk.red('Extraction failed.'))
72
+ // Re-throw or handle error
73
+ throw error
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Processes an individual source file for translation key extraction.
79
+ *
80
+ * This function:
81
+ * 1. Reads the source file
82
+ * 2. Runs plugin onLoad hooks for code transformation
83
+ * 3. Parses the code into an Abstract Syntax Tree (AST) using SWC
84
+ * 4. Extracts keys from comments using regex patterns
85
+ * 5. Traverses the AST using visitors to find translation calls
86
+ * 6. Runs plugin onVisitNode hooks for custom extraction logic
87
+ *
88
+ * @param file - Path to the source file to process
89
+ * @param config - The i18next toolkit configuration object
90
+ * @param logger - Logger instance for output
91
+ * @param allKeys - Map to accumulate found translation keys
92
+ *
93
+ * @throws {ExtractorError} When file processing fails
94
+ *
95
+ * @internal
96
+ */
97
+ export async function processFile (
98
+ file: string,
99
+ config: I18nextToolkitConfig,
100
+ logger: Logger,
101
+ allKeys: Map<string, ExtractedKey>
102
+ ): Promise<void> {
103
+ try {
104
+ let code = await readFile(file, 'utf-8')
105
+
106
+ // Run onLoad hooks from plugins
107
+ for (const plugin of (config.plugins || [])) {
108
+ code = (await plugin.onLoad?.(code, file)) ?? code
109
+ }
110
+
111
+ const ast = await parse(code, {
112
+ syntax: 'typescript',
113
+ tsx: true,
114
+ comments: true
115
+ })
116
+
117
+ const pluginContext = createPluginContext(allKeys)
118
+
119
+ // Extract keys from comments
120
+ extractKeysFromComments(code, config.extract.functions || ['t'], pluginContext, config)
121
+
122
+ // Extract keys from AST using visitors
123
+ const astVisitors = new ASTVisitors(
124
+ config,
125
+ pluginContext,
126
+ logger
127
+ )
128
+
129
+ astVisitors.visit(ast)
130
+
131
+ // Run plugin visitors
132
+ if ((config.plugins || []).length > 0) {
133
+ traverseEveryNode(ast, (config.plugins || []), pluginContext)
134
+ }
135
+ } catch (error) {
136
+ throw new ExtractorError('Failed to process file', file, error as Error)
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Recursively traverses AST nodes and calls plugin onVisitNode hooks.
142
+ *
143
+ * @param node - The AST node to traverse
144
+ * @param plugins - Array of plugins to run hooks for
145
+ * @param pluginContext - Context object with helper methods for plugins
146
+ *
147
+ * @internal
148
+ */
149
+ function traverseEveryNode (node: any, plugins: any[], pluginContext: PluginContext): void {
150
+ if (!node || typeof node !== 'object') return
151
+
152
+ // Call plugins for this node
153
+ for (const plugin of plugins) {
154
+ try {
155
+ plugin.onVisitNode?.(node, pluginContext)
156
+ } catch (err) {
157
+ console.warn(`Plugin ${plugin.name} onVisitNode failed:`, err)
158
+ }
159
+ }
160
+
161
+ for (const key of Object.keys(node)) {
162
+ const child = node[key]
163
+ if (Array.isArray(child)) {
164
+ for (const c of child) {
165
+ if (c && typeof c === 'object') traverseEveryNode(c, plugins, pluginContext)
166
+ }
167
+ } else if (child && typeof child === 'object') {
168
+ traverseEveryNode(child, plugins, pluginContext)
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Simplified extraction function that returns translation results without file writing.
175
+ * Used primarily for testing and programmatic access.
176
+ *
177
+ * @param config - The i18next toolkit configuration object
178
+ * @returns Promise resolving to array of translation results
179
+ *
180
+ * @example
181
+ * ```typescript
182
+ * const results = await extract(config)
183
+ * for (const result of results) {
184
+ * console.log(`${result.path}: ${result.updated ? 'Updated' : 'No changes'}`)
185
+ * }
186
+ * ```
187
+ */
188
+ export async function extract (config: I18nextToolkitConfig) {
189
+ if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0]
190
+ if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
191
+ if (!config.extract.functions) config.extract.functions = ['t']
192
+ if (!config.extract.transComponents) config.extract.transComponents = ['Trans']
193
+ const allKeys = await findKeys(config)
194
+ return getTranslations(allKeys, config)
195
+ }
@@ -0,0 +1,70 @@
1
+ import { glob } from 'glob'
2
+ import type { ExtractedKey, Logger, I18nextToolkitConfig } from '../../types'
3
+ import { processFile } from './extractor'
4
+ import { ConsoleLogger } from '../../utils/logger'
5
+ import { initializePlugins } from '../plugin-manager'
6
+
7
+ /**
8
+ * Main function for finding translation keys across all source files in a project.
9
+ *
10
+ * This function orchestrates the key extraction process:
11
+ * 1. Processes source files based on input patterns
12
+ * 2. Initializes and manages plugins
13
+ * 3. Processes each file through AST parsing and key extraction
14
+ * 4. Runs plugin lifecycle hooks
15
+ * 5. Returns a deduplicated map of all found keys
16
+ *
17
+ * @param config - The i18next toolkit configuration object
18
+ * @param logger - Logger instance for output (defaults to ConsoleLogger)
19
+ * @returns Promise resolving to a Map of unique translation keys with metadata
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const config = {
24
+ * extract: {
25
+ * input: ['src/**\/*.{ts,tsx}'],
26
+ * functions: ['t'],
27
+ * transComponents: ['Trans']
28
+ * }
29
+ * }
30
+ *
31
+ * const keys = await findKeys(config)
32
+ * console.log(`Found ${keys.size} unique translation keys`)
33
+ * ```
34
+ */
35
+ export async function findKeys (
36
+ config: I18nextToolkitConfig,
37
+ logger: Logger = new ConsoleLogger()
38
+ ): Promise<Map<string, ExtractedKey>> {
39
+ const sourceFiles = await processSourceFiles(config)
40
+ const allKeys = new Map<string, ExtractedKey>()
41
+
42
+ await initializePlugins(config.plugins || [])
43
+
44
+ for (const file of sourceFiles) {
45
+ await processFile(file, config, logger, allKeys)
46
+ }
47
+
48
+ // Run onEnd hooks
49
+ for (const plugin of (config.plugins || [])) {
50
+ await plugin.onEnd?.(allKeys)
51
+ }
52
+
53
+ return allKeys
54
+ }
55
+
56
+ /**
57
+ * Processes source files using glob patterns from configuration.
58
+ * Excludes node_modules by default and resolves relative to current working directory.
59
+ *
60
+ * @param config - The i18next toolkit configuration object
61
+ * @returns Promise resolving to array of file paths to process
62
+ *
63
+ * @internal
64
+ */
65
+ async function processSourceFiles (config: I18nextToolkitConfig): Promise<string[]> {
66
+ return await glob(config.extract.input, {
67
+ ignore: 'node_modules/**',
68
+ cwd: process.cwd(),
69
+ })
70
+ }
@@ -0,0 +1,115 @@
1
+ import { TranslationResult, ExtractedKey, I18nextToolkitConfig } from '../../types'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { resolve } from 'node:path'
4
+ import { getNestedValue, setNestedValue, getNestedKeys } from '../../utils/nested-object'
5
+ import { getOutputPath } from '../../utils/file-utils'
6
+
7
+ /**
8
+ * Converts a glob pattern to a regular expression for matching keys
9
+ * @param glob - The glob pattern to convert
10
+ * @returns A RegExp object that matches the glob pattern
11
+ */
12
+ function globToRegex (glob: string): RegExp {
13
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
14
+ const regexString = `^${escaped.replace(/\*/g, '.*')}$`
15
+ return new RegExp(regexString)
16
+ }
17
+
18
+ /**
19
+ * Processes extracted translation keys and generates translation files for all configured locales.
20
+ *
21
+ * This function:
22
+ * 1. Groups keys by namespace
23
+ * 2. For each locale and namespace combination:
24
+ * - Reads existing translation files
25
+ * - Preserves keys matching `preservePatterns`
26
+ * - Merges in newly extracted keys
27
+ * - Uses primary language defaults or empty strings for secondary languages
28
+ * - Maintains key sorting based on configuration
29
+ * 3. Determines if files need updating by comparing content
30
+ *
31
+ * @param keys - Map of extracted translation keys with metadata
32
+ * @param config - The i18next toolkit configuration object
33
+ * @returns Promise resolving to array of translation results with update status
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const keys = new Map([
38
+ * ['translation:welcome', { key: 'welcome', defaultValue: 'Welcome!', ns: 'translation' }],
39
+ * ['common:button.save', { key: 'button.save', defaultValue: 'Save', ns: 'common' }]
40
+ * ])
41
+ *
42
+ * const results = await getTranslations(keys, config)
43
+ * // Results contain update status and new/existing translations for each locale
44
+ * ```
45
+ */
46
+ export async function getTranslations (
47
+ keys: Map<string, ExtractedKey>,
48
+ config: I18nextToolkitConfig
49
+ ): Promise<TranslationResult[]> {
50
+ const defaultNS = config.extract.defaultNS ?? 'translation'
51
+ const keySeparator = config.extract.keySeparator ?? '.'
52
+ const preservePatterns = (config.extract.preservePatterns ?? []).map(globToRegex)
53
+ if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0] || 'en'
54
+ if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config.extract.primaryLanguage)
55
+
56
+ // Group keys by namespace
57
+ const keysByNS = new Map<string, ExtractedKey[]>()
58
+ for (const key of keys.values()) {
59
+ const ns = key.ns || defaultNS
60
+ if (!keysByNS.has(ns)) {
61
+ keysByNS.set(ns, [])
62
+ }
63
+ keysByNS.get(ns)!.push(key)
64
+ }
65
+
66
+ const results: TranslationResult[] = []
67
+
68
+ for (const locale of config.locales) {
69
+ for (const [ns, nsKeys] of keysByNS.entries()) {
70
+ const outputPath = getOutputPath(config.extract.output, locale, ns)
71
+
72
+ const fullPath = resolve(process.cwd(), outputPath)
73
+
74
+ let oldContent = ''
75
+ let existingTranslations: Record<string, any> = {}
76
+ try {
77
+ oldContent = await readFile(fullPath, 'utf-8')
78
+ existingTranslations = JSON.parse(oldContent)
79
+ } catch (e) { /* File doesn't exist, which is fine */ }
80
+
81
+ const newTranslations: Record<string, any> = {}
82
+
83
+ // 1. Preserve keys from existing translations that match patterns
84
+ const existingKeys = getNestedKeys(existingTranslations, keySeparator)
85
+ for (const existingKey of existingKeys) {
86
+ if (preservePatterns.some(re => re.test(existingKey))) {
87
+ const value = getNestedValue(existingTranslations, existingKey, keySeparator)
88
+ setNestedValue(newTranslations, existingKey, value, keySeparator)
89
+ }
90
+ }
91
+
92
+ // 2. Merge in newly found keys for this namespace
93
+ const sortedKeys = (config.extract.sort === false)
94
+ ? nsKeys
95
+ : nsKeys.sort((a, b) => a.key.localeCompare(b.key))
96
+ for (const { key, defaultValue } of sortedKeys) {
97
+ const existingValue = getNestedValue(existingTranslations, key, keySeparator)
98
+ const valueToSet = existingValue ?? (locale === config.extract?.primaryLanguage ? defaultValue : '')
99
+ setNestedValue(newTranslations, key, valueToSet, keySeparator)
100
+ }
101
+
102
+ const indentation = config.extract.indentation ?? 2
103
+ const newContent = JSON.stringify(newTranslations, null, indentation)
104
+
105
+ results.push({
106
+ path: fullPath,
107
+ updated: newContent !== oldContent,
108
+ newTranslations,
109
+ existingTranslations,
110
+ })
111
+ }
112
+ }
113
+
114
+ return results
115
+ }
@@ -0,0 +1,7 @@
1
+ export * from './core/extractor'
2
+ export * from './core/key-finder'
3
+ export * from './core/translation-manager'
4
+ export * from './parsers/ast-visitors'
5
+ export * from './parsers/comment-parser'
6
+ export * from './parsers/jsx-parser'
7
+ export * from './plugin-manager'