react-i18next-scanner 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ CLI tool to scan React projects for missing and unused `react-i18next` translati
11
11
  - Sync missing keys into translation JSON files
12
12
  - Remove unused keys from translation JSON files
13
13
  - Sort translation JSON files alphabetically
14
- - Works with `.js`, `.jsx`, `.ts`, `.tsx`
14
+ - Detect dynamic translation usages and write them into a separate report
15
15
 
16
16
  ---
17
17
 
@@ -37,6 +37,8 @@ Create one of these config files in your project root:
37
37
  - `i18n-scanner.config.cjs`
38
38
  - `i18n-scanner.config.mjs`
39
39
 
40
+ > `.ts` config is not supported yet.
41
+
40
42
  Example:
41
43
 
42
44
  ```js
@@ -112,6 +114,20 @@ If `true`, unused keys will be removed from translation JSON files.
112
114
 
113
115
  ## Commands
114
116
 
117
+ ### Initialize config
118
+
119
+ ```bash
120
+ npx react-i18next-scanner init
121
+ ```
122
+
123
+ This command creates a config file automatically and lets you choose:
124
+
125
+ - `.js`
126
+ - `.mjs`
127
+ - `.cjs`
128
+
129
+ ---
130
+
115
131
  ### Scan project
116
132
 
117
133
  ```bash
@@ -130,6 +146,7 @@ What it does:
130
146
  - extracts translation keys
131
147
  - compares them with translation JSON files
132
148
  - writes unused translation reports
149
+ - writes dynamic translation usage reports
133
150
  - optionally syncs missing keys
134
151
  - optionally removes unused keys
135
152
 
@@ -178,7 +195,40 @@ These files contain only unused translations, so you can review them safely befo
178
195
 
179
196
  ---
180
197
 
