react-i18next-scanner 0.1.2 → 0.1.3
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.
|
@@ -1,94 +1,180 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
function getScriptKind(filePath) {
|
|
5
|
+
if (filePath.endsWith(".tsx"))
|
|
6
|
+
return ts.ScriptKind.TSX;
|
|
7
|
+
if (filePath.endsWith(".ts"))
|
|
8
|
+
return ts.ScriptKind.TS;
|
|
9
|
+
if (filePath.endsWith(".jsx"))
|
|
10
|
+
return ts.ScriptKind.JSX;
|
|
11
|
+
return ts.ScriptKind.JS;
|
|
12
|
+
}
|
|
13
|
+
function createSourceFile(filePath, content) {
|
|
14
|
+
return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
|
|
15
|
+
}
|
|
16
|
+
function extractImportMap(sourceFile) {
|
|
17
|
+
const imports = new Map();
|
|
18
|
+
sourceFile.forEachChild((node) => {
|
|
19
|
+
if (!ts.isImportDeclaration(node))
|
|
20
|
+
return;
|
|
21
|
+
if (!node.importClause || !node.moduleSpecifier)
|
|
22
|
+
return;
|
|
23
|
+
if (!ts.isStringLiteral(node.moduleSpecifier))
|
|
24
|
+
return;
|
|
25
|
+
const modulePath = node.moduleSpecifier.text;
|
|
26
|
+
const importClause = node.importClause;
|
|
27
|
+
if (importClause.name) {
|
|
28
|
+
imports.set(importClause.name.text, modulePath);
|
|
14
29
|
}
|
|
15
|
-
if (
|
|
16
|
-
|
|
30
|
+
if (importClause.namedBindings &&
|
|
31
|
+
ts.isNamedImports(importClause.namedBindings)) {
|
|
32
|
+
for (const element of importClause.namedBindings.elements) {
|
|
33
|
+
const localName = element.name.text;
|
|
34
|
+
imports.set(localName, modulePath);
|
|
35
|
+
}
|
|
17
36
|
}
|
|
18
|
-
}
|
|
19
|
-
return
|
|
37
|
+
});
|
|
38
|
+
return imports;
|
|
20
39
|
}
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
function extractNamespaces(sourceFile) {
|
|
41
|
+
const namespaces = new Set();
|
|
42
|
+
function visit(node) {
|
|
43
|
+
if (ts.isCallExpression(node) &&
|
|
44
|
+
ts.isIdentifier(node.expression) &&
|
|
45
|
+
node.expression.text === "useTranslation") {
|
|
46
|
+
const firstArg = node.arguments[0];
|
|
47
|
+
if (!firstArg) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (ts.isStringLiteral(firstArg) ||
|
|
51
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg)) {
|
|
52
|
+
namespaces.add(firstArg.text);
|
|
53
|
+
}
|
|
54
|
+
if (ts.isArrayLiteralExpression(firstArg)) {
|
|
55
|
+
for (const element of firstArg.elements) {
|
|
56
|
+
if (ts.isStringLiteral(element) ||
|
|
57
|
+
ts.isNoSubstitutionTemplateLiteral(element)) {
|
|
58
|
+
namespaces.add(element.text);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
29
62
|
}
|
|
30
|
-
|
|
63
|
+
ts.forEachChild(node, visit);
|
|
31
64
|
}
|
|
32
|
-
|
|
65
|
+
visit(sourceFile);
|
|
66
|
+
return [...namespaces];
|
|
33
67
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const namedImports = match[1]
|
|
41
|
-
.split(",")
|
|
42
|
-
.map((value) => value.trim())
|
|
43
|
-
.map((value) => value.split(" as ")[1]?.trim() ?? value.split(" as ")[0]?.trim())
|
|
44
|
-
.filter(Boolean);
|
|
45
|
-
namedImports.forEach((value) => identifiers.add(value));
|
|
46
|
-
}
|
|
47
|
-
if (match[2]) {
|
|
48
|
-
identifiers.add(match[2]);
|
|
49
|
-
}
|
|
68
|
+
function isTranslationCall(node) {
|
|
69
|
+
if (ts.isIdentifier(node.expression)) {
|
|
70
|
+
return node.expression.text === "t";
|
|
71
|
+
}
|
|
72
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
73
|
+
return node.expression.name.text === "t";
|
|
50
74
|
}
|
|
51
|
-
return
|
|
75
|
+
return false;
|
|
52
76
|
}
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
function isDirectLiteralTranslationArg(node) {
|
|
78
|
+
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
|
79
|
+
}
|
|
80
|
+
function getExpressionKind(node) {
|
|
81
|
+
if (ts.isTemplateExpression(node))
|
|
82
|
+
return "template-literal";
|
|
83
|
+
if (ts.isIdentifier(node))
|
|
84
|
+
return "identifier";
|
|
85
|
+
if (ts.isCallExpression(node))
|
|
86
|
+
return "function-call";
|
|
87
|
+
if (ts.isPropertyAccessExpression(node))
|
|
88
|
+
return "property-access";
|
|
89
|
+
if (ts.isElementAccessExpression(node))
|
|
90
|
+
return "element-access";
|
|
91
|
+
if (ts.isBinaryExpression(node))
|
|
92
|
+
return "binary-expression";
|
|
93
|
+
if (ts.isConditionalExpression(node))
|
|
94
|
+
return "conditional-expression";
|
|
95
|
+
return "expression";
|
|
96
|
+
}
|
|
97
|
+
function findRelatedImports(expressionText, importMap) {
|
|
98
|
+
const related = [];
|
|
99
|
+
for (const [localName, source] of importMap.entries()) {
|
|
100
|
+
const identifierRegex = new RegExp(`\\b${localName}\\b`);
|
|
101
|
+
if (identifierRegex.test(expressionText)) {
|
|
102
|
+
related.push(`${localName} -> ${source}`);
|
|
59
103
|
}
|
|
60
104
|
}
|
|
61
|
-
return
|
|
105
|
+
return related;
|
|
62
106
|
}
|
|
63
|
-
function buildFinding(namespaces, expression,
|
|
64
|
-
|
|
107
|
+
function buildFinding(namespaces, expression, kind, importHints) {
|
|
108
|
+
let reason = "Non-literal translation key";
|
|
109
|
+
let suggestion = "Translation key is not passed as a direct string literal. Manual or deeper analysis may be required.";
|
|
110
|
+
if (kind === "template-literal") {
|
|
111
|
+
reason = "Template-literal translation key";
|
|
112
|
+
suggestion =
|
|
113
|
+
"Translation key uses a template literal with dynamic interpolation. Manual or deeper analysis may be required.";
|
|
114
|
+
}
|
|
115
|
+
if (kind === "function-call") {
|
|
116
|
+
reason = "Function-based translation key";
|
|
117
|
+
suggestion =
|
|
118
|
+
"Translation key is returned from a function call. Manual or deeper analysis may be required.";
|
|
119
|
+
}
|
|
120
|
+
if (kind === "property-access" ||
|
|
121
|
+
kind === "element-access" ||
|
|
122
|
+
kind === "identifier") {
|
|
123
|
+
reason = "Object-based non-literal translation key";
|
|
124
|
+
suggestion =
|
|
125
|
+
"Translation key may come from an object, variable, or imported map. Manual or deeper analysis may be required.";
|
|
126
|
+
}
|
|
127
|
+
if (importHints.length > 0) {
|
|
128
|
+
suggestion += ` Related imports: ${importHints.join(", ")}.`;
|
|
129
|
+
}
|
|
65
130
|
return {
|
|
66
131
|
namespaces,
|
|
67
132
|
expression,
|
|
68
|
-
reason
|
|
69
|
-
|
|
70
|
-
: "Non-literal translation key",
|
|
71
|
-
suggestion: isObjectBased
|
|
72
|
-
? `Translation key may come from imported object(s): ${objectBasedHints.join(", ")}. Manual or deeper analysis may be required.`
|
|
73
|
-
: "Translation key may come from a variable or helper function. Manual or deeper analysis may be required.",
|
|
133
|
+
reason,
|
|
134
|
+
suggestion,
|
|
74
135
|
};
|
|
75
136
|
}
|
|
137
|
+
function collectDynamicFromExpression(expression, namespaces, importMap, sourceFile, bucket) {
|
|
138
|
+
if (isDirectLiteralTranslationArg(expression)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (ts.isConditionalExpression(expression)) {
|
|
142
|
+
collectDynamicFromExpression(expression.whenTrue, namespaces, importMap, sourceFile, bucket);
|
|
143
|
+
collectDynamicFromExpression(expression.whenFalse, namespaces, importMap, sourceFile, bucket);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const expressionText = expression.getText(sourceFile).trim();
|
|
147
|
+
const kind = getExpressionKind(expression);
|
|
148
|
+
const importHints = findRelatedImports(expressionText, importMap);
|
|
149
|
+
bucket.push(buildFinding(namespaces, expressionText, kind, importHints));
|
|
150
|
+
}
|
|
76
151
|
export async function detectDynamicTranslationUsages(files) {
|
|
77
152
|
const report = {};
|
|
78
153
|
for (const file of files) {
|
|
79
154
|
const content = await fs.readFile(file, "utf-8");
|
|
80
|
-
if (!content.includes("
|
|
155
|
+
if (!content.includes("t(")) {
|
|
81
156
|
continue;
|
|
82
157
|
}
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
158
|
+
const sourceFile = createSourceFile(file, content);
|
|
159
|
+
const namespaces = extractNamespaces(sourceFile);
|
|
160
|
+
const importMap = extractImportMap(sourceFile);
|
|
161
|
+
const findings = [];
|
|
162
|
+
function visit(node) {
|
|
163
|
+
if (ts.isCallExpression(node) && isTranslationCall(node)) {
|
|
164
|
+
const firstArg = node.arguments[0];
|
|
165
|
+
if (firstArg) {
|
|
166
|
+
collectDynamicFromExpression(firstArg, namespaces, importMap, sourceFile, findings);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
ts.forEachChild(node, visit);
|
|
170
|
+
}
|
|
171
|
+
visit(sourceFile);
|
|
172
|
+
if (findings.length > 0) {
|
|
173
|
+
const relativePath = path
|
|
174
|
+
.relative(process.cwd(), file)
|
|
175
|
+
.replace(/\\/g, "/");
|
|
176
|
+
report[relativePath] = findings;
|
|
87
177
|
}
|
|
88
|
-
const importedIdentifiers = extractImportedIdentifiers(content);
|
|
89
|
-
const objectBasedHints = detectObjectBasedUsageHints(content, importedIdentifiers);
|
|
90
|
-
const relativePath = path.relative(process.cwd(), file).replace(/\\/g, "/");
|
|
91
|
-
report[relativePath] = expressions.map((expression) => buildFinding(namespaces, expression, objectBasedHints));
|
|
92
178
|
}
|
|
93
179
|
return report;
|
|
94
180
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-i18next-scanner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
|
|
5
|
-
"bin":
|
|
6
|
-
"react-i18next-scanner": "./dist/cli/cli.js"
|
|
7
|
-
},
|
|
5
|
+
"bin": "./dist/cli/cli.js",
|
|
8
6
|
"scripts": {
|
|
9
7
|
"build": "tsc",
|
|
10
8
|
"dev": "tsx src/cli/cli.ts",
|
|
@@ -26,12 +24,12 @@
|
|
|
26
24
|
],
|
|
27
25
|
"devDependencies": {
|
|
28
26
|
"@types/node": "^24.6.2",
|
|
29
|
-
"tsx": "^4.22.4"
|
|
30
|
-
"typescript": "^5.9.3"
|
|
27
|
+
"tsx": "^4.22.4"
|
|
31
28
|
},
|
|
32
29
|
"dependencies": {
|
|
33
30
|
"chalk": "^5.6.2",
|
|
34
31
|
"commander": "^15.0.0",
|
|
35
|
-
"fast-glob": "^3.3.3"
|
|
32
|
+
"fast-glob": "^3.3.3",
|
|
33
|
+
"typescript": "^6.0.3"
|
|
36
34
|
}
|
|
37
35
|
}
|