react-i18next-scanner 0.1.0
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 +291 -0
- package/dist/cli/cli.js +99 -0
- package/dist/config/findConfig.js +17 -0
- package/dist/config/loadConfig.js +57 -0
- package/dist/core/actions/removeUnusedTranslations.js +28 -0
- package/dist/core/actions/syncMissingTranslations.js +32 -0
- package/dist/core/actions/writeUnusedTranslationsReport.js +47 -0
- package/dist/core/analysis/analyzer.js +27 -0
- package/dist/core/analysis/extractKeysFromFiles.js +30 -0
- package/dist/core/io/getFiles.js +9 -0
- package/dist/core/io/jsonLoader.js +31 -0
- package/dist/core/runSyncMissing.js +14 -0
- package/dist/core/scanner.js +21 -0
- package/dist/core/sortJsonFiles.js +30 -0
- package/dist/core/types.js +2 -0
- package/dist/core/utils/addKey.js +22 -0
- package/dist/core/utils/isValidTranslationKey.js +7 -0
- package/dist/core/utils/removeKey.js +28 -0
- package/dist/core/utils/sortObject.js +22 -0
- package/dist/extractor/keyExtractor.js +18 -0
- package/dist/extractor/regexExtractor.js +43 -0
- package/dist/extractor/shouldScan.js +6 -0
- package/dist/extractor/ultraFast.js +31 -0
- package/dist/reporters/consoleReporter.js +8 -0
- package/dist/reporters/writeUnusedKeysReport.js +43 -0
- package/dist/utils/translationUtils.js +32 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# react-i18next-scanner
|
|
2
|
+
|
|
3
|
+
CLI tool to scan React projects for missing and unused `react-i18next` translation keys.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
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
|
+
- Works with `.js`, `.jsx`, `.ts`, `.tsx`
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -D react-i18next-scanner
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
or
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
yarn add -D react-i18next-scanner
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Config
|
|
33
|
+
|
|
34
|
+
Create one of these config files in your project root:
|
|
35
|
+
|
|
36
|
+
- `i18n-scanner.config.js`
|
|
37
|
+
- `i18n-scanner.config.cjs`
|
|
38
|
+
- `i18n-scanner.config.mjs`
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
export default {
|
|
44
|
+
srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
|
|
45
|
+
jsonDir: [
|
|
46
|
+
"./src/i18n/locales/translations.en.json",
|
|
47
|
+
"./src/i18n/locales/translations.es.json",
|
|
48
|
+
],
|
|
49
|
+
sortJsonFiles: [
|
|
50
|
+
"./src/i18n/locales/translations.en.json",
|
|
51
|
+
"./src/i18n/locales/translations.es.json",
|
|
52
|
+
],
|
|
53
|
+
outputDir: "./unused",
|
|
54
|
+
syncMissing: false,
|
|
55
|
+
removeUnused: false,
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Config Options
|
|
62
|
+
|
|
63
|
+
### `srcPaths`
|
|
64
|
+
|
|
65
|
+
Array of source file glob patterns to scan.
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"];
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `jsonDir`
|
|
72
|
+
|
|
73
|
+
Array of translation JSON files to analyze.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
jsonDir: [
|
|
77
|
+
"./src/i18n/locales/translations.en.json",
|
|
78
|
+
"./src/i18n/locales/translations.es.json",
|
|
79
|
+
];
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `sortJsonFiles`
|
|
83
|
+
|
|
84
|
+
Optional array of JSON files for the `sort` command.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
sortJsonFiles: [
|
|
88
|
+
"./src/i18n/locales/translations.en.json",
|
|
89
|
+
"./src/i18n/locales/translations.es.json",
|
|
90
|
+
];
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `outputDir`
|
|
94
|
+
|
|
95
|
+
Directory where unused translation reports will be written.
|
|
96
|
+
|
|
97
|
+
Default:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
"./unused-translations";
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `syncMissing`
|
|
104
|
+
|
|
105
|
+
If `true`, missing keys found in code will be added to translation JSON files.
|
|
106
|
+
|
|
107
|
+
### `removeUnused`
|
|
108
|
+
|
|
109
|
+
If `true`, unused keys will be removed from translation JSON files.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
### Scan project
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx react-i18next-scanner scan
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
or with custom config:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx react-i18next-scanner scan --config ./i18n-scanner.config.mjs
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
What it does:
|
|
128
|
+
|
|
129
|
+
- scans source files
|
|
130
|
+
- extracts translation keys
|
|
131
|
+
- compares them with translation JSON files
|
|
132
|
+
- writes unused translation reports
|
|
133
|
+
- optionally syncs missing keys
|
|
134
|
+
- optionally removes unused keys
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### Sync missing translations
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npx react-i18next-scanner sync-missing
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This command:
|
|
145
|
+
|
|
146
|
+
- scans your source files
|
|
147
|
+
- finds missing translation keys
|
|
148
|
+
- adds them to your translation JSON files
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### Sort translation JSON files
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npx react-i18next-scanner sort
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
If no files are passed manually, the command uses `sortJsonFiles` from config.
|
|
159
|
+
|
|
160
|
+
You can also pass files manually:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npx react-i18next-scanner sort ./src/i18n/locales/translations.en.json ./src/i18n/locales/translations.es.json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Unused Translation Reports
|
|
169
|
+
|
|
170
|
+
When unused keys are found, the scanner creates report files inside `outputDir`.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
|
|
174
|
+
- `translations.en.json` → `unused.translations.en.json`
|
|
175
|
+
- `translations.es.json` → `unused.translations.es.json`
|
|
176
|
+
|
|
177
|
+
These files contain only unused translations, so you can review them safely before deleting anything.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Supported Patterns
|
|
182
|
+
|
|
183
|
+
The scanner currently detects translation keys used with:
|
|
184
|
+
|
|
185
|
+
- `t("key")`
|
|
186
|
+
- `i18n.t("key")`
|
|
187
|
+
- `<Trans i18nKey="key" />`
|
|
188
|
+
- `useTranslation("namespace")`
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Example Workflow
|
|
193
|
+
|
|
194
|
+
### 1. Scan without modifying JSON files
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
export default {
|
|
198
|
+
srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
|
|
199
|
+
jsonDir: ["./src/i18n/locales/translations.en.json"],
|
|
200
|
+
syncMissing: false,
|
|
201
|
+
removeUnused: false,
|
|
202
|
+
};
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Run:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npx react-i18next-scanner scan
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 2. Sync missing keys
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
export default {
|
|
215
|
+
srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
|
|
216
|
+
jsonDir: ["./src/i18n/locales/translations.en.json"],
|
|
217
|
+
syncMissing: true,
|
|
218
|
+
removeUnused: false,
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Run:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npx react-i18next-scanner scan
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
or
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npx react-i18next-scanner sync-missing
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 3. Remove unused keys
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
export default {
|
|
238
|
+
srcPaths: ["./src/**/*.{js,jsx,ts,tsx}"],
|
|
239
|
+
jsonDir: ["./src/i18n/locales/translations.en.json"],
|
|
240
|
+
syncMissing: false,
|
|
241
|
+
removeUnused: true,
|
|
242
|
+
};
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Run:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx react-i18next-scanner scan
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Notes
|
|
254
|
+
|
|
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.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
Build the project:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
yarn build
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Run CLI in development mode:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
yarn dev scan
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Sort JSON files:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
yarn sort
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Sync missing keys:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
yarn sync
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
ISC
|
package/dist/cli/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { runScanner } from "../core/scanner.js";
|
|
5
|
+
import { sortJsonFiles } from "../core/sortJsonFiles.js";
|
|
6
|
+
import { runSyncMissing } from "../core/runSyncMissing.js";
|
|
7
|
+
import { loadConfig } from "../config/loadConfig.js";
|
|
8
|
+
import { findDefaultConfig, POSSIBLE_CONFIGS } from "../config/findConfig.js";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
function resolveConfigPath(configPath) {
|
|
11
|
+
let finalConfigPath = configPath;
|
|
12
|
+
if (!finalConfigPath) {
|
|
13
|
+
finalConfigPath = findDefaultConfig() ?? undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!finalConfigPath) {
|
|
16
|
+
throw new Error(`Config file not found. Create one of: ${POSSIBLE_CONFIGS.join(", ")}`);
|
|
17
|
+
}
|
|
18
|
+
return finalConfigPath;
|
|
19
|
+
}
|
|
20
|
+
async function runSyncMissingCommand(opts) {
|
|
21
|
+
const config = await getConfig(opts);
|
|
22
|
+
console.log(chalk.cyan("\nSyncing missing translations..."));
|
|
23
|
+
await runSyncMissing(config);
|
|
24
|
+
console.log(chalk.green("Missing translations synced successfully!"));
|
|
25
|
+
}
|
|
26
|
+
async function getConfig(opts) {
|
|
27
|
+
const configPath = resolveConfigPath(opts.config);
|
|
28
|
+
return await loadConfig(configPath);
|
|
29
|
+
}
|
|
30
|
+
async function runScan(opts) {
|
|
31
|
+
const config = await getConfig(opts);
|
|
32
|
+
console.log(chalk.cyan("\nScanning project..."));
|
|
33
|
+
await runScanner(config);
|
|
34
|
+
console.log(chalk.green("Scan complete!"));
|
|
35
|
+
}
|
|
36
|
+
async function runSort(files, opts) {
|
|
37
|
+
let filesToSort = files;
|
|
38
|
+
if (filesToSort.length === 0) {
|
|
39
|
+
const config = await getConfig(opts);
|
|
40
|
+
if (!Array.isArray(config.sortJsonFiles) ||
|
|
41
|
+
config.sortJsonFiles.length === 0) {
|
|
42
|
+
throw new Error('No files provided. Pass JSON files manually or add "sortJsonFiles" to your config.');
|
|
43
|
+
}
|
|
44
|
+
filesToSort = config.sortJsonFiles;
|
|
45
|
+
}
|
|
46
|
+
console.log(chalk.cyan("\nSorting JSON files..."));
|
|
47
|
+
await sortJsonFiles(filesToSort);
|
|
48
|
+
console.log(chalk.green("All JSON files sorted successfully!"));
|
|
49
|
+
}
|
|
50
|
+
function handleCliError(error) {
|
|
51
|
+
if (error instanceof Error) {
|
|
52
|
+
console.error(chalk.red("Error:"), error.message);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.error(chalk.red("Error:"), String(error));
|
|
56
|
+
}
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
program
|
|
60
|
+
.name("react-i18next-scanner")
|
|
61
|
+
.description("Scan your project for missing and unused i18next translations")
|
|
62
|
+
.version("1.0.0");
|
|
63
|
+
program
|
|
64
|
+
.command("scan")
|
|
65
|
+
.description("Scan project translations")
|
|
66
|
+
.option("-c, --config <path>", "Path to config file")
|
|
67
|
+
.action(async (opts) => {
|
|
68
|
+
try {
|
|
69
|
+
await runScan(opts);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
handleCliError(error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
program
|
|
76
|
+
.command("sync-missing")
|
|
77
|
+
.description("Scan project and add missing translation keys to JSON files")
|
|
78
|
+
.option("-c, --config <path>", "Path to config file")
|
|
79
|
+
.action(async (opts) => {
|
|
80
|
+
try {
|
|
81
|
+
await runSyncMissingCommand(opts);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
handleCliError(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
program
|
|
88
|
+
.command("sort [files...]")
|
|
89
|
+
.description("Sort translation JSON files alphabetically. Uses files from config if none are passed manually.")
|
|
90
|
+
.option("-c, --config <path>", "Path to config file")
|
|
91
|
+
.action(async (files, opts) => {
|
|
92
|
+
try {
|
|
93
|
+
await runSort(files, opts);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
handleCliError(error);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
export const POSSIBLE_CONFIGS = [
|
|
4
|
+
"i18n-scanner.config.js",
|
|
5
|
+
"i18n-scanner.config.cjs",
|
|
6
|
+
"i18n-scanner.config.mjs",
|
|
7
|
+
];
|
|
8
|
+
export const findDefaultConfig = () => {
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
for (const file of POSSIBLE_CONFIGS) {
|
|
11
|
+
const fullPath = path.join(cwd, file);
|
|
12
|
+
if (fs.existsSync(fullPath)) {
|
|
13
|
+
return fullPath;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/config/loadConfig.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
export async function loadConfig(configPath) {
|
|
6
|
+
const resolvedPath = path.resolve(process.cwd(), configPath);
|
|
7
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
8
|
+
throw new Error(`Config file not found: ${resolvedPath}`);
|
|
9
|
+
}
|
|
10
|
+
// if (resolvedPath.endsWith(".ts")) {
|
|
11
|
+
// const tsNode = await import("ts-node");
|
|
12
|
+
// tsNode.register({ transpileOnly: true });
|
|
13
|
+
// }
|
|
14
|
+
const moduleUrl = pathToFileURL(resolvedPath).href;
|
|
15
|
+
const imported = await import(moduleUrl);
|
|
16
|
+
const config = imported.default ?? imported;
|
|
17
|
+
validateConfig(config);
|
|
18
|
+
return normalizeConfig(config, path.dirname(resolvedPath));
|
|
19
|
+
}
|
|
20
|
+
function validateConfig(config) {
|
|
21
|
+
if (!config || typeof config !== "object") {
|
|
22
|
+
throw new Error("Config must export an object.");
|
|
23
|
+
}
|
|
24
|
+
const cfg = config;
|
|
25
|
+
if (!Array.isArray(cfg.jsonDir) ||
|
|
26
|
+
!cfg.jsonDir.every((x) => typeof x === "string")) {
|
|
27
|
+
throw new Error("jsonDir must be an array of strings.");
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(cfg.srcPaths) ||
|
|
30
|
+
!cfg.srcPaths.every((x) => typeof x === "string")) {
|
|
31
|
+
throw new Error("srcPaths must be an array of strings.");
|
|
32
|
+
}
|
|
33
|
+
if (cfg.sortJsonFiles !== undefined &&
|
|
34
|
+
(!Array.isArray(cfg.sortJsonFiles) ||
|
|
35
|
+
!cfg.sortJsonFiles.every((x) => typeof x === "string"))) {
|
|
36
|
+
throw new Error("sortJsonFiles must be an array of strings");
|
|
37
|
+
}
|
|
38
|
+
if (cfg.removeUnused !== undefined && typeof cfg.removeUnused !== "boolean") {
|
|
39
|
+
throw new Error("removeUnused must be a boolean.");
|
|
40
|
+
}
|
|
41
|
+
if (cfg.syncMissing !== undefined && typeof cfg.syncMissing !== "boolean") {
|
|
42
|
+
throw new Error("syncMissing must be a boolean.");
|
|
43
|
+
}
|
|
44
|
+
if (cfg.outputDir !== undefined && typeof cfg.outputDir !== "string") {
|
|
45
|
+
throw new Error("outputDir must be a string.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizeConfig(config, baseDir) {
|
|
49
|
+
return {
|
|
50
|
+
jsonDir: config.jsonDir.map((d) => path.resolve(baseDir, d)),
|
|
51
|
+
srcPaths: config.srcPaths.map((p) => path.resolve(baseDir, p)),
|
|
52
|
+
sortJsonFiles: config.sortJsonFiles?.map((p) => path.resolve(baseDir, p)),
|
|
53
|
+
removeUnused: config.removeUnused ?? false,
|
|
54
|
+
syncMissing: config.syncMissing ?? false,
|
|
55
|
+
outputDir: path.resolve(baseDir, config.outputDir ?? "unused-translations"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { removeKey } from "../utils/removeKey.js";
|
|
3
|
+
import { sortObject } from "../utils/sortObject.js";
|
|
4
|
+
export async function removeUnusedTranslations(result, jsonFiles) {
|
|
5
|
+
const { unusedKeysByFile } = result;
|
|
6
|
+
for (const file of jsonFiles) {
|
|
7
|
+
const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
|
|
8
|
+
if (unusedKeys.length === 0) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
let updated = false;
|
|
12
|
+
for (const key of unusedKeys) {
|
|
13
|
+
if (file.keys.has(key)) {
|
|
14
|
+
removeKey(file.data, key);
|
|
15
|
+
file.keys.delete(key);
|
|
16
|
+
updated = true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (!updated) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const sorted = sortObject(file.data);
|
|
23
|
+
file.data = sorted;
|
|
24
|
+
await fs.writeFile(file.filePath, JSON.stringify(sorted, null, 2), "utf-8");
|
|
25
|
+
console.log(`\n✅ Unused keys removed: ${file.filePath}`);
|
|
26
|
+
console.log(`➖ Removed: ${unusedKeys.length}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { addKey } from "../utils/addKey.js";
|
|
3
|
+
import { sortObject } from "../utils/sortObject.js";
|
|
4
|
+
import { isValidTranslationKey } from "../utils/isValidTranslationKey.js";
|
|
5
|
+
export async function syncMissingTranslations(result, jsonFiles) {
|
|
6
|
+
const { missingKeysByFile } = result;
|
|
7
|
+
for (const file of jsonFiles) {
|
|
8
|
+
const missingKeys = missingKeysByFile[file.filePath] ?? [];
|
|
9
|
+
if (missingKeys.length === 0) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
let updated = false;
|
|
13
|
+
for (const key of missingKeys) {
|
|
14
|
+
if (!isValidTranslationKey(key)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!file.keys.has(key)) {
|
|
18
|
+
addKey(file.data, key);
|
|
19
|
+
file.keys.add(key);
|
|
20
|
+
updated = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!updated) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const sorted = sortObject(file.data);
|
|
27
|
+
file.data = sorted;
|
|
28
|
+
await fs.writeFile(file.filePath, JSON.stringify(sorted, null, 2), "utf-8");
|
|
29
|
+
console.log(`\n✅ Missing keys synced: ${file.filePath}`);
|
|
30
|
+
console.log(`➕ Added: ${missingKeys.length}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { addKey } from "../utils/addKey.js";
|
|
4
|
+
import { sortObject } from "../utils/sortObject.js";
|
|
5
|
+
export async function writeUnusedTranslationsReport(result, jsonFiles, outputDir) {
|
|
6
|
+
const { unusedKeysByFile } = result;
|
|
7
|
+
const filesWithUnused = jsonFiles.filter((file) => {
|
|
8
|
+
const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
|
|
9
|
+
return unusedKeys.length > 0;
|
|
10
|
+
});
|
|
11
|
+
if (filesWithUnused.length === 0) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
15
|
+
for (const file of filesWithUnused) {
|
|
16
|
+
const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
|
|
17
|
+
const reportData = {};
|
|
18
|
+
for (const key of unusedKeys) {
|
|
19
|
+
const value = getValueByPath(file.data, key);
|
|
20
|
+
if (value !== undefined) {
|
|
21
|
+
addKey(reportData, key, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const sorted = sortObject(reportData);
|
|
25
|
+
const reportFileName = getUnusedReportFileName(file.filePath);
|
|
26
|
+
const reportPath = path.join(outputDir, reportFileName);
|
|
27
|
+
await fs.writeFile(reportPath, JSON.stringify(sorted, null, 2), "utf-8");
|
|
28
|
+
console.log(`📝 Unused report created: ${reportPath}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function getUnusedReportFileName(filePath) {
|
|
32
|
+
return `unused.${path.basename(filePath)}`;
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function analyzeKeys(usedKeys, jsonFiles) {
|
|
2
|
+
const missingKeysByFile = {};
|
|
3
|
+
const unusedKeysByFile = {};
|
|
4
|
+
for (const file of jsonFiles) {
|
|
5
|
+
const missingKeys = [];
|
|
6
|
+
const unusedKeys = [];
|
|
7
|
+
for (const key of usedKeys) {
|
|
8
|
+
if (!file.keys.has(key)) {
|
|
9
|
+
missingKeys.push(key);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
for (const key of file.keys) {
|
|
13
|
+
if (!usedKeys.has(key)) {
|
|
14
|
+
unusedKeys.push(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
missingKeysByFile[file.filePath] = missingKeys.sort();
|
|
18
|
+
unusedKeysByFile[file.filePath] = unusedKeys.sort();
|
|
19
|
+
}
|
|
20
|
+
const hasMissingKeys = Object.values(missingKeysByFile).some((keys) => keys.length > 0);
|
|
21
|
+
return {
|
|
22
|
+
usedKeys: [...usedKeys].sort(),
|
|
23
|
+
missingKeysByFile,
|
|
24
|
+
unusedKeysByFile,
|
|
25
|
+
hasErrors: Object.values(missingKeysByFile).some((keys) => keys.length > 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { extractKeys } from "../../extractor/keyExtractor.js";
|
|
3
|
+
export async function extractKeysFromFiles(files) {
|
|
4
|
+
const allKeys = new Set();
|
|
5
|
+
for (const file of files) {
|
|
6
|
+
try {
|
|
7
|
+
const content = await fs.readFile(file, "utf-8");
|
|
8
|
+
const keys = extractKeys(content);
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
allKeys.add(key);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new Error(`Failed to read or process file: ${file}. ${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return allKeys;
|
|
18
|
+
}
|
|
19
|
+
// const results = await Promise.all(
|
|
20
|
+
// files.map(async (file) => {
|
|
21
|
+
// try {
|
|
22
|
+
// const content = await fs.readFile(file, "utf-8");
|
|
23
|
+
// const keys = extractKeys(content);
|
|
24
|
+
// return keys;
|
|
25
|
+
// } catch (error) {
|
|
26
|
+
// console.warn(`Failed to read file: ${file}`);
|
|
27
|
+
// return [];
|
|
28
|
+
// }
|
|
29
|
+
// }),
|
|
30
|
+
// );
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
export async function getFiles(srcPaths) {
|
|
3
|
+
const normalizedPatterns = srcPaths.map((pattern) => pattern.replace(/\\/g, "/"));
|
|
4
|
+
return fg(normalizedPatterns, {
|
|
5
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"],
|
|
6
|
+
onlyFiles: true,
|
|
7
|
+
unique: true,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
export async function loadJsonKeys(filePaths) {
|
|
3
|
+
return Promise.all(filePaths.map(async (file) => {
|
|
4
|
+
const raw = await fs.readFile(file, "utf8");
|
|
5
|
+
let data;
|
|
6
|
+
try {
|
|
7
|
+
data = JSON.parse(raw);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error(`Invalid JSON file: ${file}`);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
filePath: file,
|
|
14
|
+
data,
|
|
15
|
+
keys: new Set(flattenKeys(data)),
|
|
16
|
+
};
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
function flattenKeys(obj, prefix = "", result = []) {
|
|
20
|
+
for (const key of Object.keys(obj)) {
|
|
21
|
+
const value = obj[key];
|
|
22
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
23
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
24
|
+
flattenKeys(value, fullKey, result);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
result.push(fullKey);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getFiles } from "./io/getFiles.js";
|
|
2
|
+
import { extractKeysFromFiles } from "./analysis/extractKeysFromFiles.js";
|
|
3
|
+
import { loadJsonKeys } from "./io/jsonLoader.js";
|
|
4
|
+
import { analyzeKeys } from "./analysis/analyzer.js";
|
|
5
|
+
import { syncMissingTranslations } from "./actions/syncMissingTranslations.js";
|
|
6
|
+
export async function runSyncMissing(config) {
|
|
7
|
+
const files = await getFiles(config.srcPaths);
|
|
8
|
+
console.log(`🔍 Found ${files.length} files`);
|
|
9
|
+
const usedKeys = await extractKeysFromFiles(files);
|
|
10
|
+
const jsonData = await loadJsonKeys(config.jsonDir);
|
|
11
|
+
const result = analyzeKeys(usedKeys, jsonData);
|
|
12
|
+
await syncMissingTranslations(result, jsonData);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { loadJsonKeys } from "./io/jsonLoader.js";
|
|
2
|
+
import { getFiles } from "./io/getFiles.js";
|
|
3
|
+
import { analyzeKeys } from "./analysis/analyzer.js";
|
|
4
|
+
import { extractKeysFromFiles } from "./analysis/extractKeysFromFiles.js";
|
|
5
|
+
import { syncMissingTranslations } from "./actions/syncMissingTranslations.js";
|
|
6
|
+
import { removeUnusedTranslations } from "./actions/removeUnusedTranslations.js";
|
|
7
|
+
import { writeUnusedTranslationsReport } from "./actions/writeUnusedTranslationsReport.js";
|
|
8
|
+
export async function runScanner(config) {
|
|
9
|
+
const files = await getFiles(config.srcPaths);
|
|
10
|
+
const usedKeys = await extractKeysFromFiles(files);
|
|
11
|
+
const jsonData = await loadJsonKeys(config.jsonDir);
|
|
12
|
+
const result = analyzeKeys(usedKeys, jsonData);
|
|
13
|
+
await writeUnusedTranslationsReport(result, jsonData, config.outputDir);
|
|
14
|
+
if (config.syncMissing) {
|
|
15
|
+
await syncMissingTranslations(result, jsonData);
|
|
16
|
+
}
|
|
17
|
+
if (config.removeUnused) {
|
|
18
|
+
await removeUnusedTranslations(result, jsonData);
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { sortObject } from "./utils/sortObject.js";
|
|
4
|
+
export async function sortJsonFile(filePath) {
|
|
5
|
+
const resolvedPath = path.resolve(process.cwd(), filePath);
|
|
6
|
+
if (!resolvedPath.endsWith(".json")) {
|
|
7
|
+
throw new Error(`Only .json files are supported: ${resolvedPath}`);
|
|
8
|
+
}
|
|
9
|
+
let content;
|
|
10
|
+
try {
|
|
11
|
+
content = await fs.readFile(resolvedPath, "utf-8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`JSON file not found: ${resolvedPath}`);
|
|
15
|
+
}
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(content);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`Invalid JSON file: ${resolvedPath}`);
|
|
22
|
+
}
|
|
23
|
+
const sorted = sortObject(parsed);
|
|
24
|
+
await fs.writeFile(resolvedPath, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
|
|
25
|
+
}
|
|
26
|
+
export async function sortJsonFiles(filePaths) {
|
|
27
|
+
for (const filePath of filePaths) {
|
|
28
|
+
await sortJsonFile(filePath);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function addKey(obj, keyPath, value = "") {
|
|
2
|
+
const keys = keyPath.split(".");
|
|
3
|
+
let current = obj;
|
|
4
|
+
for (let i = 0; i < keys.length; i++) {
|
|
5
|
+
const key = keys[i];
|
|
6
|
+
if (i === keys.length - 1) {
|
|
7
|
+
if (!(key in current)) {
|
|
8
|
+
current[key] = value;
|
|
9
|
+
}
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (!(key in current)) {
|
|
13
|
+
current[key] = {};
|
|
14
|
+
}
|
|
15
|
+
else if (typeof current[key] !== "object" ||
|
|
16
|
+
current[key] === null ||
|
|
17
|
+
Array.isArray(current[key])) {
|
|
18
|
+
throw new Error(`Cannot create nested key ${keyPath} because ${keys.slice(0, i + 1).join(".")} is not an object`);
|
|
19
|
+
}
|
|
20
|
+
current = current[key];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function removeKey(obj, keyPath) {
|
|
2
|
+
const parts = keyPath.split(".");
|
|
3
|
+
const stack = [];
|
|
4
|
+
let current = obj;
|
|
5
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
6
|
+
const part = parts[i];
|
|
7
|
+
const next = current[part];
|
|
8
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
stack.push({ parent: current, key: part });
|
|
12
|
+
current = next;
|
|
13
|
+
}
|
|
14
|
+
delete current[parts[parts.length - 1]];
|
|
15
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
16
|
+
const { parent, key } = stack[i];
|
|
17
|
+
const value = parent[key];
|
|
18
|
+
if (typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
!Array.isArray(value) &&
|
|
21
|
+
Object.keys(value).length === 0) {
|
|
22
|
+
delete parent[key];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function sortObject(obj) {
|
|
2
|
+
if (Array.isArray(obj)) {
|
|
3
|
+
return obj.map((item) => sortObject(item));
|
|
4
|
+
}
|
|
5
|
+
if (typeof obj !== "object" || obj === null) {
|
|
6
|
+
return obj;
|
|
7
|
+
}
|
|
8
|
+
return Object.keys(obj)
|
|
9
|
+
.sort((a, b) => {
|
|
10
|
+
const insensitiveCompare = a.localeCompare(b, undefined, {
|
|
11
|
+
sensitivity: "base",
|
|
12
|
+
});
|
|
13
|
+
if (insensitiveCompare !== 0) {
|
|
14
|
+
return insensitiveCompare;
|
|
15
|
+
}
|
|
16
|
+
return a.localeCompare(b);
|
|
17
|
+
})
|
|
18
|
+
.reduce((acc, key) => {
|
|
19
|
+
acc[key] = sortObject(obj[key]);
|
|
20
|
+
return acc;
|
|
21
|
+
}, {});
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { shouldScan } from "./shouldScan.js";
|
|
2
|
+
import { extractKeysUltraFast } from "./ultraFast.js";
|
|
3
|
+
import { extractKeysWithRegex } from "./regexExtractor.js";
|
|
4
|
+
// import { extractWithAST } from "./astExtractor";
|
|
5
|
+
export function extractKeys(content) {
|
|
6
|
+
if (!shouldScan(content)) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const fastKeys = extractKeysUltraFast(content);
|
|
10
|
+
const needsDeepScan = content.includes("useTranslation") ||
|
|
11
|
+
content.includes("<Trans") ||
|
|
12
|
+
content.includes("i18n.t");
|
|
13
|
+
if (!needsDeepScan) {
|
|
14
|
+
return fastKeys;
|
|
15
|
+
}
|
|
16
|
+
const regexKeys = extractKeysWithRegex(content);
|
|
17
|
+
return [...new Set([...fastKeys, ...regexKeys])];
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function extractKeysWithRegex(content) {
|
|
2
|
+
const keys = new Set();
|
|
3
|
+
const namespaces = new Set();
|
|
4
|
+
const nsRegex = /useTranslation\s*\(\s*(?:\[\s*([^\]]+)\s*\]|['"`]([^'"`]+)['"`])?/g;
|
|
5
|
+
let nsMatch;
|
|
6
|
+
while ((nsMatch = nsRegex.exec(content)) !== null) {
|
|
7
|
+
if (nsMatch[1]) {
|
|
8
|
+
const nsValues = nsMatch[1]
|
|
9
|
+
.split(",")
|
|
10
|
+
.map((s) => s.replace(/['"`]/g, "").trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
nsValues.forEach((ns) => namespaces.add(ns));
|
|
13
|
+
}
|
|
14
|
+
if (nsMatch[2]) {
|
|
15
|
+
namespaces.add(nsMatch[2]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const tRegex = /(^|[^\w$.])t\s*\(\s*['"`]([^'"`]+)['"`]/g;
|
|
19
|
+
let match;
|
|
20
|
+
while ((match = tRegex.exec(content)) !== null) {
|
|
21
|
+
const key = match[2];
|
|
22
|
+
if (key.includes(".")) {
|
|
23
|
+
keys.add(key);
|
|
24
|
+
}
|
|
25
|
+
else if (namespaces.size > 0) {
|
|
26
|
+
namespaces.forEach((ns) => keys.add(`${ns}.${key}`));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
keys.add(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const i18nRegex = /i18n\.t\s*\(\s*['"`]([^'"`]+)['"`]/g;
|
|
33
|
+
let i18nMatch;
|
|
34
|
+
while ((i18nMatch = i18nRegex.exec(content)) !== null) {
|
|
35
|
+
keys.add(i18nMatch[1]);
|
|
36
|
+
}
|
|
37
|
+
const transRegex = /<Trans[^>]*i18nKey\s*=\s*['"`]([^'"`]+)['"`]/g;
|
|
38
|
+
let transMatch;
|
|
39
|
+
while ((transMatch = transRegex.exec(content)) !== null) {
|
|
40
|
+
keys.add(transMatch[1]);
|
|
41
|
+
}
|
|
42
|
+
return [...keys];
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const T_LOOKAHEAD = 80;
|
|
2
|
+
const TRANS_LOOKAHEAD = 150;
|
|
3
|
+
export function extractKeysUltraFast(content) {
|
|
4
|
+
const keys = new Set();
|
|
5
|
+
const len = content.length;
|
|
6
|
+
for (let i = 0; i < len; i++) {
|
|
7
|
+
const char = content[i];
|
|
8
|
+
// t(
|
|
9
|
+
if (char === "t" && (i === 0 || !/[\w$.]/.test(content[i - 1] ?? ""))) {
|
|
10
|
+
const slice = content.slice(i, i + T_LOOKAHEAD);
|
|
11
|
+
const match = /^t\s*\(\s*['"`]([^'"`]+)['"`]/.exec(slice);
|
|
12
|
+
if (match)
|
|
13
|
+
keys.add(match[1]);
|
|
14
|
+
}
|
|
15
|
+
// i18n.t(
|
|
16
|
+
if (char === "i" && content.slice(i, i + 7) === "i18n.t(") {
|
|
17
|
+
const slice = content.slice(i, i + T_LOOKAHEAD);
|
|
18
|
+
const match = /^i18n\.t\s*\(\s*['"`]([^'"`]+)['"`]/.exec(slice);
|
|
19
|
+
if (match)
|
|
20
|
+
keys.add(match[1]);
|
|
21
|
+
}
|
|
22
|
+
// <Trans ... i18nKey="..." />
|
|
23
|
+
if (char === "<" && content.slice(i, i + 6) === "<Trans") {
|
|
24
|
+
const slice = content.slice(i, i + TRANS_LOOKAHEAD);
|
|
25
|
+
const match = /i18nKey\s*=\s*['"`]([^'"`]+)['"`]/.exec(slice);
|
|
26
|
+
if (match)
|
|
27
|
+
keys.add(match[1]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...keys];
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export function printConsoleReport(result) {
|
|
3
|
+
console.log("");
|
|
4
|
+
console.log(chalk.blue(`Used keys: ${result.usedKeys.length}`));
|
|
5
|
+
console.log(chalk.yellow(`Unused keys: ${result.unusedKeysByFile.size}`));
|
|
6
|
+
console.log(chalk.red(`Missing keys: ${result.missingKeysByFile.size}`));
|
|
7
|
+
console.log("");
|
|
8
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { addKey } from "../core/utils/addKey.js";
|
|
4
|
+
import { sortObject } from "../core/utils/sortObject.js";
|
|
5
|
+
export async function writeUnusedKeysReport(result, jsonFiles, outputDir) {
|
|
6
|
+
const { unusedKeysByFile } = result;
|
|
7
|
+
const filesWithUnused = jsonFiles.filter((file) => {
|
|
8
|
+
const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
|
|
9
|
+
return unusedKeys.length > 0;
|
|
10
|
+
});
|
|
11
|
+
if (filesWithUnused.length === 0) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
15
|
+
for (const file of filesWithUnused) {
|
|
16
|
+
const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
|
|
17
|
+
const reportData = {};
|
|
18
|
+
for (const key of unusedKeys) {
|
|
19
|
+
const value = getValueByPath(file.data, key);
|
|
20
|
+
if (value !== undefined) {
|
|
21
|
+
addKey(reportData, key, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const sorted = sortObject(reportData);
|
|
25
|
+
const reportFileName = `unused.${path.basename(file.filePath)}`;
|
|
26
|
+
const reportPath = path.join(outputDir, reportFileName);
|
|
27
|
+
await fs.writeFile(reportPath, JSON.stringify(sorted, null, 2), "utf8");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function getValueByPath(obj, keyPath) {
|
|
31
|
+
const parts = keyPath.split(".");
|
|
32
|
+
let current = obj;
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
if (typeof current !== "object" ||
|
|
35
|
+
current === null ||
|
|
36
|
+
Array.isArray(current) ||
|
|
37
|
+
!(part in current)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
current = current[part];
|
|
41
|
+
}
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function getUsedKeysFromSource(content) {
|
|
2
|
+
const keys = [];
|
|
3
|
+
const nsRegex = /useTranslation\s*\(\s*(?:\[\s*([^\]]+)\s*\]|['"`]([^'"`]+)['"`])?/g;
|
|
4
|
+
const namespaces = [];
|
|
5
|
+
let nsMatch;
|
|
6
|
+
while ((nsMatch = nsRegex.exec(content)) !== null) {
|
|
7
|
+
if (nsMatch[1]) {
|
|
8
|
+
const nsValues = nsMatch[1]
|
|
9
|
+
.split(",")
|
|
10
|
+
.map((s) => s.replace(/['"`]/g, "").trim());
|
|
11
|
+
namespaces.push(...nsValues);
|
|
12
|
+
}
|
|
13
|
+
if (nsMatch[2]) {
|
|
14
|
+
namespaces.push(nsMatch[2]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const tRegex = /\bt\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
18
|
+
let match;
|
|
19
|
+
while ((match = tRegex.exec(content)) !== null) {
|
|
20
|
+
const key = match[1];
|
|
21
|
+
if (key.includes(".")) {
|
|
22
|
+
keys.push(key);
|
|
23
|
+
}
|
|
24
|
+
else if (namespaces.length > 0) {
|
|
25
|
+
namespaces.forEach((ns) => keys.push(`${ns}.${key}`));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
keys.push(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(keys)];
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-i18next-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"dev": "tsx src/cli/cli.ts",
|
|
11
|
+
"scan": "yarn build && node ./dist/cli/cli.js scan",
|
|
12
|
+
"sort": "yarn build && node ./dist/cli/cli.js sort",
|
|
13
|
+
"sync": "yarn build && node ./dist/cli/cli.js sync-missing"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"i18n",
|
|
17
|
+
"react",
|
|
18
|
+
"translations",
|
|
19
|
+
"scanner"
|
|
20
|
+
],
|
|
21
|
+
"author": "Boybekov Abdullokh",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.6.2",
|
|
29
|
+
"tsx": "^4.22.4",
|
|
30
|
+
"typescript": "^5.9.3"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"chalk": "^5.6.2",
|
|
34
|
+
"commander": "^15.0.0",
|
|
35
|
+
"fast-glob": "^3.3.3"
|
|
36
|
+
}
|
|
37
|
+
}
|