181
- ## Supported Patterns
198
+ ## Dynamic Translation Report
199
+
200
+ If the scanner finds dynamic translation usage such as:
201
+
202
+ ```ts
203
+ t(getDescription());
204
+ t(labelKey);
205
+ t(transaction.descriptionKey);
206
+ ```
207
+
208
+ it creates a report file:
209
+
210
+ ```bash
211
+ reports-dynamic/dynamic-translation-usages.json
212
+ ```
213
+
214
+ Example structure:
215
+
216
+ ```json
217
+ {
218
+ "src/views/Account/BalancesTransactionsPage/PaymentDescription.tsx": [
219
+ {
220
+ "namespaces": ["accountInformation"],
221
+ "expression": "getDescription()",
222
+ "reason": "Object-based non-literal translation key",
223
+ "suggestion": "Translation key may come from imported object(s) or helper functions. Manual or deeper analysis may be required."
224
+ }
225
+ ]
226
+ }
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Supported Translation Patterns
182
232
 
183
233
  The scanner currently detects translation keys used with:
184
234
 
@@ -187,6 +237,31 @@ The scanner currently detects translation keys used with:
187
237
  - `<Trans i18nKey="key" />`
188
238
  - `useTranslation("namespace")`
189
239
 
240
+ It can also report dynamic usages where translation keys are not passed directly as string literals.
241
+
242
+ ---
243
+
244
+ ## Supported Files
245
+
246
+ ### Source files
247
+
248
+ The scanner can scan:
249
+
250
+ - `.js`
251
+ - `.jsx`
252
+ - `.ts`
253
+ - `.tsx`
254
+
255
+ ### Config files
256
+
257
+ The scanner currently supports:
258
+
259
+ - `.js`
260
+ - `.cjs`
261
+ - `.mjs`
262
+
263
+ > `.ts` config is not supported yet.
264
+
190
265
  ---
191
266
 
192
267
  ## Example Workflow
@@ -252,9 +327,10 @@ npx react-i18next-scanner scan
252
327
 
253
328
  ## Notes
254
329
 
255
- - Use `syncMissing` carefully if you do not want automatic changes in translation files.
256
- - Use `removeUnused` only after reviewing generated unused translation reports.
257
- - Translation paths are resolved relative to the config file location.
330
+ - `syncMissing` does not translate values automatically. It only adds missing keys with empty values.
331
+ - `removeUnused` should be used carefully after reviewing unused translation reports.
332
+ - Dynamic translation usages are written into a separate report and are not automatically resolved.
333
+ - Config paths are resolved relative to the config file location.
258
334
 
259
335
  ---
260
336
 
@@ -41,6 +41,9 @@ function validateConfig(config) {
41
41
  if (cfg.syncMissing !== undefined && typeof cfg.syncMissing !== "boolean") {
42
42
  throw new Error("syncMissing must be a boolean.");
43
43
  }
44
+ if (cfg.sortJson !== undefined && typeof cfg.sortJson !== "boolean") {
45
+ throw new Error("sortJson must be a boolean.");
46
+ }
44
47
  if (cfg.outputDir !== undefined && typeof cfg.outputDir !== "string") {
45
48
  throw new Error("outputDir must be a string.");
46
49
  }
@@ -52,6 +55,7 @@ function normalizeConfig(config, baseDir) {
52
55
  sortJsonFiles: config.sortJsonFiles?.map((p) => path.resolve(baseDir, p)),
53
56
  removeUnused: config.removeUnused ?? false,
54
57
  syncMissing: config.syncMissing ?? false,
58
+ sortJson: config.sortJson ?? false,
55
59
  outputDir: path.resolve(baseDir, config.outputDir ?? "unused-translations"),
56
60
  };
57
61
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import { removeKey } from "../utils/removeKey.js";
3
3
  import { sortObject } from "../utils/sortObject.js";
4
- export async function removeUnusedTranslations(result, jsonFiles) {
4
+ export async function removeUnusedTranslations(result, jsonFiles, sortJson) {
5
5
  const { unusedKeysByFile } = result;
6
6
  for (const file of jsonFiles) {
7
7
  const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
@@ -19,7 +19,7 @@ export async function removeUnusedTranslations(result, jsonFiles) {
19
19
  if (!updated) {
20
20
  continue;
21
21
  }
22
- const sorted = sortObject(file.data);
22
+ const sorted = sortJson ? sortObject(file.data) : file.data;
23
23
  file.data = sorted;
24
24
  await fs.writeFile(file.filePath, JSON.stringify(sorted, null, 2), "utf-8");
25
25
  console.log(`\n✅ Unused keys removed: ${file.filePath}`);
@@ -2,7 +2,7 @@ import fs from "fs/promises";
2
2
  import { addKey } from "../utils/addKey.js";
3
3
  import { sortObject } from "../utils/sortObject.js";
4
4
  import { isValidTranslationKey } from "../utils/isValidTranslationKey.js";
5
- export async function syncMissingTranslations(result, jsonFiles) {
5
+ export async function syncMissingTranslations(result, jsonFiles, sortJson) {
6
6
  const { missingKeysByFile } = result;
7
7
  for (const file of jsonFiles) {
8
8
  const missingKeys = missingKeysByFile[file.filePath] ?? [];
@@ -23,7 +23,7 @@ export async function syncMissingTranslations(result, jsonFiles) {
23
23
  if (!updated) {
24
24
  continue;
25
25
  }
26
- const sorted = sortObject(file.data);
26
+ const sorted = sortJson ? sortObject(file.data) : file.data;
27
27
  file.data = sorted;
28
28
  await fs.writeFile(file.filePath, JSON.stringify(sorted, null, 2), "utf-8");
29
29
  console.log(`\n✅ Missing keys synced: ${file.filePath}`);
@@ -0,0 +1,14 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { sortObject } from "../utils/sortObject.js";
4
+ const DEFAULT_DYNAMIC_REPORT_DIR = path.resolve(process.cwd(), "reports-dynamic");
5
+ export async function writeDynamicTranslationReport(report, outputDir = DEFAULT_DYNAMIC_REPORT_DIR) {
6
+ if (Object.keys(report).length === 0) {
7
+ return;
8
+ }
9
+ await fs.mkdir(outputDir, { recursive: true });
10
+ const reportPath = path.join(outputDir, "dynamic-translation-usages.json");
11
+ const sortedReport = sortObject(report);
12
+ await fs.writeFile(reportPath, `${JSON.stringify(sortedReport, null, 2)}\n`, "utf-8");
13
+ console.log(`📝 Dynamic translation report created: ${reportPath}`);
14
+ }
@@ -0,0 +1,180 @@
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 extractImportMap(sourceFile) {
17
+ const imports = new Map();
18
+ sourceFile.forEachChild((node) => {
19
+ if (!ts.isImportDeclaration(node))
20
+ return;
21
+ if (!node.importClause || !node.moduleSpecifier)
22
+ return;
23
+ if (!ts.isStringLiteral(node.moduleSpecifier))
24
+ return;
25
+ const modulePath = node.moduleSpecifier.text;
26
+ const importClause = node.importClause;
27
+ if (importClause.name) {
28
+ imports.set(importClause.name.text, modulePath);
29
+ }
30
+ if (importClause.namedBindings &&
31
+ ts.isNamedImports(importClause.namedBindings)) {
32
+ for (const element of importClause.namedBindings.elements) {
33
+ const localName = element.name.text;
34
+ imports.set(localName, modulePath);
35
+ }
36
+ }
37
+ });
38
+ return imports;
39
+ }
40
+ function extractNamespaces(sourceFile) {
41
+ const namespaces = new Set();
42
+ function visit(node) {
43
+ if (ts.isCallExpression(node) &&
44
+ ts.isIdentifier(node.expression) &&
45
+ node.expression.text === "useTranslation") {
46
+ const firstArg = node.arguments[0];
47
+ if (!firstArg) {
48
+ return;
49
+ }
50
+ if (ts.isStringLiteral(firstArg) ||
51
+ ts.isNoSubstitutionTemplateLiteral(firstArg)) {
52
+ namespaces.add(firstArg.text);
53
+ }
54
+ if (ts.isArrayLiteralExpression(firstArg)) {
55
+ for (const element of firstArg.elements) {
56
+ if (ts.isStringLiteral(element) ||
57
+ ts.isNoSubstitutionTemplateLiteral(element)) {
58
+ namespaces.add(element.text);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ts.forEachChild(node, visit);
64
+ }
65
+ visit(sourceFile);
66
+ return [...namespaces];
67
+ }
68
+ function isTranslationCall(node) {
69
+ if (ts.isIdentifier(node.expression)) {
70
+ return node.expression.text === "t";
71
+ }
72
+ if (ts.isPropertyAccessExpression(node.expression)) {
73
+ return node.expression.name.text === "t";
74
+ }
75
+ return false;
76
+ }
77
+ function isDirectLiteralTranslationArg(node) {
78
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
79
+ }
80
+ function getExpressionKind(node) {
81
+ if (ts.isTemplateExpression(node))
82
+ return "template-literal";
83
+ if (ts.isIdentifier(node))
84
+ return "identifier";
85
+ if (ts.isCallExpression(node))
86
+ return "function-call";
87
+ if (ts.isPropertyAccessExpression(node))
88
+ return "property-access";
89
+ if (ts.isElementAccessExpression(node))
90
+ return "element-access";
91
+ if (ts.isBinaryExpression(node))
92
+ return "binary-expression";
93
+ if (ts.isConditionalExpression(node))
94
+ return "conditional-expression";
95
+ return "expression";
96
+ }
97
+ function findRelatedImports(expressionText, importMap) {
98
+ const related = [];
99
+ for (const [localName, source] of importMap.entries()) {
100
+ const identifierRegex = new RegExp(`\\b${localName}\\b`);
101
+ if (identifierRegex.test(expressionText)) {
102
+ related.push(`${localName} -> ${source}`);
103
+ }
104
+ }
105
+ return related;
106
+ }
107
+ function buildFinding(namespaces, expression, kind, importHints) {
108
+ let reason = "Non-literal translation key";
109
+ let suggestion = "Translation key is not passed as a direct string literal. Manual or deeper analysis may be required.";
110
+ if (kind === "template-literal") {
111
+ reason = "Template-literal translation key";
112
+ suggestion =
113
+ "Translation key uses a template literal with dynamic interpolation. Manual or deeper analysis may be required.";
114
+ }
115
+ if (kind === "function-call") {
116
+ reason = "Function-based translation key";
117
+ suggestion =
118
+ "Translation key is returned from a function call. Manual or deeper analysis may be required.";
119
+ }
120
+ if (kind === "property-access" ||
121
+ kind === "element-access" ||
122
+ kind === "identifier") {
123
+ reason = "Object-based non-literal translation key";
124
+ suggestion =
125
+ "Translation key may come from an object, variable, or imported map. Manual or deeper analysis may be required.";
126
+ }
127
+ if (importHints.length > 0) {
128
+ suggestion += ` Related imports: ${importHints.join(", ")}.`;
129
+ }
130
+ return {
131
+ namespaces,
132
+ expression,
133
+ reason,
134
+ suggestion,
135
+ };
136
+ }
137
+ function collectDynamicFromExpression(expression, namespaces, importMap, sourceFile, bucket) {
138
+ if (isDirectLiteralTranslationArg(expression)) {
139
+ return;
140
+ }
141
+ if (ts.isConditionalExpression(expression)) {
142
+ collectDynamicFromExpression(expression.whenTrue, namespaces, importMap, sourceFile, bucket);
143
+ collectDynamicFromExpression(expression.whenFalse, namespaces, importMap, sourceFile, bucket);
144
+ return;
145
+ }
146
+ const expressionText = expression.getText(sourceFile).trim();
147
+ const kind = getExpressionKind(expression);
148
+ const importHints = findRelatedImports(expressionText, importMap);
149
+ bucket.push(buildFinding(namespaces, expressionText, kind, importHints));
150
+ }
151
+ export async function detectDynamicTranslationUsages(files) {
152
+ const report = {};
153
+ for (const file of files) {
154
+ const content = await fs.readFile(file, "utf-8");
155
+ if (!content.includes("t(")) {
156
+ continue;
157
+ }
158
+ const sourceFile = createSourceFile(file, content);
159
+ const namespaces = extractNamespaces(sourceFile);
160
+ const importMap = extractImportMap(sourceFile);
161
+ const findings = [];
162
+ function visit(node) {
163
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
164
+ const firstArg = node.arguments[0];
165
+ if (firstArg) {
166
+ collectDynamicFromExpression(firstArg, namespaces, importMap, sourceFile, findings);
167
+ }
168
+ }
169
+ ts.forEachChild(node, visit);
170
+ }
171
+ visit(sourceFile);
172
+ if (findings.length > 0) {
173
+ const relativePath = path
174
+ .relative(process.cwd(), file)
175
+ .replace(/\\/g, "/");
176
+ report[relativePath] = findings;
177
+ }
178
+ }
179
+ return report;
180
+ }
@@ -9,6 +9,6 @@ export async function runSyncMissing(config) {
9
9
  const usedKeys = await extractKeysFromFiles(files);
10
10
  const jsonData = await loadJsonKeys(config.jsonDir);
11
11
  const result = analyzeKeys(usedKeys, jsonData);
12
- await syncMissingTranslations(result, jsonData);
12
+ await syncMissingTranslations(result, jsonData, config.sortJson);
13
13
  return result;
14
14
  }
@@ -2,20 +2,24 @@ import { loadJsonKeys } from "./io/jsonLoader.js";
2
2
  import { getFiles } from "./io/getFiles.js";
3
3
  import { analyzeKeys } from "./analysis/analyzer.js";
4
4
  import { extractKeysFromFiles } from "./analysis/extractKeysFromFiles.js";
5
+ import { detectDynamicTranslationUsages } from "./analysis/detectDynamicTranslationUsages.js";
6
+ import { writeDynamicTranslationReport } from "./actions/writeDynamicTranslationReport.js";
5
7
  import { syncMissingTranslations } from "./actions/syncMissingTranslations.js";
6
8
  import { removeUnusedTranslations } from "./actions/removeUnusedTranslations.js";
7
9
  import { writeUnusedTranslationsReport } from "./actions/writeUnusedTranslationsReport.js";
8
10
  export async function runScanner(config) {
9
11
  const files = await getFiles(config.srcPaths);
12
+ const dynamicUsages = await detectDynamicTranslationUsages(files);
13
+ await writeDynamicTranslationReport(dynamicUsages);
10
14
  const usedKeys = await extractKeysFromFiles(files);
11
15
  const jsonData = await loadJsonKeys(config.jsonDir);
12
16
  const result = analyzeKeys(usedKeys, jsonData);
13
17
  await writeUnusedTranslationsReport(result, jsonData, config.outputDir);
14
18
  if (config.syncMissing) {
15
- await syncMissingTranslations(result, jsonData);
19
+ await syncMissingTranslations(result, jsonData, config.sortJson);
16
20
  }
17
21
  if (config.removeUnused) {
18
- await removeUnusedTranslations(result, jsonData);
22
+ await removeUnusedTranslations(result, jsonData, config.sortJson);
19
23
  }
20
24
  return result;
21
25
  }
package/package.json CHANGED
@@ -1,10 +1,8 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
5
- "bin": {
6
- "react-i18next-scanner": "./dist/cli/cli.js"
7
- },
5
+ "bin": "./dist/cli/cli.js",
8
6
  "scripts": {
9
7
  "build": "tsc",
10
8
  "dev": "tsx src/cli/cli.ts",
@@ -26,12 +24,12 @@
26
24
  ],
27
25
  "devDependencies": {
28
26
  "@types/node": "^24.6.2",
29
- "tsx": "^4.22.4",
30
- "typescript": "^5.9.3"
27
+ "tsx": "^4.22.4"
31
28
  },
32
29
  "dependencies": {
33
30
  "chalk": "^5.6.2",
34
31
  "commander": "^15.0.0",
35
- "fast-glob": "^3.3.3"
32
+ "fast-glob": "^3.3.3",
33
+ "typescript": "^6.0.3"
36
34
  }
37
35
  }