react-i18next-scanner 0.1.0 → 0.1.2

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
 
package/dist/cli/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import chalk from "chalk";
4
+ import { runInit } from "../core/runInit.js";
4
5
  import { runScanner } from "../core/scanner.js";
5
6
  import { sortJsonFiles } from "../core/sortJsonFiles.js";
6
7
  import { runSyncMissing } from "../core/runSyncMissing.js";
@@ -13,7 +14,7 @@ function resolveConfigPath(configPath) {
13
14
  finalConfigPath = findDefaultConfig() ?? undefined;
14
15
  }
15
16
  if (!finalConfigPath) {
16
- throw new Error(`Config file not found. Create one of: ${POSSIBLE_CONFIGS.join(", ")}`);
17
+ throw new Error(`Config file not found. Create one of: ${POSSIBLE_CONFIGS.join(", ")}\nRun: react-i18next-scanner init`);
17
18
  }
18
19
  return finalConfigPath;
19
20
  }
@@ -96,4 +97,15 @@ program
96
97
  handleCliError(error);
97
98
  }
98
99
  });
100
+ program
101
+ .command("init")
102
+ .description("Create scanner config file automatically")
103
+ .action(async () => {
104
+ try {
105
+ await runInit();
106
+ }
107
+ catch (error) {
108
+ handleCliError(error);
109
+ }
110
+ });
99
111
  await program.parseAsync(process.argv);
