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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +489 -0
- package/dist/cjs/cli.js +2 -0
- package/dist/cjs/config.js +1 -0
- package/dist/cjs/extractor/core/extractor.js +1 -0
- package/dist/cjs/extractor/core/key-finder.js +1 -0
- package/dist/cjs/extractor/core/translation-manager.js +1 -0
- package/dist/cjs/extractor/parsers/ast-visitors.js +1 -0
- package/dist/cjs/extractor/parsers/comment-parser.js +1 -0
- package/dist/cjs/extractor/parsers/jsx-parser.js +1 -0
- package/dist/cjs/extractor/plugin-manager.js +1 -0
- package/dist/cjs/heuristic-config.js +1 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/init.js +1 -0
- package/dist/cjs/linter.js +1 -0
- package/dist/cjs/locize.js +1 -0
- package/dist/cjs/migrator.js +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/status.js +1 -0
- package/dist/cjs/syncer.js +1 -0
- package/dist/cjs/types-generator.js +1 -0
- package/dist/cjs/utils/file-utils.js +1 -0
- package/dist/cjs/utils/logger.js +1 -0
- package/dist/cjs/utils/nested-object.js +1 -0
- package/dist/cjs/utils/validation.js +1 -0
- package/dist/esm/cli.js +2 -0
- package/dist/esm/config.js +1 -0
- package/dist/esm/extractor/core/extractor.js +1 -0
- package/dist/esm/extractor/core/key-finder.js +1 -0
- package/dist/esm/extractor/core/translation-manager.js +1 -0
- package/dist/esm/extractor/parsers/ast-visitors.js +1 -0
- package/dist/esm/extractor/parsers/comment-parser.js +1 -0
- package/dist/esm/extractor/parsers/jsx-parser.js +1 -0
- package/dist/esm/extractor/plugin-manager.js +1 -0
- package/dist/esm/heuristic-config.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/init.js +1 -0
- package/dist/esm/linter.js +1 -0
- package/dist/esm/locize.js +1 -0
- package/dist/esm/migrator.js +1 -0
- package/dist/esm/status.js +1 -0
- package/dist/esm/syncer.js +1 -0
- package/dist/esm/types-generator.js +1 -0
- package/dist/esm/utils/file-utils.js +1 -0
- package/dist/esm/utils/logger.js +1 -0
- package/dist/esm/utils/nested-object.js +1 -0
- package/dist/esm/utils/validation.js +1 -0
- package/package.json +81 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +158 -0
- package/src/extractor/core/extractor.ts +195 -0
- package/src/extractor/core/key-finder.ts +70 -0
- package/src/extractor/core/translation-manager.ts +115 -0
- package/src/extractor/index.ts +7 -0
- package/src/extractor/parsers/ast-visitors.ts +637 -0
- package/src/extractor/parsers/comment-parser.ts +125 -0
- package/src/extractor/parsers/jsx-parser.ts +166 -0
- package/src/extractor/plugin-manager.ts +54 -0
- package/src/extractor.ts +15 -0
- package/src/heuristic-config.ts +64 -0
- package/src/index.ts +12 -0
- package/src/init.ts +156 -0
- package/src/linter.ts +191 -0
- package/src/locize.ts +251 -0
- package/src/migrator.ts +139 -0
- package/src/status.ts +192 -0
- package/src/syncer.ts +114 -0
- package/src/types-generator.ts +116 -0
- package/src/types.ts +312 -0
- package/src/utils/file-utils.ts +81 -0
- package/src/utils/logger.ts +36 -0
- package/src/utils/nested-object.ts +113 -0
- package/src/utils/validation.ts +69 -0
- package/tryme.js +8 -0
- package/tsconfig.json +71 -0
- package/types/cli.d.ts +3 -0
- package/types/cli.d.ts.map +1 -0
- package/types/config.d.ts +50 -0
- package/types/config.d.ts.map +1 -0
- package/types/extractor/core/extractor.d.ts +66 -0
- package/types/extractor/core/extractor.d.ts.map +1 -0
- package/types/extractor/core/key-finder.d.ts +31 -0
- package/types/extractor/core/key-finder.d.ts.map +1 -0
- package/types/extractor/core/translation-manager.d.ts +31 -0
- package/types/extractor/core/translation-manager.d.ts.map +1 -0
- package/types/extractor/index.d.ts +8 -0
- package/types/extractor/index.d.ts.map +1 -0
- package/types/extractor/parsers/ast-visitors.d.ts +235 -0
- package/types/extractor/parsers/ast-visitors.d.ts.map +1 -0
- package/types/extractor/parsers/comment-parser.d.ts +24 -0
- package/types/extractor/parsers/comment-parser.d.ts.map +1 -0
- package/types/extractor/parsers/jsx-parser.d.ts +35 -0
- package/types/extractor/parsers/jsx-parser.d.ts.map +1 -0
- package/types/extractor/plugin-manager.d.ts +37 -0
- package/types/extractor/plugin-manager.d.ts.map +1 -0
- package/types/extractor.d.ts +7 -0
- package/types/extractor.d.ts.map +1 -0
- package/types/heuristic-config.d.ts +10 -0
- package/types/heuristic-config.d.ts.map +1 -0
- package/types/index.d.ts +4 -0
- package/types/index.d.ts.map +1 -0
- package/types/init.d.ts +29 -0
- package/types/init.d.ts.map +1 -0
- package/types/linter.d.ts +33 -0
- package/types/linter.d.ts.map +1 -0
- package/types/locize.d.ts +5 -0
- package/types/locize.d.ts.map +1 -0
- package/types/migrator.d.ts +37 -0
- package/types/migrator.d.ts.map +1 -0
- package/types/status.d.ts +20 -0
- package/types/status.d.ts.map +1 -0
- package/types/syncer.d.ts +33 -0
- package/types/syncer.d.ts.map +1 -0
- package/types/types-generator.d.ts +29 -0
- package/types/types-generator.d.ts.map +1 -0
- package/types/types.d.ts +268 -0
- package/types/types.d.ts.map +1 -0
- package/types/utils/file-utils.d.ts +61 -0
- package/types/utils/file-utils.d.ts.map +1 -0
- package/types/utils/logger.d.ts +34 -0
- package/types/utils/logger.d.ts.map +1 -0
- package/types/utils/nested-object.d.ts +71 -0
- package/types/utils/nested-object.d.ts.map +1 -0
- package/types/utils/validation.d.ts +47 -0
- package/types/utils/validation.d.ts.map +1 -0
- package/vitest.config.ts +13 -0
package/src/linter.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { glob } from 'glob'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { parse } from '@swc/core'
|
|
4
|
+
import { ancestor } from 'swc-walk'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
import ora from 'ora'
|
|
7
|
+
import type { I18nextToolkitConfig } from './types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs the i18next linter to detect hardcoded strings and other potential issues.
|
|
11
|
+
*
|
|
12
|
+
* This function performs static analysis on source files to identify:
|
|
13
|
+
* - Hardcoded text strings in JSX elements
|
|
14
|
+
* - Hardcoded strings in JSX attributes (like alt text, titles, etc.)
|
|
15
|
+
* - Text that should be extracted for translation
|
|
16
|
+
*
|
|
17
|
+
* The linter respects configuration settings:
|
|
18
|
+
* - Uses the same input patterns as the extractor
|
|
19
|
+
* - Ignores content inside configured Trans components
|
|
20
|
+
* - Skips technical content like script/style tags
|
|
21
|
+
* - Identifies numeric values and interpolation syntax to avoid false positives
|
|
22
|
+
*
|
|
23
|
+
* @param config - The toolkit configuration with input patterns and component names
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const config = {
|
|
28
|
+
* extract: {
|
|
29
|
+
* input: ['src/**\/*.{ts,tsx}'],
|
|
30
|
+
* transComponents: ['Trans', 'Translation']
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* await runLinter(config)
|
|
35
|
+
* // Outputs issues found or success message
|
|
36
|
+
* // Exits with code 1 if issues found, 0 if clean
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export async function runLinter (config: I18nextToolkitConfig) {
|
|
40
|
+
const spinner = ora('Analyzing source files...\n').start()
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const sourceFiles = await glob(config.extract.input)
|
|
44
|
+
let totalIssues = 0
|
|
45
|
+
const issuesByFile = new Map<string, HardcodedString[]>()
|
|
46
|
+
|
|
47
|
+
for (const file of sourceFiles) {
|
|
48
|
+
const code = await readFile(file, 'utf-8')
|
|
49
|
+
const ast = await parse(code, { syntax: 'typescript', tsx: true })
|
|
50
|
+
const hardcodedStrings = findHardcodedStrings(ast, code, config)
|
|
51
|
+
|
|
52
|
+
if (hardcodedStrings.length > 0) {
|
|
53
|
+
totalIssues += hardcodedStrings.length
|
|
54
|
+
issuesByFile.set(file, hardcodedStrings)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (totalIssues > 0) {
|
|
59
|
+
spinner.fail(chalk.red.bold(`Linter found ${totalIssues} potential issues.`))
|
|
60
|
+
|
|
61
|
+
// Print detailed report after spinner fails
|
|
62
|
+
for (const [file, issues] of issuesByFile.entries()) {
|
|
63
|
+
console.log(chalk.yellow(`\n${file}`))
|
|
64
|
+
issues.forEach(({ text, line }) => {
|
|
65
|
+
console.log(` ${chalk.gray(`${line}:`)} ${chalk.red('Error:')} Found hardcoded string: "${text}"`)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
process.exit(1)
|
|
69
|
+
} else {
|
|
70
|
+
spinner.succeed(chalk.green.bold('No issues found.'))
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
spinner.fail(chalk.red('Linter failed to run.'))
|
|
74
|
+
console.error(error)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Represents a found hardcoded string with its location information.
|
|
81
|
+
*/
|
|
82
|
+
interface HardcodedString {
|
|
83
|
+
/** The hardcoded text content */
|
|
84
|
+
text: string;
|
|
85
|
+
/** Line number where the string was found */
|
|
86
|
+
line: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Analyzes an AST to find potentially hardcoded strings that should be translated.
|
|
91
|
+
*
|
|
92
|
+
* This function traverses the syntax tree looking for:
|
|
93
|
+
* 1. JSX text nodes with translatable content
|
|
94
|
+
* 2. String literals in JSX attributes that might need translation
|
|
95
|
+
*
|
|
96
|
+
* It applies several filters to reduce false positives:
|
|
97
|
+
* - Ignores content inside Trans components (already handled)
|
|
98
|
+
* - Skips script and style tag content (technical, not user-facing)
|
|
99
|
+
* - Filters out numeric values (usually not translatable)
|
|
100
|
+
* - Ignores interpolation syntax starting with `{{`
|
|
101
|
+
* - Only reports non-empty, trimmed strings
|
|
102
|
+
*
|
|
103
|
+
* @param ast - The parsed AST to analyze
|
|
104
|
+
* @param code - Original source code for line number calculation
|
|
105
|
+
* @param config - Configuration containing Trans component names
|
|
106
|
+
* @returns Array of found hardcoded strings with location info
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* const issues = findHardcodedStrings(ast, sourceCode, config)
|
|
111
|
+
* issues.forEach(issue => {
|
|
112
|
+
* console.log(`Line ${issue.line}: "${issue.text}"`)
|
|
113
|
+
* })
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
function findHardcodedStrings (ast: any, code: string, config: I18nextToolkitConfig): HardcodedString[] {
|
|
117
|
+
const issues: HardcodedString[] = []
|
|
118
|
+
const lineStarts: number[] = [0]
|
|
119
|
+
for (let i = 0; i < code.length; i++) {
|
|
120
|
+
if (code[i] === '\n') lineStarts.push(i + 1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Converts a character position to a line number.
|
|
125
|
+
*
|
|
126
|
+
* @param pos - Character position in the source code
|
|
127
|
+
* @returns Line number (1-based)
|
|
128
|
+
*/
|
|
129
|
+
const getLineNumber = (pos: number): number => {
|
|
130
|
+
let line = 1
|
|
131
|
+
for (let i = 0; i < lineStarts.length; i++) {
|
|
132
|
+
if (lineStarts[i] > pos) break
|
|
133
|
+
line = i + 1
|
|
134
|
+
}
|
|
135
|
+
return line
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const transComponents = config.extract.transComponents || ['Trans']
|
|
139
|
+
const defaultIgnoredAttributes = ['className', 'key', 'id', 'style', 'href', 'i18nKey', 'defaults', 'type']
|
|
140
|
+
const customIgnoredAttributes = config.extract.ignoredAttributes || []
|
|
141
|
+
const ignoredAttributes = new Set([...defaultIgnoredAttributes, ...customIgnoredAttributes])
|
|
142
|
+
|
|
143
|
+
ancestor(ast, {
|
|
144
|
+
/**
|
|
145
|
+
* Processes JSX text nodes to identify hardcoded content.
|
|
146
|
+
*
|
|
147
|
+
* @param node - JSX text node
|
|
148
|
+
* @param ancestors - Array of ancestor nodes for context
|
|
149
|
+
*/
|
|
150
|
+
JSXText (node: any, ancestors: any[]) {
|
|
151
|
+
const parent = ancestors[ancestors.length - 2]
|
|
152
|
+
const parentName = parent?.opening?.name?.value
|
|
153
|
+
|
|
154
|
+
if (parentName && (transComponents.includes(parentName) || parentName === 'script' || parentName === 'style')) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isIgnored = ancestors.some(ancestorNode => {
|
|
159
|
+
if (ancestorNode.type !== 'JSXElement') return false
|
|
160
|
+
const elementName = ancestorNode.opening?.name?.value
|
|
161
|
+
return transComponents.includes(elementName) || ['script', 'style', 'code'].includes(elementName)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
if (isIgnored) return
|
|
165
|
+
|
|
166
|
+
const text = node.value.trim()
|
|
167
|
+
if (text && isNaN(Number(text)) && !text.startsWith('{{')) {
|
|
168
|
+
issues.push({ text, line: getLineNumber(node.span.start) })
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Processes string literals in JSX attributes.
|
|
174
|
+
*
|
|
175
|
+
* @param node - String literal node
|
|
176
|
+
* @param ancestors - Array of ancestor nodes for context
|
|
177
|
+
*/
|
|
178
|
+
StringLiteral (node: any, ancestors: any[]) {
|
|
179
|
+
const parent = ancestors[ancestors.length - 2]
|
|
180
|
+
|
|
181
|
+
// This check now uses the new combined Set
|
|
182
|
+
if (parent?.type === 'JSXAttribute' && !ignoredAttributes.has(parent.name.value)) {
|
|
183
|
+
const text = node.value.trim()
|
|
184
|
+
if (text && isNaN(Number(text))) {
|
|
185
|
+
issues.push({ text, line: getLineNumber(node.span.start) })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
return issues
|
|
191
|
+
}
|
package/src/locize.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import inquirer from 'inquirer'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import type { I18nextToolkitConfig } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Verifies that the locize-cli tool is installed and accessible.
|
|
10
|
+
*
|
|
11
|
+
* @throws Exits the process with error code 1 if locize-cli is not found
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* await checkLocizeCliExists()
|
|
16
|
+
* // Continues execution if locize-cli is available
|
|
17
|
+
* // Otherwise exits with installation instructions
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
async function checkLocizeCliExists (): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
await execa('locize', ['--version'])
|
|
23
|
+
} catch (error: any) {
|
|
24
|
+
if (error.code === 'ENOENT') {
|
|
25
|
+
console.error(chalk.red('Error: `locize-cli` command not found.'))
|
|
26
|
+
console.log(chalk.yellow('Please install it globally to use the locize integration:'))
|
|
27
|
+
console.log(chalk.cyan('npm install -g locize-cli'))
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Interactive setup wizard for configuring Locize credentials.
|
|
35
|
+
*
|
|
36
|
+
* This function guides users through setting up their Locize integration when
|
|
37
|
+
* configuration is missing or invalid. It:
|
|
38
|
+
* 1. Prompts for Project ID, API Key, and version
|
|
39
|
+
* 2. Validates required fields
|
|
40
|
+
* 3. Temporarily sets credentials for the current run
|
|
41
|
+
* 4. Provides security recommendations for storing credentials
|
|
42
|
+
* 5. Shows code examples for proper configuration
|
|
43
|
+
*
|
|
44
|
+
* @param config - Configuration object to update with new credentials
|
|
45
|
+
* @returns Promise resolving to the Locize configuration or undefined if setup was cancelled
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const locizeConfig = await interactiveCredentialSetup(config)
|
|
50
|
+
* if (locizeConfig) {
|
|
51
|
+
* // Proceed with sync using the new credentials
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
async function interactiveCredentialSetup (config: I18nextToolkitConfig): Promise<{ projectId?: string, apiKey?: string, version?: string } | undefined> {
|
|
56
|
+
console.log(chalk.yellow('\nLocize configuration is missing or invalid. Let\'s set it up!'))
|
|
57
|
+
|
|
58
|
+
const answers = await inquirer.prompt([
|
|
59
|
+
{
|
|
60
|
+
type: 'input',
|
|
61
|
+
name: 'projectId',
|
|
62
|
+
message: 'What is your locize Project ID? (Find this in your project settings on www.locize.app)',
|
|
63
|
+
validate: input => !!input || 'Project ID cannot be empty.',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'password',
|
|
67
|
+
name: 'apiKey',
|
|
68
|
+
message: 'What is your locize API key? (Create or use one in your project settings > "API Keys")',
|
|
69
|
+
validate: input => !!input || 'API Key cannot be empty.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'input',
|
|
73
|
+
name: 'version',
|
|
74
|
+
message: 'What version do you want to sync with?',
|
|
75
|
+
default: 'latest',
|
|
76
|
+
},
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
if (!answers.projectId) {
|
|
80
|
+
console.error(chalk.red('Project ID is required to continue.'))
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use the entered credentials for the current run
|
|
85
|
+
config.locize = {
|
|
86
|
+
projectId: answers.projectId,
|
|
87
|
+
apiKey: answers.apiKey,
|
|
88
|
+
version: answers.version,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { save } = await inquirer.prompt([{
|
|
92
|
+
type: 'confirm',
|
|
93
|
+
name: 'save',
|
|
94
|
+
message: 'Would you like to see how to save these credentials for future use?',
|
|
95
|
+
default: true,
|
|
96
|
+
}])
|
|
97
|
+
|
|
98
|
+
if (save) {
|
|
99
|
+
const envSnippet = `
|
|
100
|
+
# Add this to your .env file (and ensure .env is in your .gitignore!)
|
|
101
|
+
LOCIZE_API_KEY=${answers.apiKey}
|
|
102
|
+
`
|
|
103
|
+
const configSnippet = `
|
|
104
|
+
// Add this to your i18next.config.ts file
|
|
105
|
+
locize: {
|
|
106
|
+
projectId: '${answers.projectId}',
|
|
107
|
+
// For security, apiKey is best set via an environment variable
|
|
108
|
+
apiKey: process.env.LOCIZE_API_KEY,
|
|
109
|
+
version: '${answers.version}',
|
|
110
|
+
},`
|
|
111
|
+
|
|
112
|
+
console.log(chalk.cyan('\nGreat! For the best security, we recommend using environment variables for your API key.'))
|
|
113
|
+
console.log(chalk.bold('\nRecommended approach (.env file):'))
|
|
114
|
+
console.log(chalk.green(envSnippet))
|
|
115
|
+
console.log(chalk.bold('Then, in your i18next.config.ts:'))
|
|
116
|
+
console.log(chalk.green(configSnippet))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return config.locize
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Converts CLI options and configuration into locize-cli command arguments.
|
|
124
|
+
*
|
|
125
|
+
* Maps toolkit configuration and CLI flags to the appropriate locize-cli arguments:
|
|
126
|
+
* - `updateValues` → `--update-values`
|
|
127
|
+
* - `sourceLanguageOnly` → `--reference-language-only`
|
|
128
|
+
* - `compareModificationTime` → `--compare-modification-time`
|
|
129
|
+
* - `dryRun` → `--dry`
|
|
130
|
+
*
|
|
131
|
+
* @param command - The locize command being executed
|
|
132
|
+
* @param cliOptions - CLI options passed to the command
|
|
133
|
+
* @param locizeConfig - Locize configuration from the config file
|
|
134
|
+
* @returns Array of command-line arguments
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const args = cliOptionsToArgs('sync', { updateValues: true }, { dryRun: false })
|
|
139
|
+
* // Returns: ['--update-values', 'true']
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
function cliOptionsToArgs (command: 'sync' | 'download' | 'migrate', cliOptions: any = {}, locizeConfig: any = {}) {
|
|
143
|
+
const commandArgs: string[] = []
|
|
144
|
+
|
|
145
|
+
// Pass-through options
|
|
146
|
+
if (command === 'sync') {
|
|
147
|
+
const updateValues = cliOptions.updateValues ?? locizeConfig.updateValues
|
|
148
|
+
if (updateValues) commandArgs.push('--update-values', 'true')
|
|
149
|
+
const srcLngOnly = cliOptions.srcLngOnly ?? locizeConfig.sourceLanguageOnly
|
|
150
|
+
if (srcLngOnly) commandArgs.push('--reference-language-only', 'true')
|
|
151
|
+
const compareMtime = cliOptions.compareMtime ?? locizeConfig.compareModificationTime
|
|
152
|
+
if (compareMtime) commandArgs.push('--compare-modification-time', 'true')
|
|
153
|
+
const dryRun = cliOptions.dryRun ?? locizeConfig.dryRun
|
|
154
|
+
if (dryRun) commandArgs.push('--dry', 'true')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return commandArgs
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Executes a locize-cli command with proper error handling and credential management.
|
|
162
|
+
*
|
|
163
|
+
* This is the core function that:
|
|
164
|
+
* 1. Validates that locize-cli is installed
|
|
165
|
+
* 2. Builds command arguments from configuration and CLI options
|
|
166
|
+
* 3. Executes the locize command with proper credential handling
|
|
167
|
+
* 4. Provides interactive credential setup on authentication errors
|
|
168
|
+
* 5. Handles retries with new credentials
|
|
169
|
+
* 6. Reports success or failure with appropriate exit codes
|
|
170
|
+
*
|
|
171
|
+
* @param command - The locize command to execute ('sync', 'download', or 'migrate')
|
|
172
|
+
* @param config - The toolkit configuration with locize settings
|
|
173
|
+
* @param cliOptions - Additional options passed from CLI arguments
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* // Sync local files to Locize
|
|
178
|
+
* await runLocizeCommand('sync', config, { updateValues: true })
|
|
179
|
+
*
|
|
180
|
+
* // Download translations from Locize
|
|
181
|
+
* await runLocizeCommand('download', config)
|
|
182
|
+
*
|
|
183
|
+
* // Migrate local files to a new Locize project
|
|
184
|
+
* await runLocizeCommand('migrate', config)
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
async function runLocizeCommand (command: 'sync' | 'download' | 'migrate', config: I18nextToolkitConfig, cliOptions: any = {}) {
|
|
188
|
+
await checkLocizeCliExists()
|
|
189
|
+
|
|
190
|
+
const spinner = ora(`Running 'locize ${command}'...\n`).start()
|
|
191
|
+
|
|
192
|
+
const locizeConfig = config.locize || {}
|
|
193
|
+
const { projectId, apiKey, version } = locizeConfig
|
|
194
|
+
let commandArgs: string[] = [command]
|
|
195
|
+
|
|
196
|
+
if (projectId) commandArgs.push('--project-id', projectId)
|
|
197
|
+
if (apiKey) commandArgs.push('--api-key', apiKey)
|
|
198
|
+
if (version) commandArgs.push('--ver', version)
|
|
199
|
+
// TODO: there might be more configurable locize-cli options in future
|
|
200
|
+
|
|
201
|
+
commandArgs.push(...cliOptionsToArgs(command, cliOptions, locizeConfig))
|
|
202
|
+
|
|
203
|
+
const basePath = resolve(process.cwd(), config.extract.output.split('/{{language}}/')[0])
|
|
204
|
+
commandArgs.push('--path', basePath)
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
console.log(chalk.cyan(`\nRunning 'locize ${commandArgs.join(' ')}'...`))
|
|
208
|
+
const result = await execa('locize', commandArgs, { stdio: 'pipe' })
|
|
209
|
+
spinner.succeed(chalk.green(`'locize ${command}' completed successfully.`))
|
|
210
|
+
if (result?.stdout) console.log(result.stdout) // Print captured output on success
|
|
211
|
+
} catch (error: any) {
|
|
212
|
+
const stderr = error.stderr || ''
|
|
213
|
+
if (stderr.includes('missing required argument')) {
|
|
214
|
+
// Fallback to interactive setup
|
|
215
|
+
const newCredentials = await interactiveCredentialSetup(config)
|
|
216
|
+
if (newCredentials) {
|
|
217
|
+
// Retry the command with the new credentials
|
|
218
|
+
commandArgs = [command]
|
|
219
|
+
if (newCredentials.projectId) commandArgs.push('--project-id', newCredentials.projectId)
|
|
220
|
+
if (newCredentials.apiKey) commandArgs.push('--api-key', newCredentials.apiKey)
|
|
221
|
+
if (newCredentials.version) commandArgs.push('--ver', newCredentials.version)
|
|
222
|
+
// TODO: there might be more configurable locize-cli options in future
|
|
223
|
+
commandArgs.push(...cliOptionsToArgs(command, cliOptions, locizeConfig))
|
|
224
|
+
commandArgs.push('--path', basePath)
|
|
225
|
+
try {
|
|
226
|
+
spinner.start('Retrying with new credentials...') // Restart spinner
|
|
227
|
+
const result = await execa('locize', commandArgs, { stdio: 'pipe' })
|
|
228
|
+
spinner.succeed(chalk.green('Retry successful!'))
|
|
229
|
+
if (result?.stdout) console.log(result.stdout) // Print captured output on success
|
|
230
|
+
} catch (retryError: any) {
|
|
231
|
+
spinner.fail(chalk.red('Error during retry.'))
|
|
232
|
+
console.error(retryError.stderr || retryError.message)
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
spinner.fail('Operation cancelled.')
|
|
237
|
+
process.exit(1) // User aborted the prompt
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Handle other errors
|
|
241
|
+
spinner.fail(chalk.red(`Error executing 'locize ${command}'.`))
|
|
242
|
+
console.error(stderr || error.message)
|
|
243
|
+
process.exit(1)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
console.log(chalk.green(`\n✅ 'locize ${command}' completed successfully.`))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const runLocizeSync = (config: I18nextToolkitConfig, cliOptions?: any) => runLocizeCommand('sync', config, cliOptions)
|
|
250
|
+
export const runLocizeDownload = (config: I18nextToolkitConfig, cliOptions?: any) => runLocizeCommand('download', config, cliOptions)
|
|
251
|
+
export const runLocizeMigrate = (config: I18nextToolkitConfig, cliOptions?: any) => runLocizeCommand('migrate', config, cliOptions)
|
package/src/migrator.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { writeFile, access } from 'node:fs/promises'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Path to the legacy i18next-parser configuration file
|
|
7
|
+
*/
|
|
8
|
+
const oldConfigPath = resolve(process.cwd(), 'i18next-parser.config.js')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Path where the new configuration file will be created
|
|
12
|
+
*/
|
|
13
|
+
const newConfigPath = resolve(process.cwd(), 'i18next.config.ts')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* List of possible new configuration file names that would prevent migration
|
|
17
|
+
*/
|
|
18
|
+
const POSSIBLE_NEW_CONFIGS = [
|
|
19
|
+
'i18next.config.ts',
|
|
20
|
+
'i18next.config.js',
|
|
21
|
+
'i18next.config.mjs',
|
|
22
|
+
'i18next.config.cjs',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Migrates a legacy i18next-parser.config.js configuration file to the new
|
|
27
|
+
* i18next-cli configuration format.
|
|
28
|
+
*
|
|
29
|
+
* This function:
|
|
30
|
+
* 1. Checks if a legacy config file exists
|
|
31
|
+
* 2. Prevents migration if any new config file already exists
|
|
32
|
+
* 3. Dynamically imports the old configuration
|
|
33
|
+
* 4. Maps old configuration properties to new format:
|
|
34
|
+
* - `$LOCALE` → `{{language}}`
|
|
35
|
+
* - `$NAMESPACE` → `{{namespace}}`
|
|
36
|
+
* - Maps lexer functions and components
|
|
37
|
+
* - Creates sensible defaults for new features
|
|
38
|
+
* 5. Generates a new TypeScript configuration file
|
|
39
|
+
* 6. Provides warnings for deprecated features
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* // Legacy config (i18next-parser.config.js):
|
|
44
|
+
* module.exports = {
|
|
45
|
+
* locales: ['en', 'de'],
|
|
46
|
+
* output: 'locales/$LOCALE/$NAMESPACE.json',
|
|
47
|
+
* input: ['src/**\/*.js']
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* // After migration (i18next.config.ts):
|
|
51
|
+
* export default defineConfig({
|
|
52
|
+
* locales: ['en', 'de'],
|
|
53
|
+
* extract: {
|
|
54
|
+
* input: ['src/**\/*.js'],
|
|
55
|
+
* output: 'locales/{{language}}/{{namespace}}.json'
|
|
56
|
+
* }
|
|
57
|
+
* })
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export async function runMigrator () {
|
|
61
|
+
console.log('Attempting to migrate legacy i18next-parser.config.js...')
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await access(oldConfigPath)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.log('No i18next-parser.config.js found. Nothing to migrate.')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await access(oldConfigPath)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.log('No i18next-parser.config.js found. Nothing to migrate.')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if ANY new config file already exists
|
|
78
|
+
for (const configFile of POSSIBLE_NEW_CONFIGS) {
|
|
79
|
+
try {
|
|
80
|
+
const fullPath = resolve(process.cwd(), configFile)
|
|
81
|
+
await access(fullPath)
|
|
82
|
+
console.warn(`Warning: A new configuration file already exists at "${configFile}". Migration skipped to avoid overwriting.`)
|
|
83
|
+
return
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// File doesn't exist, which is good
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Dynamically import the CJS config file
|
|
90
|
+
const oldConfigUrl = pathToFileURL(oldConfigPath).href
|
|
91
|
+
const oldConfigModule = await import(oldConfigUrl)
|
|
92
|
+
const oldConfig = oldConfigModule.default
|
|
93
|
+
|
|
94
|
+
if (!oldConfig) {
|
|
95
|
+
console.error('Could not read the legacy config file.')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Start Migration Logic ---
|
|
100
|
+
const newConfig = {
|
|
101
|
+
locales: oldConfig.locales || ['en'],
|
|
102
|
+
extract: {
|
|
103
|
+
input: oldConfig.input || 'src/**/*.{js,jsx,ts,tsx}',
|
|
104
|
+
output: (oldConfig.output || 'locales/$LOCALE/$NAMESPACE.json').replace('$LOCALE', '{{language}}').replace('$NAMESPACE', '{{namespace}}'),
|
|
105
|
+
defaultNS: oldConfig.defaultNamespace || 'translation',
|
|
106
|
+
keySeparator: oldConfig.keySeparator,
|
|
107
|
+
nsSeparator: oldConfig.namespaceSeparator,
|
|
108
|
+
contextSeparator: oldConfig.contextSeparator,
|
|
109
|
+
// A simple mapping for functions
|
|
110
|
+
functions: oldConfig.lexers?.js?.functions || ['t'],
|
|
111
|
+
transComponents: oldConfig.lexers?.js?.components || ['Trans'],
|
|
112
|
+
},
|
|
113
|
+
typesafe: {
|
|
114
|
+
input: 'locales/{{language}}/{{namespace}}.json', // Sensible default
|
|
115
|
+
output: 'src/types/i18next.d.ts', // Sensible default
|
|
116
|
+
},
|
|
117
|
+
sync: {
|
|
118
|
+
primaryLanguage: oldConfig.locales?.[0] || 'en',
|
|
119
|
+
secondaryLanguages: oldConfig.locales.filter((l: string) => l !== (oldConfig.locales?.[0] || 'en'))
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
// --- End Migration Logic ---
|
|
123
|
+
|
|
124
|
+
// Generate the new file content as a string
|
|
125
|
+
const newConfigFileContent = `
|
|
126
|
+
import { defineConfig } from 'i18next-cli';
|
|
127
|
+
|
|
128
|
+
export default defineConfig(${JSON.stringify(newConfig, null, 2)});
|
|
129
|
+
`
|
|
130
|
+
|
|
131
|
+
await writeFile(newConfigPath, newConfigFileContent.trim())
|
|
132
|
+
|
|
133
|
+
console.log('✅ Success! Migration complete.')
|
|
134
|
+
console.log(`New configuration file created at: ${newConfigPath}`)
|
|
135
|
+
console.warn('\nPlease review the generated file and adjust paths for "typesafe.input" if necessary.')
|
|
136
|
+
if (oldConfig.keepRemoved) {
|
|
137
|
+
console.warn('Warning: The "keepRemoved" option is deprecated. Consider using the "preservePatterns" feature for dynamic keys.')
|
|
138
|
+
}
|
|
139
|
+
}
|