i18n-sharpen 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,1599 @@
1
+ #!/usr/bin/env node
2
+ import * as fs8 from 'fs';
3
+ import * as path3 from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { Command } from 'commander';
6
+ import pc5 from 'picocolors';
7
+ import YAML from 'yaml';
8
+ import { z } from 'zod';
9
+
10
+ // src/core/errors.ts
11
+ var I18nSharpenError = class extends Error {
12
+ constructor(error, message) {
13
+ super(message ?? error.message);
14
+ this.error = error;
15
+ this.name = "I18nSharpenError";
16
+ }
17
+ error;
18
+ };
19
+
20
+ // src/core/scanner/regex.ts
21
+ function escapeRegex(input) {
22
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ }
24
+ function buildKeyRegex(matchFunctions) {
25
+ const functionsJoined = matchFunctions.map(escapeRegex).join("|");
26
+ return new RegExp(
27
+ "\\b(?:" + functionsJoined + ")\\s*\\(\\s*(['\"`])([a-zA-Z0-9_\\-.:]+)\\1",
28
+ "g"
29
+ );
30
+ }
31
+ function buildAttrRegex(matchAttributes) {
32
+ const attrsJoined = matchAttributes.map(escapeRegex).join("|");
33
+ return new RegExp(
34
+ "(?:^|[\\s/{(>])(?:" + attrsJoined + ")\\s*=\\s*(['\"`])([a-zA-Z0-9_\\-.:]+)\\1",
35
+ "g"
36
+ );
37
+ }
38
+ function buildDynamicCallRegex(matchFunctions) {
39
+ const functionsJoined = matchFunctions.map(escapeRegex).join("|");
40
+ return new RegExp("\\b(?:" + functionsJoined + ")\\s*\\(\\s*([^)]*)\\)", "g");
41
+ }
42
+
43
+ // src/core/scanner/text.ts
44
+ function stripComments(code) {
45
+ const out = [];
46
+ const stack = [];
47
+ let i = 0;
48
+ const n = code.length;
49
+ while (i < n) {
50
+ const ch = code[i];
51
+ const next = i + 1 < n ? code[i + 1] : "";
52
+ const top = stack.length > 0 ? stack[stack.length - 1] : null;
53
+ if (top) {
54
+ if (top.kind === "template" && ch === "$" && next === "{") {
55
+ top.templateDepth++;
56
+ out.push("${");
57
+ i += 2;
58
+ continue;
59
+ }
60
+ if (top.kind === "template" && top.templateDepth > 0 && ch === "}") {
61
+ top.templateDepth--;
62
+ out.push("}");
63
+ i++;
64
+ continue;
65
+ }
66
+ if (top.kind === "template" && top.templateDepth > 0) ; else {
67
+ if (ch === "\\" && i + 1 < n) {
68
+ out.push(ch, code[i + 1]);
69
+ i += 2;
70
+ continue;
71
+ }
72
+ if (top.kind === "single" && ch === "'" || top.kind === "double" && ch === '"' || top.kind === "template" && ch === "`") {
73
+ stack.pop();
74
+ out.push(ch);
75
+ i++;
76
+ continue;
77
+ }
78
+ out.push(ch);
79
+ i++;
80
+ continue;
81
+ }
82
+ }
83
+ if (ch === "/" && next === "*") {
84
+ i += 2;
85
+ while (i < n && !(code[i] === "*" && i + 1 < n && code[i + 1] === "/")) {
86
+ i++;
87
+ }
88
+ i += 2;
89
+ out.push(" ");
90
+ continue;
91
+ }
92
+ if (ch === "/" && next === "/") {
93
+ i += 2;
94
+ while (i < n && code[i] !== "\n") {
95
+ i++;
96
+ }
97
+ out.push(" ");
98
+ continue;
99
+ }
100
+ if (ch === "'") {
101
+ stack.push({ kind: "single", templateDepth: 0 });
102
+ out.push(ch);
103
+ i++;
104
+ continue;
105
+ }
106
+ if (ch === '"') {
107
+ stack.push({ kind: "double", templateDepth: 0 });
108
+ out.push(ch);
109
+ i++;
110
+ continue;
111
+ }
112
+ if (ch === "`") {
113
+ stack.push({ kind: "template", templateDepth: 0 });
114
+ out.push(ch);
115
+ i++;
116
+ continue;
117
+ }
118
+ out.push(ch);
119
+ i++;
120
+ }
121
+ return out.join("");
122
+ }
123
+ function isStaticStringLiteral(arg) {
124
+ const trimmed = arg.trim();
125
+ if (trimmed.length < 2) return false;
126
+ const quote = trimmed[0];
127
+ if (quote !== "'" && quote !== '"' && quote !== "`") return false;
128
+ let i = 1;
129
+ while (i < trimmed.length) {
130
+ const ch = trimmed[i];
131
+ if (ch === "\\" && i + 1 < trimmed.length) {
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (quote === "`" && ch === "$" && trimmed[i + 1] === "{") {
136
+ return false;
137
+ }
138
+ if (ch === quote) {
139
+ return i === trimmed.length - 1;
140
+ }
141
+ i++;
142
+ }
143
+ return false;
144
+ }
145
+ function getBaseKey(key, suffixes) {
146
+ for (const suffix of suffixes) {
147
+ if (key.endsWith(suffix)) {
148
+ return key.slice(0, -suffix.length);
149
+ }
150
+ }
151
+ return key;
152
+ }
153
+ function matchWildcard(pattern, key) {
154
+ if (pattern === "*") return true;
155
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
156
+ const regex = new RegExp(`^${escaped}$`);
157
+ return regex.test(key);
158
+ }
159
+ function isKeyUsed(key, usedKeys, ignoreKeys, pluralSuffixes) {
160
+ if (usedKeys.has(key)) return true;
161
+ if (ignoreKeys) {
162
+ for (const pattern of ignoreKeys) {
163
+ if (matchWildcard(pattern, key)) {
164
+ return true;
165
+ }
166
+ }
167
+ }
168
+ const baseKey = getBaseKey(key, pluralSuffixes);
169
+ if (baseKey !== key && usedKeys.has(baseKey)) {
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+ function getFiles(dir, extensions, excludeDirs) {
175
+ const results = [];
176
+ if (!fs8.existsSync(dir)) return results;
177
+ let entries;
178
+ try {
179
+ entries = fs8.readdirSync(dir, { withFileTypes: true });
180
+ } catch {
181
+ return results;
182
+ }
183
+ for (const entry of entries) {
184
+ if (entry.isSymbolicLink()) continue;
185
+ const filePath = path3.join(dir, entry.name);
186
+ if (entry.isDirectory()) {
187
+ if (!excludeDirs.includes(entry.name)) {
188
+ results.push(...getFiles(filePath, extensions, excludeDirs));
189
+ }
190
+ } else if (entry.isFile()) {
191
+ if (extensions.includes(path3.extname(entry.name))) {
192
+ results.push(filePath);
193
+ }
194
+ }
195
+ }
196
+ return results;
197
+ }
198
+ function scanSourceFiles(config, cwd) {
199
+ const filesToScan = [];
200
+ for (const scanDir of config.scanDirs) {
201
+ const scanDirAbs = path3.resolve(cwd, scanDir);
202
+ if (fs8.existsSync(scanDirAbs)) {
203
+ filesToScan.push(
204
+ ...getFiles(
205
+ scanDirAbs,
206
+ config.fileExtensions ?? [],
207
+ config.excludeDirs ?? []
208
+ )
209
+ );
210
+ }
211
+ }
212
+ return filesToScan;
213
+ }
214
+
215
+ // src/core/scanner/index.ts
216
+ function detectUsedKeys(files, matchFunctions, matchAttributes) {
217
+ const keyRegex = buildKeyRegex(matchFunctions);
218
+ const attrRegex = buildAttrRegex(matchAttributes);
219
+ const fileContents = files.map((file) => {
220
+ try {
221
+ const content = fs8.readFileSync(file, "utf8");
222
+ return stripComments(content);
223
+ } catch {
224
+ return "";
225
+ }
226
+ });
227
+ const usedKeys = /* @__PURE__ */ new Set();
228
+ for (const cleanContent of fileContents) {
229
+ for (const match of cleanContent.matchAll(keyRegex)) {
230
+ const key = match[2];
231
+ if (key.endsWith(".")) continue;
232
+ usedKeys.add(key);
233
+ }
234
+ for (const match of cleanContent.matchAll(attrRegex)) {
235
+ const key = match[2];
236
+ if (key.endsWith(".")) continue;
237
+ usedKeys.add(key);
238
+ }
239
+ }
240
+ return { usedKeys, fileContents };
241
+ }
242
+
243
+ // src/utils.ts
244
+ var emojiDisabled = typeof process !== "undefined" && !!process.env.NO_EMOJI && process.env.NO_EMOJI !== "0" && process.env.NO_EMOJI.toLowerCase() !== "false";
245
+ var glyphs = {
246
+ ok: emojiDisabled ? "[OK]" : "\u2705",
247
+ warn: emojiDisabled ? "[WARN]" : "\u26A0\uFE0F ",
248
+ err: emojiDisabled ? "[ERR]" : "\u274C"
249
+ };
250
+ var log = {
251
+ header(title) {
252
+ console.log(`
253
+ ${pc5.bold(pc5.cyan(`=== ${title} ===`))}`);
254
+ },
255
+ info(msg) {
256
+ console.log(msg);
257
+ },
258
+ success(msg) {
259
+ console.log(`${pc5.green(glyphs.ok)} ${msg}`);
260
+ },
261
+ warn(msg) {
262
+ console.log(`${pc5.yellow(`${glyphs.warn} Warning:`)} ${msg}`);
263
+ },
264
+ error(msg) {
265
+ console.error(`${pc5.red(`${glyphs.err} Error:`)} ${msg}`);
266
+ }
267
+ };
268
+
269
+ // src/core/locale-io/transform.ts
270
+ var FORBIDDEN_KEY_SEGMENTS = /* @__PURE__ */ new Set([
271
+ "__proto__",
272
+ "prototype",
273
+ "constructor"
274
+ ]);
275
+ function flattenObject(obj, prefix = "") {
276
+ const map = {};
277
+ for (const key in obj) {
278
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
279
+ if (FORBIDDEN_KEY_SEGMENTS.has(key)) continue;
280
+ const value = obj[key];
281
+ const newKey = prefix ? `${prefix}.${key}` : key;
282
+ if (Object.prototype.hasOwnProperty.call(map, newKey)) {
283
+ log.warn(
284
+ `Key collision in locale: '${newKey}' is produced both as a flat key and as a nested path. The later definition wins.`
285
+ );
286
+ }
287
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
288
+ Object.assign(
289
+ map,
290
+ flattenObject(value, newKey)
291
+ );
292
+ } else {
293
+ map[newKey] = String(value);
294
+ }
295
+ }
296
+ }
297
+ return map;
298
+ }
299
+ function setNestedValue(obj, keyPath, value) {
300
+ const parts = keyPath.split(".");
301
+ for (const part of parts) {
302
+ if (FORBIDDEN_KEY_SEGMENTS.has(part)) {
303
+ return;
304
+ }
305
+ }
306
+ let current = obj;
307
+ for (let i = 0; i < parts.length; i++) {
308
+ const part = parts[i];
309
+ if (i === parts.length - 1) {
310
+ current[part] = value;
311
+ } else {
312
+ if (current[part] === void 0 || typeof current[part] !== "object" || current[part] === null) {
313
+ current[part] = {};
314
+ }
315
+ current = current[part];
316
+ }
317
+ }
318
+ }
319
+ function buildNestedObject(flatObj) {
320
+ const result = {};
321
+ for (const key in flatObj) {
322
+ if (Object.prototype.hasOwnProperty.call(flatObj, key)) {
323
+ setNestedValue(result, key, flatObj[key]);
324
+ }
325
+ }
326
+ return result;
327
+ }
328
+ var unflattenObject = buildNestedObject;
329
+ function normalizeDisplayPath(p) {
330
+ return p.split(path3.sep).join("/");
331
+ }
332
+ function findLocaleFile(localesDir, lang) {
333
+ const extensions = [".json", ".yaml", ".yml"];
334
+ const found = extensions.map((ext) => path3.join(localesDir, `${lang}${ext}`)).filter((p) => fs8.existsSync(p));
335
+ if (found.length === 0) return null;
336
+ if (found.length > 1) {
337
+ log.warn(
338
+ `Multiple locale files found for '${lang}' in ${localesDir}: ${found.map((p) => path3.basename(p)).join(
339
+ ", "
340
+ )}. Using '${path3.basename(found[0])}'. Remove the duplicates to silence this warning.`
341
+ );
342
+ }
343
+ return found[0];
344
+ }
345
+ function readLocaleFile(filePath) {
346
+ const ext = path3.extname(filePath).toLowerCase();
347
+ let content = fs8.readFileSync(filePath, "utf8");
348
+ if (content.charCodeAt(0) === 65279) {
349
+ content = content.slice(1);
350
+ }
351
+ const trimmed = content.trim();
352
+ if (trimmed.length === 0) {
353
+ return {};
354
+ }
355
+ if (ext === ".yaml" || ext === ".yml") {
356
+ const parsed = YAML.parse(trimmed);
357
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
358
+ }
359
+ const parsedJson = JSON.parse(trimmed);
360
+ return parsedJson && typeof parsedJson === "object" && !Array.isArray(parsedJson) ? parsedJson : {};
361
+ }
362
+ function writeLocaleFile(filePath, obj) {
363
+ const ext = path3.extname(filePath).toLowerCase();
364
+ let content = "";
365
+ if (ext === ".yaml" || ext === ".yml") {
366
+ content = YAML.stringify(obj, { indent: 2 });
367
+ } else {
368
+ content = JSON.stringify(obj, null, 2);
369
+ }
370
+ if (!content.endsWith("\n")) {
371
+ content += "\n";
372
+ }
373
+ const tmpPath = `${filePath}.tmp`;
374
+ fs8.writeFileSync(tmpPath, content, "utf8");
375
+ try {
376
+ fs8.renameSync(tmpPath, filePath);
377
+ } catch (error) {
378
+ try {
379
+ fs8.unlinkSync(tmpPath);
380
+ } catch {
381
+ }
382
+ throw error;
383
+ }
384
+ }
385
+ function loadAllLocales(localesDir, supportedLanguages, onMissing = () => {
386
+ }) {
387
+ const locales = {};
388
+ const localesFlat = {};
389
+ const localeKeySets = {};
390
+ const localePaths = {};
391
+ for (const lang of supportedLanguages) {
392
+ const langPath = findLocaleFile(localesDir, lang);
393
+ localePaths[lang] = langPath;
394
+ if (!langPath) {
395
+ onMissing(lang, localesDir);
396
+ locales[lang] = {};
397
+ localesFlat[lang] = {};
398
+ localeKeySets[lang] = /* @__PURE__ */ new Set();
399
+ continue;
400
+ }
401
+ const parsed = readLocaleFile(langPath);
402
+ locales[lang] = parsed;
403
+ localesFlat[lang] = flattenObject(parsed);
404
+ localeKeySets[lang] = new Set(Object.keys(localesFlat[lang]));
405
+ }
406
+ return { locales, localesFlat, localeKeySets, localePaths };
407
+ }
408
+ function loadNamespacedLocales(localesDir, supportedLanguages, onMissing = () => {
409
+ }) {
410
+ const locales = {};
411
+ const localesFlat = {};
412
+ const localeKeySets = {};
413
+ const localeNamespaces = {};
414
+ for (const lang of supportedLanguages) {
415
+ const langDir = path3.join(localesDir, lang);
416
+ localeNamespaces[lang] = {};
417
+ if (!fs8.existsSync(langDir) || !fs8.statSync(langDir).isDirectory()) {
418
+ onMissing(lang, localesDir);
419
+ locales[lang] = {};
420
+ localesFlat[lang] = {};
421
+ localeKeySets[lang] = /* @__PURE__ */ new Set();
422
+ continue;
423
+ }
424
+ const entries = fs8.readdirSync(langDir, { withFileTypes: true });
425
+ const merged = {};
426
+ const mergedFlat = {};
427
+ for (const entry of entries) {
428
+ if (!entry.isFile()) continue;
429
+ const ext = path3.extname(entry.name).toLowerCase();
430
+ if (ext !== ".json" && ext !== ".yaml" && ext !== ".yml") continue;
431
+ const ns = path3.basename(entry.name, ext);
432
+ const filePath = path3.join(langDir, entry.name);
433
+ localeNamespaces[lang][ns] = filePath;
434
+ const parsed = readLocaleFile(filePath);
435
+ merged[ns] = parsed;
436
+ const nsFlat = flattenObject(parsed);
437
+ for (const [k, v] of Object.entries(nsFlat)) {
438
+ mergedFlat[`${ns}:${k}`] = v;
439
+ }
440
+ }
441
+ locales[lang] = merged;
442
+ localesFlat[lang] = mergedFlat;
443
+ localeKeySets[lang] = new Set(Object.keys(mergedFlat));
444
+ }
445
+ return { locales, localesFlat, localeKeySets, localeNamespaces };
446
+ }
447
+
448
+ // src/commands/extract.ts
449
+ function extract(config, cwd = process.cwd()) {
450
+ log.header("I18N-SHARPEN EXTRACTOR");
451
+ const localesDirAbs = path3.resolve(cwd, config.localesDir);
452
+ if (!fs8.existsSync(localesDirAbs)) {
453
+ throw new I18nSharpenError({
454
+ kind: "filesystem",
455
+ message: `Locales directory not found: ${localesDirAbs}`,
456
+ path: localesDirAbs
457
+ });
458
+ }
459
+ const files = scanSourceFiles(config, cwd);
460
+ const matchFunctions = config.matchFunctions ?? ["t", "getTranslation"];
461
+ const matchAttributes = config.matchAttributes ?? ["i18nKey", "id"];
462
+ const { usedKeys } = detectUsedKeys(files, matchFunctions, matchAttributes);
463
+ log.info(
464
+ `Found ${pc5.green(usedKeys.size)} unique translation keys referenced in code.`
465
+ );
466
+ if (config.localesLayout === "namespaced") {
467
+ extractNamespaced(config, localesDirAbs, usedKeys);
468
+ } else {
469
+ extractFlat(config, localesDirAbs, usedKeys);
470
+ }
471
+ }
472
+ function extractFlat(config, localesDirAbs, usedKeys) {
473
+ const writePlans = [];
474
+ const suffixes = config.pluralSuffixes ?? [];
475
+ for (const lang of config.supportedLanguages) {
476
+ let langPath = findLocaleFile(localesDirAbs, lang);
477
+ let flatJson = {};
478
+ if (!langPath) {
479
+ langPath = path3.join(localesDirAbs, `${lang}.json`);
480
+ } else {
481
+ try {
482
+ const langJson = readLocaleFile(langPath);
483
+ flatJson = flattenObject(langJson);
484
+ } catch (error) {
485
+ throw new I18nSharpenError({
486
+ kind: "parse",
487
+ message: `Failed to parse locale file '${path3.basename(langPath)}': ${error.message}`,
488
+ path: langPath
489
+ });
490
+ }
491
+ }
492
+ const missingKeys = [];
493
+ for (const key of usedKeys) {
494
+ let exists = key in flatJson;
495
+ if (!exists) {
496
+ for (const suffix of suffixes) {
497
+ if (key + suffix in flatJson) {
498
+ exists = true;
499
+ break;
500
+ }
501
+ }
502
+ }
503
+ if (!exists) {
504
+ missingKeys.push(key);
505
+ }
506
+ }
507
+ if (missingKeys.length > 0) {
508
+ missingKeys.sort();
509
+ for (const key of missingKeys) {
510
+ flatJson[key] = key;
511
+ }
512
+ const nestedJson = unflattenObject(flatJson);
513
+ writePlans.push({ lang, langPath, nestedJson, missingKeys });
514
+ } else {
515
+ log.info(
516
+ `\u2728 No new keys to extract for ${pc5.cyan(path3.basename(langPath))}.`
517
+ );
518
+ }
519
+ }
520
+ let totalExtractedCount = 0;
521
+ for (const plan of writePlans) {
522
+ log.info(
523
+ `\u{1F4E5} Extracting ${pc5.green(plan.missingKeys.length)} new keys to ${pc5.cyan(path3.basename(plan.langPath))}:`
524
+ );
525
+ for (const key of plan.missingKeys) {
526
+ log.info(` + ${pc5.green(key)}`);
527
+ }
528
+ try {
529
+ writeLocaleFile(plan.langPath, plan.nestedJson);
530
+ totalExtractedCount += plan.missingKeys.length;
531
+ } catch (error) {
532
+ throw new I18nSharpenError({
533
+ kind: "filesystem",
534
+ message: `Failed to write to file '${plan.langPath}': ${error.message}`,
535
+ path: plan.langPath,
536
+ cause: error
537
+ });
538
+ }
539
+ }
540
+ if (totalExtractedCount > 0) {
541
+ log.success("Locale files updated successfully!\n");
542
+ } else {
543
+ log.success(
544
+ "All used translation keys are already present in locale files.\n"
545
+ );
546
+ }
547
+ }
548
+ function extractNamespaced(config, localesDirAbs, usedKeys) {
549
+ const suffixes = config.pluralSuffixes ?? [];
550
+ const { localesFlat, localeNamespaces } = loadNamespacedLocales(
551
+ localesDirAbs,
552
+ config.supportedLanguages
553
+ );
554
+ const writePlans = [];
555
+ for (const lang of config.supportedLanguages) {
556
+ const existingFlat = localesFlat[lang] ?? {};
557
+ const nsFilePaths = localeNamespaces[lang] ?? {};
558
+ const missingByNs = /* @__PURE__ */ new Map();
559
+ for (const fullKey of usedKeys) {
560
+ const colonIdx = fullKey.indexOf(":");
561
+ const ns = colonIdx >= 0 ? fullKey.slice(0, colonIdx) : "default";
562
+ const keyPath = colonIdx >= 0 ? fullKey.slice(colonIdx + 1) : fullKey;
563
+ const namespacedKey = `${ns}:${keyPath}`;
564
+ let exists = namespacedKey in existingFlat;
565
+ if (!exists) {
566
+ for (const suffix of suffixes) {
567
+ if (`${ns}:${keyPath}${suffix}` in existingFlat) {
568
+ exists = true;
569
+ break;
570
+ }
571
+ }
572
+ }
573
+ if (!exists) {
574
+ let arr = missingByNs.get(ns);
575
+ if (!arr) {
576
+ arr = [];
577
+ missingByNs.set(ns, arr);
578
+ }
579
+ arr.push(keyPath);
580
+ }
581
+ }
582
+ for (const [ns, missingKeys] of missingByNs) {
583
+ missingKeys.sort();
584
+ const langDir = path3.join(localesDirAbs, lang);
585
+ const filePath = nsFilePaths[ns] ?? path3.join(langDir, `${ns}.json`);
586
+ writePlans.push({ lang, ns, filePath, missingKeys });
587
+ }
588
+ if (missingByNs.size === 0) {
589
+ log.info(`\u2728 No new keys to extract for ${pc5.cyan(lang)} (namespaced).`);
590
+ }
591
+ }
592
+ const writeItems = [];
593
+ for (const plan of writePlans) {
594
+ const langDir = path3.dirname(plan.filePath);
595
+ let existingFlat = {};
596
+ if (fs8.existsSync(plan.filePath)) {
597
+ try {
598
+ existingFlat = flattenObject(readLocaleFile(plan.filePath));
599
+ } catch (error) {
600
+ throw new I18nSharpenError({
601
+ kind: "parse",
602
+ message: `Failed to parse namespace file '${plan.filePath}': ${error.message}`,
603
+ path: plan.filePath
604
+ });
605
+ }
606
+ } else {
607
+ fs8.mkdirSync(langDir, { recursive: true });
608
+ }
609
+ for (const keyPath of plan.missingKeys) {
610
+ existingFlat[keyPath] = keyPath;
611
+ }
612
+ const nestedJson = unflattenObject(existingFlat);
613
+ const displayLabel = `${plan.lang}/${plan.ns}.json`;
614
+ writeItems.push({
615
+ filePath: plan.filePath,
616
+ nestedJson,
617
+ missingKeys: plan.missingKeys,
618
+ displayLabel
619
+ });
620
+ }
621
+ let totalExtractedCount = 0;
622
+ for (const item of writeItems) {
623
+ log.info(
624
+ `\u{1F4E5} Extracting ${pc5.green(item.missingKeys.length)} new keys to ${pc5.cyan(item.displayLabel)}:`
625
+ );
626
+ for (const key of item.missingKeys) {
627
+ log.info(` + ${pc5.green(key)}`);
628
+ }
629
+ try {
630
+ writeLocaleFile(item.filePath, item.nestedJson);
631
+ totalExtractedCount += item.missingKeys.length;
632
+ } catch (error) {
633
+ throw new I18nSharpenError({
634
+ kind: "filesystem",
635
+ message: `Failed to write to file '${item.filePath}': ${error.message}`,
636
+ path: item.filePath,
637
+ cause: error
638
+ });
639
+ }
640
+ }
641
+ if (totalExtractedCount > 0) {
642
+ log.success("Locale files updated successfully!\n");
643
+ } else {
644
+ log.success(
645
+ "All used translation keys are already present in locale files.\n"
646
+ );
647
+ }
648
+ }
649
+ function executePrunePlans(writePlans, perLocale, dryRun) {
650
+ let totalPrunedCount = 0;
651
+ let written = false;
652
+ if (dryRun) {
653
+ log.header(
654
+ writePlans.length === 0 ? "PRUNE PREVIEW (no changes)" : "PRUNE PREVIEW (dry-run \u2014 no files written)"
655
+ );
656
+ }
657
+ for (const plan of writePlans) {
658
+ const displayName = plan.displayName ?? path3.basename(plan.langPath);
659
+ const verb = dryRun ? "Would prune" : "Pruning";
660
+ log.info(
661
+ `${verb} ${pc5.yellow(plan.prunedKeys.length)} unused keys from ${pc5.cyan(displayName)}`
662
+ );
663
+ const sample = plan.prunedKeys.slice(0, 10);
664
+ for (const k of sample) {
665
+ log.info(` - ${pc5.yellow(k)}`);
666
+ }
667
+ if (plan.prunedKeys.length > sample.length) {
668
+ log.info(
669
+ ` ... and ${plan.prunedKeys.length - sample.length} more (run with verbose flag to see all)`
670
+ );
671
+ }
672
+ if (!dryRun) {
673
+ try {
674
+ writeLocaleFile(plan.langPath, plan.nestedJson);
675
+ totalPrunedCount += plan.prunedKeys.length;
676
+ written = true;
677
+ } catch (error) {
678
+ throw new I18nSharpenError({
679
+ kind: "filesystem",
680
+ message: `Failed to write to file '${plan.langPath}': ${error.message}`,
681
+ path: plan.langPath,
682
+ cause: error
683
+ });
684
+ }
685
+ } else {
686
+ totalPrunedCount += plan.prunedKeys.length;
687
+ }
688
+ }
689
+ if (dryRun) {
690
+ if (totalPrunedCount > 0) {
691
+ log.warn(
692
+ `Dry-run: ${totalPrunedCount} key${totalPrunedCount === 1 ? "" : "s"} would be removed. Re-run with --force (or set prune.force: true in config) to apply.
693
+ `
694
+ );
695
+ } else {
696
+ log.success("Dry-run: no unused keys found to prune.\n");
697
+ }
698
+ } else if (totalPrunedCount > 0) {
699
+ log.success(
700
+ `Files have been successfully cleaned! Total pruned: ${totalPrunedCount} keys.
701
+ `
702
+ );
703
+ } else {
704
+ log.success("No unused keys found to prune.\n");
705
+ }
706
+ return { written, dryRun, perLocale, totalPruned: totalPrunedCount };
707
+ }
708
+ function pruneFlat(config, localesDirAbs, usedKeys, fileContents, dryRun) {
709
+ const allLocaleKeys = /* @__PURE__ */ new Set();
710
+ const localesFlat = {};
711
+ const localeFilePaths = {};
712
+ for (const lang of config.supportedLanguages) {
713
+ const langPath = findLocaleFile(localesDirAbs, lang);
714
+ if (langPath) {
715
+ localeFilePaths[lang] = langPath;
716
+ try {
717
+ const parsed = readLocaleFile(langPath);
718
+ localesFlat[lang] = flattenObject(parsed);
719
+ Object.keys(localesFlat[lang]).forEach((key) => allLocaleKeys.add(key));
720
+ } catch (error) {
721
+ throw new I18nSharpenError({
722
+ kind: "parse",
723
+ message: `Failed to parse locale file '${path3.basename(langPath)}': ${error.message}`,
724
+ path: langPath
725
+ });
726
+ }
727
+ }
728
+ }
729
+ if (config.looseKeyMatch) {
730
+ for (const key of allLocaleKeys) {
731
+ if (usedKeys.has(key)) continue;
732
+ const dq = `"${key}"`, sq = `'${key}'`, bq = `\`${key}\``;
733
+ for (const cleanContent of fileContents) {
734
+ if (cleanContent.includes(dq) || cleanContent.includes(sq) || cleanContent.includes(bq)) {
735
+ usedKeys.add(key);
736
+ break;
737
+ }
738
+ }
739
+ }
740
+ }
741
+ const suffixes = config.pluralSuffixes ?? [];
742
+ const isUsed = (key) => isKeyUsed(key, usedKeys, config.ignoreKeys, suffixes);
743
+ const writePlans = [];
744
+ const perLocale = [];
745
+ for (const lang of config.supportedLanguages) {
746
+ const langPath = localeFilePaths[lang];
747
+ if (!langPath) continue;
748
+ const flatJson = localesFlat[lang];
749
+ const newFlatJson = {};
750
+ const prunedKeys = [];
751
+ for (const key in flatJson) {
752
+ if (isUsed(key)) {
753
+ newFlatJson[key] = flatJson[key];
754
+ } else {
755
+ prunedKeys.push(key);
756
+ }
757
+ }
758
+ if (prunedKeys.length > 0) {
759
+ const nestedJson = unflattenObject(newFlatJson);
760
+ writePlans.push({ lang, langPath, nestedJson, prunedKeys });
761
+ } else {
762
+ log.info(
763
+ `\u2728 No unused keys to prune in ${pc5.cyan(path3.basename(langPath))}.`
764
+ );
765
+ }
766
+ perLocale.push({ lang, file: langPath, prunedKeys });
767
+ }
768
+ return executePrunePlans(writePlans, perLocale, dryRun);
769
+ }
770
+ function pruneNamespaced(config, localesDirAbs, usedKeys, fileContents, dryRun) {
771
+ const suffixes = config.pluralSuffixes ?? [];
772
+ const { localesFlat, localeNamespaces } = loadNamespacedLocales(
773
+ localesDirAbs,
774
+ config.supportedLanguages
775
+ );
776
+ const allLocaleKeys = /* @__PURE__ */ new Set();
777
+ for (const lang of config.supportedLanguages) {
778
+ for (const key of Object.keys(localesFlat[lang] ?? {})) {
779
+ allLocaleKeys.add(key);
780
+ }
781
+ }
782
+ if (config.looseKeyMatch) {
783
+ for (const key of allLocaleKeys) {
784
+ if (usedKeys.has(key)) continue;
785
+ const dq = `"${key}"`, sq = `'${key}'`, bq = `\`${key}\``;
786
+ for (const cleanContent of fileContents) {
787
+ if (cleanContent.includes(dq) || cleanContent.includes(sq) || cleanContent.includes(bq)) {
788
+ usedKeys.add(key);
789
+ break;
790
+ }
791
+ }
792
+ }
793
+ }
794
+ const isUsed = (namespacedKey) => isKeyUsed(namespacedKey, usedKeys, config.ignoreKeys, suffixes);
795
+ const writePlans = [];
796
+ const perLocale = [];
797
+ for (const lang of config.supportedLanguages) {
798
+ const nsFilePaths = localeNamespaces[lang] ?? {};
799
+ const langFlat = localesFlat[lang] ?? {};
800
+ const keysByNs = /* @__PURE__ */ new Map();
801
+ for (const [namespacedKey, value] of Object.entries(langFlat)) {
802
+ const colonIdx = namespacedKey.indexOf(":");
803
+ const ns = colonIdx >= 0 ? namespacedKey.slice(0, colonIdx) : "default";
804
+ const keyPath = colonIdx >= 0 ? namespacedKey.slice(colonIdx + 1) : namespacedKey;
805
+ let nsObj = keysByNs.get(ns);
806
+ if (!nsObj) {
807
+ nsObj = {};
808
+ keysByNs.set(ns, nsObj);
809
+ }
810
+ nsObj[keyPath] = value;
811
+ }
812
+ for (const [ns, nsFlatKeys] of keysByNs) {
813
+ const filePath = nsFilePaths[ns];
814
+ if (!filePath) continue;
815
+ const newFlatJson = {};
816
+ const prunedKeys = [];
817
+ for (const keyPath of Object.keys(nsFlatKeys)) {
818
+ const namespacedKey = `${ns}:${keyPath}`;
819
+ if (isUsed(namespacedKey)) {
820
+ newFlatJson[keyPath] = nsFlatKeys[keyPath];
821
+ } else {
822
+ prunedKeys.push(keyPath);
823
+ }
824
+ }
825
+ if (prunedKeys.length > 0) {
826
+ const nestedJson = unflattenObject(newFlatJson);
827
+ writePlans.push({ lang, ns, filePath, nestedJson, prunedKeys });
828
+ } else {
829
+ log.info(
830
+ `\u2728 No unused keys to prune in ${pc5.cyan(`${lang}/${ns}.json`)}.`
831
+ );
832
+ }
833
+ perLocale.push({ lang, file: filePath, prunedKeys });
834
+ }
835
+ if (keysByNs.size === 0) {
836
+ log.info(`\u2728 No locale files found for ${pc5.cyan(lang)}.`);
837
+ }
838
+ }
839
+ const flatPlans = writePlans.map((p) => ({
840
+ lang: p.lang,
841
+ langPath: p.filePath,
842
+ nestedJson: p.nestedJson,
843
+ prunedKeys: p.prunedKeys,
844
+ displayName: `${p.lang}/${p.ns}.json`
845
+ }));
846
+ return executePrunePlans(flatPlans, perLocale, dryRun);
847
+ }
848
+
849
+ // src/commands/prune.ts
850
+ function prune(config, cwd = process.cwd(), options = {}) {
851
+ log.header("I18N-SHARPEN PRUNER");
852
+ const configForce = config.prune?.force === true;
853
+ const optForce = options.force === true;
854
+ const optDryRun = options.dryRun === true;
855
+ const dryRun = optDryRun ? true : !(optForce || configForce);
856
+ const localesDirAbs = path3.resolve(cwd, config.localesDir);
857
+ if (!fs8.existsSync(localesDirAbs)) {
858
+ throw new I18nSharpenError({
859
+ kind: "filesystem",
860
+ message: `Locales directory not found: ${localesDirAbs}`,
861
+ path: localesDirAbs
862
+ });
863
+ }
864
+ const files = scanSourceFiles(config, cwd);
865
+ const matchFunctions = config.matchFunctions ?? ["t", "getTranslation"];
866
+ const matchAttributes = config.matchAttributes ?? ["i18nKey", "id"];
867
+ const { usedKeys, fileContents } = detectUsedKeys(
868
+ files,
869
+ matchFunctions,
870
+ matchAttributes
871
+ );
872
+ log.info(
873
+ `Found ${pc5.green(usedKeys.size)} unique translation keys referenced in code.`
874
+ );
875
+ if (config.localesLayout === "namespaced") {
876
+ return pruneNamespaced(
877
+ config,
878
+ localesDirAbs,
879
+ usedKeys,
880
+ fileContents,
881
+ dryRun
882
+ );
883
+ }
884
+ return pruneFlat(config, localesDirAbs, usedKeys, fileContents, dryRun);
885
+ }
886
+
887
+ // src/commands/validate/checks.ts
888
+ function findMissingKeys(usedKeys, defaultKeySet, config) {
889
+ const suffixes = config.pluralSuffixes ?? [];
890
+ const missing = [];
891
+ for (const key of usedKeys) {
892
+ let exists = defaultKeySet.has(key);
893
+ if (!exists) {
894
+ for (const suffix of suffixes) {
895
+ if (defaultKeySet.has(key + suffix)) {
896
+ exists = true;
897
+ break;
898
+ }
899
+ }
900
+ }
901
+ if (!exists) {
902
+ missing.push(key);
903
+ }
904
+ }
905
+ return missing;
906
+ }
907
+ function findUnusedKeys(defaultKeys, usedKeys, config) {
908
+ const suffixes = config.pluralSuffixes ?? [];
909
+ const unused = [];
910
+ for (const key of defaultKeys) {
911
+ if (!isKeyUsed(key, usedKeys, config.ignoreKeys, suffixes)) {
912
+ unused.push(key);
913
+ }
914
+ }
915
+ return unused;
916
+ }
917
+ function findAlignmentMismatches(config, defaultKeys, defaultKeySet, localesFlat, localeKeySets) {
918
+ const mismatches = [];
919
+ for (const lang of config.supportedLanguages) {
920
+ if (lang === config.defaultLanguage) continue;
921
+ const langKeySet = localeKeySets[lang];
922
+ const onlyInDefault = defaultKeys.filter((key) => !langKeySet.has(key));
923
+ const onlyInTarget = Object.keys(localesFlat[lang]).filter(
924
+ (key) => !defaultKeySet.has(key)
925
+ );
926
+ if (onlyInDefault.length > 0) {
927
+ mismatches.push({
928
+ from: config.defaultLanguage,
929
+ to: lang,
930
+ keys: onlyInDefault
931
+ });
932
+ }
933
+ if (onlyInTarget.length > 0) {
934
+ mismatches.push({
935
+ from: lang,
936
+ to: config.defaultLanguage,
937
+ keys: onlyInTarget
938
+ });
939
+ }
940
+ }
941
+ return mismatches;
942
+ }
943
+ function findPlaceholderKeys(config, usedKeys, localesFlat) {
944
+ const suffixes = config.pluralSuffixes ?? [];
945
+ const activePlaceholderKeys = [];
946
+ const unusedPlaceholderKeys = [];
947
+ for (const lang of config.supportedLanguages) {
948
+ const flatMap = localesFlat[lang];
949
+ for (const key in flatMap) {
950
+ if (flatMap[key] === key) {
951
+ if (isKeyUsed(key, usedKeys, config.ignoreKeys, suffixes)) {
952
+ activePlaceholderKeys.push({ key, lang });
953
+ } else {
954
+ unusedPlaceholderKeys.push({ key, lang });
955
+ }
956
+ }
957
+ }
958
+ }
959
+ return { activePlaceholderKeys, unusedPlaceholderKeys };
960
+ }
961
+ function printValidationResults(results, keyToFilesMap, pluralSuffixes) {
962
+ const {
963
+ missingKeys,
964
+ activePlaceholderKeys,
965
+ unusedKeys,
966
+ unusedPlaceholderKeys,
967
+ keysOnlyInLanguages
968
+ } = results;
969
+ const getBK = (key) => getBaseKey(key, pluralSuffixes);
970
+ log.header("VALIDATION RESULTS");
971
+ if (missingKeys.length > 0) {
972
+ log.info(pc5.bold(pc5.red(`\u274C Missing Keys (${missingKeys.length}):`)));
973
+ missingKeys.sort().forEach((key) => {
974
+ const files = keyToFilesMap.get(key) ?? [];
975
+ log.info(` - ${pc5.red(key)} (referenced in: ${files.join(", ")})`);
976
+ });
977
+ } else {
978
+ log.success("Zero missing keys detected in the source code!");
979
+ }
980
+ if (activePlaceholderKeys.length > 0) {
981
+ log.info(
982
+ `
983
+ ${pc5.bold(pc5.red(`\u274C Active Placeholder/Untranslated Keys Used in Code (${activePlaceholderKeys.length}):`))}`
984
+ );
985
+ activePlaceholderKeys.sort((a, b) => a.key.localeCompare(b.key)).forEach(({ key, lang }) => {
986
+ const direct = keyToFilesMap.get(key);
987
+ const baseKey = getBK(key);
988
+ const baseFiles = baseKey !== key ? keyToFilesMap.get(baseKey) : void 0;
989
+ const files = direct ?? baseFiles ?? [];
990
+ log.info(
991
+ ` - [${lang.toUpperCase()}] ${pc5.red(key)} ${files.length > 0 ? `(referenced in: ${files.join(", ")})` : ""}`
992
+ );
993
+ });
994
+ } else {
995
+ log.success("Zero active placeholder keys detected in the source code!");
996
+ }
997
+ if (keysOnlyInLanguages.length > 0) {
998
+ log.info(`
999
+ ${pc5.bold(pc5.red("\u274C Locale Alignment Mismatches:"))}`);
1000
+ for (const mismatch of keysOnlyInLanguages) {
1001
+ log.info(
1002
+ ` ${pc5.yellow(`Keys present in ${mismatch.from} but missing in ${mismatch.to} (${mismatch.keys.length}):`)}`
1003
+ );
1004
+ mismatch.keys.slice().sort().forEach((k) => {
1005
+ log.info(` - ${k}`);
1006
+ });
1007
+ }
1008
+ } else {
1009
+ log.success("Perfect key alignment across all locale files!");
1010
+ }
1011
+ if (unusedKeys.length > 0) {
1012
+ log.info(
1013
+ `
1014
+ ${pc5.bold(pc5.yellow(`\u26A0\uFE0F Unused Keys in locales (${unusedKeys.length}):`))}`
1015
+ );
1016
+ unusedKeys.sort().forEach((key) => {
1017
+ log.info(` - ${pc5.yellow(key)}`);
1018
+ });
1019
+ } else {
1020
+ log.success(
1021
+ "Zero unused keys detected! All defined keys are referenced in code."
1022
+ );
1023
+ }
1024
+ if (unusedPlaceholderKeys.length > 0) {
1025
+ log.info(
1026
+ `
1027
+ ${pc5.bold(pc5.yellow(`\u26A0\uFE0F Unused Placeholder Keys in locales (${unusedPlaceholderKeys.length}):`))}`
1028
+ );
1029
+ unusedPlaceholderKeys.sort((a, b) => a.key.localeCompare(b.key)).forEach(({ key, lang }) => {
1030
+ log.info(` - [${lang.toUpperCase()}] ${pc5.yellow(key)}`);
1031
+ });
1032
+ }
1033
+ log.header("QUALITY METRICS SUMMARY");
1034
+ log.info(
1035
+ `- Translation Key Coverage (Code -> Locales): ${pc5.bold(results.codeKeyCoverage === "100.00" ? pc5.green(results.codeKeyCoverage + "%") : pc5.red(results.codeKeyCoverage + "%"))}`
1036
+ );
1037
+ log.info(
1038
+ `- Translation Key Utilization (Locales -> Code): ${pc5.bold(pc5.magenta(results.utilizationPercent + "%"))}`
1039
+ );
1040
+ log.info(`- Total Defined Keys: ${pc5.bold(results.totalDefinedKeys)}`);
1041
+ log.info(`- Actually Used in Code: ${pc5.bold(results.usedDefinedKeysCount)}`);
1042
+ log.info(`- Missing/Undefined: ${pc5.bold(missingKeys.length)}`);
1043
+ log.info(`- Unused/Stale: ${pc5.bold(unusedKeys.length)}`);
1044
+ }
1045
+ function writeMarkdownReport(args) {
1046
+ const reportPath = path3.resolve(args.cwd, args.outputReport);
1047
+ const markdownContent = renderMarkdownReport(args);
1048
+ fs8.mkdirSync(path3.dirname(reportPath), { recursive: true });
1049
+ fs8.writeFileSync(reportPath, markdownContent, "utf8");
1050
+ log.info(
1051
+ `\u{1F4BE} Markdown report saved to: ${pc5.cyan(normalizeDisplayPath(path3.relative(args.cwd, reportPath)))}
1052
+ `
1053
+ );
1054
+ return reportPath;
1055
+ }
1056
+ function renderMarkdownReport(args) {
1057
+ const {
1058
+ missingKeys,
1059
+ activePlaceholderKeys,
1060
+ unusedKeys,
1061
+ unusedPlaceholderKeys,
1062
+ keysOnlyInLanguages,
1063
+ codeKeyCoverage,
1064
+ utilizationPercent,
1065
+ totalDefinedKeys,
1066
+ usedDefinedKeysCount
1067
+ } = args.results;
1068
+ const { keyToFilesMap, getBaseKey: getBaseKey2, defaultBasename } = args;
1069
+ return `# i18n Quality and Coverage Report
1070
+
1071
+ Generated on: ${(/* @__PURE__ */ new Date()).toISOString()}
1072
+
1073
+ ## Quality Metrics Summary
1074
+
1075
+ | Metric | Value | Status |
1076
+ | :--- | :--- | :--- |
1077
+ | **Code Translation Coverage** | ${codeKeyCoverage}% | ${codeKeyCoverage === "100.00" ? "\u{1F7E2} 100% Perfect" : "\u{1F534} Missing Translations"} |
1078
+ | **Locales Key Utilization** | ${utilizationPercent}% | ${Number(utilizationPercent) > 90 ? "\u{1F7E2} High" : "\u{1F7E1} Medium"} |
1079
+ | **Total Defined Keys** | ${totalDefinedKeys} | - |
1080
+ | **Actually Used Keys** | ${usedDefinedKeysCount} | - |
1081
+ | **Missing Keys** | ${missingKeys.length} | ${missingKeys.length === 0 ? "\u{1F7E2} Clean" : "\u{1F534} Action Required"} |
1082
+ | **Active Placeholders** | ${activePlaceholderKeys.length} | ${activePlaceholderKeys.length === 0 ? "\u{1F7E2} Clean" : "\u{1F534} Action Required"} |
1083
+ | **Unused Keys** | ${unusedKeys.length} | ${unusedKeys.length === 0 ? "\u{1F7E2} Optimized" : "\u{1F7E1} Can be pruned"} |
1084
+ | **Locale Alignment** | ${keysOnlyInLanguages.length === 0 ? "Align'd" : "Mismatch"} | ${keysOnlyInLanguages.length === 0 ? "\u{1F7E2} Perfect" : "\u{1F534} Action Required"} |
1085
+
1086
+ ${renderMissingKeysSection(missingKeys, keyToFilesMap, defaultBasename)}
1087
+
1088
+ ${renderActivePlaceholdersSection(activePlaceholderKeys, keyToFilesMap, getBaseKey2)}
1089
+
1090
+ ${renderAlignmentSection(keysOnlyInLanguages)}
1091
+
1092
+ ${renderUnusedKeysSection(unusedKeys)}
1093
+
1094
+ ${renderUnusedPlaceholdersSection(unusedPlaceholderKeys)}
1095
+ `;
1096
+ }
1097
+ function renderMissingKeysSection(missingKeys, keyToFilesMap, defaultBasename) {
1098
+ if (missingKeys.length === 0) {
1099
+ return "## \u2705 Missing Keys\n\nNo missing translation keys detected in the source code.";
1100
+ }
1101
+ return `## \u274C Missing Keys (${missingKeys.length})
1102
+
1103
+ The following keys are used in the source code but are not defined in the main locale file \`${defaultBasename}\`:
1104
+
1105
+ ${missingKeys.sort().map(
1106
+ (key) => `- **\`${key}\`** (referenced in: ${keyToFilesMap.get(key)?.map((f) => `\`${f}\``).join(", ")})`
1107
+ ).join("\n")}
1108
+ `;
1109
+ }
1110
+ function renderActivePlaceholdersSection(activePlaceholderKeys, keyToFilesMap, getBaseKey2) {
1111
+ if (activePlaceholderKeys.length === 0) {
1112
+ return "## \u2705 Active Placeholders\n\nNo active placeholder keys detected in the source code.";
1113
+ }
1114
+ return `## \u274C Active Placeholders (${activePlaceholderKeys.length})
1115
+
1116
+ The following keys are referenced in the source code but only have placeholder values (identical to the key path):
1117
+
1118
+ ${activePlaceholderKeys.sort((a, b) => a.key.localeCompare(b.key)).map(
1119
+ ({ key, lang }) => `- **\`${key}\`** [\`${lang.toUpperCase()}\`] ${keyToFilesMap.has(key) ? `(referenced in: ${keyToFilesMap.get(key)?.map((f) => `\`${f}\``).join(", ")})` : keyToFilesMap.has(getBaseKey2(key)) ? `(referenced in: ${keyToFilesMap.get(getBaseKey2(key))?.map((f) => `\`${f}\``).join(", ")})` : ""}`
1120
+ ).join("\n")}
1121
+ `;
1122
+ }
1123
+ function renderAlignmentSection(keysOnlyInLanguages) {
1124
+ if (keysOnlyInLanguages.length === 0) {
1125
+ return "## \u2705 Locale Alignment\n\nPerfect key alignment between all locale files.";
1126
+ }
1127
+ return `## \u274C Locale Alignment Mismatches
1128
+
1129
+ ${keysOnlyInLanguages.map(
1130
+ (m) => `### Keys in ${m.from} but missing in ${m.to} (${m.keys.length})
1131
+ ${m.keys.slice().sort().map((k) => `- \`${k}\``).join("\n")}
1132
+ `
1133
+ ).join("\n")}
1134
+ `;
1135
+ }
1136
+ function renderUnusedKeysSection(unusedKeys) {
1137
+ if (unusedKeys.length === 0) {
1138
+ return "## \u2705 Unused Keys\n\nAll defined translation keys are used in the source code.";
1139
+ }
1140
+ return `## \u26A0\uFE0F Unused Keys (${unusedKeys.length})
1141
+
1142
+ These keys are defined in the locale file but are not used anywhere in the source code. They can be safely pruned to reduce bundle size:
1143
+
1144
+ ${unusedKeys.sort().map((key) => `- \`${key}\``).join("\n")}
1145
+ `;
1146
+ }
1147
+ function renderUnusedPlaceholdersSection(unusedPlaceholderKeys) {
1148
+ if (unusedPlaceholderKeys.length === 0) return "";
1149
+ return `## \u26A0\uFE0F Unused Placeholders (${unusedPlaceholderKeys.length})
1150
+
1151
+ These keys have placeholder values but are not currently used in the source code. They should be translated before use:
1152
+
1153
+ ${unusedPlaceholderKeys.sort((a, b) => a.key.localeCompare(b.key)).map(({ key, lang }) => `- \`${key}\` [\`${lang.toUpperCase()}\`]`).join("\n")}
1154
+ `;
1155
+ }
1156
+
1157
+ // src/commands/validate.ts
1158
+ function validate(config, cwd = process.cwd()) {
1159
+ log.header("I18N-SHARPEN VALIDATOR");
1160
+ const localesDirAbs = path3.resolve(cwd, config.localesDir);
1161
+ const { localesFlat, localeKeySets, localePaths } = loadAllLocales(
1162
+ localesDirAbs,
1163
+ config.supportedLanguages,
1164
+ (lang) => {
1165
+ log.warn(
1166
+ `Locale file not found for language '${lang}' in: ${localesDirAbs}`
1167
+ );
1168
+ }
1169
+ );
1170
+ const defaultLocalePath = localePaths[config.defaultLanguage] ?? null;
1171
+ if (!defaultLocalePath) {
1172
+ throw new I18nSharpenError({
1173
+ kind: "filesystem",
1174
+ message: `Default language '${config.defaultLanguage}' locale file not found.`,
1175
+ path: localesDirAbs
1176
+ });
1177
+ }
1178
+ const defaultKeys = Object.keys(localesFlat[config.defaultLanguage]);
1179
+ const defaultKeySet = localeKeySets[config.defaultLanguage];
1180
+ log.info(
1181
+ `Loaded default language '${config.defaultLanguage}' with ${pc5.green(defaultKeys.length)} keys.`
1182
+ );
1183
+ for (const lang of config.supportedLanguages) {
1184
+ if (lang !== config.defaultLanguage) {
1185
+ const keysCount = Object.keys(localesFlat[lang]).length;
1186
+ log.info(`Loaded language '${lang}' with ${pc5.green(keysCount)} keys.`);
1187
+ }
1188
+ }
1189
+ const files = scanSourceFiles(config, cwd);
1190
+ for (const scanDir of config.scanDirs) {
1191
+ const scanDirAbs = path3.resolve(cwd, scanDir);
1192
+ if (fs8.existsSync(scanDirAbs)) {
1193
+ log.info(
1194
+ `Scanning directory: ${pc5.cyan(normalizeDisplayPath(path3.relative(cwd, scanDirAbs)))}`
1195
+ );
1196
+ } else {
1197
+ log.warn(`Scan directory does not exist: ${scanDirAbs}`);
1198
+ }
1199
+ }
1200
+ log.info(`Found ${pc5.green(files.length)} source files to check.`);
1201
+ const matchFunctions = config.matchFunctions ?? ["t", "getTranslation"];
1202
+ const matchAttributes = config.matchAttributes ?? ["i18nKey", "id"];
1203
+ const { usedKeys, fileContents } = detectUsedKeys(
1204
+ files,
1205
+ matchFunctions,
1206
+ matchAttributes
1207
+ );
1208
+ const keyToFilesSet = /* @__PURE__ */ new Map();
1209
+ const keyToFilesMap = {
1210
+ has(key) {
1211
+ return keyToFilesSet.has(key);
1212
+ },
1213
+ get(key) {
1214
+ const s = keyToFilesSet.get(key);
1215
+ return s ? Array.from(s) : void 0;
1216
+ },
1217
+ add(key, file) {
1218
+ let s = keyToFilesSet.get(key);
1219
+ if (!s) {
1220
+ s = /* @__PURE__ */ new Set();
1221
+ keyToFilesSet.set(key, s);
1222
+ }
1223
+ s.add(file);
1224
+ }
1225
+ };
1226
+ const keyRegex = buildKeyRegex(matchFunctions);
1227
+ const attrRegex = buildAttrRegex(matchAttributes);
1228
+ const dynamicCallRegex = buildDynamicCallRegex(matchFunctions);
1229
+ for (let i = 0; i < files.length; i++) {
1230
+ const file = files[i];
1231
+ const cleanContent = fileContents[i];
1232
+ const relativePath = normalizeDisplayPath(path3.relative(cwd, file));
1233
+ for (const match of cleanContent.matchAll(keyRegex)) {
1234
+ const key = match[2];
1235
+ if (key.endsWith(".")) continue;
1236
+ keyToFilesMap.add(key, relativePath);
1237
+ }
1238
+ for (const match of cleanContent.matchAll(attrRegex)) {
1239
+ const key = match[2];
1240
+ if (key.endsWith(".")) continue;
1241
+ keyToFilesMap.add(key, relativePath);
1242
+ }
1243
+ for (const match of cleanContent.matchAll(dynamicCallRegex)) {
1244
+ const arg = match[1].trim();
1245
+ if (arg.length === 0) continue;
1246
+ if (!isStaticStringLiteral(arg)) {
1247
+ log.warn(
1248
+ `Potential dynamic translation key reference in ${pc5.cyan(relativePath)}: ${pc5.yellow(match[0])}`
1249
+ );
1250
+ }
1251
+ }
1252
+ }
1253
+ if (config.looseKeyMatch) {
1254
+ for (const key of defaultKeys) {
1255
+ if (usedKeys.has(key)) continue;
1256
+ const dq = `"${key}"`;
1257
+ const sq = `'${key}'`;
1258
+ const bq = `\`${key}\``;
1259
+ for (let i = 0; i < files.length; i++) {
1260
+ const cleanContent = fileContents[i];
1261
+ if (cleanContent.includes(dq) || cleanContent.includes(sq) || cleanContent.includes(bq)) {
1262
+ usedKeys.add(key);
1263
+ keyToFilesMap.add(
1264
+ key,
1265
+ normalizeDisplayPath(path3.relative(cwd, files[i]))
1266
+ );
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+ log.info(
1272
+ `Found ${pc5.green(usedKeys.size)} unique translation keys used in source code.`
1273
+ );
1274
+ const suffixes = config.pluralSuffixes ?? [];
1275
+ const missingKeys = findMissingKeys(usedKeys, defaultKeySet, config);
1276
+ const unusedKeys = findUnusedKeys(defaultKeys, usedKeys, config);
1277
+ const keysOnlyInLanguages = findAlignmentMismatches(
1278
+ config,
1279
+ defaultKeys,
1280
+ defaultKeySet,
1281
+ localesFlat,
1282
+ localeKeySets
1283
+ );
1284
+ const { activePlaceholderKeys, unusedPlaceholderKeys } = findPlaceholderKeys(
1285
+ config,
1286
+ usedKeys,
1287
+ localesFlat
1288
+ );
1289
+ const totalDefinedKeys = defaultKeys.length;
1290
+ const usedDefinedKeysCount = defaultKeys.length - unusedKeys.length;
1291
+ const utilizationPercent = totalDefinedKeys > 0 ? (usedDefinedKeysCount / totalDefinedKeys * 100).toFixed(2) : "0.00";
1292
+ const codeKeyCoverage = usedKeys.size > 0 ? (usedDefinedKeysCount / usedKeys.size * 100).toFixed(2) : "100.00";
1293
+ const results = {
1294
+ missingKeys,
1295
+ activePlaceholderKeys,
1296
+ unusedKeys,
1297
+ unusedPlaceholderKeys,
1298
+ keysOnlyInLanguages,
1299
+ codeKeyCoverage,
1300
+ utilizationPercent,
1301
+ totalDefinedKeys,
1302
+ usedDefinedKeysCount
1303
+ };
1304
+ printValidationResults(results, keyToFilesMap, suffixes);
1305
+ if (config.outputReport) {
1306
+ writeMarkdownReport({
1307
+ cwd,
1308
+ outputReport: config.outputReport,
1309
+ defaultBasename: path3.basename(defaultLocalePath),
1310
+ results,
1311
+ keyToFilesMap,
1312
+ getBaseKey: (key) => getBaseKey(key, suffixes)
1313
+ });
1314
+ }
1315
+ const hasError = missingKeys.length > 0 || activePlaceholderKeys.length > 0 || keysOnlyInLanguages.length > 0;
1316
+ if (hasError) {
1317
+ log.error(
1318
+ "Validation failed. Please fix the missing keys, active placeholders, or locale mismatches."
1319
+ );
1320
+ } else {
1321
+ log.success("i18n Quality Validation passed successfully!\n");
1322
+ }
1323
+ return results;
1324
+ }
1325
+ var DEFAULT_CONFIG = {
1326
+ scanDirs: ["src"],
1327
+ localesDir: "src/locales",
1328
+ excludeDirs: [
1329
+ "node_modules",
1330
+ "dist",
1331
+ ".git",
1332
+ ".next",
1333
+ "build",
1334
+ "coverage",
1335
+ ".agent",
1336
+ ".claude"
1337
+ ],
1338
+ fileExtensions: [".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".astro"],
1339
+ matchFunctions: ["t", "getTranslation"],
1340
+ outputReport: "i18n-coverage.md",
1341
+ defaultLanguage: "en",
1342
+ supportedLanguages: ["en"],
1343
+ matchAttributes: ["i18nKey", "id", "i18n", ":label", "v-t", "t:"],
1344
+ ignoreKeys: [],
1345
+ pluralSuffixes: [
1346
+ "_zero",
1347
+ "_one",
1348
+ "_two",
1349
+ "_few",
1350
+ "_many",
1351
+ "_other",
1352
+ "_male",
1353
+ "_female"
1354
+ ],
1355
+ localesLayout: "flat"
1356
+ };
1357
+ var identifierLikePattern = /^[A-Za-z_$][A-Za-z0-9_$.]*$/;
1358
+ var identifierLike = z.string().regex(
1359
+ identifierLikePattern,
1360
+ "must be an identifier-like token matching /^[A-Za-z_$][A-Za-z0-9_$.]*$/"
1361
+ );
1362
+ var attributeNamePattern = /^[:]?[A-Za-z_$][A-Za-z0-9_$.\-:]*$/;
1363
+ var attributeName = z.string().regex(
1364
+ attributeNamePattern,
1365
+ "must match /^[:]?[A-Za-z_$][A-Za-z0-9_$.\\-:]*$/ (HTML/Vue/Astro-style attribute)"
1366
+ );
1367
+ var languageCodePattern = /^[a-zA-Z0-9_-]+$/;
1368
+ var languageCode = z.string().regex(
1369
+ languageCodePattern,
1370
+ "must match /^[a-zA-Z0-9_-]+$/ (no path separators)"
1371
+ );
1372
+ var I18nSharpenConfigSchema = z.object({
1373
+ scanDirs: z.array(z.string()).nonempty("scanDirs must contain at least one directory path"),
1374
+ localesDir: z.string().nonempty("localesDir must be a non-empty string"),
1375
+ defaultLanguage: languageCode,
1376
+ supportedLanguages: z.array(languageCode).nonempty("supportedLanguages must contain at least one language"),
1377
+ excludeDirs: z.array(z.string()).optional(),
1378
+ fileExtensions: z.array(z.string()).optional(),
1379
+ matchFunctions: z.array(identifierLike).optional(),
1380
+ outputReport: z.string().optional(),
1381
+ matchAttributes: z.array(attributeName).optional(),
1382
+ ignoreKeys: z.array(z.string()).optional(),
1383
+ pluralSuffixes: z.array(z.string()).optional(),
1384
+ looseKeyMatch: z.boolean().optional(),
1385
+ localesLayout: z.enum(["flat", "namespaced"]).optional(),
1386
+ prune: z.object({
1387
+ force: z.boolean().optional()
1388
+ }).optional()
1389
+ });
1390
+ function loadConfig(cwd = process.cwd(), configPath) {
1391
+ if (!fs8.existsSync(cwd)) {
1392
+ throw new I18nSharpenError({
1393
+ kind: "config",
1394
+ message: `cwd does not exist: ${cwd}`,
1395
+ path: cwd
1396
+ });
1397
+ }
1398
+ if (!fs8.statSync(cwd).isDirectory()) {
1399
+ throw new I18nSharpenError({
1400
+ kind: "config",
1401
+ message: `cwd is not a directory: ${cwd}`,
1402
+ path: cwd
1403
+ });
1404
+ }
1405
+ const configPathJson = path3.join(cwd, "i18n-sharpen.json");
1406
+ const packageJsonPath = path3.join(cwd, "package.json");
1407
+ let fileConfig = {};
1408
+ if (configPath) {
1409
+ const resolved = path3.isAbsolute(configPath) ? configPath : path3.resolve(cwd, configPath);
1410
+ if (!fs8.existsSync(resolved)) {
1411
+ throw new I18nSharpenError({
1412
+ kind: "config",
1413
+ message: `Config file not found: ${resolved}`,
1414
+ path: resolved
1415
+ });
1416
+ }
1417
+ if (!fs8.statSync(resolved).isFile()) {
1418
+ throw new I18nSharpenError({
1419
+ kind: "config",
1420
+ message: `Config path is not a file: ${resolved}`,
1421
+ path: resolved
1422
+ });
1423
+ }
1424
+ try {
1425
+ const content = fs8.readFileSync(resolved, "utf8");
1426
+ fileConfig = JSON.parse(content);
1427
+ } catch (error) {
1428
+ throw new I18nSharpenError({
1429
+ kind: "parse",
1430
+ message: `Failed to parse config file '${resolved}': ${error.message}`,
1431
+ path: resolved
1432
+ });
1433
+ }
1434
+ } else if (fs8.existsSync(configPathJson)) {
1435
+ try {
1436
+ const content = fs8.readFileSync(configPathJson, "utf8");
1437
+ fileConfig = JSON.parse(content);
1438
+ } catch (error) {
1439
+ console.warn(
1440
+ `\u26A0\uFE0F Failed to parse i18n-sharpen.json: ${error.message}`
1441
+ );
1442
+ }
1443
+ } else if (fs8.existsSync(packageJsonPath)) {
1444
+ try {
1445
+ const content = fs8.readFileSync(packageJsonPath, "utf8");
1446
+ const pkg = JSON.parse(content);
1447
+ if (pkg.i18nSharpen) {
1448
+ fileConfig = pkg.i18nSharpen;
1449
+ }
1450
+ } catch (error) {
1451
+ console.warn(
1452
+ `\u26A0\uFE0F Failed to read package.json for i18nSharpen config: ${error.message}`
1453
+ );
1454
+ }
1455
+ }
1456
+ const rawConfig = {
1457
+ scanDirs: fileConfig.scanDirs ?? DEFAULT_CONFIG.scanDirs,
1458
+ localesDir: fileConfig.localesDir ?? DEFAULT_CONFIG.localesDir,
1459
+ defaultLanguage: fileConfig.defaultLanguage ?? DEFAULT_CONFIG.defaultLanguage,
1460
+ supportedLanguages: fileConfig.supportedLanguages ?? DEFAULT_CONFIG.supportedLanguages,
1461
+ excludeDirs: fileConfig.excludeDirs ?? DEFAULT_CONFIG.excludeDirs,
1462
+ fileExtensions: fileConfig.fileExtensions ?? DEFAULT_CONFIG.fileExtensions,
1463
+ matchFunctions: fileConfig.matchFunctions ?? DEFAULT_CONFIG.matchFunctions,
1464
+ outputReport: fileConfig.outputReport ?? DEFAULT_CONFIG.outputReport,
1465
+ matchAttributes: fileConfig.matchAttributes ?? DEFAULT_CONFIG.matchAttributes,
1466
+ ignoreKeys: fileConfig.ignoreKeys ?? DEFAULT_CONFIG.ignoreKeys,
1467
+ pluralSuffixes: fileConfig.pluralSuffixes ?? DEFAULT_CONFIG.pluralSuffixes,
1468
+ looseKeyMatch: fileConfig.looseKeyMatch ?? false,
1469
+ localesLayout: fileConfig.localesLayout ?? DEFAULT_CONFIG.localesLayout,
1470
+ prune: fileConfig.prune ?? { force: false }
1471
+ };
1472
+ const result = I18nSharpenConfigSchema.safeParse(rawConfig);
1473
+ if (!result.success) {
1474
+ const errors = result.error.issues.map((err) => ` - ${err.path.join(".")}: ${err.message}`).join("\n");
1475
+ throw new I18nSharpenError({
1476
+ kind: "config",
1477
+ message: `Invalid configuration:
1478
+ ${errors}`
1479
+ });
1480
+ }
1481
+ const config = result.data;
1482
+ const cwdResolved = path3.resolve(cwd);
1483
+ const isInsideCwd = (p) => {
1484
+ const rel = path3.relative(cwdResolved, path3.resolve(cwdResolved, p));
1485
+ return !rel.startsWith("..") && !path3.isAbsolute(rel);
1486
+ };
1487
+ if (!isInsideCwd(config.localesDir)) {
1488
+ console.warn(
1489
+ `\u26A0\uFE0F localesDir '${config.localesDir}' resolves outside cwd ('${cwdResolved}').`
1490
+ );
1491
+ }
1492
+ for (const dir of config.scanDirs) {
1493
+ if (!isInsideCwd(dir)) {
1494
+ console.warn(
1495
+ `\u26A0\uFE0F scanDirs entry '${dir}' resolves outside cwd ('${cwdResolved}').`
1496
+ );
1497
+ }
1498
+ }
1499
+ if (config.outputReport && !isInsideCwd(config.outputReport)) {
1500
+ console.warn(
1501
+ `\u26A0\uFE0F outputReport '${config.outputReport}' resolves outside cwd ('${cwdResolved}').`
1502
+ );
1503
+ }
1504
+ if (!config.supportedLanguages.includes(config.defaultLanguage)) {
1505
+ config.supportedLanguages = Array.from(
1506
+ /* @__PURE__ */ new Set([config.defaultLanguage, ...config.supportedLanguages])
1507
+ );
1508
+ }
1509
+ return config;
1510
+ }
1511
+
1512
+ // src/cli.ts
1513
+ function reportError(error) {
1514
+ if (error instanceof I18nSharpenError) {
1515
+ log.error(`[${error.error.kind}] ${error.message}`);
1516
+ } else {
1517
+ log.error(error.message);
1518
+ }
1519
+ }
1520
+ function readVersion() {
1521
+ try {
1522
+ const here = path3.dirname(fileURLToPath(import.meta.url));
1523
+ const candidates = [
1524
+ path3.resolve(here, "..", "package.json"),
1525
+ path3.resolve(here, "..", "..", "package.json")
1526
+ ];
1527
+ for (const c of candidates) {
1528
+ if (fs8.existsSync(c)) {
1529
+ const pkg = JSON.parse(fs8.readFileSync(c, "utf8"));
1530
+ if (typeof pkg.version === "string") return pkg.version;
1531
+ }
1532
+ }
1533
+ } catch {
1534
+ }
1535
+ return "0.0.0";
1536
+ }
1537
+ var program = new Command();
1538
+ program.name("i18n-sharpen").description(
1539
+ "Type-safe, configuration-driven i18n validator, extractor, and pruner CLI tool."
1540
+ ).version(readVersion()).option(
1541
+ "-c, --config <path>",
1542
+ "Path to configuration file (i18n-sharpen.json)"
1543
+ ).option("-d, --cwd <path>", "Working directory", process.cwd());
1544
+ program.command("validate").description(
1545
+ "Validate translation keys, active placeholders, and cross-locale alignment."
1546
+ ).action(() => {
1547
+ const opts = program.opts();
1548
+ const cwd = typeof opts.cwd === "string" ? opts.cwd : void 0;
1549
+ const configPath = typeof opts.config === "string" ? opts.config : void 0;
1550
+ try {
1551
+ const config = loadConfig(cwd, configPath);
1552
+ const results = validate(config, cwd);
1553
+ const hasErrors = results.missingKeys.length > 0 || results.activePlaceholderKeys.length > 0 || results.keysOnlyInLanguages.length > 0;
1554
+ process.exitCode = hasErrors ? 1 : 0;
1555
+ } catch (error) {
1556
+ reportError(error);
1557
+ process.exitCode = 1;
1558
+ }
1559
+ });
1560
+ program.command("extract").description(
1561
+ "Extract new translation keys referenced in code and inject them into JSON files."
1562
+ ).action(() => {
1563
+ const opts = program.opts();
1564
+ const cwd = typeof opts.cwd === "string" ? opts.cwd : void 0;
1565
+ const configPath = typeof opts.config === "string" ? opts.config : void 0;
1566
+ try {
1567
+ const config = loadConfig(cwd, configPath);
1568
+ extract(config, cwd);
1569
+ process.exitCode = 0;
1570
+ } catch (error) {
1571
+ reportError(error);
1572
+ process.exitCode = 1;
1573
+ }
1574
+ });
1575
+ program.command("prune").description(
1576
+ "Prune unused translation keys from JSON files (dry-run by default; use --force to actually write)."
1577
+ ).option(
1578
+ "--dry-run",
1579
+ "Preview only \u2014 never write. Default behavior; flag exists for explicit CI scripts.",
1580
+ false
1581
+ ).option("--force", "Actually write the pruned locale files to disk.", false).action((cmdOpts) => {
1582
+ const opts = program.opts();
1583
+ const cwd = typeof opts.cwd === "string" ? opts.cwd : void 0;
1584
+ const configPath = typeof opts.config === "string" ? opts.config : void 0;
1585
+ try {
1586
+ const config = loadConfig(cwd, configPath);
1587
+ prune(config, cwd, {
1588
+ force: cmdOpts.force === true,
1589
+ dryRun: cmdOpts.dryRun === true
1590
+ });
1591
+ process.exitCode = 0;
1592
+ } catch (error) {
1593
+ reportError(error);
1594
+ process.exitCode = 1;
1595
+ }
1596
+ });
1597
+ program.parse(process.argv);
1598
+ //# sourceMappingURL=cli.js.map
1599
+ //# sourceMappingURL=cli.js.map