react-i18next-scanner 0.1.6 → 0.1.8

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
@@ -1,21 +1,23 @@
1
1
  # react-i18next-scanner
2
2
 
3
- CLI tool to scan React projects for missing and unused `react-i18next` translation keys.
3
+ CLI tool for React / `react-i18next` projects.
4
4
 
5
- ## Features
5
+ It helps you:
6
6
 
7
- - Scan React source files for translation keys
8
- - Detect missing translation keys
9
- - Detect unused translation keys
10
- - Generate separate unused translation reports
11
- - Sync missing keys into translation JSON files
12
- - Remove unused keys from translation JSON files
13
- - Sort translation JSON files alphabetically
14
- - Detect dynamic translation usages and write them into a separate report
7
+ - find missing translation keys
8
+ - find unused translation keys
9
+ - detect dynamic translation usages
10
+ - protect possibly used dynamic keys from removal
11
+ - generate reports
12
+ - sync missing keys
13
+ - remove definitely unused keys
14
+ - sort translation JSON files
15
15
 
16
16
  ---
17
17
 
18
- ## Installation
18
+ ## Quick Start
19
+
20
+ ### 1. Install
19
21
 
20
22
  ```bash
21
23
  npm install -D react-i18next-scanner
@@ -29,150 +31,139 @@ yarn add -D react-i18next-scanner
29
31
 
30
32
  ---
31
33
 
32
- ## Config
33
-
34
- Create one of these config files in your project root:
34
+ ### 2. Create config
35
35
 
36
- - `i18n-scanner.config.js`
37
- - `i18n-scanner.config.cjs`
38
- - `i18n-scanner.config.mjs`
36
+ ```bash
37
+ npx react-i18next-scanner init
38
+ ```
39
39
 
40
- > `.ts` config is not supported yet.
40
+ This will create a config file for your project.
41
41
 
42
- Example:
42
+ Supported config formats:
43
43
 
44
- ```js
45
- export default {
46
- srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
47
- jsonDir: [
48
- "./src/i18n/locales/translations.en.json",
49
- "./src/i18n/locales/translations.es.json",
50
- ],
51
- sortJsonFiles: [
52
- "./src/i18n/locales/translations.en.json",
53
- "./src/i18n/locales/translations.es.json",
54
- ],
55
- outputDir: "./unused",
56
- syncMissing: false,
57
- removeUnused: false,
58
- };
44
+ ```txt
45
+ i18n-scanner.config.js
46
+ i18n-scanner.config.cjs
47
+ i18n-scanner.config.mjs
59
48
  ```
60
49
 
61
- ---
62
-
63
- ## Config Options
50
+ > `.ts` config is not supported yet.
64
51
 
65
- ### `srcPaths`
52
+ ---
66
53
 
67
- Array of source file glob patterns to scan.
54
+ ### 3. Run scan
68
55
 
69
- ```js
70
- srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"];
56
+ ```bash
57
+ npx react-i18next-scanner scan
71
58
  ```
72
59
 
73
- ### `jsonDir`
60
+ The scanner will check your source files and translation JSON files.
74
61
 
75
- Array of translation JSON files to analyze.
62
+ Reports will be created inside:
76
63
 
77
- ```js
78
- jsonDir: [
79
- "./src/i18n/locales/translations.en.json",
80
- "./src/i18n/locales/translations.es.json",
81
- ];
64
+ ```txt
65
+ reports/
82
66
  ```
83
67
 
84
- ### `sortJsonFiles`
68
+ ---
85
69
 
86
- Optional array of JSON files for the `sort` command.
70
+ ## Example Config
87
71
 
