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