react-i18next-scanner 0.1.3 → 0.1.4
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.
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/core/actions/writePossiblyDynamicallyUsedReport.ts
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { addKey } from "../utils/addKey.js";
|
|
5
|
+
import { sortObject } from "../utils/sortObject.js";
|
|
6
|
+
const DEFAULT_OUTPUT_DIR = path.resolve(process.cwd(), "reports-dynamic");
|
|
7
|
+
export async function writePossiblyDynamicallyUsedReport(keysByFile, jsonFiles, outputDir = DEFAULT_OUTPUT_DIR) {
|
|
8
|
+
const reportDataByFile = {};
|
|
9
|
+
for (const file of jsonFiles) {
|
|
10
|
+
const possibleKeys = keysByFile[file.filePath] ?? [];
|
|
11
|
+
if (possibleKeys.length === 0) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const nestedData = {};
|
|
15
|
+
for (const key of possibleKeys) {
|
|
16
|
+
const value = getValueByPath(file.data, key);
|
|
17
|
+
if (value !== undefined) {
|
|
18
|
+
addKey(nestedData, key, value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const relativeTranslationFilePath = path
|
|
22
|
+
.relative(process.cwd(), file.filePath)
|
|
23
|
+
.replace(/\\/g, "/");
|
|
24
|
+
reportDataByFile[relativeTranslationFilePath] = sortObject(nestedData);
|
|
25
|
+
}
|
|
26
|
+
if (Object.keys(reportDataByFile).length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
30
|
+
const reportPath = path.join(outputDir, "possibly-dynamically-used.json");
|
|
31
|
+
await fs.writeFile(reportPath, `${JSON.stringify(sortObject(reportDataByFile), null, 2)}\n`, "utf-8");
|
|
32
|
+
console.log(`📝 Possibly dynamically used report created: ${reportPath}`);
|
|
33
|
+
}
|
|
34
|
+
function getValueByPath(obj, keyPath) {
|
|
35
|
+
const parts = keyPath.split(".");
|
|
36
|
+
let current = obj;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (typeof current !== "object" ||
|
|
39
|
+
current === null ||
|
|
40
|
+
Array.isArray(current) ||
|
|
41
|
+
!(part in current)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
current = current[part];
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
@@ -2,18 +2,17 @@ import fs from "fs/promises";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { addKey } from "../utils/addKey.js";
|
|
4
4
|
import { sortObject } from "../utils/sortObject.js";
|
|
5
|
-
export async function writeUnusedTranslationsReport(
|
|
6
|
-
const { unusedKeysByFile } = result;
|
|
5
|
+
export async function writeUnusedTranslationsReport(keysByFile, jsonFiles, outputDir) {
|
|
7
6
|
const filesWithUnused = jsonFiles.filter((file) => {
|
|
8
|
-
const
|
|
9
|
-
return
|
|
7
|
+
const keys = keysByFile[file.filePath] ?? [];
|
|
8
|
+
return keys.length > 0;
|
|
10
9
|
});
|
|
11
10
|
if (filesWithUnused.length === 0) {
|
|
12
11
|
return;
|
|
13
12
|
}
|
|
14
13
|
await fs.mkdir(outputDir, { recursive: true });
|
|
15
14
|
for (const file of filesWithUnused) {
|
|
16
|
-
const unusedKeys =
|
|
15
|
+
const unusedKeys = keysByFile[file.filePath] ?? [];
|
|
17
16
|
const reportData = {};
|
|
18
17
|
for (const key of unusedKeys) {
|
|
19
18
|
const value = getValueByPath(file.data, key);
|
|
@@ -22,15 +21,12 @@ export async function writeUnusedTranslationsReport(result, jsonFiles, outputDir
|
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
23
|
const sorted = sortObject(reportData);
|
|
25
|
-
const reportFileName =
|
|
24
|
+
const reportFileName = `unused.${path.basename(file.filePath)}`;
|
|
26
25
|
const reportPath = path.join(outputDir, reportFileName);
|
|
27
26
|
await fs.writeFile(reportPath, JSON.stringify(sorted, null, 2), "utf-8");
|
|
28
27
|
console.log(`📝 Unused report created: ${reportPath}`);
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
|
-
function getUnusedReportFileName(filePath) {
|
|
32
|
-
return `unused.${path.basename(filePath)}`;
|
|
33
|
-
}
|
|
34
30
|
function getValueByPath(obj, keyPath) {
|
|
35
31
|
const parts = keyPath.split(".");
|
|
36
32
|
let current = obj;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
const KEY_REGEX = /^[A-Za-z0-9_-]+(?:[.:][A-Za-z0-9_-]+)*$/;
|
|
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 isTranslationCall(node) {
|
|
17
|
+
if (ts.isIdentifier(node.expression)) {
|
|
18
|
+
return node.expression.text === "t";
|
|
19
|
+
}
|
|
20
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
21
|
+
return node.expression.name.text === "t";
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
function isDirectLiteral(node) {
|
|
26
|
+
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
|
27
|
+
}
|
|
28
|
+
function isTransTagName(name) {
|
|
29
|
+
return ts.isIdentifier(name) && name.text === "Trans";
|
|
30
|
+
}
|
|
31
|
+
function getJsxAttribute(attributes, attributeName) {
|
|
32
|
+
for (const prop of attributes.properties) {
|
|
33
|
+
if (ts.isJsxAttribute(prop) &&
|
|
34
|
+
ts.isIdentifier(prop.name) &&
|
|
35
|
+
prop.name.text === attributeName) {
|
|
36
|
+
return prop;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function getJsxLiteralAttributeValue(attribute) {
|
|
42
|
+
if (!attribute?.initializer) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (ts.isStringLiteral(attribute.initializer)) {
|
|
46
|
+
return attribute.initializer.text;
|
|
47
|
+
}
|
|
48
|
+
if (ts.isJsxExpression(attribute.initializer) &&
|
|
49
|
+
attribute.initializer.expression &&
|
|
50
|
+
(ts.isStringLiteral(attribute.initializer.expression) ||
|
|
51
|
+
ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))) {
|
|
52
|
+
return attribute.initializer.expression.text;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
function splitKey(fullKey) {
|
|
57
|
+
const firstDotIndex = fullKey.indexOf(".");
|
|
58
|
+
if (firstDotIndex === -1) {
|
|
59
|
+
return { localKey: fullKey };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
namespace: fullKey.slice(0, firstDotIndex),
|
|
63
|
+
localKey: fullKey.slice(firstDotIndex + 1),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function extractNamespaces(sourceFile) {
|
|
67
|
+
const namespaces = new Set();
|
|
68
|
+
function visit(node) {
|
|
69
|
+
if (ts.isCallExpression(node) &&
|
|
70
|
+
ts.isIdentifier(node.expression) &&
|
|
71
|
+
node.expression.text === "useTranslation") {
|
|
72
|
+
const firstArg = node.arguments[0];
|
|
73
|
+
if (firstArg) {
|
|
74
|
+
if (ts.isStringLiteral(firstArg) ||
|
|
75
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg)) {
|
|
76
|
+
namespaces.add(firstArg.text);
|
|
77
|
+
}
|
|
78
|
+
if (ts.isArrayLiteralExpression(firstArg)) {
|
|
79
|
+
for (const element of firstArg.elements) {
|
|
80
|
+
if (ts.isStringLiteral(element) ||
|
|
81
|
+
ts.isNoSubstitutionTemplateLiteral(element)) {
|
|
82
|
+
namespaces.add(element.text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
|
|
89
|
+
const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
|
|
90
|
+
if (nsValue) {
|
|
91
|
+
namespaces.add(nsValue);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
|
|
95
|
+
const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
|
|
96
|
+
if (nsValue) {
|
|
97
|
+
namespaces.add(nsValue);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
ts.forEachChild(node, visit);
|
|
101
|
+
}
|
|
102
|
+
visit(sourceFile);
|
|
103
|
+
return namespaces;
|
|
104
|
+
}
|
|
105
|
+
function collectLiteralKeys(sourceFile) {
|
|
106
|
+
const keys = new Set();
|
|
107
|
+
function visit(node) {
|
|
108
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
109
|
+
const value = node.text.trim();
|
|
110
|
+
if (KEY_REGEX.test(value)) {
|
|
111
|
+
keys.add(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
}
|
|
116
|
+
visit(sourceFile);
|
|
117
|
+
return keys;
|
|
118
|
+
}
|
|
119
|
+
function collectTemplatePrefixes(sourceFile) {
|
|
120
|
+
const prefixes = new Set();
|
|
121
|
+
function addPrefix(prefix) {
|
|
122
|
+
const normalized = prefix.trim();
|
|
123
|
+
if (!normalized) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
prefixes.add(normalized);
|
|
127
|
+
}
|
|
128
|
+
function visit(node) {
|
|
129
|
+
if (ts.isCallExpression(node) && isTranslationCall(node)) {
|
|
130
|
+
const firstArg = node.arguments[0];
|
|
131
|
+
if (firstArg && ts.isTemplateExpression(firstArg)) {
|
|
132
|
+
addPrefix(firstArg.head.text);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
|
|
136
|
+
const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
|
|
137
|
+
if (i18nKeyAttr?.initializer &&
|
|
138
|
+
ts.isJsxExpression(i18nKeyAttr.initializer) &&
|
|
139
|
+
i18nKeyAttr.initializer.expression &&
|
|
140
|
+
ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
|
|
141
|
+
addPrefix(i18nKeyAttr.initializer.expression.head.text);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
|
|
145
|
+
const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
|
|
146
|
+
if (i18nKeyAttr?.initializer &&
|
|
147
|
+
ts.isJsxExpression(i18nKeyAttr.initializer) &&
|
|
148
|
+
i18nKeyAttr.initializer.expression &&
|
|
149
|
+
ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
|
|
150
|
+
addPrefix(i18nKeyAttr.initializer.expression.head.text);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
ts.forEachChild(node, visit);
|
|
154
|
+
}
|
|
155
|
+
visit(sourceFile);
|
|
156
|
+
return [...prefixes];
|
|
157
|
+
}
|
|
158
|
+
function detectDynamicTranslationUsage(sourceFile) {
|
|
159
|
+
let found = false;
|
|
160
|
+
function visit(node) {
|
|
161
|
+
if (found) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (ts.isCallExpression(node) && isTranslationCall(node)) {
|
|
165
|
+
const firstArg = node.arguments[0];
|
|
166
|
+
if (firstArg && !isDirectLiteral(firstArg)) {
|
|
167
|
+
found = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
|
|
172
|
+
const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
|
|
173
|
+
if (i18nKeyAttr?.initializer &&
|
|
174
|
+
ts.isJsxExpression(i18nKeyAttr.initializer) &&
|
|
175
|
+
i18nKeyAttr.initializer.expression) {
|
|
176
|
+
const expr = i18nKeyAttr.initializer.expression;
|
|
177
|
+
if (!isDirectLiteral(expr)) {
|
|
178
|
+
found = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
|
|
184
|
+
const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
|
|
185
|
+
if (i18nKeyAttr?.initializer &&
|
|
186
|
+
ts.isJsxExpression(i18nKeyAttr.initializer) &&
|
|
187
|
+
i18nKeyAttr.initializer.expression) {
|
|
188
|
+
const expr = i18nKeyAttr.initializer.expression;
|
|
189
|
+
if (!isDirectLiteral(expr)) {
|
|
190
|
+
found = true;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
ts.forEachChild(node, visit);
|
|
196
|
+
}
|
|
197
|
+
visit(sourceFile);
|
|
198
|
+
return found;
|
|
199
|
+
}
|
|
200
|
+
async function collectFileEvidence(filePath) {
|
|
201
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
202
|
+
const sourceFile = createSourceFile(filePath, content);
|
|
203
|
+
return {
|
|
204
|
+
namespaces: extractNamespaces(sourceFile),
|
|
205
|
+
hasDynamicTranslationUsage: detectDynamicTranslationUsage(sourceFile),
|
|
206
|
+
literalKeys: collectLiteralKeys(sourceFile),
|
|
207
|
+
templatePrefixes: collectTemplatePrefixes(sourceFile),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence) {
|
|
211
|
+
for (const prefix of evidence.templatePrefixes) {
|
|
212
|
+
if (fullKey.startsWith(prefix)) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (namespace &&
|
|
216
|
+
evidence.namespaces.has(namespace) &&
|
|
217
|
+
localKey.startsWith(prefix)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
function keyIsPossiblyDynamic(fullKey, evidenceList) {
|
|
224
|
+
const { namespace, localKey } = splitKey(fullKey);
|
|
225
|
+
for (const evidence of evidenceList) {
|
|
226
|
+
// case 1: full key exists as a string literal somewhere in code/object
|
|
227
|
+
if (evidence.literalKeys.has(fullKey)) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
// case 2: local key exists and file uses matching namespace + dynamic t/Trans usage
|
|
231
|
+
if (namespace &&
|
|
232
|
+
evidence.hasDynamicTranslationUsage &&
|
|
233
|
+
evidence.namespaces.has(namespace) &&
|
|
234
|
+
evidence.literalKeys.has(localKey)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
// case 3: template literal prefix
|
|
238
|
+
if (keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
|
|
245
|
+
const definitelyUnusedByFile = {};
|
|
246
|
+
const possiblyDynamicallyUsedByFile = {};
|
|
247
|
+
const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
|
|
248
|
+
for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
|
|
249
|
+
const definitelyUnused = [];
|
|
250
|
+
const possiblyDynamic = [];
|
|
251
|
+
for (const key of unusedKeys) {
|
|
252
|
+
if (keyIsPossiblyDynamic(key, evidenceList)) {
|
|
253
|
+
possiblyDynamic.push(key);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
definitelyUnused.push(key);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
definitelyUnusedByFile[translationFilePath] = definitelyUnused;
|
|
260
|
+
possiblyDynamicallyUsedByFile[translationFilePath] = possiblyDynamic;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
definitelyUnusedByFile,
|
|
264
|
+
possiblyDynamicallyUsedByFile,
|
|
265
|
+
};
|
|
266
|
+
}
|
package/dist/core/scanner.js
CHANGED
|
@@ -7,6 +7,7 @@ import { writeDynamicTranslationReport } from "./actions/writeDynamicTranslation
|
|
|
7
7
|
import { syncMissingTranslations } from "./actions/syncMissingTranslations.js";
|
|
8
8
|
import { removeUnusedTranslations } from "./actions/removeUnusedTranslations.js";
|
|
9
9
|
import { writeUnusedTranslationsReport } from "./actions/writeUnusedTranslationsReport.js";
|
|
10
|
+
import { deepVerifyUnusedKeys } from "./analysis/deepVerifyUnusedKeys.js";
|
|
10
11
|
export async function runScanner(config) {
|
|
11
12
|
const files = await getFiles(config.srcPaths);
|
|
12
13
|
const dynamicUsages = await detectDynamicTranslationUsages(files);
|
|
@@ -14,12 +15,17 @@ export async function runScanner(config) {
|
|
|
14
15
|
const usedKeys = await extractKeysFromFiles(files);
|
|
15
16
|
const jsonData = await loadJsonKeys(config.jsonDir);
|
|
16
17
|
const result = analyzeKeys(usedKeys, jsonData);
|
|
17
|
-
await
|
|
18
|
+
const verification = await deepVerifyUnusedKeys(files, result.unusedKeysByFile);
|
|
19
|
+
await writeUnusedTranslationsReport(verification.definitelyUnusedByFile, jsonData, config.outputDir);
|
|
18
20
|
if (config.syncMissing) {
|
|
19
21
|
await syncMissingTranslations(result, jsonData, config.sortJson);
|
|
20
22
|
}
|
|
21
23
|
if (config.removeUnused) {
|
|
22
|
-
|
|
24
|
+
const filteredResult = {
|
|
25
|
+
...result,
|
|
26
|
+
unusedKeysByFile: verification.definitelyUnusedByFile,
|
|
27
|
+
};
|
|
28
|
+
await removeUnusedTranslations(filteredResult, jsonData, config.sortJson);
|
|
23
29
|
}
|
|
24
30
|
return result;
|
|
25
31
|
}
|