88
72
  ```js
89
- sortJsonFiles: [
90
- "./src/i18n/locales/translations.en.json",
91
- "./src/i18n/locales/translations.es.json",
92
- ];
93
- ```
73
+ export default {
74
+ srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
94
75
 
95
- ### `outputDir`
76
+ jsonDir: [
77
+ "./src/i18n/locales/translations.en.json",
78
+ "./src/i18n/locales/translations.es.json",
79
+ ],
96
80
 
97
- Directory where unused translation reports will be written.
81
+ sortJsonFiles: [
82
+ "./src/i18n/locales/translations.en.json",
83
+ "./src/i18n/locales/translations.es.json",
84
+ ],
98
85
 
99
- Default:
86
+ outputDir: "./reports",
100
87
 
101
- ```js
102
- "./unused-translations";
88
+ syncMissing: false,
89
+ removeUnused: false,
90
+ sortJson: true,
91
+ };
103
92
  ```
104
93
 
105
- ### `syncMissing`
106
-
107
- If `true`, missing keys found in code will be added to translation JSON files.
108
-
109
- ### `removeUnused`
110
-
111
- If `true`, unused keys will be removed from translation JSON files.
112
-
113
94
  ---
114
95
 
115
96
  ## Commands
116
97
 
117
- ### Initialize config
98
+ ### `init`
99
+
100
+ Creates config file automatically.
118
101
 
119
102
  ```bash
120
103
  npx react-i18next-scanner init
121
104
  ```
122
105
 
123
- This command creates a config file automatically and lets you choose:
124
-
125
- - `.js`
126
- - `.mjs`
127
- - `.cjs`
128
-
129
106
  ---
130
107
 
131
- ### Scan project
108
+ ### `scan`
109
+
110
+ Scans your project and creates reports.
132
111
 
133
112
  ```bash
134
113
  npx react-i18next-scanner scan
135
114
  ```
136
115
 
137
- or with custom config:
116
+ With custom config:
138
117
 
139
118
  ```bash
140
119
  npx react-i18next-scanner scan --config ./i18n-scanner.config.mjs
141
120
  ```
142
121
 
143
- What it does:
122
+ The `scan` command:
144
123
 
145
124
  - scans source files
146
- - extracts translation keys
147
- - compares them with translation JSON files
148
- - writes unused translation reports
149
- - writes dynamic translation usage reports
125
+ - extracts used translation keys
126
+ - compares used keys with translation JSON files
127
+ - detects missing keys
128
+ - detects unused keys
129
+ - detects dynamic translation usages
130
+ - writes reports
150
131
  - optionally syncs missing keys
151
- - optionally removes unused keys
132
+ - optionally removes definitely unused keys
152
133
 
153
134
  ---
154
135
 
155
- ### Sync missing translations
136
+ ### `sync-missing`
137
+
138
+ Finds missing keys and adds them to translation JSON files.
156
139
 
157
140
  ```bash
158
141
  npx react-i18next-scanner sync-missing
159
142
  ```
160
143
 
161
- This command:
144
+ Missing keys are added with empty values.
145
+
146
+ Example:
162
147
 
163
- - scans your source files
164
- - finds missing translation keys
165
- - adds them to your translation JSON files
148
+ ```json
149
+ {
150
+ "common": {
151
+ "Save": ""
152
+ }
153
+ }
154
+ ```
166
155
 
167
156
  ---
168
157
 
169
- ### Sort translation JSON files
158
+ ### `sort`
159
+
160
+ Sorts translation JSON files alphabetically.
170
161
 
171
162
  ```bash
172
163
  npx react-i18next-scanner sort
173
164
  ```
174
165
 
175
- If no files are passed manually, the command uses `sortJsonFiles` from config.
166
+ If no files are passed, the command uses `sortJsonFiles` from config.
176
167
 
177
168
  You can also pass files manually:
178
169
 
@@ -182,62 +173,387 @@ npx react-i18next-scanner sort ./src/i18n/locales/translations.en.json ./src/i18
182
173
 
183
174
  ---
184
175
 
176
+ ## Report Structure
177
+
178
+ All reports are written inside one root folder:
179
+
180
+ ```txt
181
+ reports/
182
+ ```
183
+
184
+ Each report type has its own folder:
185
+
186
+ ```txt
187
+ reports/
188
+ unused-translations/
189
+ unused.translations.en.json
190
+ unused.translations.es.json
191
+
192
+ dynamic-translations/
193
+ dynamic-translation-usages.json
194
+
195
+ possibly-dynamically-used/
196
+ possibly-dynamically-used.json
197
+
198
+ imported-object-usages/
199
+ resolved-imported-object-usages.json
200
+ ```
201
+
202
+ ---
203
+
185
204
  ## Unused Translation Reports
186
205
 
187
- When unused keys are found, the scanner creates report files inside `outputDir`.
206
+ Folder:
207
+
208
+ ```txt
209
+ reports/unused-translations/
210
+ ```
211
+
212
+ These reports contain translation keys that look unused after deep verification.
188
213
 
189
214
  Example:
190
215
 
191
- - `translations.en.json` → `unused.translations.en.json`
192
- - `translations.es.json` → `unused.translations.es.json`
216
+ ```txt
217
+ reports/unused-translations/unused.translations.en.json
218
+ ```
193
219
 
194
- These files contain only unused translations, so you can review them safely before deleting anything.
220
+ Example content:
221
+
222
+ ```json
223
+ {
224
+ "common": {
225
+ "Old button text": "Old button text"
226
+ }
227
+ }
228
+ ```
229
+
230
+ You can review this file before removing keys.
231
+
232
+ If `removeUnused: true`, only definitely unused keys will be removed.
195
233
 
196
234
  ---
197
235
 
198
236
  ## Dynamic Translation Report
199
237
 
200
- If the scanner finds dynamic translation usage such as:
238
+ Folder:
201
239
 
202
- ```ts
203
- t(getDescription());
204
- t(labelKey);
205
- t(transaction.descriptionKey);
240
+ ```txt
241
+ reports/dynamic-translations/
206
242
  ```
207
243
 
208
- it creates a report file:
244
+ This report shows places where the scanner found dynamic translation usage.
209
245
 
210
- ```bash
211
- reports-dynamic/dynamic-translation-usages.json
246
+ Examples:
247
+
248
+ ```ts
249
+ t(labelKey);
250
+ t(getDescription());
251
+ t(`${section}.${name}`);
252
+ <Trans i18nKey={text} />;
253
+ <Trans i18nKey={`account.${text}`} />;
212
254
  ```
213
255
 
214
- Example structure:
256
+ Example report:
215
257
 
216
258
  ```json
217
259
  {
218
- "src/views/Account/BalancesTransactionsPage/PaymentDescription.tsx": [
260
+ "src/components/Profile.tsx": [
219
261
  {
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."
262
+ "namespaces": ["common"],
263
+ "expression": "labelKey",
264
+ "reason": "Non-literal translation key",
265
+ "suggestion": "Translation key may come from a variable or helper function. Manual or deeper analysis may be required."
224
266
  }
225
267
  ]
226
268
  }
227
269
  ```
228
270
 
271
+ This report answers:
272
+
273
+ ```txt
274
+ Where do we use dynamic translation keys in source code?
275
+ ```
276
+
277
+ ---
278
+
279
+ ## Possibly Dynamically Used Report
280
+
281
+ Folder:
282
+
283
+ ```txt
284
+ reports/possibly-dynamically-used/
285
+ ```
286
+
287
+ This report shows keys that looked unused at first, but were later detected as possibly used dynamically.
288
+
289
+ Example:
290
+
291
+ ```ts
292
+ const titleKey = MODAL_DATA[type].TITLE;
293
+
294
+ t(String(titleKey));
295
+ ```
296
+
297
+ The key may not appear directly as:
298
+
299
+ ```ts
300
+ t("accountInformation.Account Number");
301
+ ```
302
+
303
+ But the scanner can understand that it may come from an object config.
304
+
305
+ This report answers:
306
+
307
+ ```txt
308
+ Which unused keys were saved from removal because they may be used dynamically?
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Imported Object Usages Report
314
+
315
+ Folder:
316
+
317
+ ```txt
318
+ reports/imported-object-usages/
319
+ ```
320
+
321
+ File:
322
+
323
+ ```txt
324
+ resolved-imported-object-usages.json
325
+ ```
326
+
327
+ This report shows where translation keys from imported object configs are used.
328
+
329
+ Example config object:
330
+
331
+ ```ts
332
+ export const MODAL_DATA = {
333
+ accountNumber: {
334
+ TITLE: "accountInformation.Account Number",
335
+ TEXT: "accountInformation.Your Account Number is",
336
+ },
337
+ };
338
+ ```
339
+
340
+ Example usage:
341
+
342
+ ```ts
343
+ import { MODAL_DATA } from "./modalData";
344
+
345
+ const additionalInformationData = MODAL_DATA[additionalInformationModalType];
346
+
347
+ t(String(additionalInformationData.TITLE));
348
+ ```
349
+
350
+ The scanner can resolve this as:
351
+
352
+ ```txt
353
+ additionalInformationData.TITLE
354
+ → MODAL_DATA[*].TITLE
355
+ → accountInformation.Account Number
356
+ ```
357
+
358
+ Example report:
359
+
360
+ ```json
361
+ [
362
+ {
363
+ "key": "accountInformation.Account Number",
364
+ "sourceFilePath": "src/components/AdditionalInformationModal.tsx",
365
+ "importedFilePath": "src/config/modalData.ts",
366
+ "importedObjectName": "MODAL_DATA",
367
+ "accessPath": "MODAL_DATA.*.TITLE",
368
+ "usageKind": "t"
369
+ }
370
+ ]
371
+ ```
372
+
373
+ This report answers:
374
+
375
+ ```txt
376
+ Where is this imported object translation key used?
377
+ ```
378
+
229
379
  ---
230
380
 
231
381
  ## Supported Translation Patterns
232
382
 
233
- The scanner currently detects translation keys used with:
383
+ The scanner detects direct translation keys:
384
+
385
+ ```ts
386
+ t("common.Save");
387
+ i18n.t("common.Save");
388
+ <Trans i18nKey="common.Save" />;
389
+ ```
390
+
391
+ It also supports namespaces:
392
+
393
+ ```ts
394
+ const { t } = useTranslation("common");
395
+
396
+ t("Save");
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Supported Dynamic Patterns
402
+
403
+ The scanner can detect and report dynamic usages:
404
+
405
+ ```ts
406
+ t(labelKey);
407
+ t(getTitleKey());
408
+ t(`${section}.${name}`);
409
+ <Trans i18nKey={labelKey} />;
410
+ <Trans i18nKey={`account.${name}`} />;
411
+ ```
412
+
413
+ It can also resolve some imported object config usages:
414
+
415
+ ```ts
416
+ t(String(MODAL_DATA[type].TITLE));
417
+ ```
418
+
419
+ ```ts
420
+ const data = MODAL_DATA[type];
421
+
422
+ t(String(data.TITLE));
423
+ ```
424
+
425
+ ```tsx
426
+ <Trans i18nKey={MODAL_DATA[type].TITLE} />
427
+ ```
428
+
429
+ This works not only for `MODAL_DATA`, but also for similar exported object configs:
430
+
431
+ ```ts
432
+ export const WARNING_CONFIG = {
433
+ deleteAccount: {
434
+ TITLE: "settings.Delete account",
435
+ TEXT: "settings.This action cannot be undone",
436
+ },
437
+ };
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Config Options
443
+
444
+ ### `srcPaths`
445
+
446
+ Source files to scan.
447
+
448
+ ```js
449
+ srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"];
450
+ ```
451
+
452
+ ---
453
+
454
+ ### `jsonDir`
455
+
456
+ Translation JSON files to analyze.
457
+
458
+ ```js
459
+ jsonDir: [
460
+ "./src/i18n/locales/translations.en.json",
461
+ "./src/i18n/locales/translations.es.json",
462
+ ];
463
+ ```
464
+
465
+ ---
466
+
467
+ ### `sortJsonFiles`
468
+
469
+ JSON files used by the `sort` command.
470
+
471
+ ```js
472
+ sortJsonFiles: [
473
+ "./src/i18n/locales/translations.en.json",
474
+ "./src/i18n/locales/translations.es.json",
475
+ ];
476
+ ```
477
+
478
+ ---
479
+
480
+ ### `outputDir`
481
+
482
+ Root folder for scanner reports.
483
+
484
+ Default:
485
+
486
+ ```js
487
+ outputDir: "./reports";
488
+ ```
489
+
490
+ Reports will be grouped into subfolders:
491
+
492
+ ```txt
493
+ reports/
494
+ unused-translations/
495
+ dynamic-translations/
496
+ possibly-dynamically-used/
497
+ imported-object-usages/
498
+ ```
499
+
500
+ ---
501
+
502
+ ### `syncMissing`
503
+
504
+ If `true`, missing keys found in code will be added to translation JSON files.
505
+
506
+ ```js
507
+ syncMissing: true;
508
+ ```
509
+
510
+ Values are created as empty strings.
511
+
512
+ ---
513
+
514
+ ### `removeUnused`
515
+
516
+ If `true`, definitely unused keys will be removed from translation JSON files.
517
+
518
+ ```js
519
+ removeUnused: true;
520
+ ```
521
+
522
+ The scanner does not remove keys that are possibly dynamically used.
523
+
524
+ ---
525
+
526
+ ### `sortJson`
527
+
528
+ If `true`, JSON files will be sorted after sync or remove actions.
529
+
530
+ ```js
531
+ sortJson: true;
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Safe Remove Behavior
234
537
 
235
- - `t("key")`
236
- - `i18n.t("key")`
237
- - `<Trans i18nKey="key" />`
238
- - `useTranslation("namespace")`
538
+ The scanner tries to avoid removing keys that may still be used dynamically.
239
539
 
240
- It can also report dynamic usages where translation keys are not passed directly as string literals.
540
+ For example:
541
+
542
+ ```ts
543
+ const data = MODAL_DATA[type];
544
+
545
+ t(String(data.TITLE));
546
+ ```
547
+
548
+ If `MODAL_DATA[type].TITLE` contains:
549
+
550
+ ```ts
551
+ "accountInformation.Account Number";
552
+ ```
553
+
554
+ then this key will not be treated as definitely unused.
555
+
556
+ It will be moved to a dynamic safety report instead.
241
557
 
242
558
  ---
243
559
 
@@ -245,20 +561,20 @@ It can also report dynamic usages where translation keys are not passed directly
245
561
 
246
562
  ### Source files
247
563
 
248
- The scanner can scan:
249
-
250
- - `.js`
251
- - `.jsx`
252
- - `.ts`
253
- - `.tsx`
564
+ ```txt
565
+ .js
566
+ .jsx
567
+ .ts
568
+ .tsx
569
+ ```
254
570
 
255
571
  ### Config files
256
572
 
257
- The scanner currently supports:
258
-
259
- - `.js`
260
- - `.cjs`
261
- - `.mjs`
573
+ ```txt
574
+ .js
575
+ .cjs
576
+ .mjs
577
+ ```
262
578
 
263
579
  > `.ts` config is not supported yet.
264
580
 
@@ -266,12 +582,15 @@ The scanner currently supports:
266
582
 
267
583
  ## Example Workflow
268
584
 
269
- ### 1. Scan without modifying JSON files
585
+ ### 1. Scan only
586
+
587
+ Use this when you want reports without modifying translation JSON files.
270
588
 
271
589
  ```js
272
590
  export default {
273
591
  srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
274
592
  jsonDir: ["./src/i18n/locales/translations.en.json"],
593
+ outputDir: "./reports",
275
594
  syncMissing: false,
276
595
  removeUnused: false,
277
596
  };
@@ -283,12 +602,15 @@ Run:
283
602
  npx react-i18next-scanner scan
284
603
  ```
285
604
 
605
+ ---
606
+
286
607
  ### 2. Sync missing keys
287
608
 
288
609
  ```js
289
610
  export default {
290
611
  srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
291
612
  jsonDir: ["./src/i18n/locales/translations.en.json"],
613
+ outputDir: "./reports",
292
614
  syncMissing: true,
293
615
  removeUnused: false,
294
616
  };
@@ -300,18 +622,21 @@ Run:
300
622
  npx react-i18next-scanner scan
301
623
  ```
302
624
 
303
- or
625
+ or:
304
626
 
305
627
  ```bash
306
628
  npx react-i18next-scanner sync-missing
307
629
  ```
308
630
 
309
- ### 3. Remove unused keys
631
+ ---
632
+
633
+ ### 3. Remove definitely unused keys
310
634
 
311
635
  ```js
312
636
  export default {
313
637
  srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
314
638
  jsonDir: ["./src/i18n/locales/translations.en.json"],
639
+ outputDir: "./reports",
315
640
  syncMissing: false,
316
641
  removeUnused: true,
317
642
  };
@@ -323,14 +648,46 @@ Run:
323
648
  npx react-i18next-scanner scan
324
649
  ```
325
650
 
651
+ Before using `removeUnused: true`, review reports first.
652
+
653
+ ---
654
+
655
+ ## Limitations
656
+
657
+ The scanner does not fully support every possible runtime data-flow case.
658
+
659
+ Currently not fully covered:
660
+
661
+ ```ts
662
+ const data = getModalData(type);
663
+ t(data.TITLE);
664
+ ```
665
+
666
+ ```ts
667
+ const key = MODAL_DATA[type].TITLE + ".extra";
668
+ t(key);
669
+ ```
670
+
671
+ ```ts
672
+ const data = someWrapper(MODAL_DATA[type]);
673
+ t(data.TITLE);
674
+ ```
675
+
676
+ ```ts
677
+ export default MODAL_DATA;
678
+ ```
679
+
680
+ The scanner is designed to be safe. If it is not sure, it reports dynamic usage instead of removing keys automatically.
681
+
326
682
  ---
327
683
 
328
684
  ## Notes
329
685
 
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.
686
+ - `syncMissing` does not translate values automatically.
687
+ - `removeUnused` should be used carefully.
688
+ - Possibly dynamic keys are not removed automatically.
333
689
  - Config paths are resolved relative to the config file location.
690
+ - `.ts` config is not supported yet.
334
691
 
335
692
  ---
336
693
 
@@ -342,7 +699,7 @@ Build the project:
342
699
  yarn build
343
700
  ```
344
701
 
345
- Run CLI in development mode:
702
+ Run scanner in development:
346
703
 
347
704
  ```bash
348
705
  yarn dev scan
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { sortObject } from "../utils/sortObject.js";
4
- const DEFAULT_DYNAMIC_REPORT_DIR = path.resolve(process.cwd(), "reports-dynamic");
4
+ const DEFAULT_DYNAMIC_REPORT_DIR = path.resolve(process.cwd(), "reports/dynamic-translations");
5
5
  export async function writeDynamicTranslationReport(report, outputDir = DEFAULT_DYNAMIC_REPORT_DIR) {
6
6
  if (Object.keys(report).length === 0) {
7
7
  return;
@@ -3,7 +3,7 @@ import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import { addKey } from "../utils/addKey.js";
5
5
  import { sortObject } from "../utils/sortObject.js";
6
- const DEFAULT_OUTPUT_DIR = path.resolve(process.cwd(), "reports-dynamic");
6
+ const DEFAULT_OUTPUT_DIR = path.resolve(process.cwd(), "dynamic-translations");
7
7
  export async function writePossiblyDynamicallyUsedReport(keysByFile, jsonFiles, outputDir = DEFAULT_OUTPUT_DIR) {
8
8
  const reportDataByFile = {};
9
9
  for (const file of jsonFiles) {
@@ -0,0 +1,13 @@
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 rootReportDir = outputDir ?? "reports";
9
+ const reportDir = path.join(rootReportDir, "imported-object-usages");
10
+ await fs.mkdir(reportDir, { recursive: true });
11
+ const reportPath = path.join(reportDir, "resolved-imported-object-usages.json");
12
+ await fs.writeFile(reportPath, JSON.stringify(usages, null, 2), "utf-8");
13
+ }
@@ -10,7 +10,9 @@ export async function writeUnusedTranslationsReport(keysByFile, jsonFiles, outpu
10
10
  if (filesWithUnused.length === 0) {
11
11
  return;
12
12
  }
13
- await fs.mkdir(outputDir, { recursive: true });
13
+ const rootReportDir = outputDir ?? "reports";
14
+ const reportDir = path.join(rootReportDir, "unused-translations");
15
+ await fs.mkdir(reportDir, { recursive: true });
14
16
  for (const file of filesWithUnused) {
15
17
  const unusedKeys = keysByFile[file.filePath] ?? [];
16
18
  const reportData = {};
@@ -22,7 +24,7 @@ export async function writeUnusedTranslationsReport(keysByFile, jsonFiles, outpu
22
24
  }
23
25
  const sorted = sortObject(reportData);
24
26
  const reportFileName = `unused.${path.basename(file.filePath)}`;
25
- const reportPath = path.join(outputDir, reportFileName);
27
+ const reportPath = path.join(reportDir, reportFileName);
26
28
  await fs.writeFile(reportPath, JSON.stringify(sorted, null, 2), "utf-8");
27
29
  console.log(`📝 Unused report created: ${reportPath}`);
28
30
  }
@@ -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.8",
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": {