@@ -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,94 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ function extractNamespaces(content) {
4
+ const namespaces = new Set();
5
+ const nsRegex = /useTranslation\s*\(\s*(?:\[\s*([^\]]+)\s*\]|['"`]([^'"`]+)['"`])?/g;
6
+ let match;
7
+ while ((match = nsRegex.exec(content)) !== null) {
8
+ if (match[1]) {
9
+ const values = match[1]
10
+ .split(",")
11
+ .map((value) => value.replace(/['"`]/g, "").trim())
12
+ .filter(Boolean);
13
+ values.forEach((value) => namespaces.add(value));
14
+ }
15
+ if (match[2]) {
16
+ namespaces.add(match[2]);
17
+ }
18
+ }
19
+ return [...namespaces];
20
+ }
21
+ function extractDynamicTExpressions(content) {
22
+ const expressions = new Set();
23
+ const dynamicTRegex = /(^|[^\w$.])t\s*\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\([^()]*\))?)/g;
24
+ let match;
25
+ while ((match = dynamicTRegex.exec(content)) !== null) {
26
+ const expression = match[2]?.trim();
27
+ if (!expression) {
28
+ continue;
29
+ }
30
+ expressions.add(expression);
31
+ }
32
+ return [...expressions];
33
+ }
34
+ function extractImportedIdentifiers(content) {
35
+ const identifiers = new Set();
36
+ const importRegex = /import\s+(?:\{([^}]+)\}|([A-Za-z_$][\w$]*))\s+from\s+['"`][^'"`]+['"`]/g;
37
+ let match;
38
+ while ((match = importRegex.exec(content)) !== null) {
39
+ if (match[1]) {
40
+ const namedImports = match[1]
41
+ .split(",")
42
+ .map((value) => value.trim())
43
+ .map((value) => value.split(" as ")[1]?.trim() ?? value.split(" as ")[0]?.trim())
44
+ .filter(Boolean);
45
+ namedImports.forEach((value) => identifiers.add(value));
46
+ }
47
+ if (match[2]) {
48
+ identifiers.add(match[2]);
49
+ }
50
+ }
51
+ return [...identifiers];
52
+ }
53
+ function detectObjectBasedUsageHints(content, importedIdentifiers) {
54
+ const hints = new Set();
55
+ for (const identifier of importedIdentifiers) {
56
+ const objectAccessRegex = new RegExp(`${identifier}\\s*\\[[^\\]]+\\]\\.(description|label|title|placeholder|message)|${identifier}\\.[A-Za-z_$][\\w$]*\\.(description|label|title|placeholder|message)`, "g");
57
+ if (objectAccessRegex.test(content)) {
58
+ hints.add(identifier);
59
+ }
60
+ }
61
+ return [...hints];
62
+ }
63
+ function buildFinding(namespaces, expression, objectBasedHints) {
64
+ const isObjectBased = objectBasedHints.length > 0;
65
+ return {
66
+ namespaces,
67
+ expression,
68
+ reason: isObjectBased
69
+ ? "Object-based non-literal translation key"
70
+ : "Non-literal translation key",
71
+ suggestion: isObjectBased
72
+ ? `Translation key may come from imported object(s): ${objectBasedHints.join(", ")}. Manual or deeper analysis may be required.`
73
+ : "Translation key may come from a variable or helper function. Manual or deeper analysis may be required.",
74
+ };
75
+ }
76
+ export async function detectDynamicTranslationUsages(files) {
77
+ const report = {};
78
+ for (const file of files) {
79
+ const content = await fs.readFile(file, "utf-8");
80
+ if (!content.includes("useTranslation") || !content.includes("t(")) {
81
+ continue;
82
+ }
83
+ const namespaces = extractNamespaces(content);
84
+ const expressions = extractDynamicTExpressions(content);
85
+ if (expressions.length === 0) {
86
+ continue;
87
+ }
88
+ const importedIdentifiers = extractImportedIdentifiers(content);
89
+ const objectBasedHints = detectObjectBasedUsageHints(content, importedIdentifiers);
90
+ const relativePath = path.relative(process.cwd(), file).replace(/\\/g, "/");
91
+ report[relativePath] = expressions.map((expression) => buildFinding(namespaces, expression, objectBasedHints));
92
+ }
93
+ return report;
94
+ }
@@ -0,0 +1,58 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import readline from "readline/promises";
4
+ import { stdin as input, stdout as output } from "process";
5
+ function getConfigFileName(format) {
6
+ return `i18n-scanner.config.${format}`;
7
+ }
8
+ function getConfigTemplate(format) {
9
+ const configObject = `{
10
+ srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
11
+ jsonDir: [
12
+ "./src/i18n/locales/translations.en.json",
13
+ "./src/i18n/locales/translations.es.json"
14
+ ],
15
+ sortJsonFiles: [
16
+ "./src/i18n/locales/translations.en.json",
17
+ "./src/i18n/locales/translations.es.json"
18
+ ],
19
+ outputDir: "./unused",
20
+ syncMissing: false,
21
+ removeUnused: false
22
+ }`;
23
+ if (format === "cjs") {
24
+ return `module.exports = ${configObject};\n`;
25
+ }
26
+ return `export default ${configObject};\n`;
27
+ }
28
+ export async function runInit() {
29
+ const rl = readline.createInterface({ input, output });
30
+ try {
31
+ const answer = await rl.question("Choose config format (js / mjs / cjs): ");
32
+ const normalized = answer.trim().toLowerCase();
33
+ if (normalized !== "js" && normalized !== "mjs" && normalized !== "cjs") {
34
+ throw new Error("Invalid format. Use js, mjs, or cjs.");
35
+ }
36
+ const format = normalized;
37
+ const fileName = getConfigFileName(format);
38
+ const filePath = path.resolve(process.cwd(), fileName);
39
+ try {
40
+ await fs.access(filePath);
41
+ throw new Error(`${fileName} already exists.`);
42
+ }
43
+ catch (error) {
44
+ if (error instanceof Error && !error.message.includes("already exists")) {
45
+ // file does not exist, continue
46
+ }
47
+ else if (error instanceof Error) {
48
+ throw error;
49
+ }
50
+ }
51
+ const content = getConfigTemplate(format);
52
+ await fs.writeFile(filePath, content, "utf-8");
53
+ console.log(`✅ Created: ${fileName}`);
54
+ }
55
+ finally {
56
+ rl.close();
57
+ }
58
+ }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
5
5
  "bin": {
6
6
  "react-i18next-scanner": "./dist/cli/cli.js"