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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {