i18next-cli 1.34.0 → 1.34.1
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/README.md +1 -1
- package/dist/cjs/cli.js +271 -1
- package/dist/cjs/config.js +211 -1
- package/dist/cjs/extractor/core/ast-visitors.js +364 -1
- package/dist/cjs/extractor/core/extractor.js +245 -1
- package/dist/cjs/extractor/core/key-finder.js +132 -1
- package/dist/cjs/extractor/core/translation-manager.js +745 -1
- package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
- package/dist/cjs/extractor/parsers/call-expression-handler.js +941 -1
- package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
- package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
- package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
- package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
- package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
- package/dist/cjs/extractor/plugin-manager.js +106 -1
- package/dist/cjs/heuristic-config.js +99 -1
- package/dist/cjs/index.js +28 -1
- package/dist/cjs/init.js +174 -1
- package/dist/cjs/linter.js +431 -1
- package/dist/cjs/locize.js +269 -1
- package/dist/cjs/migrator.js +196 -1
- package/dist/cjs/rename-key.js +354 -1
- package/dist/cjs/status.js +336 -1
- package/dist/cjs/syncer.js +120 -1
- package/dist/cjs/types-generator.js +165 -1
- package/dist/cjs/utils/default-value.js +43 -1
- package/dist/cjs/utils/file-utils.js +136 -1
- package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
- package/dist/cjs/utils/logger.js +36 -1
- package/dist/cjs/utils/nested-object.js +124 -1
- package/dist/cjs/utils/validation.js +71 -1
- package/dist/esm/cli.js +269 -1
- package/dist/esm/config.js +206 -1
- package/dist/esm/extractor/core/ast-visitors.js +362 -1
- package/dist/esm/extractor/core/extractor.js +241 -1
- package/dist/esm/extractor/core/key-finder.js +130 -1
- package/dist/esm/extractor/core/translation-manager.js +743 -1
- package/dist/esm/extractor/parsers/ast-utils.js +80 -1
- package/dist/esm/extractor/parsers/call-expression-handler.js +939 -1
- package/dist/esm/extractor/parsers/comment-parser.js +373 -1
- package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
- package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
- package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
- package/dist/esm/extractor/parsers/scope-manager.js +406 -1
- package/dist/esm/extractor/plugin-manager.js +103 -1
- package/dist/esm/heuristic-config.js +97 -1
- package/dist/esm/index.js +11 -1
- package/dist/esm/init.js +172 -1
- package/dist/esm/linter.js +425 -1
- package/dist/esm/locize.js +265 -1
- package/dist/esm/migrator.js +194 -1
- package/dist/esm/rename-key.js +352 -1
- package/dist/esm/status.js +334 -1
- package/dist/esm/syncer.js +118 -1
- package/dist/esm/types-generator.js +163 -1
- package/dist/esm/utils/default-value.js +41 -1
- package/dist/esm/utils/file-utils.js +131 -1
- package/dist/esm/utils/funnel-msg-tracker.js +72 -1
- package/dist/esm/utils/logger.js +34 -1
- package/dist/esm/utils/nested-object.js +120 -1
- package/dist/esm/utils/validation.js +68 -1
- package/package.json +2 -2
- package/types/locize.d.ts.map +1 -1
package/dist/esm/init.js
CHANGED
|
@@ -1 +1,172 @@
|
|
|
1
|
-
import
|
|
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.js';
|
|
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() {
|
|
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
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return true; // Default to ESM if package.json is not found or readable
|
|
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
|
+
async function runInit() {
|
|
61
|
+
console.log('Welcome to the i18next-cli setup wizard!');
|
|
62
|
+
console.log('Scanning your project for a recommended configuration...');
|
|
63
|
+
const detectedConfig = await detectConfig();
|
|
64
|
+
if (detectedConfig) {
|
|
65
|
+
console.log('✅ Found a potential project structure. Using it for suggestions.');
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.log('Could not detect a project structure. Using standard defaults.');
|
|
69
|
+
}
|
|
70
|
+
if (typeof detectedConfig?.extract?.input === 'string')
|
|
71
|
+
detectedConfig.extract.input = [detectedConfig?.extract?.input];
|
|
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.output;
|
|
76
|
+
}
|
|
77
|
+
const answers = await inquirer.prompt([
|
|
78
|
+
{
|
|
79
|
+
type: 'select',
|
|
80
|
+
name: 'fileType',
|
|
81
|
+
message: 'What kind of configuration file do you want?',
|
|
82
|
+
choices: ['TypeScript (i18next.config.ts)', 'JavaScript (i18next.config.js)'],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: 'input',
|
|
86
|
+
name: 'locales',
|
|
87
|
+
message: 'What locales does your project support? (comma-separated)',
|
|
88
|
+
default: detectedConfig?.locales?.join(',') || 'en,de,fr',
|
|
89
|
+
filter: (input) => input.split(',').map(s => s.trim()),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: 'input',
|
|
93
|
+
name: 'input',
|
|
94
|
+
message: 'What is the glob pattern for your source files?',
|
|
95
|
+
default: detectedConfig?.extract?.input ? (detectedConfig.extract.input || [])[0] : 'src/**/*.{js,jsx,ts,tsx}',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'input',
|
|
99
|
+
name: 'output',
|
|
100
|
+
message: 'What is the path for your output resource files?',
|
|
101
|
+
// ensure the default is a string (detectedConfig.extract.output may be a function)
|
|
102
|
+
default: typeof detectedConfig?.extract?.output === 'string'
|
|
103
|
+
? detectedConfig.extract.output
|
|
104
|
+
: 'public/locales/{{language}}/{{namespace}}.json',
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
const isTypeScript = answers.fileType.includes('TypeScript');
|
|
108
|
+
const isEsm = await isEsmProject();
|
|
109
|
+
const fileName = isTypeScript ? 'i18next.config.ts' : 'i18next.config.js';
|
|
110
|
+
const configObject = {
|
|
111
|
+
locales: answers.locales,
|
|
112
|
+
extract: {
|
|
113
|
+
input: answers.input,
|
|
114
|
+
output: answers.output,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
// Helper to serialize a JS value as a JS literal:
|
|
118
|
+
function toJs(value, indent = 2, level = 0) {
|
|
119
|
+
const pad = (n) => ' '.repeat(n * indent);
|
|
120
|
+
const currentPad = pad(level);
|
|
121
|
+
const nextPad = pad(level + 1);
|
|
122
|
+
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
|
|
123
|
+
return JSON.stringify(value);
|
|
124
|
+
}
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
return JSON.stringify(value); // keeps double quotes and proper escaping
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
if (value.length === 0)
|
|
130
|
+
return '[]';
|
|
131
|
+
const items = value.map(v => `${nextPad}${toJs(v, indent, level + 1)}`).join(',\n');
|
|
132
|
+
return `[\n${items}\n${currentPad}]`;
|
|
133
|
+
}
|
|
134
|
+
if (typeof value === 'object') {
|
|
135
|
+
const keys = Object.keys(value);
|
|
136
|
+
if (keys.length === 0)
|
|
137
|
+
return '{}';
|
|
138
|
+
const entries = keys.map(key => {
|
|
139
|
+
// Use unquoted key if it's a valid identifier otherwise JSON.stringify(key)
|
|
140
|
+
const validId = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key);
|
|
141
|
+
const printedKey = validId ? key : JSON.stringify(key);
|
|
142
|
+
return `${nextPad}${printedKey}: ${toJs(value[key], indent, level + 1)}`;
|
|
143
|
+
}).join(',\n');
|
|
144
|
+
return `{\n${entries}\n${currentPad}}`;
|
|
145
|
+
}
|
|
146
|
+
// Fallback
|
|
147
|
+
return JSON.stringify(value);
|
|
148
|
+
}
|
|
149
|
+
let fileContent = '';
|
|
150
|
+
if (isTypeScript) {
|
|
151
|
+
fileContent = `import { defineConfig } from 'i18next-cli';
|
|
152
|
+
|
|
153
|
+
export default defineConfig(${toJs(configObject)});`;
|
|
154
|
+
}
|
|
155
|
+
else if (isEsm) {
|
|
156
|
+
fileContent = `import { defineConfig } from 'i18next-cli';
|
|
157
|
+
|
|
158
|
+
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
|
159
|
+
export default defineConfig(${toJs(configObject)});`;
|
|
160
|
+
}
|
|
161
|
+
else { // CJS
|
|
162
|
+
fileContent = `const { defineConfig } = require('i18next-cli');
|
|
163
|
+
|
|
164
|
+
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
|
165
|
+
module.exports = defineConfig(${toJs(configObject)});`;
|
|
166
|
+
}
|
|
167
|
+
const outputPath = resolve(process.cwd(), fileName);
|
|
168
|
+
await writeFile(outputPath, fileContent.trim());
|
|
169
|
+
console.log(`✅ Configuration file created at: ${outputPath}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { runInit };
|
package/dist/esm/linter.js
CHANGED
|
@@ -1 +1,425 @@
|
|
|
1
|
-
import{
|
|
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
|
+
|
|
9
|
+
const recommendedAcceptedTags = [
|
|
10
|
+
'a', 'abbr', 'address', 'article', 'aside', 'bdi', 'bdo', 'blockquote', 'button', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dt', 'em', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'img', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'mark', 'nav', 'option', 'output', 'p', 'pre', 'q', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'td', 'textarea', 'th', 'time', 'title', 'var'
|
|
11
|
+
].map(s => s.toLowerCase());
|
|
12
|
+
const recommendedAcceptedAttributes = ['abbr', 'accesskey', 'alt', 'aria-description', 'aria-label', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', 'content', 'label', 'placeholder', 'summary', 'title'].map(s => s.toLowerCase());
|
|
13
|
+
const defaultIgnoredAttributes = ['className', 'key', 'id', 'style', 'href', 'i18nKey', 'defaults', 'type', 'target'].map(s => s.toLowerCase());
|
|
14
|
+
const defaultIgnoredTags = ['script', 'style', 'code'];
|
|
15
|
+
class Linter extends EventEmitter {
|
|
16
|
+
config;
|
|
17
|
+
constructor(config) {
|
|
18
|
+
super({ captureRejections: true });
|
|
19
|
+
this.config = config;
|
|
20
|
+
}
|
|
21
|
+
wrapError(error) {
|
|
22
|
+
const prefix = 'Linter failed to run: ';
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
if (error.message.startsWith(prefix)) {
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
const wrappedError = new Error(`${prefix}${error.message}`);
|
|
28
|
+
wrappedError.stack = error.stack;
|
|
29
|
+
return wrappedError;
|
|
30
|
+
}
|
|
31
|
+
return new Error(`${prefix}${String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
async run() {
|
|
34
|
+
const { config } = this;
|
|
35
|
+
try {
|
|
36
|
+
this.emit('progress', { message: 'Finding source files to analyze...' });
|
|
37
|
+
const defaultIgnore = ['node_modules/**'];
|
|
38
|
+
const extractIgnore = Array.isArray(config.extract.ignore)
|
|
39
|
+
? config.extract.ignore
|
|
40
|
+
: config.extract.ignore ? [config.extract.ignore] : [];
|
|
41
|
+
const lintIgnore = Array.isArray(config.lint?.ignore)
|
|
42
|
+
? config.lint.ignore
|
|
43
|
+
: config.lint?.ignore ? [config.lint.ignore] : [];
|
|
44
|
+
const sourceFiles = await glob(config.extract.input, {
|
|
45
|
+
ignore: [...defaultIgnore, ...extractIgnore, ...lintIgnore]
|
|
46
|
+
});
|
|
47
|
+
this.emit('progress', { message: `Analyzing ${sourceFiles.length} source files...` });
|
|
48
|
+
let totalIssues = 0;
|
|
49
|
+
const issuesByFile = new Map();
|
|
50
|
+
for (const file of sourceFiles) {
|
|
51
|
+
const code = await readFile(file, 'utf-8');
|
|
52
|
+
// Determine parser options from file extension so .ts is not parsed as TSX
|
|
53
|
+
const fileExt = extname(file).toLowerCase();
|
|
54
|
+
const isTypeScriptFile = fileExt === '.ts' || fileExt === '.tsx' || fileExt === '.mts' || fileExt === '.cts';
|
|
55
|
+
const isTSX = fileExt === '.tsx';
|
|
56
|
+
const isJSX = fileExt === '.jsx';
|
|
57
|
+
let ast;
|
|
58
|
+
try {
|
|
59
|
+
ast = await parse(code, {
|
|
60
|
+
syntax: isTypeScriptFile ? 'typescript' : 'ecmascript',
|
|
61
|
+
tsx: isTSX,
|
|
62
|
+
jsx: isJSX,
|
|
63
|
+
decorators: true
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// Fallback for .ts files with JSX
|
|
68
|
+
if (fileExt === '.ts' && !isTSX) {
|
|
69
|
+
try {
|
|
70
|
+
ast = await parse(code, {
|
|
71
|
+
syntax: 'typescript',
|
|
72
|
+
tsx: true,
|
|
73
|
+
decorators: true
|
|
74
|
+
});
|
|
75
|
+
this.emit('progress', { message: `Parsed ${file} using TSX fallback` });
|
|
76
|
+
}
|
|
77
|
+
catch (err2) {
|
|
78
|
+
const wrapped = this.wrapError(err2);
|
|
79
|
+
this.emit('error', wrapped);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Fallback for .js files with JSX
|
|
83
|
+
}
|
|
84
|
+
else if (fileExt === '.js' && !isJSX) {
|
|
85
|
+
try {
|
|
86
|
+
ast = await parse(code, {
|
|
87
|
+
syntax: 'ecmascript',
|
|
88
|
+
jsx: true,
|
|
89
|
+
decorators: true
|
|
90
|
+
});
|
|
91
|
+
this.emit('progress', { message: `Parsed ${file} using JSX fallback` });
|
|
92
|
+
}
|
|
93
|
+
catch (err2) {
|
|
94
|
+
const wrapped = this.wrapError(err2);
|
|
95
|
+
this.emit('error', wrapped);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const wrapped = this.wrapError(err);
|
|
101
|
+
this.emit('error', wrapped);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const hardcodedStrings = findHardcodedStrings(ast, code, config);
|
|
106
|
+
if (hardcodedStrings.length > 0) {
|
|
107
|
+
totalIssues += hardcodedStrings.length;
|
|
108
|
+
issuesByFile.set(file, hardcodedStrings);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const files = Object.fromEntries(issuesByFile.entries());
|
|
112
|
+
const data = { success: totalIssues === 0, message: totalIssues > 0 ? `Linter found ${totalIssues} potential issues.` : 'No issues found.', files };
|
|
113
|
+
this.emit('done', data);
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const wrappedError = this.wrapError(error);
|
|
118
|
+
this.emit('error', wrappedError);
|
|
119
|
+
throw wrappedError;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Runs the i18next linter to detect hardcoded strings and other potential issues.
|
|
125
|
+
*
|
|
126
|
+
* This function performs static analysis on source files to identify:
|
|
127
|
+
* - Hardcoded text strings in JSX elements
|
|
128
|
+
* - Hardcoded strings in JSX attributes (like alt text, titles, etc.)
|
|
129
|
+
* - Text that should be extracted for translation
|
|
130
|
+
*
|
|
131
|
+
* The linter respects configuration settings:
|
|
132
|
+
* - Uses the same input patterns as the extractor
|
|
133
|
+
* - Ignores content inside configured Trans components
|
|
134
|
+
* - Skips technical content like script/style tags
|
|
135
|
+
* - Identifies numeric values and interpolation syntax to avoid false positives
|
|
136
|
+
*
|
|
137
|
+
* @param config - The toolkit configuration with input patterns and component names
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* const config = {
|
|
142
|
+
* extract: {
|
|
143
|
+
* input: ['src/**\/*.{ts,tsx}'],
|
|
144
|
+
* transComponents: ['Trans', 'Translation']
|
|
145
|
+
* }
|
|
146
|
+
* }
|
|
147
|
+
*
|
|
148
|
+
* await runLinter(config)
|
|
149
|
+
* // Outputs issues found or success message
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
async function runLinter(config) {
|
|
153
|
+
return new Linter(config).run();
|
|
154
|
+
}
|
|
155
|
+
async function runLinterCli(config) {
|
|
156
|
+
const linter = new Linter(config);
|
|
157
|
+
const spinner = ora().start();
|
|
158
|
+
linter.on('progress', (event) => {
|
|
159
|
+
spinner.text = event.message;
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const { success, message, files } = await linter.run();
|
|
163
|
+
if (!success) {
|
|
164
|
+
spinner.fail(chalk.red.bold(message));
|
|
165
|
+
// Print detailed report after spinner fails
|
|
166
|
+
for (const [file, issues] of Object.entries(files)) {
|
|
167
|
+
console.log(chalk.yellow(`\n${file}`));
|
|
168
|
+
issues.forEach(({ text, line }) => {
|
|
169
|
+
console.log(` ${chalk.gray(`${line}:`)} ${chalk.red('Error:')} Found hardcoded string: "${text}"`);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
spinner.succeed(chalk.green.bold(message));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const wrappedError = linter.wrapError(error);
|
|
180
|
+
spinner.fail(wrappedError.message);
|
|
181
|
+
console.error(wrappedError);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const isUrlOrPath = (text) => /^(https|http|\/\/|^\/)/.test(text);
|
|
186
|
+
/**
|
|
187
|
+
* Analyzes an AST to find potentially hardcoded strings that should be translated.
|
|
188
|
+
*
|
|
189
|
+
* This function traverses the syntax tree looking for:
|
|
190
|
+
* 1. JSX text nodes with translatable content
|
|
191
|
+
* 2. String literals in JSX attributes that might need translation
|
|
192
|
+
*
|
|
193
|
+
* It applies several filters to reduce false positives:
|
|
194
|
+
* - Ignores content inside Trans components (already handled)
|
|
195
|
+
* - Skips script and style tag content (technical, not user-facing)
|
|
196
|
+
* - Filters out numeric values (usually not translatable)
|
|
197
|
+
* - Ignores interpolation syntax starting with `{{`
|
|
198
|
+
* - Filters out ellipsis/spread operator notation `...`
|
|
199
|
+
* - Only reports non-empty, trimmed strings
|
|
200
|
+
*
|
|
201
|
+
* @param config - The toolkit configuration with input patterns and component names
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```typescript
|
|
205
|
+
* const config = {
|
|
206
|
+
* extract: {
|
|
207
|
+
* input: ['src/**\/*.{ts,tsx}'],
|
|
208
|
+
* transComponents: ['Trans', 'Translation']
|
|
209
|
+
* }
|
|
210
|
+
* }
|
|
211
|
+
*
|
|
212
|
+
* await runLinter(config)
|
|
213
|
+
* // Outputs issues found or success message
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
function findHardcodedStrings(ast, code, config) {
|
|
217
|
+
const issues = [];
|
|
218
|
+
// A list of AST nodes that have been identified as potential issues.
|
|
219
|
+
const nodesToLint = [];
|
|
220
|
+
const getLineNumber = (pos) => {
|
|
221
|
+
return code.substring(0, pos).split('\n').length;
|
|
222
|
+
};
|
|
223
|
+
const transComponents = (config.extract.transComponents || ['Trans']).map((s) => s.toLowerCase());
|
|
224
|
+
const customIgnoredTags = (config?.lint?.ignoredTags || config.extract.ignoredTags || []).map((s) => s.toLowerCase());
|
|
225
|
+
const allIgnoredTags = new Set([...transComponents, ...defaultIgnoredTags.map(s => s.toLowerCase()), ...customIgnoredTags]);
|
|
226
|
+
const customIgnoredAttributes = (config?.lint?.ignoredAttributes || config.extract.ignoredAttributes || []).map((s) => s.toLowerCase());
|
|
227
|
+
const ignoredAttributes = new Set([...defaultIgnoredAttributes, ...customIgnoredAttributes]);
|
|
228
|
+
const lintAcceptedTags = config?.lint?.acceptedTags ? config.lint.acceptedTags : null;
|
|
229
|
+
const extractAcceptedTags = config?.extract?.acceptedTags ? config.extract.acceptedTags : null;
|
|
230
|
+
const acceptedTagsList = (lintAcceptedTags ?? extractAcceptedTags ?? recommendedAcceptedTags)?.map((s) => s.toLowerCase()) ?? null;
|
|
231
|
+
const lintAcceptedAttrs = config?.lint?.acceptedAttributes ? config.lint.acceptedAttributes : null;
|
|
232
|
+
const extractAcceptedAttrs = config?.extract?.acceptedAttributes ? config.extract.acceptedAttributes : null;
|
|
233
|
+
const acceptedAttributesList = (lintAcceptedAttrs ?? extractAcceptedAttrs ?? recommendedAcceptedAttributes)?.map((s) => s.toLowerCase()) ?? null;
|
|
234
|
+
const acceptedTagsSet = acceptedTagsList && acceptedTagsList.length > 0 ? new Set(acceptedTagsList) : null;
|
|
235
|
+
const acceptedAttributesSet = acceptedAttributesList && acceptedAttributesList.length > 0 ? new Set(acceptedAttributesList) : null;
|
|
236
|
+
// Helper: robustly extract a JSX element name from different node shapes
|
|
237
|
+
const extractJSXName = (node) => {
|
|
238
|
+
if (!node)
|
|
239
|
+
return null;
|
|
240
|
+
// node might be JSXOpeningElement / JSXSelfClosingElement (has .name)
|
|
241
|
+
const nameNode = node.name ?? node.opening?.name ?? node.opening?.name;
|
|
242
|
+
if (!nameNode) {
|
|
243
|
+
// maybe this node is a full JSXElement with opening.name
|
|
244
|
+
if (node.opening?.name)
|
|
245
|
+
return extractJSXName({ name: node.opening.name });
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const fromIdentifier = (n) => {
|
|
249
|
+
if (!n)
|
|
250
|
+
return null;
|
|
251
|
+
if (n.type === 'JSXIdentifier' && (n.name || n.value))
|
|
252
|
+
return (n.name ?? n.value);
|
|
253
|
+
if (n.type === 'Identifier' && (n.name || n.value))
|
|
254
|
+
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
|
+
const rawName = fromIdentifier(nameNode);
|
|
264
|
+
return rawName ? String(rawName) : null;
|
|
265
|
+
};
|
|
266
|
+
// Helper: extract attribute name from a JSXAttribute.name node
|
|
267
|
+
const extractAttrName = (nameNode) => {
|
|
268
|
+
if (!nameNode)
|
|
269
|
+
return null;
|
|
270
|
+
// Direct string (unlikely, but be defensive)
|
|
271
|
+
if (typeof nameNode === 'string')
|
|
272
|
+
return nameNode;
|
|
273
|
+
// Common SWC shapes:
|
|
274
|
+
// JSXIdentifier: { type: 'JSXIdentifier', value: 'alt' } or { name: 'alt' }
|
|
275
|
+
if (nameNode.type === 'JSXIdentifier' || nameNode.type === 'Identifier') {
|
|
276
|
+
const n = (nameNode.name ?? nameNode.value ?? nameNode.raw ?? null);
|
|
277
|
+
return n ? String(n) : null;
|
|
278
|
+
}
|
|
279
|
+
// JSXNamespacedName: { type: 'JSXNamespacedName', namespace: {...}, name: {...} }
|
|
280
|
+
if (nameNode.type === 'JSXNamespacedName') {
|
|
281
|
+
// prefer the local name (after the colon)
|
|
282
|
+
return extractAttrName(nameNode.name) ?? extractAttrName(nameNode.namespace);
|
|
283
|
+
}
|
|
284
|
+
// Member-like expressions (defensive)
|
|
285
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
286
|
+
const left = extractAttrName(nameNode.object);
|
|
287
|
+
const right = extractAttrName(nameNode.property);
|
|
288
|
+
if (left && right)
|
|
289
|
+
return `${left}.${right}`;
|
|
290
|
+
return right ?? left;
|
|
291
|
+
}
|
|
292
|
+
// Some AST variants put the identifier under `.name` or `.value`
|
|
293
|
+
if (nameNode.name || nameNode.value || nameNode.property) {
|
|
294
|
+
return (nameNode.name ?? nameNode.value ?? nameNode.property?.name ?? nameNode.property?.value ?? null);
|
|
295
|
+
}
|
|
296
|
+
// Last-resort: try to stringify and extract an identifier-looking token
|
|
297
|
+
try {
|
|
298
|
+
const s = JSON.stringify(nameNode);
|
|
299
|
+
const m = /"?(?:name|value)"?\s*:\s*"?([a-zA-Z0-9_\-:.$]+)"?/.exec(s);
|
|
300
|
+
return m ? m[1] : null;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
// Helper: return true if any JSX ancestor is in the ignored tags set
|
|
307
|
+
const isWithinIgnoredElement = (ancestors) => {
|
|
308
|
+
// First: if ANY ancestor is in the ignored set -> ignore (ignored always wins)
|
|
309
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
310
|
+
const an = ancestors[i];
|
|
311
|
+
if (!an || typeof an !== 'object')
|
|
312
|
+
continue;
|
|
313
|
+
if (an.type === 'JSXElement' || an.type === 'JSXOpeningElement' || an.type === 'JSXSelfClosingElement') {
|
|
314
|
+
const name = extractJSXName(an);
|
|
315
|
+
if (!name)
|
|
316
|
+
continue;
|
|
317
|
+
if (allIgnoredTags.has(String(name).toLowerCase()))
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// If acceptedTags is set: use nearest enclosing JSX element to decide acceptance
|
|
322
|
+
if (acceptedTagsSet) {
|
|
323
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
324
|
+
const an = ancestors[i];
|
|
325
|
+
if (!an || typeof an !== 'object')
|
|
326
|
+
continue;
|
|
327
|
+
if (an.type === 'JSXElement' || an.type === 'JSXOpeningElement' || an.type === 'JSXSelfClosingElement') {
|
|
328
|
+
const name = extractJSXName(an);
|
|
329
|
+
if (!name)
|
|
330
|
+
continue;
|
|
331
|
+
return !acceptedTagsSet.has(String(name).toLowerCase());
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// no enclosing element found -> treat as ignored
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
// Default: not inside an ignored element
|
|
338
|
+
return false;
|
|
339
|
+
};
|
|
340
|
+
// --- PHASE 1: Collect all potentially problematic nodes ---
|
|
341
|
+
const walk = (node, ancestors) => {
|
|
342
|
+
if (!node || typeof node !== 'object')
|
|
343
|
+
return;
|
|
344
|
+
const currentAncestors = [...ancestors, node];
|
|
345
|
+
if (node.type === 'JSXText') {
|
|
346
|
+
// If acceptedAttributesSet exists but acceptedTagsSet does not, we're in attribute-only mode:
|
|
347
|
+
// do not collect JSXText nodes when attribute-only mode is active.
|
|
348
|
+
if (acceptedAttributesSet && !acceptedTagsSet) ;
|
|
349
|
+
else {
|
|
350
|
+
const isIgnored = isWithinIgnoredElement(currentAncestors);
|
|
351
|
+
if (!isIgnored) {
|
|
352
|
+
const text = node.value.trim();
|
|
353
|
+
if (text && text.length > 1 && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text)) && !text.startsWith('{{')) {
|
|
354
|
+
nodesToLint.push(node);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (node.type === 'StringLiteral') {
|
|
360
|
+
const parent = currentAncestors[currentAncestors.length - 2];
|
|
361
|
+
// Determine whether this attribute is inside any ignored element (handles nested Trans etc.)
|
|
362
|
+
const insideIgnored = isWithinIgnoredElement(currentAncestors);
|
|
363
|
+
if (parent?.type === 'JSXAttribute' && !insideIgnored) {
|
|
364
|
+
const rawAttrName = extractAttrName(parent.name);
|
|
365
|
+
const attrNameLower = rawAttrName ? String(rawAttrName).toLowerCase() : null;
|
|
366
|
+
// Check tag-level acceptance if acceptedTagsSet provided: attributes should only be considered
|
|
367
|
+
// when the nearest enclosing element is accepted.
|
|
368
|
+
const parentElement = currentAncestors.slice(0, -2).reverse().find(a => a && typeof a === 'object' && (a.type === 'JSXElement' || a.type === 'JSXOpeningElement' || a.type === 'JSXSelfClosingElement'));
|
|
369
|
+
if (acceptedTagsSet && parentElement) {
|
|
370
|
+
const parentName = extractJSXName(parentElement);
|
|
371
|
+
if (!parentName || !acceptedTagsSet.has(String(parentName).toLowerCase())) {
|
|
372
|
+
// attribute is inside a non-accepted tag -> skip
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (acceptedTagsSet && !parentElement) {
|
|
377
|
+
// no enclosing element -> skip
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// If acceptedAttributesSet exists, only lint attributes explicitly accepted.
|
|
381
|
+
const shouldLintAttribute = acceptedAttributesSet
|
|
382
|
+
? (attrNameLower != null && acceptedAttributesSet.has(attrNameLower))
|
|
383
|
+
: (attrNameLower != null ? !ignoredAttributes.has(attrNameLower) : false);
|
|
384
|
+
if (shouldLintAttribute) {
|
|
385
|
+
const text = node.value.trim();
|
|
386
|
+
// Filter out: empty strings, URLs, numbers, and ellipsis
|
|
387
|
+
if (text && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text))) {
|
|
388
|
+
nodesToLint.push(node); // Collect the node
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Recurse into children
|
|
394
|
+
for (const key of Object.keys(node)) {
|
|
395
|
+
if (key === 'span')
|
|
396
|
+
continue;
|
|
397
|
+
const child = node[key];
|
|
398
|
+
if (Array.isArray(child)) {
|
|
399
|
+
child.forEach(item => walk(item, currentAncestors));
|
|
400
|
+
}
|
|
401
|
+
else if (child && typeof child === 'object') {
|
|
402
|
+
walk(child, currentAncestors);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
walk(ast, []); // Run the walk to collect nodes
|
|
407
|
+
// --- PHASE 2: Find line numbers using a tracked search on the raw source code ---
|
|
408
|
+
let lastSearchIndex = 0;
|
|
409
|
+
for (const node of nodesToLint) {
|
|
410
|
+
// For StringLiterals, the `raw` property includes the quotes ("..."), which is
|
|
411
|
+
// much more unique for searching than the plain `value`.
|
|
412
|
+
const searchText = node.raw ?? node.value;
|
|
413
|
+
const position = code.indexOf(searchText, lastSearchIndex);
|
|
414
|
+
if (position > -1) {
|
|
415
|
+
issues.push({
|
|
416
|
+
text: node.value.trim(),
|
|
417
|
+
line: getLineNumber(position),
|
|
418
|
+
});
|
|
419
|
+
lastSearchIndex = position + searchText.length;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return issues;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export { Linter, recommendedAcceptedAttributes, recommendedAcceptedTags, runLinter, runLinterCli };
|