react-i18next-scanner 0.1.4 → 0.1.6
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,380 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
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 isProbablyTranslationKey(value) {
|
|
17
|
+
const normalized = value.trim();
|
|
18
|
+
return (normalized.length > 0 &&
|
|
19
|
+
!normalized.includes("\n") &&
|
|
20
|
+
!normalized.includes("\r") &&
|
|
21
|
+
(normalized.includes(".") || normalized.includes(":")));
|
|
22
|
+
}
|
|
23
|
+
function flattenObjectLiteral(node, prefix = "", result = new Map()) {
|
|
24
|
+
for (const property of node.properties) {
|
|
25
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const name = getPropertyName(property.name);
|
|
29
|
+
if (!name) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const nextPath = prefix ? `${prefix}.${name}` : name;
|
|
33
|
+
const initializer = property.initializer;
|
|
34
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
35
|
+
flattenObjectLiteral(initializer, nextPath, result);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ts.isStringLiteral(initializer) ||
|
|
39
|
+
ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
|
40
|
+
const value = initializer.text.trim();
|
|
41
|
+
if (isProbablyTranslationKey(value)) {
|
|
42
|
+
result.set(nextPath, value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function getPropertyName(name) {
|
|
49
|
+
if (ts.isIdentifier(name)) {
|
|
50
|
+
return name.text;
|
|
51
|
+
}
|
|
52
|
+
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
|
53
|
+
return name.text;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function hasExportModifier(node) {
|
|
58
|
+
return !!node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
59
|
+
}
|
|
60
|
+
function buildObjectRegistry(filePaths, sourceFiles) {
|
|
61
|
+
const registry = new Map();
|
|
62
|
+
for (const filePath of filePaths) {
|
|
63
|
+
const sourceFile = sourceFiles.get(filePath);
|
|
64
|
+
if (!sourceFile) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
sourceFile.forEachChild((node) => {
|
|
68
|
+
if (!ts.isVariableStatement(node) || !hasExportModifier(node)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const declaration of node.declarationList.declarations) {
|
|
72
|
+
if (!ts.isIdentifier(declaration.name) ||
|
|
73
|
+
!declaration.initializer ||
|
|
74
|
+
!ts.isObjectLiteralExpression(declaration.initializer)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const exportName = declaration.name.text;
|
|
78
|
+
const values = flattenObjectLiteral(declaration.initializer);
|
|
79
|
+
if (values.size > 0) {
|
|
80
|
+
registry.set(`${filePath}::${exportName}`, values);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return registry;
|
|
86
|
+
}
|
|
87
|
+
function collectImportBindings(sourceFile) {
|
|
88
|
+
const bindings = new Map();
|
|
89
|
+
sourceFile.forEachChild((node) => {
|
|
90
|
+
if (!ts.isImportDeclaration(node)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!node.importClause ||
|
|
94
|
+
!node.moduleSpecifier ||
|
|
95
|
+
!ts.isStringLiteral(node.moduleSpecifier)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const source = node.moduleSpecifier.text;
|
|
99
|
+
if (node.importClause.namedBindings &&
|
|
100
|
+
ts.isNamedImports(node.importClause.namedBindings)) {
|
|
101
|
+
for (const element of node.importClause.namedBindings.elements) {
|
|
102
|
+
const localName = element.name.text;
|
|
103
|
+
const importedName = element.propertyName
|
|
104
|
+
? element.propertyName.text
|
|
105
|
+
: element.name.text;
|
|
106
|
+
bindings.set(localName, { importedName, source });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return bindings;
|
|
111
|
+
}
|
|
112
|
+
function resolveImportSource(currentFilePath, importSource, availableFiles) {
|
|
113
|
+
if (!importSource.startsWith(".")) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const basePath = path.resolve(path.dirname(currentFilePath), importSource);
|
|
117
|
+
const candidates = [
|
|
118
|
+
basePath,
|
|
119
|
+
`${basePath}.ts`,
|
|
120
|
+
`${basePath}.tsx`,
|
|
121
|
+
`${basePath}.js`,
|
|
122
|
+
`${basePath}.jsx`,
|
|
123
|
+
path.join(basePath, "index.ts"),
|
|
124
|
+
path.join(basePath, "index.tsx"),
|
|
125
|
+
path.join(basePath, "index.js"),
|
|
126
|
+
path.join(basePath, "index.jsx"),
|
|
127
|
+
];
|
|
128
|
+
for (const candidate of candidates) {
|
|
129
|
+
if (availableFiles.has(candidate)) {
|
|
130
|
+
return candidate;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function isTranslationCall(node) {
|
|
136
|
+
if (ts.isIdentifier(node.expression)) {
|
|
137
|
+
return node.expression.text === "t";
|
|
138
|
+
}
|
|
139
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
140
|
+
return node.expression.name.text === "t";
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
function isTransTagName(name) {
|
|
145
|
+
return ts.isIdentifier(name) && name.text === "Trans";
|
|
146
|
+
}
|
|
147
|
+
function getJsxAttribute(attributes, attributeName) {
|
|
148
|
+
for (const prop of attributes.properties) {
|
|
149
|
+
if (ts.isJsxAttribute(prop) &&
|
|
150
|
+
ts.isIdentifier(prop.name) &&
|
|
151
|
+
prop.name.text === attributeName) {
|
|
152
|
+
return prop;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
function unwrapExpression(expression) {
|
|
158
|
+
let current = expression;
|
|
159
|
+
while (true) {
|
|
160
|
+
if (ts.isParenthesizedExpression(current)) {
|
|
161
|
+
current = current.expression;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ts.isAsExpression(current)) {
|
|
165
|
+
current = current.expression;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ts.isTypeAssertionExpression(current)) {
|
|
169
|
+
current = current.expression;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ts.isNonNullExpression(current)) {
|
|
173
|
+
current = current.expression;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ts.isCallExpression(current) &&
|
|
177
|
+
ts.isIdentifier(current.expression) &&
|
|
178
|
+
current.expression.text === "String" &&
|
|
179
|
+
current.arguments[0]) {
|
|
180
|
+
current = current.arguments[0];
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
return current;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function extractPropertyChain(expression) {
|
|
187
|
+
const unwrapped = unwrapExpression(expression);
|
|
188
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
189
|
+
return {
|
|
190
|
+
root: unwrapped.text,
|
|
191
|
+
path: [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (ts.isPropertyAccessExpression(unwrapped)) {
|
|
195
|
+
const left = extractPropertyChain(unwrapped.expression);
|
|
196
|
+
if (!left) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
root: left.root,
|
|
201
|
+
path: [...left.path, unwrapped.name.text],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (ts.isElementAccessExpression(unwrapped)) {
|
|
205
|
+
const left = extractPropertyChain(unwrapped.expression);
|
|
206
|
+
if (!left) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const argument = unwrapped.argumentExpression;
|
|
210
|
+
if (argument &&
|
|
211
|
+
(ts.isStringLiteral(argument) ||
|
|
212
|
+
ts.isNoSubstitutionTemplateLiteral(argument))) {
|
|
213
|
+
return {
|
|
214
|
+
root: left.root,
|
|
215
|
+
path: [...left.path, argument.text],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
root: left.root,
|
|
220
|
+
path: [...left.path, "*"],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function resolvePropertyChainToTranslationKeys(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
226
|
+
const access = resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
227
|
+
if (!access) {
|
|
228
|
+
return new Set();
|
|
229
|
+
}
|
|
230
|
+
const objectMap = objectRegistry.get(`${access.importedFilePath}::${access.importedName}`);
|
|
231
|
+
if (!objectMap || access.path.length === 0) {
|
|
232
|
+
return new Set();
|
|
233
|
+
}
|
|
234
|
+
return resolveObjectValuesByPath(objectMap, access.path);
|
|
235
|
+
}
|
|
236
|
+
function collectLocalAliases(sourceFile, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
237
|
+
const objectAliases = new Map();
|
|
238
|
+
const keyAliases = new Map();
|
|
239
|
+
function visit(node) {
|
|
240
|
+
if (ts.isVariableDeclaration(node) &&
|
|
241
|
+
ts.isIdentifier(node.name) &&
|
|
242
|
+
node.initializer) {
|
|
243
|
+
const initializer = unwrapExpression(node.initializer);
|
|
244
|
+
const importedAccess = resolveImportedObjectAccess(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
245
|
+
if (importedAccess) {
|
|
246
|
+
const keys = resolvePropertyChainToTranslationKeys(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
247
|
+
if (keys.size > 0) {
|
|
248
|
+
keyAliases.set(node.name.text, keys);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
objectAliases.set(node.name.text, importedAccess);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
ts.forEachChild(node, visit);
|
|
256
|
+
}
|
|
257
|
+
visit(sourceFile);
|
|
258
|
+
return { objectAliases, keyAliases };
|
|
259
|
+
}
|
|
260
|
+
function resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry) {
|
|
261
|
+
const unwrapped = unwrapExpression(expression);
|
|
262
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
263
|
+
return keyAliases.get(unwrapped.text) ?? new Set();
|
|
264
|
+
}
|
|
265
|
+
const chain = extractPropertyChain(unwrapped);
|
|
266
|
+
if (!chain) {
|
|
267
|
+
return new Set();
|
|
268
|
+
}
|
|
269
|
+
const objectAlias = objectAliases.get(chain.root);
|
|
270
|
+
if (!objectAlias) {
|
|
271
|
+
return new Set();
|
|
272
|
+
}
|
|
273
|
+
const objectMap = objectRegistry.get(`${objectAlias.importedFilePath}::${objectAlias.importedName}`);
|
|
274
|
+
if (!objectMap) {
|
|
275
|
+
return new Set();
|
|
276
|
+
}
|
|
277
|
+
const fullPath = [...objectAlias.path, ...chain.path];
|
|
278
|
+
return resolveObjectValuesByPath(objectMap, fullPath);
|
|
279
|
+
}
|
|
280
|
+
export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
|
|
281
|
+
const normalizedFilePaths = filePaths.map((filePath) => path.resolve(filePath));
|
|
282
|
+
const sourceFiles = new Map();
|
|
283
|
+
const availableFiles = new Set(normalizedFilePaths);
|
|
284
|
+
const resolvedKeys = new Set();
|
|
285
|
+
for (const filePath of normalizedFilePaths) {
|
|
286
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
287
|
+
sourceFiles.set(filePath, createSourceFile(filePath, content));
|
|
288
|
+
}
|
|
289
|
+
const objectRegistry = buildObjectRegistry(normalizedFilePaths, sourceFiles);
|
|
290
|
+
for (const filePath of normalizedFilePaths) {
|
|
291
|
+
const sourceFile = sourceFiles.get(filePath);
|
|
292
|
+
if (!sourceFile) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const importBindings = collectImportBindings(sourceFile);
|
|
296
|
+
const { objectAliases, keyAliases } = collectLocalAliases(sourceFile, filePath, importBindings, objectRegistry, availableFiles);
|
|
297
|
+
function visit(node) {
|
|
298
|
+
if (ts.isCallExpression(node) && isTranslationCall(node)) {
|
|
299
|
+
const firstArg = node.arguments[0];
|
|
300
|
+
if (firstArg) {
|
|
301
|
+
const directResolved = resolvePropertyChainToTranslationKeys(firstArg, filePath, importBindings, objectRegistry, availableFiles);
|
|
302
|
+
for (const key of directResolved) {
|
|
303
|
+
resolvedKeys.add(key);
|
|
304
|
+
}
|
|
305
|
+
const aliasResolved = resolveAliasPropertyAccessToTranslationKeys(firstArg, objectAliases, keyAliases, objectRegistry);
|
|
306
|
+
for (const key of aliasResolved) {
|
|
307
|
+
resolvedKeys.add(key);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
|
|
312
|
+
collectFromTrans(node.attributes);
|
|
313
|
+
}
|
|
314
|
+
if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
|
|
315
|
+
collectFromTrans(node.attributes);
|
|
316
|
+
}
|
|
317
|
+
ts.forEachChild(node, visit);
|
|
318
|
+
}
|
|
319
|
+
function collectFromTrans(attributes) {
|
|
320
|
+
const i18nKeyAttr = getJsxAttribute(attributes, "i18nKey");
|
|
321
|
+
if (!i18nKeyAttr?.initializer ||
|
|
322
|
+
!ts.isJsxExpression(i18nKeyAttr.initializer) ||
|
|
323
|
+
!i18nKeyAttr.initializer.expression) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const expr = i18nKeyAttr.initializer.expression;
|
|
327
|
+
const directResolved = resolvePropertyChainToTranslationKeys(expr, filePath, importBindings, objectRegistry, availableFiles);
|
|
328
|
+
for (const key of directResolved) {
|
|
329
|
+
resolvedKeys.add(key);
|
|
330
|
+
}
|
|
331
|
+
const aliasResolved = resolveAliasPropertyAccessToTranslationKeys(expr, objectAliases, keyAliases, objectRegistry);
|
|
332
|
+
for (const key of aliasResolved) {
|
|
333
|
+
resolvedKeys.add(key);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
visit(sourceFile);
|
|
337
|
+
}
|
|
338
|
+
return resolvedKeys;
|
|
339
|
+
}
|
|
340
|
+
function matchObjectPath(pattern, actual) {
|
|
341
|
+
if (pattern.length !== actual.length) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return pattern.every((part, index) => {
|
|
345
|
+
return part === "*" || part === actual[index];
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function resolveObjectValuesByPath(objectMap, pathPattern) {
|
|
349
|
+
const result = new Set();
|
|
350
|
+
for (const [objectPath, translationKey] of objectMap.entries()) {
|
|
351
|
+
const actualPath = objectPath.split(".");
|
|
352
|
+
if (matchObjectPath(pathPattern, actualPath)) {
|
|
353
|
+
result.add(translationKey);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
function resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
359
|
+
const chain = extractPropertyChain(expression);
|
|
360
|
+
if (!chain) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const binding = importBindings.get(chain.root);
|
|
364
|
+
if (!binding) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const importedFilePath = resolveImportSource(currentFilePath, binding.source, availableFiles);
|
|
368
|
+
if (!importedFilePath) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const objectMap = objectRegistry.get(`${importedFilePath}::${binding.importedName}`);
|
|
372
|
+
if (!objectMap) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
importedFilePath,
|
|
377
|
+
importedName: binding.importedName,
|
|
378
|
+
path: chain.path,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import ts from "typescript";
|
|
3
|
+
import { collectResolvedImportedObjectTranslationKeys } from "./collectResolvedImportedObjectTranslationKeys.js";
|
|
3
4
|
const KEY_REGEX = /^[A-Za-z0-9_-]+(?:[.:][A-Za-z0-9_-]+)*$/;
|
|
4
5
|
function getScriptKind(filePath) {
|
|
5
6
|
if (filePath.endsWith(".tsx"))
|
|
@@ -220,21 +221,21 @@ function keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence) {
|
|
|
220
221
|
}
|
|
221
222
|
return false;
|
|
222
223
|
}
|
|
223
|
-
function keyIsPossiblyDynamic(fullKey, evidenceList) {
|
|
224
|
+
function keyIsPossiblyDynamic(fullKey, evidenceList, resolvedImportedObjectKeys) {
|
|
224
225
|
const { namespace, localKey } = splitKey(fullKey);
|
|
226
|
+
if (resolvedImportedObjectKeys.has(fullKey)) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
225
229
|
for (const evidence of evidenceList) {
|
|
226
|
-
// case 1: full key exists as a string literal somewhere in code/object
|
|
227
230
|
if (evidence.literalKeys.has(fullKey)) {
|
|
228
231
|
return true;
|
|
229
232
|
}
|
|
230
|
-
// case 2: local key exists and file uses matching namespace + dynamic t/Trans usage
|
|
231
233
|
if (namespace &&
|
|
232
234
|
evidence.hasDynamicTranslationUsage &&
|
|
233
235
|
evidence.namespaces.has(namespace) &&
|
|
234
236
|
evidence.literalKeys.has(localKey)) {
|
|
235
237
|
return true;
|
|
236
238
|
}
|
|
237
|
-
// case 3: template literal prefix
|
|
238
239
|
if (keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence)) {
|
|
239
240
|
return true;
|
|
240
241
|
}
|
|
@@ -245,11 +246,12 @@ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
|
|
|
245
246
|
const definitelyUnusedByFile = {};
|
|
246
247
|
const possiblyDynamicallyUsedByFile = {};
|
|
247
248
|
const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
|
|
249
|
+
const resolvedImportedObjectKeys = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
|
|
248
250
|
for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
|
|
249
251
|
const definitelyUnused = [];
|
|
250
252
|
const possiblyDynamic = [];
|
|
251
253
|
for (const key of unusedKeys) {
|
|
252
|
-
if (keyIsPossiblyDynamic(key, evidenceList)) {
|
|
254
|
+
if (keyIsPossiblyDynamic(key, evidenceList, resolvedImportedObjectKeys)) {
|
|
253
255
|
possiblyDynamic.push(key);
|
|
254
256
|
}
|
|
255
257
|
else {
|