i18next-cli 1.24.13 → 1.24.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/cli.js +1 -1
- package/dist/esm/cli.js +1 -1
- package/package.json +6 -6
- package/types/cli.d.ts +3 -1
- package/types/cli.d.ts.map +1 -1
- package/CHANGELOG.md +0 -599
- package/src/cli.ts +0 -283
- package/src/config.ts +0 -215
- package/src/extractor/core/ast-visitors.ts +0 -259
- package/src/extractor/core/extractor.ts +0 -250
- package/src/extractor/core/key-finder.ts +0 -142
- package/src/extractor/core/translation-manager.ts +0 -750
- package/src/extractor/index.ts +0 -7
- package/src/extractor/parsers/ast-utils.ts +0 -87
- package/src/extractor/parsers/call-expression-handler.ts +0 -793
- package/src/extractor/parsers/comment-parser.ts +0 -424
- package/src/extractor/parsers/expression-resolver.ts +0 -391
- package/src/extractor/parsers/jsx-handler.ts +0 -488
- package/src/extractor/parsers/jsx-parser.ts +0 -1463
- package/src/extractor/parsers/scope-manager.ts +0 -445
- package/src/extractor/plugin-manager.ts +0 -116
- package/src/extractor.ts +0 -15
- package/src/heuristic-config.ts +0 -92
- package/src/index.ts +0 -22
- package/src/init.ts +0 -175
- package/src/linter.ts +0 -345
- package/src/locize.ts +0 -263
- package/src/migrator.ts +0 -208
- package/src/rename-key.ts +0 -398
- package/src/status.ts +0 -380
- package/src/syncer.ts +0 -133
- package/src/types-generator.ts +0 -139
- package/src/types.ts +0 -577
- package/src/utils/default-value.ts +0 -45
- package/src/utils/file-utils.ts +0 -167
- package/src/utils/funnel-msg-tracker.ts +0 -84
- package/src/utils/logger.ts +0 -36
- package/src/utils/nested-object.ts +0 -135
- package/src/utils/validation.ts +0 -72
package/src/init.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import inquirer from 'inquirer'
|
|
2
|
-
import { writeFile, readFile } from 'node:fs/promises'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
4
|
-
import { detectConfig } from './heuristic-config'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Determines if the current project is configured as an ESM project.
|
|
8
|
-
* Checks the package.json file for `"type": "module"`.
|
|
9
|
-
*
|
|
10
|
-
* @returns Promise resolving to true if ESM, false if CommonJS
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```typescript
|
|
14
|
-
* const isESM = await isEsmProject()
|
|
15
|
-
* if (isESM) {
|
|
16
|
-
* // Generate ESM syntax
|
|
17
|
-
* } else {
|
|
18
|
-
* // Generate CommonJS syntax
|
|
19
|
-
* }
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
async function isEsmProject (): Promise<boolean> {
|
|
23
|
-
try {
|
|
24
|
-
const packageJsonPath = resolve(process.cwd(), 'package.json')
|
|
25
|
-
const content = await readFile(packageJsonPath, 'utf-8')
|
|
26
|
-
const packageJson = JSON.parse(content)
|
|
27
|
-
return packageJson.type === 'module'
|
|
28
|
-
} catch {
|
|
29
|
-
return true // Default to ESM if package.json is not found or readable
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Interactive setup wizard for creating a new i18next-cli configuration file.
|
|
35
|
-
*
|
|
36
|
-
* This function provides a guided setup experience that:
|
|
37
|
-
* 1. Asks the user for their preferred configuration file type (TypeScript or JavaScript)
|
|
38
|
-
* 2. Collects basic project settings (locales, input patterns, output paths)
|
|
39
|
-
* 3. Detects the project module system (ESM vs CommonJS) for JavaScript files
|
|
40
|
-
* 4. Generates an appropriate configuration file with proper syntax
|
|
41
|
-
* 5. Provides helpful defaults for common use cases
|
|
42
|
-
*
|
|
43
|
-
* The generated configuration includes:
|
|
44
|
-
* - Locale specification
|
|
45
|
-
* - Input file patterns for source scanning
|
|
46
|
-
* - Output path templates with placeholders
|
|
47
|
-
* - Proper imports and exports for the detected module system
|
|
48
|
-
* - JSDoc type annotations for JavaScript files
|
|
49
|
-
*
|
|
50
|
-
* @example
|
|
51
|
-
* ```typescript
|
|
52
|
-
* // Run the interactive setup
|
|
53
|
-
* await runInit()
|
|
54
|
-
*
|
|
55
|
-
* // This will create either:
|
|
56
|
-
* // - i18next.config.ts (TypeScript)
|
|
57
|
-
* // - i18next.config.js (JavaScript ESM/CommonJS)
|
|
58
|
-
* ```
|
|
59
|
-
*/
|
|
60
|
-
export async function runInit () {
|
|
61
|
-
console.log('Welcome to the i18next-cli setup wizard!')
|
|
62
|
-
console.log('Scanning your project for a recommended configuration...')
|
|
63
|
-
|
|
64
|
-
const detectedConfig = await detectConfig()
|
|
65
|
-
if (detectedConfig) {
|
|
66
|
-
console.log('✅ Found a potential project structure. Using it for suggestions.')
|
|
67
|
-
} else {
|
|
68
|
-
console.log('Could not detect a project structure. Using standard defaults.')
|
|
69
|
-
}
|
|
70
|
-
if (typeof detectedConfig?.extract?.input === 'string') detectedConfig.extract.input = [detectedConfig?.extract?.input]
|
|
71
|
-
|
|
72
|
-
// If heuristic detection returned a function for extract.output, don't use it as a prompt default.
|
|
73
|
-
// Prompt defaults must be strings; leave undefined so the prompt falls back to a sensible default.
|
|
74
|
-
if (detectedConfig && typeof detectedConfig.extract?.output === 'function') {
|
|
75
|
-
delete (detectedConfig.extract as any).output
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const answers = await inquirer.prompt([
|
|
79
|
-
{
|
|
80
|
-
type: 'list',
|
|
81
|
-
name: 'fileType',
|
|
82
|
-
message: 'What kind of configuration file do you want?',
|
|
83
|
-
choices: ['TypeScript (i18next.config.ts)', 'JavaScript (i18next.config.js)'],
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
type: 'input',
|
|
87
|
-
name: 'locales',
|
|
88
|
-
message: 'What locales does your project support? (comma-separated)',
|
|
89
|
-
default: detectedConfig?.locales?.join(',') || 'en,de,fr',
|
|
90
|
-
filter: (input: string) => input.split(',').map(s => s.trim()),
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
type: 'input',
|
|
94
|
-
name: 'input',
|
|
95
|
-
message: 'What is the glob pattern for your source files?',
|
|
96
|
-
default: detectedConfig?.extract?.input ? (detectedConfig.extract.input || [])[0] : 'src/**/*.{js,jsx,ts,tsx}',
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
type: 'input',
|
|
100
|
-
name: 'output',
|
|
101
|
-
message: 'What is the path for your output resource files?',
|
|
102
|
-
// ensure the default is a string (detectedConfig.extract.output may be a function)
|
|
103
|
-
default: typeof detectedConfig?.extract?.output === 'string'
|
|
104
|
-
? detectedConfig!.extract!.output!
|
|
105
|
-
: 'public/locales/{{language}}/{{namespace}}.json',
|
|
106
|
-
},
|
|
107
|
-
])
|
|
108
|
-
|
|
109
|
-
const isTypeScript = answers.fileType.includes('TypeScript')
|
|
110
|
-
const isEsm = await isEsmProject()
|
|
111
|
-
const fileName = isTypeScript ? 'i18next.config.ts' : 'i18next.config.js'
|
|
112
|
-
|
|
113
|
-
const configObject = {
|
|
114
|
-
locales: answers.locales,
|
|
115
|
-
extract: {
|
|
116
|
-
input: answers.input,
|
|
117
|
-
output: answers.output,
|
|
118
|
-
},
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Helper to serialize a JS value as a JS literal:
|
|
122
|
-
function toJs (value: any, indent = 2, level = 0): string {
|
|
123
|
-
const pad = (n: number) => ' '.repeat(n * indent)
|
|
124
|
-
const currentPad = pad(level)
|
|
125
|
-
const nextPad = pad(level + 1)
|
|
126
|
-
|
|
127
|
-
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
|
|
128
|
-
return JSON.stringify(value)
|
|
129
|
-
}
|
|
130
|
-
if (typeof value === 'string') {
|
|
131
|
-
return JSON.stringify(value) // keeps double quotes and proper escaping
|
|
132
|
-
}
|
|
133
|
-
if (Array.isArray(value)) {
|
|
134
|
-
if (value.length === 0) return '[]'
|
|
135
|
-
const items = value.map(v => `${nextPad}${toJs(v, indent, level + 1)}`).join(',\n')
|
|
136
|
-
return `[\n${items}\n${currentPad}]`
|
|
137
|
-
}
|
|
138
|
-
if (typeof value === 'object') {
|
|
139
|
-
const keys = Object.keys(value)
|
|
140
|
-
if (keys.length === 0) return '{}'
|
|
141
|
-
const entries = keys.map(key => {
|
|
142
|
-
// Use unquoted key if it's a valid identifier otherwise JSON.stringify(key)
|
|
143
|
-
const validId = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
144
|
-
const printedKey = validId ? key : JSON.stringify(key)
|
|
145
|
-
return `${nextPad}${printedKey}: ${toJs(value[key], indent, level + 1)}`
|
|
146
|
-
}).join(',\n')
|
|
147
|
-
return `{\n${entries}\n${currentPad}}`
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Fallback
|
|
151
|
-
return JSON.stringify(value)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let fileContent = ''
|
|
155
|
-
if (isTypeScript) {
|
|
156
|
-
fileContent = `import { defineConfig } from 'i18next-cli';
|
|
157
|
-
|
|
158
|
-
export default defineConfig(${toJs(configObject)});`
|
|
159
|
-
} else if (isEsm) {
|
|
160
|
-
fileContent = `import { defineConfig } from 'i18next-cli';
|
|
161
|
-
|
|
162
|
-
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
|
163
|
-
export default defineConfig(${toJs(configObject)});`
|
|
164
|
-
} else { // CJS
|
|
165
|
-
fileContent = `const { defineConfig } = require('i18next-cli');
|
|
166
|
-
|
|
167
|
-
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
|
168
|
-
module.exports = defineConfig(${toJs(configObject)});`
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const outputPath = resolve(process.cwd(), fileName)
|
|
172
|
-
await writeFile(outputPath, fileContent.trim())
|
|
173
|
-
|
|
174
|
-
console.log(`✅ Configuration file created at: ${outputPath}`)
|
|
175
|
-
}
|
package/src/linter.ts
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
import { glob } from 'glob'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { parse } from '@swc/core'
|
|
4
|
-
import { extname } from 'node:path'
|
|
5
|
-
import { EventEmitter } from 'node:events'
|
|
6
|
-
import chalk from 'chalk'
|
|
7
|
-
import ora from 'ora'
|
|
8
|
-
import type { I18nextToolkitConfig } from './types'
|
|
9
|
-
|
|
10
|
-
type LinterEventMap = {
|
|
11
|
-
progress: [{
|
|
12
|
-
message: string;
|
|
13
|
-
}];
|
|
14
|
-
done: [{
|
|
15
|
-
success: boolean;
|
|
16
|
-
message: string;
|
|
17
|
-
files: Record<string, HardcodedString[]>;
|
|
18
|
-
}];
|
|
19
|
-
error: [error: Error];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class Linter extends EventEmitter<LinterEventMap> {
|
|
23
|
-
private config: I18nextToolkitConfig
|
|
24
|
-
|
|
25
|
-
constructor (config: I18nextToolkitConfig) {
|
|
26
|
-
super({ captureRejections: true })
|
|
27
|
-
this.config = config
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
wrapError (error: unknown) {
|
|
31
|
-
const prefix = 'Linter failed to run: '
|
|
32
|
-
if (error instanceof Error) {
|
|
33
|
-
if (error.message.startsWith(prefix)) {
|
|
34
|
-
return error
|
|
35
|
-
}
|
|
36
|
-
const wrappedError = new Error(`${prefix}${error.message}`)
|
|
37
|
-
wrappedError.stack = error.stack
|
|
38
|
-
return wrappedError
|
|
39
|
-
}
|
|
40
|
-
return new Error(`${prefix}${String(error)}`)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async run () {
|
|
44
|
-
const { config } = this
|
|
45
|
-
try {
|
|
46
|
-
this.emit('progress', { message: 'Finding source files to analyze...' })
|
|
47
|
-
const defaultIgnore = ['node_modules/**']
|
|
48
|
-
const userIgnore = Array.isArray(config.extract.ignore)
|
|
49
|
-
? config.extract.ignore
|
|
50
|
-
: config.extract.ignore ? [config.extract.ignore] : []
|
|
51
|
-
|
|
52
|
-
const sourceFiles = await glob(config.extract.input, {
|
|
53
|
-
ignore: [...defaultIgnore, ...userIgnore]
|
|
54
|
-
})
|
|
55
|
-
this.emit('progress', { message: `Analyzing ${sourceFiles.length} source files...` })
|
|
56
|
-
let totalIssues = 0
|
|
57
|
-
const issuesByFile = new Map<string, HardcodedString[]>()
|
|
58
|
-
|
|
59
|
-
for (const file of sourceFiles) {
|
|
60
|
-
const code = await readFile(file, 'utf-8')
|
|
61
|
-
|
|
62
|
-
// Determine parser options from file extension so .ts is not parsed as TSX
|
|
63
|
-
const fileExt = extname(file).toLowerCase()
|
|
64
|
-
const isTypeScriptFile = fileExt === '.ts' || fileExt === '.tsx' || fileExt === '.mts' || fileExt === '.cts'
|
|
65
|
-
const isTSX = fileExt === '.tsx'
|
|
66
|
-
const isJSX = fileExt === '.jsx'
|
|
67
|
-
|
|
68
|
-
let ast: any
|
|
69
|
-
try {
|
|
70
|
-
ast = await parse(code, {
|
|
71
|
-
syntax: isTypeScriptFile ? 'typescript' : 'ecmascript',
|
|
72
|
-
tsx: isTSX,
|
|
73
|
-
jsx: isJSX,
|
|
74
|
-
decorators: true
|
|
75
|
-
})
|
|
76
|
-
} catch (err) {
|
|
77
|
-
// Some projects use JSX/TSX in .ts files. Try one fallback parse with tsx:true
|
|
78
|
-
// if the original file was a .ts (not .tsx). If that still fails, emit error and continue.
|
|
79
|
-
if (fileExt === '.ts' && !isTSX) {
|
|
80
|
-
try {
|
|
81
|
-
ast = await parse(code, {
|
|
82
|
-
syntax: 'typescript',
|
|
83
|
-
tsx: true,
|
|
84
|
-
decorators: true
|
|
85
|
-
})
|
|
86
|
-
// optional: emit a progress message so consumers know a fallback happened
|
|
87
|
-
this.emit('progress', { message: `Parsed ${file} using TSX fallback` })
|
|
88
|
-
} catch (err2) {
|
|
89
|
-
const wrapped = this.wrapError(err2)
|
|
90
|
-
this.emit('error', wrapped)
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
} else {
|
|
94
|
-
const wrapped = this.wrapError(err)
|
|
95
|
-
this.emit('error', wrapped)
|
|
96
|
-
continue
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const hardcodedStrings = findHardcodedStrings(ast, code, config)
|
|
101
|
-
|
|
102
|
-
if (hardcodedStrings.length > 0) {
|
|
103
|
-
totalIssues += hardcodedStrings.length
|
|
104
|
-
issuesByFile.set(file, hardcodedStrings)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const files = Object.fromEntries(issuesByFile.entries())
|
|
109
|
-
const data = { success: totalIssues === 0, message: totalIssues > 0 ? `Linter found ${totalIssues} potential issues.` : 'No issues found.', files }
|
|
110
|
-
this.emit('done', data)
|
|
111
|
-
return data
|
|
112
|
-
} catch (error) {
|
|
113
|
-
const wrappedError = this.wrapError(error)
|
|
114
|
-
this.emit('error', wrappedError)
|
|
115
|
-
throw wrappedError
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Runs the i18next linter to detect hardcoded strings and other potential issues.
|
|
122
|
-
*
|
|
123
|
-
* This function performs static analysis on source files to identify:
|
|
124
|
-
* - Hardcoded text strings in JSX elements
|
|
125
|
-
* - Hardcoded strings in JSX attributes (like alt text, titles, etc.)
|
|
126
|
-
* - Text that should be extracted for translation
|
|
127
|
-
*
|
|
128
|
-
* The linter respects configuration settings:
|
|
129
|
-
* - Uses the same input patterns as the extractor
|
|
130
|
-
* - Ignores content inside configured Trans components
|
|
131
|
-
* - Skips technical content like script/style tags
|
|
132
|
-
* - Identifies numeric values and interpolation syntax to avoid false positives
|
|
133
|
-
*
|
|
134
|
-
* @param config - The toolkit configuration with input patterns and component names
|
|
135
|
-
*
|
|
136
|
-
* @example
|
|
137
|
-
* ```typescript
|
|
138
|
-
* const config = {
|
|
139
|
-
* extract: {
|
|
140
|
-
* input: ['src/**\/*.{ts,tsx}'],
|
|
141
|
-
* transComponents: ['Trans', 'Translation']
|
|
142
|
-
* }
|
|
143
|
-
* }
|
|
144
|
-
*
|
|
145
|
-
* await runLinter(config)
|
|
146
|
-
* // Outputs issues found or success message
|
|
147
|
-
* ```
|
|
148
|
-
*/
|
|
149
|
-
export async function runLinter (config: I18nextToolkitConfig) {
|
|
150
|
-
return new Linter(config).run()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function runLinterCli (config: I18nextToolkitConfig) {
|
|
154
|
-
const linter = new Linter(config)
|
|
155
|
-
const spinner = ora().start()
|
|
156
|
-
linter.on('progress', (event) => {
|
|
157
|
-
spinner.text = event.message
|
|
158
|
-
})
|
|
159
|
-
try {
|
|
160
|
-
const { success, message, files } = await linter.run()
|
|
161
|
-
if (!success) {
|
|
162
|
-
spinner.fail(chalk.red.bold(message))
|
|
163
|
-
|
|
164
|
-
// Print detailed report after spinner fails
|
|
165
|
-
for (const [file, issues] of Object.entries(files)) {
|
|
166
|
-
console.log(chalk.yellow(`\n${file}`))
|
|
167
|
-
issues.forEach(({ text, line }) => {
|
|
168
|
-
console.log(` ${chalk.gray(`${line}:`)} ${chalk.red('Error:')} Found hardcoded string: "${text}"`)
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
process.exit(1)
|
|
172
|
-
} else {
|
|
173
|
-
spinner.succeed(chalk.green.bold(message))
|
|
174
|
-
}
|
|
175
|
-
} catch (error) {
|
|
176
|
-
const wrappedError = linter.wrapError(error)
|
|
177
|
-
spinner.fail(wrappedError.message)
|
|
178
|
-
console.error(wrappedError)
|
|
179
|
-
process.exit(1)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Represents a found hardcoded string with its location information.
|
|
185
|
-
*/
|
|
186
|
-
interface HardcodedString {
|
|
187
|
-
/** The hardcoded text content */
|
|
188
|
-
text: string;
|
|
189
|
-
/** Line number where the string was found */
|
|
190
|
-
line: number;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const isUrlOrPath = (text: string) => /^(https|http|\/\/|^\/)/.test(text)
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Analyzes an AST to find potentially hardcoded strings that should be translated.
|
|
197
|
-
*
|
|
198
|
-
* This function traverses the syntax tree looking for:
|
|
199
|
-
* 1. JSX text nodes with translatable content
|
|
200
|
-
* 2. String literals in JSX attributes that might need translation
|
|
201
|
-
*
|
|
202
|
-
* It applies several filters to reduce false positives:
|
|
203
|
-
* - Ignores content inside Trans components (already handled)
|
|
204
|
-
* - Skips script and style tag content (technical, not user-facing)
|
|
205
|
-
* - Filters out numeric values (usually not translatable)
|
|
206
|
-
* - Ignores interpolation syntax starting with `{{`
|
|
207
|
-
* - Filters out ellipsis/spread operator notation `...`
|
|
208
|
-
* - Only reports non-empty, trimmed strings
|
|
209
|
-
*
|
|
210
|
-
* @param ast - The parsed AST to analyze
|
|
211
|
-
* @param code - Original source code for line number calculation
|
|
212
|
-
* @param config - Configuration containing Trans component names
|
|
213
|
-
* @returns Array of found hardcoded strings with location info
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* ```typescript
|
|
217
|
-
* const issues = findHardcodedStrings(ast, sourceCode, config)
|
|
218
|
-
* issues.forEach(issue => {
|
|
219
|
-
* console.log(`Line ${issue.line}: "${issue.text}"`)
|
|
220
|
-
* })
|
|
221
|
-
* ```
|
|
222
|
-
*/
|
|
223
|
-
function findHardcodedStrings (ast: any, code: string, config: I18nextToolkitConfig): HardcodedString[] {
|
|
224
|
-
const issues: HardcodedString[] = []
|
|
225
|
-
// A list of AST nodes that have been identified as potential issues.
|
|
226
|
-
const nodesToLint: any[] = []
|
|
227
|
-
|
|
228
|
-
const getLineNumber = (pos: number): number => {
|
|
229
|
-
return code.substring(0, pos).split('\n').length
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const transComponents = config.extract.transComponents || ['Trans']
|
|
233
|
-
const defaultIgnoredAttributes = ['className', 'key', 'id', 'style', 'href', 'i18nKey', 'defaults', 'type', 'target']
|
|
234
|
-
const defaultIgnoredTags = ['script', 'style', 'code']
|
|
235
|
-
const customIgnoredTags = config.extract.ignoredTags || []
|
|
236
|
-
const allIgnoredTags = new Set([...transComponents, ...defaultIgnoredTags, ...customIgnoredTags])
|
|
237
|
-
const customIgnoredAttributes = config.extract.ignoredAttributes || []
|
|
238
|
-
const ignoredAttributes = new Set([...defaultIgnoredAttributes, ...customIgnoredAttributes])
|
|
239
|
-
|
|
240
|
-
// Helper: robustly extract a JSX element name from different node shapes
|
|
241
|
-
const extractJSXName = (node: any): string | null => {
|
|
242
|
-
if (!node) return null
|
|
243
|
-
// node might be JSXOpeningElement / JSXSelfClosingElement (has .name)
|
|
244
|
-
const nameNode = node.name ?? node.opening?.name ?? node.opening?.name
|
|
245
|
-
if (!nameNode) {
|
|
246
|
-
// maybe this node is a full JSXElement with opening.name
|
|
247
|
-
if (node.opening?.name) return extractJSXName({ name: node.opening.name })
|
|
248
|
-
return null
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const fromIdentifier = (n: any): string | null => {
|
|
252
|
-
if (!n) return null
|
|
253
|
-
if (n.type === 'JSXIdentifier' && (n.name || n.value)) return (n.name ?? n.value)
|
|
254
|
-
if (n.type === 'Identifier' && (n.name || n.value)) return (n.name ?? n.value)
|
|
255
|
-
if (n.type === 'JSXMemberExpression') {
|
|
256
|
-
const object = fromIdentifier(n.object)
|
|
257
|
-
const property = fromIdentifier(n.property)
|
|
258
|
-
return object && property ? `${object}.${property}` : (property ?? object)
|
|
259
|
-
}
|
|
260
|
-
// fallback attempts
|
|
261
|
-
return n.name ?? n.value ?? n.property?.name ?? n.property?.value ?? null
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return fromIdentifier(nameNode)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Helper: return true if any JSX ancestor is in the ignored tags set
|
|
268
|
-
const isWithinIgnoredElement = (ancestors: any[]): boolean => {
|
|
269
|
-
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
270
|
-
const an = ancestors[i]
|
|
271
|
-
if (!an || typeof an !== 'object') continue
|
|
272
|
-
if (an.type === 'JSXElement' || an.type === 'JSXOpeningElement' || an.type === 'JSXSelfClosingElement') {
|
|
273
|
-
const name = extractJSXName(an)
|
|
274
|
-
if (name && allIgnoredTags.has(name)) return true
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return false
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// --- PHASE 1: Collect all potentially problematic nodes ---
|
|
281
|
-
const walk = (node: any, ancestors: any[]) => {
|
|
282
|
-
if (!node || typeof node !== 'object') return
|
|
283
|
-
|
|
284
|
-
const currentAncestors = [...ancestors, node]
|
|
285
|
-
|
|
286
|
-
if (node.type === 'JSXText') {
|
|
287
|
-
const isIgnored = isWithinIgnoredElement(currentAncestors)
|
|
288
|
-
|
|
289
|
-
if (!isIgnored) {
|
|
290
|
-
const text = node.value.trim()
|
|
291
|
-
// Filter out: empty strings, single chars, URLs, numbers, interpolations, and ellipsis
|
|
292
|
-
if (text && text.length > 1 && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text)) && !text.startsWith('{{')) {
|
|
293
|
-
nodesToLint.push(node) // Collect the node
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (node.type === 'StringLiteral') {
|
|
299
|
-
const parent = currentAncestors[currentAncestors.length - 2]
|
|
300
|
-
// Determine whether this attribute is inside any ignored element (handles nested Trans etc.)
|
|
301
|
-
const insideIgnored = isWithinIgnoredElement(currentAncestors)
|
|
302
|
-
|
|
303
|
-
if (parent?.type === 'JSXAttribute' && !ignoredAttributes.has(parent.name.value) && !insideIgnored) {
|
|
304
|
-
const text = node.value.trim()
|
|
305
|
-
// Filter out: empty strings, URLs, numbers, and ellipsis
|
|
306
|
-
if (text && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text))) {
|
|
307
|
-
nodesToLint.push(node) // Collect the node
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Recurse into children
|
|
313
|
-
for (const key of Object.keys(node)) {
|
|
314
|
-
if (key === 'span') continue
|
|
315
|
-
const child = node[key]
|
|
316
|
-
if (Array.isArray(child)) {
|
|
317
|
-
child.forEach(item => walk(item, currentAncestors))
|
|
318
|
-
} else if (child && typeof child === 'object') {
|
|
319
|
-
walk(child, currentAncestors)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
walk(ast, []) // Run the walk to collect nodes
|
|
325
|
-
|
|
326
|
-
// --- PHASE 2: Find line numbers using a tracked search on the raw source code ---
|
|
327
|
-
let lastSearchIndex = 0
|
|
328
|
-
for (const node of nodesToLint) {
|
|
329
|
-
// For StringLiterals, the `raw` property includes the quotes ("..."), which is
|
|
330
|
-
// much more unique for searching than the plain `value`.
|
|
331
|
-
const searchText = node.raw ?? node.value
|
|
332
|
-
|
|
333
|
-
const position = code.indexOf(searchText, lastSearchIndex)
|
|
334
|
-
|
|
335
|
-
if (position > -1) {
|
|
336
|
-
issues.push({
|
|
337
|
-
text: node.value.trim(),
|
|
338
|
-
line: getLineNumber(position),
|
|
339
|
-
})
|
|
340
|
-
lastSearchIndex = position + searchText.length
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return issues
|
|
345
|
-
}
|