@ddongule/i18n-cli 1.0.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 +365 -0
- package/dist/commands/import.js +66 -0
- package/dist/commands/scan.js +540 -0
- package/dist/index.js +10 -0
- package/dist/utils/checkGh.js +14 -0
- package/dist/utils/createPullRequest.js +18 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# i18n-cli
|
|
2
|
+
|
|
3
|
+
A practical i18n maintenance CLI for front-end repositories.
|
|
4
|
+
|
|
5
|
+
It helps you:
|
|
6
|
+
- **Scan** code usage vs. locale JSON files
|
|
7
|
+
- Detect **Missing** keys (used in code but absent in locale files)
|
|
8
|
+
- Detect **Unused** keys (present in locale files but not used in code)
|
|
9
|
+
- **Safely fix** issues (create missing keys, delete unused keys, align locale structures)
|
|
10
|
+
- **Import** translations from **Google Sheets** or **.xlsx**
|
|
11
|
+
- (Optional) **Export** locale JSON back to Google Sheets (if enabled in your build)
|
|
12
|
+
|
|
13
|
+
> Designed for real-world teams: dry-run first, safe deletes, and predictable output.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Table of Contents
|
|
18
|
+
|
|
19
|
+
- [Quick Start](#quick-start)
|
|
20
|
+
- [Project Conventions](#project-conventions)
|
|
21
|
+
- [Commands](#commands)
|
|
22
|
+
- [scan](#scan)
|
|
23
|
+
- [import](#import)
|
|
24
|
+
- [export](#export)
|
|
25
|
+
- [How It Works](#how-it-works)
|
|
26
|
+
- [Sample: Before & After](#sample-before--after)
|
|
27
|
+
- [Google Sheets Setup](#google-sheets-setup)
|
|
28
|
+
- [CI / Automation Ideas](#ci--automation-ideas)
|
|
29
|
+
- [Troubleshooting](#troubleshooting)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Install
|
|
36
|
+
|
|
37
|
+
Use `npx` (no install):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx i18n-cli scan
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or install as a dev dependency:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pnpm add -D i18n-cli
|
|
47
|
+
# or npm i -D i18n-cli
|
|
48
|
+
# or yarn add -D i18n-cli
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then run:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pnpm i18n-cli scan
|
|
55
|
+
# or npx i18n-cli scan
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Project Conventions
|
|
61
|
+
|
|
62
|
+
`i18n-cli` assumes:
|
|
63
|
+
|
|
64
|
+
- Your locale files are JSON under:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
./locales/
|
|
68
|
+
en.json
|
|
69
|
+
ko.json
|
|
70
|
+
ja.json
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- Your code uses translation keys like:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
t("home.title")
|
|
78
|
+
t("profile.logout.button")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If your project uses a different `t()` function name or different patterns, you can extend the scanner in your fork (or add a custom scanner later).
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
Run `npx i18n-cli --help` for the latest options.
|
|
88
|
+
|
|
89
|
+
### scan
|
|
90
|
+
|
|
91
|
+
Scan your repository and report i18n status.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npx i18n-cli scan
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Most used options
|
|
98
|
+
|
|
99
|
+
- `--lang <code>`: base locale language (default: `en`)
|
|
100
|
+
- `--md`: print Markdown report and save to `i18n-report.md` (unless `--no-save`)
|
|
101
|
+
- `--json`: print JSON report and save to `i18n-report.json` (unless `--no-save`)
|
|
102
|
+
- `--no-save`: do not save reports (works with `--md` / `--json`)
|
|
103
|
+
- `--check-consistency`: verify all locale structures are aligned
|
|
104
|
+
- `--delete-unused`: delete unused keys (**safe unused only**, if enabled)
|
|
105
|
+
- `--create-missing`: create missing keys with an empty placeholder
|
|
106
|
+
- `--fix-structure`: align locale structures
|
|
107
|
+
- `--locale <code>`: apply modifications to only one locale (default: all)
|
|
108
|
+
- `--dry-run`: simulate changes only (recommended first)
|
|
109
|
+
- `--backup`: backup locale files before writing
|
|
110
|
+
- `--no-confirm`: do not ask before applying changes
|
|
111
|
+
|
|
112
|
+
#### Examples
|
|
113
|
+
|
|
114
|
+
Base locale `en`, print pretty output:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npx i18n-cli scan --lang en
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Generate Markdown report without saving:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx i18n-cli scan --md --no-save
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Create missing keys across **all locales**, but do not write:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx i18n-cli scan --create-missing --dry-run
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Delete safe unused keys and apply changes:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx i18n-cli scan --delete-unused
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> Tip: Prefer **dry-run first** before writing changes.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### import
|
|
143
|
+
|
|
144
|
+
Import translations into `./locales/*.json` from either `.xlsx` or Google Sheets.
|
|
145
|
+
|
|
146
|
+
#### Import from XLSX
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx i18n-cli import --file ./translations.xlsx
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Options:
|
|
153
|
+
- `--file <path>`: path to `.xlsx`
|
|
154
|
+
- `--override`: replace existing translations
|
|
155
|
+
- `--dry-run`: preview only
|
|
156
|
+
- `--locales-dir <path>`: target locales folder (default `./locales`)
|
|
157
|
+
|
|
158
|
+
#### Import from Google Sheets
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npx i18n-cli import --sheet <SPREADSHEET_ID> --sheet-name <TAB_NAME>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Common options:
|
|
165
|
+
- `--sheet <id>`: Google spreadsheet id
|
|
166
|
+
- `--sheet-name <name>`: sheet tab name (e.g. `Translations`)
|
|
167
|
+
- `--cred <path>`: service account credentials JSON path
|
|
168
|
+
(default example: `./credentials/google-service-account.json`)
|
|
169
|
+
- `--dry-run`: preview only
|
|
170
|
+
- `--override`: replace existing translations
|
|
171
|
+
- `--locales-dir <path>`: target locales folder (default `./locales`)
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npx i18n-cli import \
|
|
177
|
+
--sheet 1L7h7Ra3hrOrp5MW7_uWV6ANNrBF0b9CgSfJ9hXy7D7w \
|
|
178
|
+
--sheet-name Translations \
|
|
179
|
+
--cred ./credentials/google-service-account.json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### export
|
|
185
|
+
|
|
186
|
+
If your build includes `export` (optional), you can export locale JSON to Google Sheets.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npx i18n-cli export --sheet <SPREADSHEET_ID> --sheet-name <TAB_NAME>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Typical options:
|
|
193
|
+
- `--sheet <id>`
|
|
194
|
+
- `--sheet-name <tab>`
|
|
195
|
+
- `--cred <path>`
|
|
196
|
+
- `--dry-run`
|
|
197
|
+
- `--locales-dir <path>`
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## How It Works
|
|
202
|
+
|
|
203
|
+
### Key concepts
|
|
204
|
+
|
|
205
|
+
- **Base locale**: the “source of truth” structure (usually `en`)
|
|
206
|
+
- **Missing keys**: keys used in code but missing in one or more locales
|
|
207
|
+
- **Unused keys**: keys present in locale files but not referenced in code
|
|
208
|
+
|
|
209
|
+
### What gets scanned
|
|
210
|
+
|
|
211
|
+
- `./locales/*.json` are loaded and flattened to dot-keys:
|
|
212
|
+
- `{"home":{"title":"Welcome"}}` → `home.title`
|
|
213
|
+
- Code scanner searches for patterns like:
|
|
214
|
+
- `t("...")`, `i18n.t("...")`, etc. (depends on your implementation)
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Sample: Before & After
|
|
219
|
+
|
|
220
|
+
### Example locale files
|
|
221
|
+
|
|
222
|
+
`locales/en.json`
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"home": {
|
|
227
|
+
"title": "Welcome",
|
|
228
|
+
"button": { "ok": "OK" }
|
|
229
|
+
},
|
|
230
|
+
"profile": {
|
|
231
|
+
"logout": { "button": "Logout" }
|
|
232
|
+
},
|
|
233
|
+
"settings2": {
|
|
234
|
+
"notification": { "enabled": "On" }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`locales/ko.json`
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"home": {
|
|
244
|
+
"title": "환영합니다"
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Example code
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
function example() {
|
|
253
|
+
t("home.title");
|
|
254
|
+
t("profile.logout.button");
|
|
255
|
+
t("settings.notification.enabled");
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Run scan
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
npx i18n-cli scan --lang en
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Example findings:
|
|
266
|
+
- **Missing (per-locale)**: `profile.logout.button` missing in `ko`, `settings.notification.enabled` missing in multiple
|
|
267
|
+
- **Unused (locale-only)**: `home.button.ok`, `settings2.notification.enabled`
|
|
268
|
+
|
|
269
|
+
### Fix structure & create missing (dry-run first)
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
npx i18n-cli scan --fix-structure --create-missing --dry-run
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Then apply:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
npx i18n-cli scan --fix-structure --create-missing
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
After (example result):
|
|
282
|
+
|
|
283
|
+
`locales/ko.json`
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
{
|
|
287
|
+
"home": {
|
|
288
|
+
"title": "환영합니다",
|
|
289
|
+
"button": { "ok": "" }
|
|
290
|
+
},
|
|
291
|
+
"profile": {
|
|
292
|
+
"logout": { "button": "" }
|
|
293
|
+
},
|
|
294
|
+
"settings": {
|
|
295
|
+
"notification": { "enabled": "" }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Google Sheets Setup
|
|
303
|
+
|
|
304
|
+
### 1) Create a Google Cloud project & service account
|
|
305
|
+
|
|
306
|
+
- Create a **Service Account** in Google Cloud Console
|
|
307
|
+
- Create a **JSON key** and save it (e.g. `./credentials/google-service-account.json`)
|
|
308
|
+
|
|
309
|
+
### 2) Share the sheet with the service account email
|
|
310
|
+
|
|
311
|
+
Open the spreadsheet → **Share** → add:
|
|
312
|
+
- `client_email` from your credentials JSON
|
|
313
|
+
|
|
314
|
+
Give it **Viewer** for import, and **Editor** for export.
|
|
315
|
+
|
|
316
|
+
### 3) Sheet format
|
|
317
|
+
|
|
318
|
+
Recommended header:
|
|
319
|
+
|
|
320
|
+
| key | en | ko | ja |
|
|
321
|
+
|---|---|---|---|
|
|
322
|
+
| home.title | Welcome | 환영합니다 | ようこそ |
|
|
323
|
+
|
|
324
|
+
Notes:
|
|
325
|
+
- First column is the key (dot format)
|
|
326
|
+
- Languages can be any codes (`en`, `ko`, `ja`, ...)
|
|
327
|
+
|
|
328
|
+
If your sheet has extra header rows, the importer should locate the header row containing `key` + language columns.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## CI / Automation Ideas
|
|
333
|
+
|
|
334
|
+
- Run `scan --json` on PR and upload `i18n-report.json` as an artifact
|
|
335
|
+
- Fail CI if missing keys exceed a threshold
|
|
336
|
+
- Use `--dry-run` in CI; apply fixes via a separate “fix PR” job
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Troubleshooting
|
|
341
|
+
|
|
342
|
+
### “Google credentials file not found”
|
|
343
|
+
Provide credentials:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
npx i18n-cli import --sheet <ID> --cred ./credentials/google-service-account.json
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### “Unable to parse range”
|
|
350
|
+
Your sheet tab name is wrong:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
npx i18n-cli import --sheet <ID> --sheet-name <TAB_NAME>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### “No locale files found”
|
|
357
|
+
Ensure:
|
|
358
|
+
- `./locales` exists
|
|
359
|
+
- files are named like `en.json`, `ko.json`
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## License
|
|
364
|
+
|
|
365
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.importCommand = importCommand;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const i18n_mcp_core_1 = require("i18n-mcp-core");
|
|
10
|
+
function importCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command("import")
|
|
13
|
+
.description("import translations from spreadsheet")
|
|
14
|
+
.option("--file <path>", "xlsx file to import")
|
|
15
|
+
.option("--override", "replace existing translations", false)
|
|
16
|
+
.option("--dry-run", "simulate only", false)
|
|
17
|
+
.option("--sheet <id>", "google sheet id")
|
|
18
|
+
.option("--sheet-name <name>", "Google Sheet tab name", "Sheet1")
|
|
19
|
+
.option("--cred <path>", "google credentials path", "./credentials/google-service-account.json")
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
const { file, sheet } = options;
|
|
22
|
+
//
|
|
23
|
+
// GOOGLE SHEET MODE
|
|
24
|
+
//
|
|
25
|
+
if (sheet) {
|
|
26
|
+
const range = options.range || `${options.sheetName}!A1:Z9999`;
|
|
27
|
+
console.log(chalk_1.default.blue(`\n📥 Importing from Google Sheet (${options.sheetName})...\n`));
|
|
28
|
+
try {
|
|
29
|
+
await (0, i18n_mcp_core_1.importGoogleSheet)({
|
|
30
|
+
sheetId: options.sheet,
|
|
31
|
+
range,
|
|
32
|
+
credentialsPath: options.cred, // 🔥 FIX
|
|
33
|
+
dryRun: options.dryRun,
|
|
34
|
+
});
|
|
35
|
+
console.log(chalk_1.default.green("\n🎉 Google Sheet Import Completed!\n"));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
console.error(chalk_1.default.red("\n❌ Google Sheet Import Failed\n"));
|
|
39
|
+
console.error(e?.message || e);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
//
|
|
44
|
+
// XLSX MODE
|
|
45
|
+
//
|
|
46
|
+
if (!file) {
|
|
47
|
+
console.log(chalk_1.default.red("❌ --file is required"));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const fullPath = path_1.default.join(process.cwd(), file);
|
|
51
|
+
console.log(chalk_1.default.blue("\n📥 Importing translations..."));
|
|
52
|
+
console.log(chalk_1.default.gray(`File: ${fullPath}`));
|
|
53
|
+
try {
|
|
54
|
+
await (0, i18n_mcp_core_1.importSpreadsheet)({
|
|
55
|
+
file: options.file,
|
|
56
|
+
override: options.override,
|
|
57
|
+
dryRun: options.dryRun,
|
|
58
|
+
});
|
|
59
|
+
console.log(chalk_1.default.green("\n🎉 Import completed!\n"));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error(chalk_1.default.red("\n❌ Import failed"));
|
|
63
|
+
console.error(e.message || e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.scanCommand = scanCommand;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const readline_1 = __importDefault(require("readline"));
|
|
11
|
+
const i18n_mcp_core_1 = require("i18n-mcp-core");
|
|
12
|
+
const createPullRequest_1 = require("../utils/createPullRequest");
|
|
13
|
+
const checkGh_1 = require("../utils/checkGh");
|
|
14
|
+
function scanCommand(program) {
|
|
15
|
+
program
|
|
16
|
+
.command("scan")
|
|
17
|
+
.description("scan project & generate report")
|
|
18
|
+
.option("--md", "output as markdown & save file")
|
|
19
|
+
.option("--json", "output as json & save file")
|
|
20
|
+
.option("--no-save", "do not save report when using md/json")
|
|
21
|
+
.option("--lang <code>", "base locale language", "en")
|
|
22
|
+
.option("--delete-unused", "delete unused keys")
|
|
23
|
+
.option("--create-missing", "create missing keys with placeholder")
|
|
24
|
+
.option("--locale <code>", "apply fixes to only specific locale (default = ALL)")
|
|
25
|
+
.option("--check-consistency", "verify all locale structures are aligned")
|
|
26
|
+
.option("--fix-structure", "force align locale structures to base locale")
|
|
27
|
+
.option("--dry-run", "simulate changes only")
|
|
28
|
+
.option("--backup", "backup locale files before modifying", false)
|
|
29
|
+
.option("--no-confirm", "do not ask before applying changes")
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const { md, json, lang, save, deleteUnused, createMissing, locale, checkConsistency, fixStructure, dryRun, backup, confirm, } = options;
|
|
32
|
+
console.log(chalk_1.default.blue("\n📦 Running i18n scan...\n"));
|
|
33
|
+
if (options.pr && options.dryRun) {
|
|
34
|
+
console.log(chalk_1.default.red("\n❌ Cannot create PR in dry-run mode.\n" +
|
|
35
|
+
"Remove --dry-run to allow file changes and PR creation.\n"));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(chalk_1.default.bgBlue.black(` BASE LOCALE `), chalk_1.default.white.bold(lang), "\n");
|
|
39
|
+
const localesDir = path_1.default.join(process.cwd(), "locales");
|
|
40
|
+
const locales = (0, i18n_mcp_core_1.loadLocales)(localesDir); // { en: {...}, ko: {...} }
|
|
41
|
+
const codeKeys = (0, i18n_mcp_core_1.scanCode)(process.cwd());
|
|
42
|
+
const diff = (0, i18n_mcp_core_1.analyzeDiff)(locales, codeKeys, lang);
|
|
43
|
+
// --- classify unused keys ---
|
|
44
|
+
const baseLang = lang;
|
|
45
|
+
const baseFlat = flattenLocaleObject(locales[baseLang]);
|
|
46
|
+
const localeFlats = {};
|
|
47
|
+
Object.entries(locales).forEach(([l, obj]) => {
|
|
48
|
+
localeFlats[l] = flattenLocaleObject(obj);
|
|
49
|
+
});
|
|
50
|
+
const safeUnused = [];
|
|
51
|
+
const unusedAndMissing = [];
|
|
52
|
+
diff.unused.forEach((key) => {
|
|
53
|
+
const missingIn = [];
|
|
54
|
+
Object.entries(localeFlats).forEach(([l, flat]) => {
|
|
55
|
+
if (!(key in flat))
|
|
56
|
+
missingIn.push(l);
|
|
57
|
+
});
|
|
58
|
+
if (missingIn.length === 0) {
|
|
59
|
+
safeUnused.push(key);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
unusedAndMissing.push({ key, missingIn });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// "." 같은 이상한 값 방지
|
|
66
|
+
diff.missing = diff.missing.filter((k) => k && k.trim() && k !== "." && !/^\.+$/.test(k));
|
|
67
|
+
diff.unused = diff.unused.filter((k) => k && k.trim() && k !== "." && !/^\.+$/.test(k));
|
|
68
|
+
// 출력 포맷
|
|
69
|
+
if (md) {
|
|
70
|
+
printMarkdown(diff);
|
|
71
|
+
if (save !== false)
|
|
72
|
+
saveMarkdown(diff);
|
|
73
|
+
}
|
|
74
|
+
else if (json) {
|
|
75
|
+
printJson(diff);
|
|
76
|
+
if (save !== false)
|
|
77
|
+
saveJson(diff);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
printPretty(diff, locales);
|
|
81
|
+
}
|
|
82
|
+
// 구조 일관성 체크
|
|
83
|
+
if (checkConsistency) {
|
|
84
|
+
runConsistencyCheck(locales, lang);
|
|
85
|
+
}
|
|
86
|
+
const willModify = !!deleteUnused || !!createMissing || !!fixStructure;
|
|
87
|
+
if (!willModify)
|
|
88
|
+
return;
|
|
89
|
+
const targetLocales = locale ? [locale] : Object.keys(locales);
|
|
90
|
+
const backupDir = path_1.default.join(localesDir, ".i18n-mcp-backup", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
91
|
+
if (!dryRun && confirm !== false) {
|
|
92
|
+
console.log(chalk_1.default.yellow(`\nAbout to modify locales: ${targetLocales.join(", ")}`));
|
|
93
|
+
const ok = await askForConfirmation("Continue?");
|
|
94
|
+
if (!ok) {
|
|
95
|
+
console.log(chalk_1.default.red("\n❌ Aborted by user.\n"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// unused 삭제 / missing 생성
|
|
100
|
+
if (deleteUnused || createMissing) {
|
|
101
|
+
applyFixes({
|
|
102
|
+
diff,
|
|
103
|
+
safeUnused,
|
|
104
|
+
baseLang: lang,
|
|
105
|
+
localesDir,
|
|
106
|
+
targetLocale: locale,
|
|
107
|
+
locales,
|
|
108
|
+
deleteUnused,
|
|
109
|
+
createMissing,
|
|
110
|
+
dryRun: !!dryRun,
|
|
111
|
+
backup: !!backup,
|
|
112
|
+
backupDir,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// 구조 강제 정렬
|
|
116
|
+
if (fixStructure) {
|
|
117
|
+
fixLocaleStructure({
|
|
118
|
+
baseLang: lang,
|
|
119
|
+
locales,
|
|
120
|
+
localesDir,
|
|
121
|
+
targetLocale: locale,
|
|
122
|
+
dryRun: !!dryRun,
|
|
123
|
+
backup: !!backup,
|
|
124
|
+
backupDir,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const hasChanges = (deleteUnused && safeUnused.length > 0) ||
|
|
128
|
+
(createMissing && diff.missing.length > 0) ||
|
|
129
|
+
fixStructure;
|
|
130
|
+
if (!options.pr) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!hasChanges) {
|
|
134
|
+
console.log(chalk_1.default.yellow("\n⚠ No changes detected.\n" +
|
|
135
|
+
"PR creation skipped because there are no file modifications.\n"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
(0, checkGh_1.ensureGhInstalled)();
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
console.log(chalk_1.default.red("\n❌ " + e.message + "\n"));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!dryRun) {
|
|
146
|
+
const branch = `i18n/update-${Date.now()}`;
|
|
147
|
+
const body = `
|
|
148
|
+
## 🧩 i18n Update Summary
|
|
149
|
+
|
|
150
|
+
### Changes
|
|
151
|
+
- 🧹 Removed unused keys: ${safeUnused.length}
|
|
152
|
+
- ✨ Created missing keys: ${diff.missing.length}
|
|
153
|
+
- 🏗 Fixed locale structure: ${fixStructure ? "yes" : "no"}
|
|
154
|
+
|
|
155
|
+
### Affected Locales
|
|
156
|
+
${Object.keys(locales)
|
|
157
|
+
.map((l) => `- ${l}`)
|
|
158
|
+
.join("\n")}
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
Generated by i18n-mcp 🤖
|
|
163
|
+
`.trim();
|
|
164
|
+
(0, createPullRequest_1.createPullRequest)({
|
|
165
|
+
title: options.prTitle,
|
|
166
|
+
body,
|
|
167
|
+
draft: options.prDraft,
|
|
168
|
+
branch,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function buildMissingByLocale(baseLang, locales) {
|
|
174
|
+
const base = locales[baseLang];
|
|
175
|
+
if (!base)
|
|
176
|
+
return {};
|
|
177
|
+
const baseFlat = flattenLocaleObject(base);
|
|
178
|
+
const baseKeys = Object.keys(baseFlat);
|
|
179
|
+
const result = {};
|
|
180
|
+
baseKeys.forEach((key) => {
|
|
181
|
+
Object.entries(locales).forEach(([lang, obj]) => {
|
|
182
|
+
if (lang === baseLang)
|
|
183
|
+
return;
|
|
184
|
+
const flat = flattenLocaleObject(obj);
|
|
185
|
+
if (!(key in flat)) {
|
|
186
|
+
if (!result[key])
|
|
187
|
+
result[key] = [];
|
|
188
|
+
result[key].push(lang);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
/* ---------------- OUTPUT ---------------- */
|
|
195
|
+
function printPretty(diff, locales) {
|
|
196
|
+
console.log(chalk_1.default.green("📦 i18n Scan Result\n"));
|
|
197
|
+
console.log(chalk_1.default.green("✔ Locale Keys in Base:"), diff.totalLocaleKeys, chalk_1.default.gray("(unique translation keys in base locale)"));
|
|
198
|
+
console.log(chalk_1.default.green("✔ Keys Used in Code:"), diff.totalCodeKeys, chalk_1.default.gray("(detected from t('...') calls)"));
|
|
199
|
+
console.log(chalk_1.default.gray("\n--------------------------------------\n"));
|
|
200
|
+
/* ---------- PER LOCALE STATUS ---------- */
|
|
201
|
+
console.log(chalk_1.default.magenta("🌍 Locale Status\n"));
|
|
202
|
+
// base locale의 실제 nested JSON 기준으로 flatten
|
|
203
|
+
const baseLocaleObj = locales[diff.baseLang];
|
|
204
|
+
if (!baseLocaleObj) {
|
|
205
|
+
console.log(chalk_1.default.red(`⚠ Base locale "${diff.baseLang}" not found in loaded locales.`));
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const baseFlat = flattenLocaleObject(baseLocaleObj);
|
|
209
|
+
const baseKeys = Object.keys(baseFlat);
|
|
210
|
+
const baseTotal = baseKeys.length;
|
|
211
|
+
Object.entries(locales).forEach(([lang, obj]) => {
|
|
212
|
+
const flat = flattenLocaleObject(obj);
|
|
213
|
+
// base key 중 이 locale이 가지고 있는 key 수
|
|
214
|
+
const present = baseKeys.filter((k) => k in flat).length;
|
|
215
|
+
const missing = baseTotal - present;
|
|
216
|
+
const extra = Object.keys(flat).filter((k) => !(k in baseFlat)).length;
|
|
217
|
+
const health = baseTotal === 0 ? 100 : Math.round((present / baseTotal) * 100);
|
|
218
|
+
const bar = healthBar(health);
|
|
219
|
+
console.log(`${chalk_1.default.cyan(lang)} → ${health}% ${bar} ` +
|
|
220
|
+
chalk_1.default.gray(`(${present}/${baseTotal} keys present, ${missing} missing, ${extra} extra)`));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
console.log(chalk_1.default.gray("\n--------------------------------------\n"));
|
|
224
|
+
/* ---------- MISSING (DETAILED BY LOCALE) ---------- */
|
|
225
|
+
const missingByLocale = buildMissingByLocale(diff.baseLang, locales);
|
|
226
|
+
const missingKeys = Object.keys(missingByLocale);
|
|
227
|
+
console.log(chalk_1.default.red(`❗ Missing Keys (${missingKeys.length})\n` +
|
|
228
|
+
chalk_1.default.gray("These keys exist in the base locale, but are missing in the following locales.")));
|
|
229
|
+
if (missingKeys.length === 0) {
|
|
230
|
+
console.log(chalk_1.default.gray(" (None 🎉 All locales are up to date)"));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
missingKeys.forEach((key) => {
|
|
234
|
+
console.log(chalk_1.default.red(` - ${key}`));
|
|
235
|
+
missingByLocale[key].forEach((lang) => {
|
|
236
|
+
console.log(chalk_1.default.gray(` ↳ missing in: ${lang}`));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/* ---------- UNUSED (CLASSIFIED) ---------- */
|
|
241
|
+
const baseLang = diff.baseLang;
|
|
242
|
+
const baseFlat = flattenLocaleObject(locales[baseLang]);
|
|
243
|
+
const localeFlats = {};
|
|
244
|
+
Object.entries(locales).forEach(([lang, obj]) => {
|
|
245
|
+
localeFlats[lang] = flattenLocaleObject(obj);
|
|
246
|
+
});
|
|
247
|
+
const safeUnused = [];
|
|
248
|
+
const unusedAndMissing = [];
|
|
249
|
+
diff.unused.forEach((key) => {
|
|
250
|
+
const missingIn = [];
|
|
251
|
+
Object.entries(localeFlats).forEach(([lang, flat]) => {
|
|
252
|
+
if (!(key in flat))
|
|
253
|
+
missingIn.push(lang);
|
|
254
|
+
});
|
|
255
|
+
if (missingIn.length === 0) {
|
|
256
|
+
safeUnused.push(key);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
unusedAndMissing.push({ key, missingIn });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
console.log(chalk_1.default.yellow(`\n🧹 Unused Keys (Safe to remove)\n` +
|
|
263
|
+
chalk_1.default.gray("These keys exist in all locale files, but are not used in code.")));
|
|
264
|
+
if (safeUnused.length === 0) {
|
|
265
|
+
console.log(chalk_1.default.gray(" (None 👍 No safe unused keys found)"));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
safeUnused.forEach((k) => console.log(chalk_1.default.yellow(" - " + k)));
|
|
269
|
+
}
|
|
270
|
+
console.log(chalk_1.default.magenta(`\n🧪 Unused & Missing Keys\n` +
|
|
271
|
+
chalk_1.default.gray("These keys are not used in code and are missing in some locales.")));
|
|
272
|
+
if (unusedAndMissing.length === 0) {
|
|
273
|
+
console.log(chalk_1.default.gray(" (None)"));
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
unusedAndMissing.forEach(({ key, missingIn }) => {
|
|
277
|
+
console.log(chalk_1.default.green(" - " + key));
|
|
278
|
+
console.log(chalk_1.default.gray(` ↳ missing in: ${missingIn.join(", ")}`));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
console.log("\n");
|
|
282
|
+
/* ---------- NEXT ACTIONS ---------- */
|
|
283
|
+
console.log(chalk_1.default.blue("💡 Next Actions"));
|
|
284
|
+
console.log(chalk_1.default.gray("• Remove unused keys:"), chalk_1.default.white("i18n-mcp scan --delete-unused"));
|
|
285
|
+
console.log(chalk_1.default.gray("• Create missing keys with placeholders:"), chalk_1.default.white("i18n-mcp scan --create-missing"));
|
|
286
|
+
console.log(chalk_1.default.gray("• Align all locale structures to base:"), chalk_1.default.white("i18n-mcp scan --fix-structure"));
|
|
287
|
+
console.log(chalk_1.default.gray("• Export markdown report:"), chalk_1.default.white("i18n-mcp scan --md"));
|
|
288
|
+
console.log(chalk_1.default.gray("• Export json report:"), chalk_1.default.white("i18n-mcp scan --json"));
|
|
289
|
+
console.log("\n" + chalk_1.default.gray("────────────────────────────────\n"));
|
|
290
|
+
}
|
|
291
|
+
/* ---------- HEALTH BAR UI ---------- */
|
|
292
|
+
function healthBar(percent) {
|
|
293
|
+
const total = 20;
|
|
294
|
+
const filled = Math.round((percent / 100) * total);
|
|
295
|
+
return (chalk_1.default.green("█".repeat(filled)) + chalk_1.default.gray("█".repeat(total - filled)));
|
|
296
|
+
}
|
|
297
|
+
function printMarkdown(diff) {
|
|
298
|
+
console.log(`## 📦 i18n Scan Result\n`);
|
|
299
|
+
console.log(`- Base Locale: **${diff.baseLang}**`);
|
|
300
|
+
console.log(`- Locale Keys: **${diff.totalLocaleKeys}**`);
|
|
301
|
+
console.log(`- Code Keys: **${diff.totalCodeKeys}**\n`);
|
|
302
|
+
console.log(`---\n`);
|
|
303
|
+
console.log(`### ❗ Missing (${diff.missing.length})`);
|
|
304
|
+
diff.missing.length === 0
|
|
305
|
+
? console.log("- none")
|
|
306
|
+
: diff.missing.forEach((k) => console.log(`- ${k}`));
|
|
307
|
+
console.log(`\n### 🧹 Unused (${diff.unused.length})`);
|
|
308
|
+
diff.unused.length === 0
|
|
309
|
+
? console.log("- none")
|
|
310
|
+
: diff.unused.forEach((k) => console.log(`- ${k}`));
|
|
311
|
+
}
|
|
312
|
+
function saveMarkdown(diff) {
|
|
313
|
+
const content = `
|
|
314
|
+
## 📦 i18n Scan Result
|
|
315
|
+
|
|
316
|
+
- Base Locale: **${diff.baseLang}**
|
|
317
|
+
- Locale Keys: **${diff.totalLocaleKeys}**
|
|
318
|
+
- Code Keys: **${diff.totalCodeKeys}**
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### ❗ Missing (${diff.missing.length})
|
|
323
|
+
${diff.missing.length === 0
|
|
324
|
+
? "- none"
|
|
325
|
+
: diff.missing.map((k) => `- ${k}`).join("\n")}
|
|
326
|
+
|
|
327
|
+
### 🧹 Unused (${diff.unused.length})
|
|
328
|
+
${diff.unused.length === 0
|
|
329
|
+
? "- none"
|
|
330
|
+
: diff.unused.map((k) => `- ${k}`).join("\n")}
|
|
331
|
+
`.trim();
|
|
332
|
+
fs_1.default.writeFileSync("i18n-report.md", content);
|
|
333
|
+
console.log(chalk_1.default.green("\n💾 Markdown saved → i18n-report.md\n"));
|
|
334
|
+
}
|
|
335
|
+
function printJson(diff) {
|
|
336
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
337
|
+
}
|
|
338
|
+
function saveJson(diff) {
|
|
339
|
+
fs_1.default.writeFileSync("i18n-report.json", JSON.stringify(diff, null, 2));
|
|
340
|
+
console.log(chalk_1.default.green("\n💾 JSON saved → i18n-report.json\n"));
|
|
341
|
+
}
|
|
342
|
+
/* ---------------- FLATTEN HELPER ---------------- */
|
|
343
|
+
// nested locale → flat { "a.b.c": value }
|
|
344
|
+
function flattenLocaleObject(obj, prefix = "", result = {}) {
|
|
345
|
+
if (typeof obj !== "object" || obj === null)
|
|
346
|
+
return result;
|
|
347
|
+
Object.entries(obj).forEach(([key, value]) => {
|
|
348
|
+
const full = prefix ? `${prefix}.${key}` : key;
|
|
349
|
+
if (typeof value === "object" && value !== null) {
|
|
350
|
+
flattenLocaleObject(value, full, result);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
result[full] = value;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
/* ---------------- CONSISTENCY ---------------- */
|
|
359
|
+
function runConsistencyCheck(locales, baseLang) {
|
|
360
|
+
const base = locales[baseLang];
|
|
361
|
+
if (!base) {
|
|
362
|
+
console.log(chalk_1.default.red(`⚠ base locale "${baseLang}" missing`));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const baseFlat = flattenLocaleObject(base);
|
|
366
|
+
const baseKeys = new Set(Object.keys(baseFlat));
|
|
367
|
+
console.log(chalk_1.default.magenta("\n🌍 Locale Consistency Report\n"));
|
|
368
|
+
Object.entries(locales).forEach(([lang, map]) => {
|
|
369
|
+
if (lang === baseLang)
|
|
370
|
+
return;
|
|
371
|
+
const flat = flattenLocaleObject(map);
|
|
372
|
+
const keys = new Set(Object.keys(flat));
|
|
373
|
+
const missing = [];
|
|
374
|
+
const extra = [];
|
|
375
|
+
baseKeys.forEach((k) => !keys.has(k) && missing.push(k));
|
|
376
|
+
keys.forEach((k) => !baseKeys.has(k) && extra.push(k));
|
|
377
|
+
if (!missing.length && !extra.length) {
|
|
378
|
+
console.log(chalk_1.default.green(`✅ ${lang}: consistent`));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(chalk_1.default.yellow(`\n🔸 ${lang}`));
|
|
382
|
+
if (missing.length) {
|
|
383
|
+
console.log(chalk_1.default.red(` ❗ Missing`));
|
|
384
|
+
missing.forEach((k) => console.log(` - ${k}`));
|
|
385
|
+
}
|
|
386
|
+
if (extra.length) {
|
|
387
|
+
console.log(chalk_1.default.yellow(` ⚠ Extra`));
|
|
388
|
+
extra.forEach((k) => console.log(` - ${k}`));
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
console.log();
|
|
392
|
+
}
|
|
393
|
+
/* ---------------- APPLY FIXES ---------------- */
|
|
394
|
+
function applyFixes({ diff, safeUnused, localesDir, targetLocale, locales, deleteUnused, createMissing, dryRun, backup, backupDir, }) {
|
|
395
|
+
const targets = targetLocale ? [targetLocale] : Object.keys(locales);
|
|
396
|
+
console.log(chalk_1.default.magenta(`\n🔧 Applying fixes to ${targets.join(", ")} (dryRun=${dryRun})\n`));
|
|
397
|
+
targets.forEach((lang) => {
|
|
398
|
+
const file = path_1.default.join(localesDir, `${lang}.json`);
|
|
399
|
+
if (!fs_1.default.existsSync(file))
|
|
400
|
+
return;
|
|
401
|
+
let obj = JSON.parse(fs_1.default.readFileSync(file, "utf-8"));
|
|
402
|
+
let deleted = 0;
|
|
403
|
+
let created = 0;
|
|
404
|
+
if (deleteUnused && diff.unused?.length)
|
|
405
|
+
deleted = deleteUnusedKeysFromObject(obj, safeUnused);
|
|
406
|
+
if (createMissing && diff.missing?.length)
|
|
407
|
+
created = createMissingKeysInObject(obj, diff.missing, "");
|
|
408
|
+
pruneEmptyObjects(obj);
|
|
409
|
+
if (dryRun)
|
|
410
|
+
console.log(chalk_1.default.gray(`[DRY-RUN] ${lang}: delete ${deleted}, create ${created}`));
|
|
411
|
+
else {
|
|
412
|
+
if (backup)
|
|
413
|
+
ensureBackup(file, lang, backupDir);
|
|
414
|
+
fs_1.default.writeFileSync(file, JSON.stringify(obj, null, 2));
|
|
415
|
+
console.log(chalk_1.default.green(`✔ ${lang}: deleted ${deleted}, created ${created}`));
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/* ---------------- FIX STRUCTURE ---------------- */
|
|
420
|
+
function fixLocaleStructure({ baseLang, locales, localesDir, targetLocale, dryRun, backup, backupDir, }) {
|
|
421
|
+
const baseFile = path_1.default.join(localesDir, `${baseLang}.json`);
|
|
422
|
+
if (!fs_1.default.existsSync(baseFile)) {
|
|
423
|
+
console.log(chalk_1.default.red(`⚠ base locale file not found: ${baseFile} (baseLang="${baseLang}")`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// ✅ 실제 base JSON 파일 (nested 구조)
|
|
427
|
+
const baseNested = JSON.parse(fs_1.default.readFileSync(baseFile, "utf-8"));
|
|
428
|
+
const baseFlat = flattenLocaleObject(baseNested);
|
|
429
|
+
const baseKeys = Object.keys(baseFlat);
|
|
430
|
+
const targets = targetLocale ? [targetLocale] : Object.keys(locales);
|
|
431
|
+
console.log(chalk_1.default.magenta(`\n🧱 Fixing structure to match "${baseLang}" (dryRun=${dryRun})\n`));
|
|
432
|
+
targets.forEach((lang) => {
|
|
433
|
+
const file = path_1.default.join(localesDir, `${lang}.json`);
|
|
434
|
+
if (!fs_1.default.existsSync(file))
|
|
435
|
+
return;
|
|
436
|
+
let obj = JSON.parse(fs_1.default.readFileSync(file, "utf-8"));
|
|
437
|
+
// 현재 locale도 실제 파일 기준(nested)으로 flatten
|
|
438
|
+
const langFlat = flattenLocaleObject(obj);
|
|
439
|
+
const langKeys = Object.keys(langFlat);
|
|
440
|
+
const extra = langKeys.filter((k) => !baseKeys.includes(k));
|
|
441
|
+
const missing = baseKeys.filter((k) => !langKeys.includes(k));
|
|
442
|
+
let deleted = deleteUnusedKeysFromObject(obj, extra);
|
|
443
|
+
let created = createMissingKeysInObject(obj, missing, "");
|
|
444
|
+
pruneEmptyObjects(obj);
|
|
445
|
+
// ✅ 여기에서 baseNested 기준으로 key 순서 맞춰 정렬
|
|
446
|
+
obj = sortLocaleByBase(baseNested, obj);
|
|
447
|
+
if (dryRun) {
|
|
448
|
+
console.log(chalk_1.default.gray(`[DRY-RUN] ${lang}: delete ${deleted}, create ${created}`));
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
if (backup)
|
|
452
|
+
ensureBackup(file, lang, backupDir);
|
|
453
|
+
fs_1.default.writeFileSync(file, JSON.stringify(obj, null, 2));
|
|
454
|
+
console.log(chalk_1.default.green(`🏗 ${lang}: aligned (deleted ${deleted}, created ${created})`));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function sortLocaleByBase(base, target) {
|
|
459
|
+
if (typeof base !== "object" || base === null)
|
|
460
|
+
return target;
|
|
461
|
+
if (typeof target !== "object" || target === null)
|
|
462
|
+
return target;
|
|
463
|
+
const sorted = {};
|
|
464
|
+
Object.keys(base).forEach((key) => {
|
|
465
|
+
const baseVal = base[key];
|
|
466
|
+
const targetVal = target[key];
|
|
467
|
+
if (typeof baseVal === "object" && baseVal !== null) {
|
|
468
|
+
sorted[key] = sortLocaleByBase(baseVal, targetVal ?? {});
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
sorted[key] = targetVal ?? "";
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
return sorted;
|
|
475
|
+
}
|
|
476
|
+
/* ---------------- HELPERS ---------------- */
|
|
477
|
+
function pruneEmptyObjects(obj) {
|
|
478
|
+
if (typeof obj !== "object" || obj === null)
|
|
479
|
+
return false;
|
|
480
|
+
for (const key of Object.keys(obj)) {
|
|
481
|
+
if (typeof obj[key] === "object") {
|
|
482
|
+
if (pruneEmptyObjects(obj[key]))
|
|
483
|
+
delete obj[key];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return Object.keys(obj).length === 0;
|
|
487
|
+
}
|
|
488
|
+
function ensureBackup(file, lang, dir) {
|
|
489
|
+
if (!fs_1.default.existsSync(dir))
|
|
490
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
491
|
+
fs_1.default.copyFileSync(file, path_1.default.join(dir, `${lang}.json`));
|
|
492
|
+
}
|
|
493
|
+
function deleteUnusedKeysFromObject(root, keys) {
|
|
494
|
+
let count = 0;
|
|
495
|
+
keys.forEach((k) => deletePath(root, k.split(".")) && count++);
|
|
496
|
+
return count;
|
|
497
|
+
}
|
|
498
|
+
function deletePath(obj, parts) {
|
|
499
|
+
const [h, ...r] = parts;
|
|
500
|
+
if (!(h in obj))
|
|
501
|
+
return false;
|
|
502
|
+
if (!r.length) {
|
|
503
|
+
delete obj[h];
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
return deletePath(obj[h], r);
|
|
507
|
+
}
|
|
508
|
+
function createMissingKeysInObject(root, keys, value) {
|
|
509
|
+
let count = 0;
|
|
510
|
+
keys.forEach((k) => createPath(root, k.split("."), value) && count++);
|
|
511
|
+
return count;
|
|
512
|
+
}
|
|
513
|
+
function createPath(obj, parts, value) {
|
|
514
|
+
const [h, ...r] = parts;
|
|
515
|
+
if (!h)
|
|
516
|
+
return false;
|
|
517
|
+
if (!r.length) {
|
|
518
|
+
if (obj[h] === undefined) {
|
|
519
|
+
obj[h] = value;
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
if (typeof obj[h] !== "object" || obj[h] === null)
|
|
525
|
+
obj[h] = {};
|
|
526
|
+
return createPath(obj[h], r, value);
|
|
527
|
+
}
|
|
528
|
+
function askForConfirmation(q) {
|
|
529
|
+
return new Promise((resolve) => {
|
|
530
|
+
const rl = readline_1.default.createInterface({
|
|
531
|
+
input: process.stdin,
|
|
532
|
+
output: process.stdout,
|
|
533
|
+
});
|
|
534
|
+
rl.question(`${q} (y/N) `, (a) => {
|
|
535
|
+
rl.close();
|
|
536
|
+
a = a.trim().toLowerCase();
|
|
537
|
+
resolve(a === "y" || a === "yes");
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const commander_1 = require("commander");
|
|
4
|
+
const scan_1 = require("./commands/scan");
|
|
5
|
+
const import_1 = require("./commands/import");
|
|
6
|
+
const program = new commander_1.Command();
|
|
7
|
+
program.name("i18n-mcp").description("i18n automation tool").version("0.0.1");
|
|
8
|
+
(0, scan_1.scanCommand)(program);
|
|
9
|
+
(0, import_1.importCommand)(program);
|
|
10
|
+
program.parse();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ensureGhInstalled = ensureGhInstalled;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
function ensureGhInstalled() {
|
|
6
|
+
try {
|
|
7
|
+
(0, child_process_1.execSync)("gh --version", { stdio: "ignore" });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error("GitHub CLI (gh) is not installed or not available in PATH.\n" +
|
|
11
|
+
"Install it from: https://cli.github.com/\n" +
|
|
12
|
+
"Then run: gh auth login");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPullRequest = createPullRequest;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
function createPullRequest({ title, body, draft, branch, }) {
|
|
6
|
+
try {
|
|
7
|
+
(0, child_process_1.execSync)(`git checkout -b ${branch}`, { stdio: "inherit" });
|
|
8
|
+
(0, child_process_1.execSync)(`git add locales`, { stdio: "inherit" });
|
|
9
|
+
(0, child_process_1.execSync)(`git commit -m "${title}"`, { stdio: "inherit" });
|
|
10
|
+
let cmd = `gh pr create --title "${title}" --body "${body}"`;
|
|
11
|
+
if (draft)
|
|
12
|
+
cmd += " --draft";
|
|
13
|
+
(0, child_process_1.execSync)(cmd, { stdio: "inherit" });
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
throw new Error("Failed to create PR. Make sure git and gh CLI are properly set up.");
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ddongule/i18n-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"bin": {
|
|
6
|
+
"i18n": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"chalk": "^5",
|
|
18
|
+
"commander": "^11",
|
|
19
|
+
"i18n-core": "workspace:*"
|
|
20
|
+
}
|
|
21
|
+
}
|