react-i18next-scanner 0.1.5 → 0.1.7
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,12 @@
|
|
|
1
|
+
// src/core/actions/writeResolvedImportedObjectUsagesReport.ts
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
export async function writeResolvedImportedObjectUsagesReport(usages, outputDir) {
|
|
5
|
+
if (usages.length === 0) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const reportDir = outputDir ?? "reports";
|
|
9
|
+
await fs.mkdir(reportDir, { recursive: true });
|
|
10
|
+
const reportPath = path.join(reportDir, "resolved-imported-object-usages.json");
|
|
11
|
+
await fs.writeFile(reportPath, JSON.stringify(usages, null, 2), "utf-8");
|
|
12
|
+
}
|
|
@@ -20,6 +20,15 @@ function isProbablyTranslationKey(value) {
|
|
|
20
20
|
!normalized.includes("\r") &&
|
|
21
21
|
(normalized.includes(".") || normalized.includes(":")));
|
|
22
22
|
}
|
|
23
|
+
function getPropertyName(name) {
|
|
24
|
+
if (ts.isIdentifier(name)) {
|
|
25
|
+
return name.text;
|
|
26
|
+
}
|
|
27
|
+
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
|
28
|
+
return name.text;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
23
32
|
function flattenObjectLiteral(node, prefix = "", result = new Map()) {
|
|
24
33
|
for (const property of node.properties) {
|
|
25
34
|
if (!ts.isPropertyAssignment(property)) {
|
|
@@ -45,15 +54,6 @@ function flattenObjectLiteral(node, prefix = "", result = new Map()) {
|
|
|
45
54
|
}
|
|
46
55
|
return result;
|
|
47
56
|
}
|
|
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
57
|
function hasExportModifier(node) {
|
|
58
58
|
return !!node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
59
59
|
}
|
|
@@ -103,7 +103,10 @@ function collectImportBindings(sourceFile) {
|
|
|
103
103
|
const importedName = element.propertyName
|
|
104
104
|
? element.propertyName.text
|
|
105
105
|
: element.name.text;
|
|
106
|
-
bindings.set(localName, {
|
|
106
|
+
bindings.set(localName, {
|
|
107
|
+
importedName,
|
|
108
|
+
source,
|
|
109
|
+
});
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
});
|
|
@@ -154,39 +157,93 @@ function getJsxAttribute(attributes, attributeName) {
|
|
|
154
157
|
}
|
|
155
158
|
return undefined;
|
|
156
159
|
}
|
|
160
|
+
function unwrapExpression(expression) {
|
|
161
|
+
let current = expression;
|
|
162
|
+
while (true) {
|
|
163
|
+
if (ts.isParenthesizedExpression(current)) {
|
|
164
|
+
current = current.expression;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (ts.isAsExpression(current)) {
|
|
168
|
+
current = current.expression;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ts.isTypeAssertionExpression(current)) {
|
|
172
|
+
current = current.expression;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ts.isNonNullExpression(current)) {
|
|
176
|
+
current = current.expression;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ts.isCallExpression(current) &&
|
|
180
|
+
ts.isIdentifier(current.expression) &&
|
|
181
|
+
current.expression.text === "String" &&
|
|
182
|
+
current.arguments[0]) {
|
|
183
|
+
current = current.arguments[0];
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
157
189
|
function extractPropertyChain(expression) {
|
|
158
|
-
|
|
190
|
+
const unwrapped = unwrapExpression(expression);
|
|
191
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
159
192
|
return {
|
|
160
|
-
root:
|
|
193
|
+
root: unwrapped.text,
|
|
161
194
|
path: [],
|
|
162
195
|
};
|
|
163
196
|
}
|
|
164
|
-
if (ts.isPropertyAccessExpression(
|
|
165
|
-
const left = extractPropertyChain(
|
|
197
|
+
if (ts.isPropertyAccessExpression(unwrapped)) {
|
|
198
|
+
const left = extractPropertyChain(unwrapped.expression);
|
|
166
199
|
if (!left) {
|
|
167
200
|
return null;
|
|
168
201
|
}
|
|
169
202
|
return {
|
|
170
203
|
root: left.root,
|
|
171
|
-
path: [...left.path,
|
|
204
|
+
path: [...left.path, unwrapped.name.text],
|
|
172
205
|
};
|
|
173
206
|
}
|
|
174
|
-
if (ts.isElementAccessExpression(
|
|
175
|
-
const left = extractPropertyChain(
|
|
176
|
-
if (!left
|
|
207
|
+
if (ts.isElementAccessExpression(unwrapped)) {
|
|
208
|
+
const left = extractPropertyChain(unwrapped.expression);
|
|
209
|
+
if (!left) {
|
|
177
210
|
return null;
|
|
178
211
|
}
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
const argument = unwrapped.argumentExpression;
|
|
213
|
+
if (argument &&
|
|
214
|
+
(ts.isStringLiteral(argument) ||
|
|
215
|
+
ts.isNoSubstitutionTemplateLiteral(argument))) {
|
|
181
216
|
return {
|
|
182
217
|
root: left.root,
|
|
183
|
-
path: [...left.path,
|
|
218
|
+
path: [...left.path, argument.text],
|
|
184
219
|
};
|
|
185
220
|
}
|
|
221
|
+
return {
|
|
222
|
+
root: left.root,
|
|
223
|
+
path: [...left.path, "*"],
|
|
224
|
+
};
|
|
186
225
|
}
|
|
187
226
|
return null;
|
|
188
227
|
}
|
|
189
|
-
function
|
|
228
|
+
function matchObjectPath(pattern, actual) {
|
|
229
|
+
if (pattern.length !== actual.length) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return pattern.every((part, index) => {
|
|
233
|
+
return part === "*" || part === actual[index];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function resolveObjectValuesByPath(objectMap, pathPattern) {
|
|
237
|
+
const result = new Set();
|
|
238
|
+
for (const [objectPath, translationKey] of objectMap.entries()) {
|
|
239
|
+
const actualPath = objectPath.split(".");
|
|
240
|
+
if (matchObjectPath(pathPattern, actualPath)) {
|
|
241
|
+
result.add(translationKey);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
function resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
190
247
|
const chain = extractPropertyChain(expression);
|
|
191
248
|
if (!chain) {
|
|
192
249
|
return null;
|
|
@@ -203,67 +260,171 @@ function resolvePropertyChainToTranslationKey(expression, currentFilePath, impor
|
|
|
203
260
|
if (!objectMap) {
|
|
204
261
|
return null;
|
|
205
262
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
263
|
+
return {
|
|
264
|
+
importedFilePath,
|
|
265
|
+
importedName: binding.importedName,
|
|
266
|
+
path: chain.path,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function resolvePropertyChainToTranslationKeys(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
270
|
+
const access = resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
271
|
+
if (!access) {
|
|
272
|
+
return new Set();
|
|
273
|
+
}
|
|
274
|
+
const objectMap = objectRegistry.get(`${access.importedFilePath}::${access.importedName}`);
|
|
275
|
+
if (!objectMap || access.path.length === 0) {
|
|
276
|
+
return new Set();
|
|
209
277
|
}
|
|
210
|
-
return objectMap.
|
|
278
|
+
return resolveObjectValuesByPath(objectMap, access.path);
|
|
211
279
|
}
|
|
212
280
|
function collectLocalAliases(sourceFile, currentFilePath, importBindings, objectRegistry, availableFiles) {
|
|
213
|
-
const
|
|
281
|
+
const objectAliases = new Map();
|
|
282
|
+
const keyAliases = new Map();
|
|
214
283
|
function visit(node) {
|
|
215
284
|
if (ts.isVariableDeclaration(node) &&
|
|
216
285
|
ts.isIdentifier(node.name) &&
|
|
217
286
|
node.initializer) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
287
|
+
const initializer = unwrapExpression(node.initializer);
|
|
288
|
+
const importedAccess = resolveImportedObjectAccess(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
289
|
+
if (importedAccess) {
|
|
290
|
+
const keys = resolvePropertyChainToTranslationKeys(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
|
|
291
|
+
if (keys.size > 0) {
|
|
292
|
+
keyAliases.set(node.name.text, {
|
|
293
|
+
keys,
|
|
294
|
+
access: importedAccess,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
objectAliases.set(node.name.text, importedAccess);
|
|
299
|
+
}
|
|
221
300
|
}
|
|
222
301
|
}
|
|
223
302
|
ts.forEachChild(node, visit);
|
|
224
303
|
}
|
|
225
304
|
visit(sourceFile);
|
|
226
|
-
return
|
|
305
|
+
return {
|
|
306
|
+
objectAliases,
|
|
307
|
+
keyAliases,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry) {
|
|
311
|
+
const unwrapped = unwrapExpression(expression);
|
|
312
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
313
|
+
return keyAliases.get(unwrapped.text)?.keys ?? new Set();
|
|
314
|
+
}
|
|
315
|
+
const chain = extractPropertyChain(unwrapped);
|
|
316
|
+
if (!chain) {
|
|
317
|
+
return new Set();
|
|
318
|
+
}
|
|
319
|
+
const objectAlias = objectAliases.get(chain.root);
|
|
320
|
+
if (!objectAlias) {
|
|
321
|
+
return new Set();
|
|
322
|
+
}
|
|
323
|
+
const objectMap = objectRegistry.get(`${objectAlias.importedFilePath}::${objectAlias.importedName}`);
|
|
324
|
+
if (!objectMap) {
|
|
325
|
+
return new Set();
|
|
326
|
+
}
|
|
327
|
+
const fullPath = [...objectAlias.path, ...chain.path];
|
|
328
|
+
return resolveObjectValuesByPath(objectMap, fullPath);
|
|
329
|
+
}
|
|
330
|
+
function resolveAliasObjectAccess(expression, objectAliases, keyAliases) {
|
|
331
|
+
const unwrapped = unwrapExpression(expression);
|
|
332
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
333
|
+
return keyAliases.get(unwrapped.text)?.access ?? null;
|
|
334
|
+
}
|
|
335
|
+
const chain = extractPropertyChain(unwrapped);
|
|
336
|
+
if (!chain) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const objectAlias = objectAliases.get(chain.root);
|
|
340
|
+
if (!objectAlias) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
importedFilePath: objectAlias.importedFilePath,
|
|
345
|
+
importedName: objectAlias.importedName,
|
|
346
|
+
path: [...objectAlias.path, ...chain.path],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function formatAccessPath(importedName, pathParts) {
|
|
350
|
+
if (pathParts.length === 0) {
|
|
351
|
+
return importedName;
|
|
352
|
+
}
|
|
353
|
+
return `${importedName}.${pathParts.join(".")}`;
|
|
354
|
+
}
|
|
355
|
+
function addResolvedUsages(usages, usageIds, keys, params) {
|
|
356
|
+
for (const key of keys) {
|
|
357
|
+
const usageId = [
|
|
358
|
+
key,
|
|
359
|
+
params.sourceFilePath,
|
|
360
|
+
params.importedFilePath,
|
|
361
|
+
params.importedObjectName,
|
|
362
|
+
params.accessPath,
|
|
363
|
+
params.usageKind,
|
|
364
|
+
].join("::");
|
|
365
|
+
if (usageIds.has(usageId)) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
usageIds.add(usageId);
|
|
369
|
+
usages.push({
|
|
370
|
+
key,
|
|
371
|
+
sourceFilePath: params.sourceFilePath,
|
|
372
|
+
importedFilePath: params.importedFilePath,
|
|
373
|
+
importedObjectName: params.importedObjectName,
|
|
374
|
+
accessPath: params.accessPath,
|
|
375
|
+
usageKind: params.usageKind,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
227
378
|
}
|
|
228
379
|
export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
|
|
380
|
+
const normalizedFilePaths = filePaths.map((filePath) => path.resolve(filePath));
|
|
229
381
|
const sourceFiles = new Map();
|
|
230
|
-
const availableFiles = new Set(
|
|
382
|
+
const availableFiles = new Set(normalizedFilePaths);
|
|
231
383
|
const resolvedKeys = new Set();
|
|
232
|
-
|
|
384
|
+
const usages = [];
|
|
385
|
+
const usageIds = new Set();
|
|
386
|
+
for (const filePath of normalizedFilePaths) {
|
|
233
387
|
const content = await fs.readFile(filePath, "utf-8");
|
|
234
388
|
sourceFiles.set(filePath, createSourceFile(filePath, content));
|
|
235
389
|
}
|
|
236
|
-
const objectRegistry = buildObjectRegistry(
|
|
237
|
-
for (const filePath of
|
|
390
|
+
const objectRegistry = buildObjectRegistry(normalizedFilePaths, sourceFiles);
|
|
391
|
+
for (const filePath of normalizedFilePaths) {
|
|
238
392
|
const sourceFile = sourceFiles.get(filePath);
|
|
239
393
|
if (!sourceFile) {
|
|
240
394
|
continue;
|
|
241
395
|
}
|
|
242
396
|
const importBindings = collectImportBindings(sourceFile);
|
|
243
|
-
const
|
|
244
|
-
function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (firstArg) {
|
|
248
|
-
const directResolved = resolvePropertyChainToTranslationKey(firstArg, filePath, importBindings, objectRegistry, availableFiles);
|
|
249
|
-
if (directResolved) {
|
|
250
|
-
resolvedKeys.add(directResolved);
|
|
251
|
-
}
|
|
252
|
-
if (ts.isIdentifier(firstArg)) {
|
|
253
|
-
const aliasResolved = localAliases.get(firstArg.text);
|
|
254
|
-
if (aliasResolved) {
|
|
255
|
-
resolvedKeys.add(aliasResolved);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
397
|
+
const { objectAliases, keyAliases } = collectLocalAliases(sourceFile, filePath, importBindings, objectRegistry, availableFiles);
|
|
398
|
+
function addKeys(keys) {
|
|
399
|
+
for (const key of keys) {
|
|
400
|
+
resolvedKeys.add(key);
|
|
259
401
|
}
|
|
260
|
-
|
|
261
|
-
|
|
402
|
+
}
|
|
403
|
+
function collectFromExpression(expression, usageKind) {
|
|
404
|
+
const directResolved = resolvePropertyChainToTranslationKeys(expression, filePath, importBindings, objectRegistry, availableFiles);
|
|
405
|
+
addKeys(directResolved);
|
|
406
|
+
const directAccess = resolveImportedObjectAccess(expression, filePath, importBindings, objectRegistry, availableFiles);
|
|
407
|
+
if (directAccess && directResolved.size > 0) {
|
|
408
|
+
addResolvedUsages(usages, usageIds, directResolved, {
|
|
409
|
+
sourceFilePath: filePath,
|
|
410
|
+
importedFilePath: directAccess.importedFilePath,
|
|
411
|
+
importedObjectName: directAccess.importedName,
|
|
412
|
+
accessPath: formatAccessPath(directAccess.importedName, directAccess.path),
|
|
413
|
+
usageKind,
|
|
414
|
+
});
|
|
262
415
|
}
|
|
263
|
-
|
|
264
|
-
|
|
416
|
+
const aliasResolved = resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry);
|
|
417
|
+
addKeys(aliasResolved);
|
|
418
|
+
const aliasAccess = resolveAliasObjectAccess(expression, objectAliases, keyAliases);
|
|
419
|
+
if (aliasAccess && aliasResolved.size > 0) {
|
|
420
|
+
addResolvedUsages(usages, usageIds, aliasResolved, {
|
|
421
|
+
sourceFilePath: filePath,
|
|
422
|
+
importedFilePath: aliasAccess.importedFilePath,
|
|
423
|
+
importedObjectName: aliasAccess.importedName,
|
|
424
|
+
accessPath: formatAccessPath(aliasAccess.importedName, aliasAccess.path),
|
|
425
|
+
usageKind,
|
|
426
|
+
});
|
|
265
427
|
}
|
|
266
|
-
ts.forEachChild(node, visit);
|
|
267
428
|
}
|
|
268
429
|
function collectFromTrans(attributes) {
|
|
269
430
|
const i18nKeyAttr = getJsxAttribute(attributes, "i18nKey");
|
|
@@ -272,19 +433,27 @@ export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
|
|
|
272
433
|
!i18nKeyAttr.initializer.expression) {
|
|
273
434
|
return;
|
|
274
435
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (aliasResolved) {
|
|
283
|
-
resolvedKeys.add(aliasResolved);
|
|
436
|
+
collectFromExpression(i18nKeyAttr.initializer.expression, "Trans");
|
|
437
|
+
}
|
|
438
|
+
function visit(node) {
|
|
439
|
+
if (ts.isCallExpression(node) && isTranslationCall(node)) {
|
|
440
|
+
const firstArg = node.arguments[0];
|
|
441
|
+
if (firstArg) {
|
|
442
|
+
collectFromExpression(firstArg, "t");
|
|
284
443
|
}
|
|
285
444
|
}
|
|
445
|
+
if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
|
|
446
|
+
collectFromTrans(node.attributes);
|
|
447
|
+
}
|
|
448
|
+
if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
|
|
449
|
+
collectFromTrans(node.attributes);
|
|
450
|
+
}
|
|
451
|
+
ts.forEachChild(node, visit);
|
|
286
452
|
}
|
|
287
453
|
visit(sourceFile);
|
|
288
454
|
}
|
|
289
|
-
return
|
|
455
|
+
return {
|
|
456
|
+
keys: resolvedKeys,
|
|
457
|
+
usages,
|
|
458
|
+
};
|
|
290
459
|
}
|
|
@@ -246,7 +246,9 @@ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
|
|
|
246
246
|
const definitelyUnusedByFile = {};
|
|
247
247
|
const possiblyDynamicallyUsedByFile = {};
|
|
248
248
|
const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
|
|
249
|
-
const
|
|
249
|
+
const resolvedImportedObjectResult = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
|
|
250
|
+
const resolvedImportedObjectKeys = resolvedImportedObjectResult.keys;
|
|
251
|
+
const resolvedImportedObjectUsages = resolvedImportedObjectResult.usages;
|
|
250
252
|
for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
|
|
251
253
|
const definitelyUnused = [];
|
|
252
254
|
const possiblyDynamic = [];
|
|
@@ -264,5 +266,6 @@ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
|
|
|
264
266
|
return {
|
|
265
267
|
definitelyUnusedByFile,
|
|
266
268
|
possiblyDynamicallyUsedByFile,
|
|
269
|
+
resolvedImportedObjectUsages,
|
|
267
270
|
};
|
|
268
271
|
}
|
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 { writeResolvedImportedObjectUsagesReport } from "./actions/writeResolvedImportedObjectUsageReport.js";
|
|
10
11
|
import { deepVerifyUnusedKeys } from "./analysis/deepVerifyUnusedKeys.js";
|
|
11
12
|
export async function runScanner(config) {
|
|
12
13
|
const files = await getFiles(config.srcPaths);
|
|
@@ -16,6 +17,7 @@ export async function runScanner(config) {
|
|
|
16
17
|
const jsonData = await loadJsonKeys(config.jsonDir);
|
|
17
18
|
const result = analyzeKeys(usedKeys, jsonData);
|
|
18
19
|
const verification = await deepVerifyUnusedKeys(files, result.unusedKeysByFile);
|
|
20
|
+
await writeResolvedImportedObjectUsagesReport(verification.resolvedImportedObjectUsages ?? [], config.outputDir);
|
|
19
21
|
await writeUnusedTranslationsReport(verification.definitelyUnusedByFile, jsonData, config.outputDir);
|
|
20
22
|
if (config.syncMissing) {
|
|
21
23
|
await syncMissingTranslations(result, jsonData, config.sortJson);
|