@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 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
+ }