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
- function extractNamespaces(content) {
4
- const namespaces = new Set();
5
- const nsRegex = /useTranslation\s*\(\s*(?:\[\s*([^\]]+)\s*\]|['"`]([^'"`]+)['"`])?/g;
6
- let match;
7
- while ((match = nsRegex.exec(content)) !== null) {
8
- if (match[1]) {
9
- const values = match[1]
10
- .split(",")
11
- .map((value) => value.replace(/['"`]/g, "").trim())
12
- .filter(Boolean);
13
- values.forEach((value) => namespaces.add(value));
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 (match[2]) {
16
- namespaces.add(match[2]);
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 [...namespaces];
37
+ });
38
+ return imports;
20
39
  }
21
- function extractDynamicTExpressions(content) {
22
- const expressions = new Set();
23
- const dynamicTRegex = /(^|[^\w$.])t\s*\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\([^()]*\))?)/g;
24
- let match;
25
- while ((match = dynamicTRegex.exec(content)) !== null) {
26
- const expression = match[2]?.trim();
27
- if (!expression) {
28
- continue;
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
- expressions.add(expression);
63
+ ts.forEachChild(node, visit);
31
64
  }
32
- return [...expressions];
65
+ visit(sourceFile);
66
+ return [...namespaces];
33
67
  }
34
- function extractImportedIdentifiers(content) {
35
- const identifiers = new Set();
36
- const importRegex = /import\s+(?:\{([^}]+)\}|([A-Za-z_$][\w$]*))\s+from\s+['"`][^'"`]+['"`]/g;
37
- let match;
38
- while ((match = importRegex.exec(content)) !== null) {
39
- if (match[1]) {
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 [...identifiers];
75
+ return false;
52
76
  }
53
- function detectObjectBasedUsageHints(content, importedIdentifiers) {
54
- const hints = new Set();
55
- for (const identifier of importedIdentifiers) {
56
- const objectAccessRegex = new RegExp(`${identifier}\\s*\\[[^\\]]+\\]\\.(description|label|title|placeholder|message)|${identifier}\\.[A-Za-z_$][\\w$]*\\.(description|label|title|placeholder|message)`, "g");
57
- if (objectAccessRegex.test(content)) {
58
- hints.add(identifier);
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 [...hints];
105
+ return related;
62
106
  }
63
- function buildFinding(namespaces, expression, objectBasedHints) {
64
- const isObjectBased = objectBasedHints.length > 0;
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: isObjectBased
69
- ? "Object-based non-literal translation key"
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("useTranslation") || !content.includes("t(")) {
155
+ if (!content.includes("t(")) {
81
156
  continue;
82
157
  }
83
- const namespaces = extractNamespaces(content);
84
- const expressions = extractDynamicTExpressions(content);
85
- if (expressions.length === 0) {
86
- continue;
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.2",
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
  }