react-i18next-scanner 0.1.3 → 0.1.5

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(result, jsonFiles, outputDir) {
6
- const { unusedKeysByFile } = result;
5
+ export async function writeUnusedTranslationsReport(keysByFile, jsonFiles, outputDir) {
7
6
  const filesWithUnused = jsonFiles.filter((file) => {
8
- const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
9
- return unusedKeys.length > 0;
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 = unusedKeysByFile[file.filePath] ?? [];
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 = getUnusedReportFileName(file.filePath);
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,290 @@
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 extractPropertyChain(expression) {
158
+ if (ts.isIdentifier(expression)) {
159
+ return {
160
+ root: expression.text,
161
+ path: [],
162
+ };
163
+ }
164
+ if (ts.isPropertyAccessExpression(expression)) {
165
+ const left = extractPropertyChain(expression.expression);
166
+ if (!left) {
167
+ return null;
168
+ }
169
+ return {
170
+ root: left.root,
171
+ path: [...left.path, expression.name.text],
172
+ };
173
+ }
174
+ if (ts.isElementAccessExpression(expression)) {
175
+ const left = extractPropertyChain(expression.expression);
176
+ if (!left || !expression.argumentExpression) {
177
+ return null;
178
+ }
179
+ if (ts.isStringLiteral(expression.argumentExpression) ||
180
+ ts.isNoSubstitutionTemplateLiteral(expression.argumentExpression)) {
181
+ return {
182
+ root: left.root,
183
+ path: [...left.path, expression.argumentExpression.text],
184
+ };
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ function resolvePropertyChainToTranslationKey(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
190
+ const chain = extractPropertyChain(expression);
191
+ if (!chain) {
192
+ return null;
193
+ }
194
+ const binding = importBindings.get(chain.root);
195
+ if (!binding) {
196
+ return null;
197
+ }
198
+ const importedFilePath = resolveImportSource(currentFilePath, binding.source, availableFiles);
199
+ if (!importedFilePath) {
200
+ return null;
201
+ }
202
+ const objectMap = objectRegistry.get(`${importedFilePath}::${binding.importedName}`);
203
+ if (!objectMap) {
204
+ return null;
205
+ }
206
+ const propertyPath = chain.path.join(".");
207
+ if (!propertyPath) {
208
+ return null;
209
+ }
210
+ return objectMap.get(propertyPath) ?? null;
211
+ }
212
+ function collectLocalAliases(sourceFile, currentFilePath, importBindings, objectRegistry, availableFiles) {
213
+ const aliases = new Map();
214
+ function visit(node) {
215
+ if (ts.isVariableDeclaration(node) &&
216
+ ts.isIdentifier(node.name) &&
217
+ node.initializer) {
218
+ const resolvedKey = resolvePropertyChainToTranslationKey(node.initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
219
+ if (resolvedKey) {
220
+ aliases.set(node.name.text, resolvedKey);
221
+ }
222
+ }
223
+ ts.forEachChild(node, visit);
224
+ }
225
+ visit(sourceFile);
226
+ return aliases;
227
+ }
228
+ export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
229
+ const sourceFiles = new Map();
230
+ const availableFiles = new Set(filePaths);
231
+ const resolvedKeys = new Set();
232
+ for (const filePath of filePaths) {
233
+ const content = await fs.readFile(filePath, "utf-8");
234
+ sourceFiles.set(filePath, createSourceFile(filePath, content));
235
+ }
236
+ const objectRegistry = buildObjectRegistry(filePaths, sourceFiles);
237
+ for (const filePath of filePaths) {
238
+ const sourceFile = sourceFiles.get(filePath);
239
+ if (!sourceFile) {
240
+ continue;
241
+ }
242
+ const importBindings = collectImportBindings(sourceFile);
243
+ const localAliases = collectLocalAliases(sourceFile, filePath, importBindings, objectRegistry, availableFiles);
244
+ function visit(node) {
245
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
246
+ const firstArg = node.arguments[0];
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
+ }
259
+ }
260
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
261
+ collectFromTrans(node.attributes);
262
+ }
263
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
264
+ collectFromTrans(node.attributes);
265
+ }
266
+ ts.forEachChild(node, visit);
267
+ }
268
+ function collectFromTrans(attributes) {
269
+ const i18nKeyAttr = getJsxAttribute(attributes, "i18nKey");
270
+ if (!i18nKeyAttr?.initializer ||
271
+ !ts.isJsxExpression(i18nKeyAttr.initializer) ||
272
+ !i18nKeyAttr.initializer.expression) {
273
+ return;
274
+ }
275
+ const expr = i18nKeyAttr.initializer.expression;
276
+ const directResolved = resolvePropertyChainToTranslationKey(expr, filePath, importBindings, objectRegistry, availableFiles);
277
+ if (directResolved) {
278
+ resolvedKeys.add(directResolved);
279
+ }
280
+ if (ts.isIdentifier(expr)) {
281
+ const aliasResolved = localAliases.get(expr.text);
282
+ if (aliasResolved) {
283
+ resolvedKeys.add(aliasResolved);
284
+ }
285
+ }
286
+ }
287
+ visit(sourceFile);
288
+ }
289
+ return resolvedKeys;
290
+ }
@@ -0,0 +1,268 @@
1
+ import fs from "fs/promises";
2
+ import ts from "typescript";
3
+ import { collectResolvedImportedObjectTranslationKeys } from "./collectResolvedImportedObjectTranslationKeys.js";
4
+ const KEY_REGEX = /^[A-Za-z0-9_-]+(?:[.:][A-Za-z0-9_-]+)*$/;
5
+ function getScriptKind(filePath) {
6
+ if (filePath.endsWith(".tsx"))
7
+ return ts.ScriptKind.TSX;
8
+ if (filePath.endsWith(".ts"))
9
+ return ts.ScriptKind.TS;
10
+ if (filePath.endsWith(".jsx"))
11
+ return ts.ScriptKind.JSX;
12
+ return ts.ScriptKind.JS;
13
+ }
14
+ function createSourceFile(filePath, content) {
15
+ return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
16
+ }
17
+ function isTranslationCall(node) {
18
+ if (ts.isIdentifier(node.expression)) {
19
+ return node.expression.text === "t";
20
+ }
21
+ if (ts.isPropertyAccessExpression(node.expression)) {
22
+ return node.expression.name.text === "t";
23
+ }
24
+ return false;
25
+ }
26
+ function isDirectLiteral(node) {
27
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
28
+ }
29
+ function isTransTagName(name) {
30
+ return ts.isIdentifier(name) && name.text === "Trans";
31
+ }
32
+ function getJsxAttribute(attributes, attributeName) {
33
+ for (const prop of attributes.properties) {
34
+ if (ts.isJsxAttribute(prop) &&
35
+ ts.isIdentifier(prop.name) &&
36
+ prop.name.text === attributeName) {
37
+ return prop;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ function getJsxLiteralAttributeValue(attribute) {
43
+ if (!attribute?.initializer) {
44
+ return undefined;
45
+ }
46
+ if (ts.isStringLiteral(attribute.initializer)) {
47
+ return attribute.initializer.text;
48
+ }
49
+ if (ts.isJsxExpression(attribute.initializer) &&
50
+ attribute.initializer.expression &&
51
+ (ts.isStringLiteral(attribute.initializer.expression) ||
52
+ ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))) {
53
+ return attribute.initializer.expression.text;
54
+ }
55
+ return undefined;
56
+ }
57
+ function splitKey(fullKey) {
58
+ const firstDotIndex = fullKey.indexOf(".");
59
+ if (firstDotIndex === -1) {
60
+ return { localKey: fullKey };
61
+ }
62
+ return {
63
+ namespace: fullKey.slice(0, firstDotIndex),
64
+ localKey: fullKey.slice(firstDotIndex + 1),
65
+ };
66
+ }
67
+ function extractNamespaces(sourceFile) {
68
+ const namespaces = new Set();
69
+ function visit(node) {
70
+ if (ts.isCallExpression(node) &&
71
+ ts.isIdentifier(node.expression) &&
72
+ node.expression.text === "useTranslation") {
73
+ const firstArg = node.arguments[0];
74
+ if (firstArg) {
75
+ if (ts.isStringLiteral(firstArg) ||
76
+ ts.isNoSubstitutionTemplateLiteral(firstArg)) {
77
+ namespaces.add(firstArg.text);
78
+ }
79
+ if (ts.isArrayLiteralExpression(firstArg)) {
80
+ for (const element of firstArg.elements) {
81
+ if (ts.isStringLiteral(element) ||
82
+ ts.isNoSubstitutionTemplateLiteral(element)) {
83
+ namespaces.add(element.text);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
90
+ const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
91
+ if (nsValue) {
92
+ namespaces.add(nsValue);
93
+ }
94
+ }
95
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
96
+ const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
97
+ if (nsValue) {
98
+ namespaces.add(nsValue);
99
+ }
100
+ }
101
+ ts.forEachChild(node, visit);
102
+ }
103
+ visit(sourceFile);
104
+ return namespaces;
105
+ }
106
+ function collectLiteralKeys(sourceFile) {
107
+ const keys = new Set();
108
+ function visit(node) {
109
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
110
+ const value = node.text.trim();
111
+ if (KEY_REGEX.test(value)) {
112
+ keys.add(value);
113
+ }
114
+ }
115
+ ts.forEachChild(node, visit);
116
+ }
117
+ visit(sourceFile);
118
+ return keys;
119
+ }
120
+ function collectTemplatePrefixes(sourceFile) {
121
+ const prefixes = new Set();
122
+ function addPrefix(prefix) {
123
+ const normalized = prefix.trim();
124
+ if (!normalized) {
125
+ return;
126
+ }
127
+ prefixes.add(normalized);
128
+ }
129
+ function visit(node) {
130
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
131
+ const firstArg = node.arguments[0];
132
+ if (firstArg && ts.isTemplateExpression(firstArg)) {
133
+ addPrefix(firstArg.head.text);
134
+ }
135
+ }
136
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
137
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
138
+ if (i18nKeyAttr?.initializer &&
139
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
140
+ i18nKeyAttr.initializer.expression &&
141
+ ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
142
+ addPrefix(i18nKeyAttr.initializer.expression.head.text);
143
+ }
144
+ }
145
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
146
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
147
+ if (i18nKeyAttr?.initializer &&
148
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
149
+ i18nKeyAttr.initializer.expression &&
150
+ ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
151
+ addPrefix(i18nKeyAttr.initializer.expression.head.text);
152
+ }
153
+ }
154
+ ts.forEachChild(node, visit);
155
+ }
156
+ visit(sourceFile);
157
+ return [...prefixes];
158
+ }
159
+ function detectDynamicTranslationUsage(sourceFile) {
160
+ let found = false;
161
+ function visit(node) {
162
+ if (found) {
163
+ return;
164
+ }
165
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
166
+ const firstArg = node.arguments[0];
167
+ if (firstArg && !isDirectLiteral(firstArg)) {
168
+ found = true;
169
+ return;
170
+ }
171
+ }
172
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
173
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
174
+ if (i18nKeyAttr?.initializer &&
175
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
176
+ i18nKeyAttr.initializer.expression) {
177
+ const expr = i18nKeyAttr.initializer.expression;
178
+ if (!isDirectLiteral(expr)) {
179
+ found = true;
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
185
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
186
+ if (i18nKeyAttr?.initializer &&
187
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
188
+ i18nKeyAttr.initializer.expression) {
189
+ const expr = i18nKeyAttr.initializer.expression;
190
+ if (!isDirectLiteral(expr)) {
191
+ found = true;
192
+ return;
193
+ }
194
+ }
195
+ }
196
+ ts.forEachChild(node, visit);
197
+ }
198
+ visit(sourceFile);
199
+ return found;
200
+ }
201
+ async function collectFileEvidence(filePath) {
202
+ const content = await fs.readFile(filePath, "utf-8");
203
+ const sourceFile = createSourceFile(filePath, content);
204
+ return {
205
+ namespaces: extractNamespaces(sourceFile),
206
+ hasDynamicTranslationUsage: detectDynamicTranslationUsage(sourceFile),
207
+ literalKeys: collectLiteralKeys(sourceFile),
208
+ templatePrefixes: collectTemplatePrefixes(sourceFile),
209
+ };
210
+ }
211
+ function keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence) {
212
+ for (const prefix of evidence.templatePrefixes) {
213
+ if (fullKey.startsWith(prefix)) {
214
+ return true;
215
+ }
216
+ if (namespace &&
217
+ evidence.namespaces.has(namespace) &&
218
+ localKey.startsWith(prefix)) {
219
+ return true;
220
+ }
221
+ }
222
+ return false;
223
+ }
224
+ function keyIsPossiblyDynamic(fullKey, evidenceList, resolvedImportedObjectKeys) {
225
+ const { namespace, localKey } = splitKey(fullKey);
226
+ if (resolvedImportedObjectKeys.has(fullKey)) {
227
+ return true;
228
+ }
229
+ for (const evidence of evidenceList) {
230
+ if (evidence.literalKeys.has(fullKey)) {
231
+ return true;
232
+ }
233
+ if (namespace &&
234
+ evidence.hasDynamicTranslationUsage &&
235
+ evidence.namespaces.has(namespace) &&
236
+ evidence.literalKeys.has(localKey)) {
237
+ return true;
238
+ }
239
+ if (keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence)) {
240
+ return true;
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
246
+ const definitelyUnusedByFile = {};
247
+ const possiblyDynamicallyUsedByFile = {};
248
+ const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
249
+ const resolvedImportedObjectKeys = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
250
+ for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
251
+ const definitelyUnused = [];
252
+ const possiblyDynamic = [];
253
+ for (const key of unusedKeys) {
254
+ if (keyIsPossiblyDynamic(key, evidenceList, resolvedImportedObjectKeys)) {
255
+ possiblyDynamic.push(key);
256
+ }
257
+ else {
258
+ definitelyUnused.push(key);
259
+ }
260
+ }
261
+ definitelyUnusedByFile[translationFilePath] = definitelyUnused;
262
+ possiblyDynamicallyUsedByFile[translationFilePath] = possiblyDynamic;
263
+ }
264
+ return {
265
+ definitelyUnusedByFile,
266
+ possiblyDynamicallyUsedByFile,
267
+ };
268
+ }
@@ -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 writeUnusedTranslationsReport(result, jsonData, config.outputDir);
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
- await removeUnusedTranslations(result, jsonData, config.sortJson);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {