react-i18next-scanner 0.1.6 → 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, { importedName, source });
106
+ bindings.set(localName, {
107
+ importedName,
108
+ source,
109
+ });
107
110
  }
108
111
  }
109
112
  });
@@ -222,6 +225,47 @@ function extractPropertyChain(expression) {
222
225
  }
223
226
  return null;
224
227
  }
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) {
247
+ const chain = extractPropertyChain(expression);
248
+ if (!chain) {
249
+ return null;
250
+ }
251
+ const binding = importBindings.get(chain.root);
252
+ if (!binding) {
253
+ return null;
254
+ }
255
+ const importedFilePath = resolveImportSource(currentFilePath, binding.source, availableFiles);
256
+ if (!importedFilePath) {
257
+ return null;
258
+ }
259
+ const objectMap = objectRegistry.get(`${importedFilePath}::${binding.importedName}`);
260
+ if (!objectMap) {
261
+ return null;
262
+ }
263
+ return {
264
+ importedFilePath,
265
+ importedName: binding.importedName,
266
+ path: chain.path,
267
+ };
268
+ }
225
269
  function resolvePropertyChainToTranslationKeys(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
226
270
  const access = resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles);
227
271
  if (!access) {
@@ -245,7 +289,10 @@ function collectLocalAliases(sourceFile, currentFilePath, importBindings, object
245
289
  if (importedAccess) {
246
290
  const keys = resolvePropertyChainToTranslationKeys(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
247
291
  if (keys.size > 0) {
248
- keyAliases.set(node.name.text, keys);
292
+ keyAliases.set(node.name.text, {
293
+ keys,
294
+ access: importedAccess,
295
+ });
249
296
  }
250
297
  else {
251
298
  objectAliases.set(node.name.text, importedAccess);
@@ -255,12 +302,15 @@ function collectLocalAliases(sourceFile, currentFilePath, importBindings, object
255
302
  ts.forEachChild(node, visit);
256
303
  }
257
304
  visit(sourceFile);
258
- return { objectAliases, keyAliases };
305
+ return {
306
+ objectAliases,
307
+ keyAliases,
308
+ };
259
309
  }
260
310
  function resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry) {
261
311
  const unwrapped = unwrapExpression(expression);
262
312
  if (ts.isIdentifier(unwrapped)) {
263
- return keyAliases.get(unwrapped.text) ?? new Set();
313
+ return keyAliases.get(unwrapped.text)?.keys ?? new Set();
264
314
  }
265
315
  const chain = extractPropertyChain(unwrapped);
266
316
  if (!chain) {
@@ -277,11 +327,62 @@ function resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases,
277
327
  const fullPath = [...objectAlias.path, ...chain.path];
278
328
  return resolveObjectValuesByPath(objectMap, fullPath);
279
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
+ }
378
+ }
280
379
  export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
281
380
  const normalizedFilePaths = filePaths.map((filePath) => path.resolve(filePath));
282
381
  const sourceFiles = new Map();
283
382
  const availableFiles = new Set(normalizedFilePaths);
284
383
  const resolvedKeys = new Set();
384
+ const usages = [];
385
+ const usageIds = new Set();
285
386
  for (const filePath of normalizedFilePaths) {
286
387
  const content = await fs.readFile(filePath, "utf-8");
287
388
  sourceFiles.set(filePath, createSourceFile(filePath, content));
@@ -294,27 +395,36 @@ export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
294
395
  }
295
396
  const importBindings = collectImportBindings(sourceFile);
296
397
  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
- }
398
+ function addKeys(keys) {
399
+ for (const key of keys) {
400
+ resolvedKeys.add(key);
310
401
  }
311
- if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
312
- collectFromTrans(node.attributes);
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
+ });
313
415
  }
314
- if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
315
- collectFromTrans(node.attributes);
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
+ });
316
427
  }
317
- ts.forEachChild(node, visit);
318
428
  }
319
429
  function collectFromTrans(attributes) {
320
430
  const i18nKeyAttr = getJsxAttribute(attributes, "i18nKey");
@@ -323,58 +433,27 @@ export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
323
433
  !i18nKeyAttr.initializer.expression) {
324
434
  return;
325
435
  }
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);
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");
443
+ }
330
444
  }
331
- const aliasResolved = resolveAliasPropertyAccessToTranslationKeys(expr, objectAliases, keyAliases, objectRegistry);
332
- for (const key of aliasResolved) {
333
- resolvedKeys.add(key);
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);
334
450
  }
451
+ ts.forEachChild(node, visit);
335
452
  }
336
453
  visit(sourceFile);
337
454
  }
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
455
  return {
376
- importedFilePath,
377
- importedName: binding.importedName,
378
- path: chain.path,
456
+ keys: resolvedKeys,
457
+ usages,
379
458
  };
380
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 resolvedImportedObjectKeys = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
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
  }
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
5
5
  "bin": "./dist/cli/cli.js",
6
6
  "scripts": {