translint 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/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/translint.js +3 -0
- package/dist/cli.js +2052 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +678 -0
- package/package.json +47 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2052 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// src/utils/open.ts
|
|
34
|
+
var open_exports = {};
|
|
35
|
+
__export(open_exports, {
|
|
36
|
+
openFile: () => openFile
|
|
37
|
+
});
|
|
38
|
+
async function openFile(filePath) {
|
|
39
|
+
const target = import_path6.default.resolve(filePath);
|
|
40
|
+
const command = process.platform === "win32" ? `start "" "${target}"` : process.platform === "darwin" ? `open "${target}"` : `xdg-open "${target}"`;
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
(0, import_child_process.exec)(command, (error) => {
|
|
43
|
+
if (error) {
|
|
44
|
+
reject(error);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
var import_child_process, import_path6;
|
|
52
|
+
var init_open = __esm({
|
|
53
|
+
"src/utils/open.ts"() {
|
|
54
|
+
"use strict";
|
|
55
|
+
import_child_process = require("child_process");
|
|
56
|
+
import_path6 = __toESM(require("path"));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// src/cli.ts
|
|
61
|
+
var import_cac = require("cac");
|
|
62
|
+
var import_chalk2 = __toESM(require("chalk"));
|
|
63
|
+
|
|
64
|
+
// src/config/loadConfig.ts
|
|
65
|
+
var import_fs = __toESM(require("fs"));
|
|
66
|
+
var import_path = __toESM(require("path"));
|
|
67
|
+
|
|
68
|
+
// src/config/defaults.ts
|
|
69
|
+
var DEFAULT_CONFIG = {
|
|
70
|
+
projectRoot: process.cwd(),
|
|
71
|
+
source: ["src"],
|
|
72
|
+
locales: {
|
|
73
|
+
dir: "src/i18n",
|
|
74
|
+
extension: ".json",
|
|
75
|
+
fileName: "trad",
|
|
76
|
+
sourceLang: "en"
|
|
77
|
+
},
|
|
78
|
+
patterns: {
|
|
79
|
+
prefix: "trad.",
|
|
80
|
+
functions: ["t", "i18n.t", "translate.instant", "this.translate.instant"]
|
|
81
|
+
},
|
|
82
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/coverage/**"],
|
|
83
|
+
rules: {
|
|
84
|
+
detectEmpty: true,
|
|
85
|
+
detectNull: true,
|
|
86
|
+
detectSameAsKey: true,
|
|
87
|
+
detectUnused: true,
|
|
88
|
+
detectDuplicateTranslations: true,
|
|
89
|
+
detectPlaceholderMismatch: true,
|
|
90
|
+
detectStructureMismatch: true
|
|
91
|
+
},
|
|
92
|
+
report: {
|
|
93
|
+
title: "Translint Report",
|
|
94
|
+
darkMode: true
|
|
95
|
+
},
|
|
96
|
+
fix: {
|
|
97
|
+
strategy: "todo",
|
|
98
|
+
placeholder: "TODO_TRANSLATE",
|
|
99
|
+
sort: true
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/utils/merge.ts
|
|
104
|
+
function mergeDeep(base, override) {
|
|
105
|
+
if (override === void 0 || override === null) {
|
|
106
|
+
return base;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(base) && Array.isArray(override)) {
|
|
109
|
+
return override;
|
|
110
|
+
}
|
|
111
|
+
if (isObject(base) && isObject(override)) {
|
|
112
|
+
const result = { ...base };
|
|
113
|
+
for (const [key, value] of Object.entries(override)) {
|
|
114
|
+
const current = base[key];
|
|
115
|
+
result[key] = mergeDeep(current, value);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
return override;
|
|
120
|
+
}
|
|
121
|
+
function isObject(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/config/loadConfig.ts
|
|
126
|
+
var CONFIG_FILES = [
|
|
127
|
+
"i18n-checker.config.js",
|
|
128
|
+
"i18n-checker.config.cjs",
|
|
129
|
+
"i18n-checker.config.mjs",
|
|
130
|
+
"i18n-checker.config.json",
|
|
131
|
+
"i18n-checker.config.ts"
|
|
132
|
+
];
|
|
133
|
+
async function loadConfig(configPath) {
|
|
134
|
+
if (configPath) {
|
|
135
|
+
return mergeDeep(DEFAULT_CONFIG, await loadConfigFile(configPath));
|
|
136
|
+
}
|
|
137
|
+
for (const fileName of CONFIG_FILES) {
|
|
138
|
+
const candidate = import_path.default.resolve(process.cwd(), fileName);
|
|
139
|
+
if (import_fs.default.existsSync(candidate)) {
|
|
140
|
+
return mergeDeep(DEFAULT_CONFIG, await loadConfigFile(candidate));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const packageJsonPath = import_path.default.resolve(process.cwd(), "package.json");
|
|
144
|
+
if (import_fs.default.existsSync(packageJsonPath)) {
|
|
145
|
+
const pkg = JSON.parse(import_fs.default.readFileSync(packageJsonPath, "utf8"));
|
|
146
|
+
if (pkg.i18nChecker) {
|
|
147
|
+
return mergeDeep(DEFAULT_CONFIG, pkg.i18nChecker);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return DEFAULT_CONFIG;
|
|
151
|
+
}
|
|
152
|
+
async function loadConfigFile(filePath) {
|
|
153
|
+
const ext = import_path.default.extname(filePath);
|
|
154
|
+
if (ext === ".json") {
|
|
155
|
+
return JSON.parse(import_fs.default.readFileSync(filePath, "utf8"));
|
|
156
|
+
}
|
|
157
|
+
if (ext === ".ts") {
|
|
158
|
+
try {
|
|
159
|
+
await import("ts-node/register/transpile-only");
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"Config file is TypeScript but ts-node is not installed. Install ts-node or use .js/.json."
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const resolved = import_path.default.isAbsolute(filePath) ? filePath : import_path.default.resolve(process.cwd(), filePath);
|
|
167
|
+
const imported = require(resolved);
|
|
168
|
+
return imported.default ?? imported;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/core/analyze.ts
|
|
172
|
+
var import_fs3 = __toESM(require("fs"));
|
|
173
|
+
var import_path3 = __toESM(require("path"));
|
|
174
|
+
|
|
175
|
+
// src/core/localeDiscovery.ts
|
|
176
|
+
var import_path2 = __toESM(require("path"));
|
|
177
|
+
var import_fs2 = __toESM(require("fs"));
|
|
178
|
+
|
|
179
|
+
// src/utils/flatten.ts
|
|
180
|
+
function flattenJson(value, prefix = "", out = /* @__PURE__ */ new Map()) {
|
|
181
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
182
|
+
const nextKey = prefix ? `${prefix}.${key}` : key;
|
|
183
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
184
|
+
flattenJson(nested, nextKey, out);
|
|
185
|
+
} else {
|
|
186
|
+
out.set(nextKey, nested);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function unflattenJson(flat) {
|
|
192
|
+
const root = {};
|
|
193
|
+
for (const [key, value] of flat.entries()) {
|
|
194
|
+
const parts = key.split(".");
|
|
195
|
+
let current = root;
|
|
196
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
197
|
+
const part = parts[index];
|
|
198
|
+
if (index === parts.length - 1) {
|
|
199
|
+
current[part] = value;
|
|
200
|
+
} else {
|
|
201
|
+
if (!current[part] || typeof current[part] !== "object") {
|
|
202
|
+
current[part] = {};
|
|
203
|
+
}
|
|
204
|
+
current = current[part];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return root;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/core/localeDiscovery.ts
|
|
212
|
+
function discoverLocales(config) {
|
|
213
|
+
const dir = import_path2.default.resolve(config.projectRoot, config.locales.dir);
|
|
214
|
+
const extension = config.locales.extension || ".json";
|
|
215
|
+
const fileName = config.locales.fileName || "trad";
|
|
216
|
+
if (!import_fs2.default.existsSync(dir)) {
|
|
217
|
+
throw new Error(`Locales directory not found: ${dir}`);
|
|
218
|
+
}
|
|
219
|
+
const entries = import_fs2.default.readdirSync(dir, { withFileTypes: true });
|
|
220
|
+
const localeFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => ({
|
|
221
|
+
locale: import_path2.default.basename(entry.name, extension),
|
|
222
|
+
path: import_path2.default.join(dir, entry.name)
|
|
223
|
+
}));
|
|
224
|
+
if (localeFiles.length > 0) {
|
|
225
|
+
return localeFiles.map((file) => {
|
|
226
|
+
const content = JSON.parse(import_fs2.default.readFileSync(file.path, "utf8"));
|
|
227
|
+
return {
|
|
228
|
+
locale: file.locale,
|
|
229
|
+
path: file.path,
|
|
230
|
+
content,
|
|
231
|
+
flat: flattenJson(content)
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const localeFolders = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
236
|
+
return localeFolders.map((locale) => {
|
|
237
|
+
const localePath = import_path2.default.join(dir, locale, `${fileName}${extension}`);
|
|
238
|
+
if (!import_fs2.default.existsSync(localePath)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const content = JSON.parse(import_fs2.default.readFileSync(localePath, "utf8"));
|
|
242
|
+
return {
|
|
243
|
+
locale,
|
|
244
|
+
path: localePath,
|
|
245
|
+
content,
|
|
246
|
+
flat: flattenJson(content)
|
|
247
|
+
};
|
|
248
|
+
}).filter((entry) => entry !== null);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/core/sourceDiscovery.ts
|
|
252
|
+
var import_fast_glob = __toESM(require("fast-glob"));
|
|
253
|
+
async function discoverSourceFiles(config) {
|
|
254
|
+
const sources = config.source.length ? config.source : ["src"];
|
|
255
|
+
const patterns = sources.map((entry) => buildGlob(entry));
|
|
256
|
+
const entries = await (0, import_fast_glob.default)(patterns, {
|
|
257
|
+
cwd: config.projectRoot,
|
|
258
|
+
ignore: config.ignore.map((value) => toPosix(value)),
|
|
259
|
+
absolute: true
|
|
260
|
+
});
|
|
261
|
+
return entries;
|
|
262
|
+
}
|
|
263
|
+
function buildGlob(entry) {
|
|
264
|
+
const normalized = toPosix(entry);
|
|
265
|
+
if (normalized.includes("*")) {
|
|
266
|
+
return normalized;
|
|
267
|
+
}
|
|
268
|
+
const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
269
|
+
return `${trimmed}/**/*.{ts,tsx,js,jsx,vue,svelte,html}`;
|
|
270
|
+
}
|
|
271
|
+
function toPosix(value) {
|
|
272
|
+
return value.replace(/\\/g, "/");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/core/codeUsage.ts
|
|
276
|
+
var import_typescript = __toESM(require("typescript"));
|
|
277
|
+
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
278
|
+
function extractCodeUsages(filePath, content, config) {
|
|
279
|
+
const extension = filePath.slice(filePath.lastIndexOf("."));
|
|
280
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const sourceFile = import_typescript.default.createSourceFile(
|
|
284
|
+
filePath,
|
|
285
|
+
content,
|
|
286
|
+
import_typescript.default.ScriptTarget.ES2020,
|
|
287
|
+
true,
|
|
288
|
+
extension === ".tsx" || extension === ".jsx" ? import_typescript.default.ScriptKind.TSX : import_typescript.default.ScriptKind.TS
|
|
289
|
+
);
|
|
290
|
+
const usages = [];
|
|
291
|
+
const functions = new Set(config.patterns.functions);
|
|
292
|
+
const visit = (node) => {
|
|
293
|
+
if (import_typescript.default.isCallExpression(node)) {
|
|
294
|
+
const calleeName = getCalleeName(node.expression);
|
|
295
|
+
if (calleeName && functions.has(calleeName)) {
|
|
296
|
+
const arg = node.arguments[0];
|
|
297
|
+
if (arg) {
|
|
298
|
+
const literal = getLiteralText(arg);
|
|
299
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(arg.getStart());
|
|
300
|
+
usages.push({
|
|
301
|
+
key: literal ?? "",
|
|
302
|
+
file: filePath,
|
|
303
|
+
line: line + 1,
|
|
304
|
+
column: character + 1,
|
|
305
|
+
dynamic: literal === null,
|
|
306
|
+
pattern: calleeName
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
import_typescript.default.forEachChild(node, visit);
|
|
312
|
+
};
|
|
313
|
+
visit(sourceFile);
|
|
314
|
+
return usages.filter((usage) => usage.key || usage.dynamic);
|
|
315
|
+
}
|
|
316
|
+
function extractLiteralUsages(filePath, content, prefix) {
|
|
317
|
+
if (!prefix) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
321
|
+
const expression = new RegExp(`(["'\`])(${escapedPrefix}[A-Za-z0-9_.-]+)\\1`, "g");
|
|
322
|
+
const usages = [];
|
|
323
|
+
let match;
|
|
324
|
+
while ((match = expression.exec(content)) !== null) {
|
|
325
|
+
const fullKey = match[2];
|
|
326
|
+
const before = content.slice(0, match.index);
|
|
327
|
+
const lines = before.split("\n");
|
|
328
|
+
const line = lines.length;
|
|
329
|
+
const column = lines[lines.length - 1].length + 1;
|
|
330
|
+
usages.push({
|
|
331
|
+
key: fullKey,
|
|
332
|
+
file: filePath,
|
|
333
|
+
line,
|
|
334
|
+
column,
|
|
335
|
+
dynamic: false,
|
|
336
|
+
pattern: "literal"
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return usages;
|
|
340
|
+
}
|
|
341
|
+
function getCalleeName(expression) {
|
|
342
|
+
if (import_typescript.default.isIdentifier(expression)) {
|
|
343
|
+
return expression.text;
|
|
344
|
+
}
|
|
345
|
+
if (import_typescript.default.isPropertyAccessExpression(expression)) {
|
|
346
|
+
const left = getCalleeName(expression.expression);
|
|
347
|
+
if (!left) {
|
|
348
|
+
return expression.name.text;
|
|
349
|
+
}
|
|
350
|
+
return `${left}.${expression.name.text}`;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
function getLiteralText(expression) {
|
|
355
|
+
if (import_typescript.default.isStringLiteral(expression)) {
|
|
356
|
+
return expression.text;
|
|
357
|
+
}
|
|
358
|
+
if (import_typescript.default.isNoSubstitutionTemplateLiteral(expression)) {
|
|
359
|
+
return expression.text;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/core/analyze.ts
|
|
365
|
+
async function analyzeProject(params) {
|
|
366
|
+
const config = applyCliOverrides(params.config, params.cliOptions);
|
|
367
|
+
const translationFiles = discoverLocales(config);
|
|
368
|
+
const sourceFiles = await discoverSourceFiles(config);
|
|
369
|
+
const codeUsages = collectCodeUsages(sourceFiles, config);
|
|
370
|
+
const issues = collectIssues(translationFiles, codeUsages, config, params.cliOptions);
|
|
371
|
+
const namespaces = buildNamespaces(translationFiles);
|
|
372
|
+
const summary = buildSummary(translationFiles, codeUsages, issues);
|
|
373
|
+
return {
|
|
374
|
+
config,
|
|
375
|
+
translationFiles,
|
|
376
|
+
codeUsages,
|
|
377
|
+
issues,
|
|
378
|
+
summary,
|
|
379
|
+
namespaces
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function applyCliOverrides(config, cli2) {
|
|
383
|
+
if (!cli2) {
|
|
384
|
+
return config;
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
...config,
|
|
388
|
+
source: cli2.source && cli2.source.length ? cli2.source : config.source,
|
|
389
|
+
locales: {
|
|
390
|
+
...config.locales,
|
|
391
|
+
dir: cli2.localesDir ?? config.locales.dir,
|
|
392
|
+
sourceLang: cli2.sourceLang ?? config.locales.sourceLang
|
|
393
|
+
},
|
|
394
|
+
ignore: cli2.ignore && cli2.ignore.length ? [...config.ignore, ...cli2.ignore] : config.ignore
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function collectCodeUsages(files, config) {
|
|
398
|
+
const usages = [];
|
|
399
|
+
for (const file of files) {
|
|
400
|
+
const content = import_fs3.default.readFileSync(file, "utf8");
|
|
401
|
+
const fileUsages = extractCodeUsages(file, content, config);
|
|
402
|
+
const literalUsages = extractLiteralUsages(file, content, config.patterns.prefix);
|
|
403
|
+
usages.push(...fileUsages, ...literalUsages);
|
|
404
|
+
}
|
|
405
|
+
const normalized = usages.map((usage) => {
|
|
406
|
+
const prefix = config.patterns.prefix;
|
|
407
|
+
if (prefix && usage.key.startsWith(prefix)) {
|
|
408
|
+
return { ...usage, key: usage.key.slice(prefix.length) };
|
|
409
|
+
}
|
|
410
|
+
return usage;
|
|
411
|
+
});
|
|
412
|
+
return dedupeUsages(normalized);
|
|
413
|
+
}
|
|
414
|
+
function dedupeUsages(usages) {
|
|
415
|
+
const seen = /* @__PURE__ */ new Set();
|
|
416
|
+
return usages.filter((usage) => {
|
|
417
|
+
const token = `${usage.key}:${usage.file}:${usage.line}:${usage.column}:${usage.dynamic}`;
|
|
418
|
+
if (seen.has(token)) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
seen.add(token);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function collectIssues(translations, codeUsages, config, cli2) {
|
|
426
|
+
const issues = [];
|
|
427
|
+
const locales = translations.map((file) => file.locale);
|
|
428
|
+
const reference = translations.find((file) => file.locale === config.locales.sourceLang);
|
|
429
|
+
const referenceKeys = reference ? new Set(reference.flat.keys()) : /* @__PURE__ */ new Set();
|
|
430
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
431
|
+
for (const file of translations) {
|
|
432
|
+
for (const key of file.flat.keys()) {
|
|
433
|
+
allKeys.add(key);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const locale of locales) {
|
|
437
|
+
const file = translations.find((entry) => entry.locale === locale);
|
|
438
|
+
if (!file) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const keysToCheck = reference ? referenceKeys : allKeys;
|
|
442
|
+
for (const key of keysToCheck) {
|
|
443
|
+
if (!file.flat.has(key)) {
|
|
444
|
+
issues.push({
|
|
445
|
+
type: "missing",
|
|
446
|
+
severity: "high",
|
|
447
|
+
key,
|
|
448
|
+
locale,
|
|
449
|
+
message: `Missing key "${key}" in ${locale}`
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (reference) {
|
|
454
|
+
for (const key of file.flat.keys()) {
|
|
455
|
+
if (!referenceKeys.has(key)) {
|
|
456
|
+
issues.push({
|
|
457
|
+
type: "extra",
|
|
458
|
+
severity: "medium",
|
|
459
|
+
key,
|
|
460
|
+
locale: file.locale,
|
|
461
|
+
message: `Extra key "${key}" in ${file.locale}`
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (config.rules.detectUnused) {
|
|
468
|
+
const usedKeys = new Set(codeUsages.filter((usage) => !usage.dynamic).map((u) => u.key));
|
|
469
|
+
const referenceKeys2 = reference ? Array.from(reference.flat.keys()) : Array.from(allKeys);
|
|
470
|
+
for (const key of referenceKeys2) {
|
|
471
|
+
if (!usedKeys.has(key)) {
|
|
472
|
+
issues.push({
|
|
473
|
+
type: "unused",
|
|
474
|
+
severity: "low",
|
|
475
|
+
key,
|
|
476
|
+
message: `Unused key "${key}"`
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (config.rules.detectEmpty || config.rules.detectNull || config.rules.detectSameAsKey) {
|
|
482
|
+
for (const file of translations) {
|
|
483
|
+
for (const [key, value] of file.flat.entries()) {
|
|
484
|
+
if (config.rules.detectNull && value === null) {
|
|
485
|
+
issues.push({
|
|
486
|
+
type: "null",
|
|
487
|
+
severity: "medium",
|
|
488
|
+
key,
|
|
489
|
+
locale: file.locale,
|
|
490
|
+
message: `Null value for "${key}" in ${file.locale}`
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (config.rules.detectEmpty && value === "") {
|
|
494
|
+
issues.push({
|
|
495
|
+
type: "empty",
|
|
496
|
+
severity: "medium",
|
|
497
|
+
key,
|
|
498
|
+
locale: file.locale,
|
|
499
|
+
message: `Empty value for "${key}" in ${file.locale}`
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (config.rules.detectSameAsKey && typeof value === "string" && value.trim() === key) {
|
|
503
|
+
issues.push({
|
|
504
|
+
type: "same-as-key",
|
|
505
|
+
severity: "low",
|
|
506
|
+
key,
|
|
507
|
+
locale: file.locale,
|
|
508
|
+
message: `Value equals key for "${key}" in ${file.locale}`
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (config.rules.detectDuplicateTranslations) {
|
|
515
|
+
for (const key of allKeys) {
|
|
516
|
+
const values = translations.map((file) => file.flat.get(key)).filter((value) => typeof value === "string");
|
|
517
|
+
if (values.length === locales.length && new Set(values).size === 1) {
|
|
518
|
+
issues.push({
|
|
519
|
+
type: "duplicate-value",
|
|
520
|
+
severity: "low",
|
|
521
|
+
key,
|
|
522
|
+
message: `Same translation for all locales: "${key}"`
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (config.rules.detectPlaceholderMismatch) {
|
|
528
|
+
for (const key of allKeys) {
|
|
529
|
+
const placeholderMap = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const file of translations) {
|
|
531
|
+
const value = file.flat.get(key);
|
|
532
|
+
if (typeof value === "string") {
|
|
533
|
+
placeholderMap.set(file.locale, extractPlaceholders(value));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const reference2 = placeholderMap.get(config.locales.sourceLang) ?? [];
|
|
537
|
+
for (const [locale, placeholders] of placeholderMap.entries()) {
|
|
538
|
+
if (!samePlaceholders(reference2, placeholders)) {
|
|
539
|
+
issues.push({
|
|
540
|
+
type: "placeholder",
|
|
541
|
+
severity: "medium",
|
|
542
|
+
key,
|
|
543
|
+
locale,
|
|
544
|
+
message: `Placeholder mismatch for "${key}" in ${locale}`
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (config.rules.detectStructureMismatch) {
|
|
551
|
+
const structure = /* @__PURE__ */ new Map();
|
|
552
|
+
for (const file of translations) {
|
|
553
|
+
structure.set(file.locale, new Set(file.flat.keys()));
|
|
554
|
+
}
|
|
555
|
+
for (const key of allKeys) {
|
|
556
|
+
for (const locale of locales) {
|
|
557
|
+
const localeKeys = structure.get(locale);
|
|
558
|
+
if (!localeKeys) continue;
|
|
559
|
+
if (!localeKeys.has(key) && localeKeysHasPrefix(localeKeys, key)) {
|
|
560
|
+
issues.push({
|
|
561
|
+
type: "structure",
|
|
562
|
+
severity: "medium",
|
|
563
|
+
key,
|
|
564
|
+
locale,
|
|
565
|
+
message: `Structure mismatch around "${key}" in ${locale}`
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (cli2?.includeDynamicWarnings) {
|
|
572
|
+
for (const usage of codeUsages.filter((usage2) => usage2.dynamic)) {
|
|
573
|
+
issues.push({
|
|
574
|
+
type: "dynamic",
|
|
575
|
+
severity: "low",
|
|
576
|
+
key: usage.key,
|
|
577
|
+
file: usage.file,
|
|
578
|
+
message: `Dynamic key usage in ${import_path3.default.basename(usage.file)}`
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return issues;
|
|
583
|
+
}
|
|
584
|
+
function buildSummary(translations, codeUsages, issues) {
|
|
585
|
+
const summary = {
|
|
586
|
+
locales: translations.map((file) => file.locale),
|
|
587
|
+
totalKeys: new Set(translations.flatMap((file) => Array.from(file.flat.keys()))).size,
|
|
588
|
+
totalUsages: codeUsages.length,
|
|
589
|
+
totalIssues: issues.length,
|
|
590
|
+
missingKeys: 0,
|
|
591
|
+
extraKeys: 0,
|
|
592
|
+
unusedKeys: 0,
|
|
593
|
+
emptyValues: 0,
|
|
594
|
+
nullValues: 0,
|
|
595
|
+
sameAsKey: 0,
|
|
596
|
+
placeholderIssues: 0,
|
|
597
|
+
duplicateValueIssues: 0,
|
|
598
|
+
structureIssues: 0,
|
|
599
|
+
dynamicWarnings: 0
|
|
600
|
+
};
|
|
601
|
+
for (const issue of issues) {
|
|
602
|
+
switch (issue.type) {
|
|
603
|
+
case "missing":
|
|
604
|
+
summary.missingKeys += 1;
|
|
605
|
+
break;
|
|
606
|
+
case "extra":
|
|
607
|
+
summary.extraKeys += 1;
|
|
608
|
+
break;
|
|
609
|
+
case "unused":
|
|
610
|
+
summary.unusedKeys += 1;
|
|
611
|
+
break;
|
|
612
|
+
case "empty":
|
|
613
|
+
summary.emptyValues += 1;
|
|
614
|
+
break;
|
|
615
|
+
case "null":
|
|
616
|
+
summary.nullValues += 1;
|
|
617
|
+
break;
|
|
618
|
+
case "same-as-key":
|
|
619
|
+
summary.sameAsKey += 1;
|
|
620
|
+
break;
|
|
621
|
+
case "placeholder":
|
|
622
|
+
summary.placeholderIssues += 1;
|
|
623
|
+
break;
|
|
624
|
+
case "duplicate-value":
|
|
625
|
+
summary.duplicateValueIssues += 1;
|
|
626
|
+
break;
|
|
627
|
+
case "structure":
|
|
628
|
+
summary.structureIssues += 1;
|
|
629
|
+
break;
|
|
630
|
+
case "dynamic":
|
|
631
|
+
summary.dynamicWarnings += 1;
|
|
632
|
+
break;
|
|
633
|
+
default:
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return summary;
|
|
638
|
+
}
|
|
639
|
+
function buildNamespaces(translations) {
|
|
640
|
+
const namespaces = {};
|
|
641
|
+
for (const file of translations) {
|
|
642
|
+
for (const key of file.flat.keys()) {
|
|
643
|
+
const prefix = key.includes(".") ? key.split(".")[0] : "root";
|
|
644
|
+
namespaces[prefix] = (namespaces[prefix] ?? 0) + 1;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return namespaces;
|
|
648
|
+
}
|
|
649
|
+
function extractPlaceholders(value) {
|
|
650
|
+
const placeholders = /* @__PURE__ */ new Set();
|
|
651
|
+
const mustache = value.match(/\{\{(\w+)\}\}/g) ?? [];
|
|
652
|
+
const brace = value.match(/\{(\w+)\}/g) ?? [];
|
|
653
|
+
const percent = value.match(/%[sdif]/g) ?? [];
|
|
654
|
+
for (const token of mustache) placeholders.add(token);
|
|
655
|
+
for (const token of brace) placeholders.add(token);
|
|
656
|
+
for (const token of percent) placeholders.add(token);
|
|
657
|
+
return Array.from(placeholders).sort();
|
|
658
|
+
}
|
|
659
|
+
function samePlaceholders(left, right) {
|
|
660
|
+
if (left.length !== right.length) {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
664
|
+
if (left[index] !== right[index]) {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
function localeKeysHasPrefix(keys, key) {
|
|
671
|
+
const prefix = `${key}.`;
|
|
672
|
+
for (const candidate of keys) {
|
|
673
|
+
if (candidate.startsWith(prefix)) {
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/formatters/console.ts
|
|
681
|
+
var import_chalk = __toESM(require("chalk"));
|
|
682
|
+
function formatConsoleReport(analysis, options = {}) {
|
|
683
|
+
if (options.showOnlyScore && options.score) {
|
|
684
|
+
return formatScoreOnly(options.score);
|
|
685
|
+
}
|
|
686
|
+
const lines = [];
|
|
687
|
+
const { summary } = analysis;
|
|
688
|
+
lines.push(
|
|
689
|
+
import_chalk.default.bold(
|
|
690
|
+
summary.totalIssues > 0 ? import_chalk.default.redBright("i18n-checker found issues") : import_chalk.default.greenBright("i18n-checker clean")
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
lines.push(
|
|
694
|
+
[
|
|
695
|
+
chip("Locales", summary.locales.join(", "), "info"),
|
|
696
|
+
chip("Keys", summary.totalKeys, summary.totalKeys > 0 ? "info" : "muted"),
|
|
697
|
+
chip("Usages", summary.totalUsages, "info"),
|
|
698
|
+
chip("Issues", summary.totalIssues, summary.totalIssues > 0 ? "danger" : "success"),
|
|
699
|
+
chip("Missing", summary.missingKeys, summary.missingKeys ? "danger" : "success"),
|
|
700
|
+
chip("Unused", summary.unusedKeys, summary.unusedKeys ? "warning" : "success")
|
|
701
|
+
].join(import_chalk.default.gray(" | "))
|
|
702
|
+
);
|
|
703
|
+
lines.push("");
|
|
704
|
+
lines.push(section("Top issues"));
|
|
705
|
+
lines.push(...formatIssues(analysis.issues, options.verbose));
|
|
706
|
+
lines.push("");
|
|
707
|
+
lines.push(section("Namespaces"));
|
|
708
|
+
lines.push(...formatNamespaces(analysis.namespaces));
|
|
709
|
+
return lines.join("\n");
|
|
710
|
+
}
|
|
711
|
+
function formatScoreOnly(score) {
|
|
712
|
+
const lines = [];
|
|
713
|
+
lines.push(
|
|
714
|
+
import_chalk.default.bold(
|
|
715
|
+
`${score.total}/100 - ${score.level.toUpperCase()}`
|
|
716
|
+
)
|
|
717
|
+
);
|
|
718
|
+
lines.push(`Completeness: ${score.breakdown.completeness}`);
|
|
719
|
+
lines.push(`Consistency: ${score.breakdown.consistency}`);
|
|
720
|
+
lines.push(`Usage: ${score.breakdown.usage}`);
|
|
721
|
+
lines.push(`Quality: ${score.breakdown.quality}`);
|
|
722
|
+
if (score.notes.length) {
|
|
723
|
+
lines.push("");
|
|
724
|
+
lines.push(...score.notes.map((note) => `- ${note}`));
|
|
725
|
+
}
|
|
726
|
+
return lines.join("\n");
|
|
727
|
+
}
|
|
728
|
+
function formatIssues(issues, verbose) {
|
|
729
|
+
if (issues.length === 0) {
|
|
730
|
+
return [import_chalk.default.green("No issues detected.")];
|
|
731
|
+
}
|
|
732
|
+
const sorted = [...issues].sort((a, b) => severityWeight(b.severity) - severityWeight(a.severity));
|
|
733
|
+
const limit = verbose ? 100 : 20;
|
|
734
|
+
const items = sorted.slice(0, limit).map((issue) => {
|
|
735
|
+
const badge = issue.severity === "high" ? import_chalk.default.red("HIGH") : issue.severity === "medium" ? import_chalk.default.yellow("MED") : import_chalk.default.gray("LOW");
|
|
736
|
+
const keyInfo = issue.key ? import_chalk.default.cyan(issue.key) : "";
|
|
737
|
+
const localeInfo = issue.locale ? import_chalk.default.gray(`(${issue.locale})`) : "";
|
|
738
|
+
return `${badge} ${keyInfo} ${localeInfo} ${issue.message}`;
|
|
739
|
+
});
|
|
740
|
+
if (sorted.length > limit) {
|
|
741
|
+
items.push(import_chalk.default.gray(`... and ${sorted.length - limit} more`));
|
|
742
|
+
}
|
|
743
|
+
return items;
|
|
744
|
+
}
|
|
745
|
+
function formatNamespaces(namespaces) {
|
|
746
|
+
const entries = Object.entries(namespaces).sort((a, b) => b[1] - a[1]);
|
|
747
|
+
return entries.map(([name, count]) => `${import_chalk.default.cyan(name)}: ${count}`);
|
|
748
|
+
}
|
|
749
|
+
function section(title) {
|
|
750
|
+
return import_chalk.default.bold(`
|
|
751
|
+
${title}
|
|
752
|
+
${"\u2500".repeat(title.length)}`);
|
|
753
|
+
}
|
|
754
|
+
function chip(label, value, tone) {
|
|
755
|
+
const painter = {
|
|
756
|
+
info: import_chalk.default.cyan,
|
|
757
|
+
success: import_chalk.default.green,
|
|
758
|
+
warning: import_chalk.default.yellow,
|
|
759
|
+
danger: import_chalk.default.red,
|
|
760
|
+
muted: import_chalk.default.gray
|
|
761
|
+
}[tone];
|
|
762
|
+
return painter(`${label}: ${value}`);
|
|
763
|
+
}
|
|
764
|
+
function severityWeight(severity) {
|
|
765
|
+
if (severity === "high") return 3;
|
|
766
|
+
if (severity === "medium") return 2;
|
|
767
|
+
return 1;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/formatters/json.ts
|
|
771
|
+
function formatJsonReport(analysis) {
|
|
772
|
+
return JSON.stringify(
|
|
773
|
+
{
|
|
774
|
+
summary: analysis.summary,
|
|
775
|
+
issues: analysis.issues,
|
|
776
|
+
namespaces: analysis.namespaces
|
|
777
|
+
},
|
|
778
|
+
null,
|
|
779
|
+
2
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/scoring/score.ts
|
|
784
|
+
function computeScore(analysis) {
|
|
785
|
+
const { summary } = analysis;
|
|
786
|
+
const completeness = scorePart(summary.missingKeys + summary.extraKeys, summary.totalKeys);
|
|
787
|
+
const usage = scorePart(summary.unusedKeys, summary.totalKeys);
|
|
788
|
+
const consistency = scorePart(summary.placeholderIssues + summary.structureIssues, summary.totalKeys);
|
|
789
|
+
const quality = scorePart(
|
|
790
|
+
summary.emptyValues + summary.nullValues + summary.sameAsKey + summary.duplicateValueIssues,
|
|
791
|
+
summary.totalKeys
|
|
792
|
+
);
|
|
793
|
+
const total = Math.round(
|
|
794
|
+
completeness * 0.35 + consistency * 0.25 + usage * 0.2 + quality * 0.2
|
|
795
|
+
);
|
|
796
|
+
const level = total >= 90 ? "excellent" : total >= 75 ? "good" : total >= 60 ? "average" : "critical";
|
|
797
|
+
const notes = [];
|
|
798
|
+
if (summary.missingKeys > 0) notes.push("Fix missing keys across locales.");
|
|
799
|
+
if (summary.unusedKeys > 0) notes.push("Clean unused translation keys.");
|
|
800
|
+
if (summary.placeholderIssues > 0) notes.push("Align placeholders between languages.");
|
|
801
|
+
if (summary.emptyValues > 0 || summary.nullValues > 0)
|
|
802
|
+
notes.push("Fill empty or null translations.");
|
|
803
|
+
return {
|
|
804
|
+
total,
|
|
805
|
+
level,
|
|
806
|
+
breakdown: {
|
|
807
|
+
completeness,
|
|
808
|
+
consistency,
|
|
809
|
+
usage,
|
|
810
|
+
quality
|
|
811
|
+
},
|
|
812
|
+
notes
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function scorePart(issues, total) {
|
|
816
|
+
if (total === 0) return 100;
|
|
817
|
+
const ratio = Math.max(0, Math.min(1, 1 - issues / total));
|
|
818
|
+
return Math.round(ratio * 100);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/report/htmlReport.ts
|
|
822
|
+
function generateHtmlReport(analysis) {
|
|
823
|
+
const score = computeScore(analysis);
|
|
824
|
+
const date = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
825
|
+
const summary = analysis.summary;
|
|
826
|
+
const issues = analysis.issues.slice(0, 200);
|
|
827
|
+
const highCount = issues.filter((i) => i.severity === "high").length;
|
|
828
|
+
const mediumCount = issues.filter((i) => i.severity === "medium").length;
|
|
829
|
+
const lowCount = issues.filter((i) => i.severity === "low").length;
|
|
830
|
+
const scoreColor = score.total >= 80 ? "#10b981" : score.total >= 50 ? "#f59e0b" : "#ef4444";
|
|
831
|
+
return `<!DOCTYPE html>
|
|
832
|
+
<html lang="en">
|
|
833
|
+
<head>
|
|
834
|
+
<meta charset="utf-8" />
|
|
835
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
836
|
+
<title>${analysis.config.report.title}</title>
|
|
837
|
+
<style>
|
|
838
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
839
|
+
|
|
840
|
+
:root {
|
|
841
|
+
--bg-primary: #fafbfc;
|
|
842
|
+
--bg-secondary: #ffffff;
|
|
843
|
+
--bg-tertiary: #f3f4f6;
|
|
844
|
+
--text-primary: #111827;
|
|
845
|
+
--text-secondary: #4b5563;
|
|
846
|
+
--text-muted: #9ca3af;
|
|
847
|
+
--border: #e5e7eb;
|
|
848
|
+
--border-light: #f3f4f6;
|
|
849
|
+
--accent: #2563eb;
|
|
850
|
+
--accent-light: #dbeafe;
|
|
851
|
+
--danger: #dc2626;
|
|
852
|
+
--danger-bg: #fef2f2;
|
|
853
|
+
--warning: #d97706;
|
|
854
|
+
--warning-bg: #fffbeb;
|
|
855
|
+
--success: #059669;
|
|
856
|
+
--success-bg: #ecfdf5;
|
|
857
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
|
858
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
|
859
|
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.08), 0 2px 4px -1px rgba(0,0,0,0.04);
|
|
860
|
+
--radius: 8px;
|
|
861
|
+
--radius-lg: 12px;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
@media (prefers-color-scheme: dark) {
|
|
865
|
+
:root {
|
|
866
|
+
--bg-primary: #0f1117;
|
|
867
|
+
--bg-secondary: #1a1d27;
|
|
868
|
+
--bg-tertiary: #252a37;
|
|
869
|
+
--text-primary: #f9fafb;
|
|
870
|
+
--text-secondary: #d1d5db;
|
|
871
|
+
--text-muted: #6b7280;
|
|
872
|
+
--border: #374151;
|
|
873
|
+
--border-light: #1f2937;
|
|
874
|
+
--accent: #3b82f6;
|
|
875
|
+
--accent-light: #1e3a5f;
|
|
876
|
+
--danger-bg: #2d1f1f;
|
|
877
|
+
--warning-bg: #2d2815;
|
|
878
|
+
--success-bg: #1a2d23;
|
|
879
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
|
|
880
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
881
|
+
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
html { font-size: 15px; -webkit-font-smoothing: antialiased; }
|
|
886
|
+
|
|
887
|
+
body {
|
|
888
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
889
|
+
background: var(--bg-primary);
|
|
890
|
+
color: var(--text-primary);
|
|
891
|
+
line-height: 1.5;
|
|
892
|
+
min-height: 100vh;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* Header */
|
|
896
|
+
.header {
|
|
897
|
+
background: var(--bg-secondary);
|
|
898
|
+
border-bottom: 1px solid var(--border);
|
|
899
|
+
padding: 20px 0;
|
|
900
|
+
position: sticky;
|
|
901
|
+
top: 0;
|
|
902
|
+
z-index: 100;
|
|
903
|
+
}
|
|
904
|
+
.header-inner {
|
|
905
|
+
max-width: 1280px;
|
|
906
|
+
margin: 0 auto;
|
|
907
|
+
padding: 0 24px;
|
|
908
|
+
display: flex;
|
|
909
|
+
align-items: center;
|
|
910
|
+
justify-content: space-between;
|
|
911
|
+
gap: 16px;
|
|
912
|
+
}
|
|
913
|
+
.logo {
|
|
914
|
+
display: flex;
|
|
915
|
+
align-items: center;
|
|
916
|
+
gap: 10px;
|
|
917
|
+
font-weight: 600;
|
|
918
|
+
font-size: 1.1rem;
|
|
919
|
+
color: var(--text-primary);
|
|
920
|
+
}
|
|
921
|
+
.logo svg { color: var(--accent); }
|
|
922
|
+
.header-meta {
|
|
923
|
+
display: flex;
|
|
924
|
+
align-items: center;
|
|
925
|
+
gap: 16px;
|
|
926
|
+
font-size: 0.87rem;
|
|
927
|
+
color: var(--text-muted);
|
|
928
|
+
}
|
|
929
|
+
.header-meta span { display: flex; align-items: center; gap: 6px; }
|
|
930
|
+
.header-actions { display: flex; gap: 8px; }
|
|
931
|
+
.btn {
|
|
932
|
+
display: inline-flex;
|
|
933
|
+
align-items: center;
|
|
934
|
+
gap: 6px;
|
|
935
|
+
padding: 8px 14px;
|
|
936
|
+
font-size: 0.87rem;
|
|
937
|
+
font-weight: 500;
|
|
938
|
+
border-radius: var(--radius);
|
|
939
|
+
border: 1px solid var(--border);
|
|
940
|
+
background: var(--bg-secondary);
|
|
941
|
+
color: var(--text-secondary);
|
|
942
|
+
cursor: pointer;
|
|
943
|
+
transition: all 0.15s ease;
|
|
944
|
+
}
|
|
945
|
+
.btn:hover { background: var(--bg-tertiary); border-color: var(--text-muted); }
|
|
946
|
+
.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
|
|
947
|
+
.btn-primary:hover { opacity: 0.9; }
|
|
948
|
+
|
|
949
|
+
/* Main */
|
|
950
|
+
.main { max-width: 1280px; margin: 0 auto; padding: 32px 24px; }
|
|
951
|
+
|
|
952
|
+
/* Score Hero */
|
|
953
|
+
.score-hero {
|
|
954
|
+
display: grid;
|
|
955
|
+
grid-template-columns: auto 1fr;
|
|
956
|
+
gap: 32px;
|
|
957
|
+
background: var(--bg-secondary);
|
|
958
|
+
border: 1px solid var(--border);
|
|
959
|
+
border-radius: var(--radius-lg);
|
|
960
|
+
padding: 28px 32px;
|
|
961
|
+
margin-bottom: 24px;
|
|
962
|
+
box-shadow: var(--shadow);
|
|
963
|
+
}
|
|
964
|
+
.score-visual {
|
|
965
|
+
display: flex;
|
|
966
|
+
flex-direction: column;
|
|
967
|
+
align-items: center;
|
|
968
|
+
gap: 8px;
|
|
969
|
+
}
|
|
970
|
+
.score-ring {
|
|
971
|
+
position: relative;
|
|
972
|
+
width: 100px;
|
|
973
|
+
height: 100px;
|
|
974
|
+
}
|
|
975
|
+
.score-ring svg { transform: rotate(-90deg); }
|
|
976
|
+
.score-ring circle {
|
|
977
|
+
fill: none;
|
|
978
|
+
stroke-width: 8;
|
|
979
|
+
stroke-linecap: round;
|
|
980
|
+
}
|
|
981
|
+
.score-ring .bg { stroke: var(--border); }
|
|
982
|
+
.score-ring .progress {
|
|
983
|
+
stroke: ${scoreColor};
|
|
984
|
+
stroke-dasharray: 283;
|
|
985
|
+
stroke-dashoffset: calc(283 - (283 * ${score.total}) / 100);
|
|
986
|
+
transition: stroke-dashoffset 1s ease;
|
|
987
|
+
}
|
|
988
|
+
.score-value {
|
|
989
|
+
position: absolute;
|
|
990
|
+
inset: 0;
|
|
991
|
+
display: flex;
|
|
992
|
+
align-items: center;
|
|
993
|
+
justify-content: center;
|
|
994
|
+
font-size: 1.8rem;
|
|
995
|
+
font-weight: 700;
|
|
996
|
+
color: ${scoreColor};
|
|
997
|
+
}
|
|
998
|
+
.score-label {
|
|
999
|
+
font-size: 0.8rem;
|
|
1000
|
+
font-weight: 600;
|
|
1001
|
+
text-transform: uppercase;
|
|
1002
|
+
letter-spacing: 0.05em;
|
|
1003
|
+
color: ${scoreColor};
|
|
1004
|
+
background: ${score.total >= 80 ? "var(--success-bg)" : score.total >= 50 ? "var(--warning-bg)" : "var(--danger-bg)"};
|
|
1005
|
+
padding: 4px 10px;
|
|
1006
|
+
border-radius: 99px;
|
|
1007
|
+
}
|
|
1008
|
+
.score-details { display: flex; flex-direction: column; justify-content: center; }
|
|
1009
|
+
.score-title { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; }
|
|
1010
|
+
.score-subtitle { color: var(--text-secondary); margin-bottom: 16px; font-size: 0.93rem; }
|
|
1011
|
+
.score-breakdown {
|
|
1012
|
+
display: flex;
|
|
1013
|
+
gap: 24px;
|
|
1014
|
+
flex-wrap: wrap;
|
|
1015
|
+
}
|
|
1016
|
+
.breakdown-item {
|
|
1017
|
+
display: flex;
|
|
1018
|
+
flex-direction: column;
|
|
1019
|
+
gap: 2px;
|
|
1020
|
+
}
|
|
1021
|
+
.breakdown-value { font-size: 1.5rem; font-weight: 600; }
|
|
1022
|
+
.breakdown-value.danger { color: var(--danger); }
|
|
1023
|
+
.breakdown-value.warning { color: var(--warning); }
|
|
1024
|
+
.breakdown-value.success { color: var(--success); }
|
|
1025
|
+
.breakdown-label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
1026
|
+
|
|
1027
|
+
/* Stats Grid */
|
|
1028
|
+
.stats-grid {
|
|
1029
|
+
display: grid;
|
|
1030
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
1031
|
+
gap: 16px;
|
|
1032
|
+
margin-bottom: 32px;
|
|
1033
|
+
}
|
|
1034
|
+
.stat-card {
|
|
1035
|
+
background: var(--bg-secondary);
|
|
1036
|
+
border: 1px solid var(--border);
|
|
1037
|
+
border-radius: var(--radius);
|
|
1038
|
+
padding: 18px 20px;
|
|
1039
|
+
display: flex;
|
|
1040
|
+
flex-direction: column;
|
|
1041
|
+
gap: 8px;
|
|
1042
|
+
}
|
|
1043
|
+
.stat-header {
|
|
1044
|
+
display: flex;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
justify-content: space-between;
|
|
1047
|
+
}
|
|
1048
|
+
.stat-label { font-size: 0.87rem; color: var(--text-secondary); }
|
|
1049
|
+
.stat-icon {
|
|
1050
|
+
width: 32px;
|
|
1051
|
+
height: 32px;
|
|
1052
|
+
border-radius: var(--radius);
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
justify-content: center;
|
|
1056
|
+
}
|
|
1057
|
+
.stat-icon.blue { background: var(--accent-light); color: var(--accent); }
|
|
1058
|
+
.stat-icon.red { background: var(--danger-bg); color: var(--danger); }
|
|
1059
|
+
.stat-icon.yellow { background: var(--warning-bg); color: var(--warning); }
|
|
1060
|
+
.stat-icon.green { background: var(--success-bg); color: var(--success); }
|
|
1061
|
+
.stat-value { font-size: 1.75rem; font-weight: 600; }
|
|
1062
|
+
|
|
1063
|
+
/* Sections */
|
|
1064
|
+
.section { margin-bottom: 32px; }
|
|
1065
|
+
.section-header {
|
|
1066
|
+
display: flex;
|
|
1067
|
+
align-items: center;
|
|
1068
|
+
justify-content: space-between;
|
|
1069
|
+
margin-bottom: 16px;
|
|
1070
|
+
gap: 16px;
|
|
1071
|
+
}
|
|
1072
|
+
.section-title {
|
|
1073
|
+
font-size: 1.1rem;
|
|
1074
|
+
font-weight: 600;
|
|
1075
|
+
display: flex;
|
|
1076
|
+
align-items: center;
|
|
1077
|
+
gap: 8px;
|
|
1078
|
+
}
|
|
1079
|
+
.section-title .count {
|
|
1080
|
+
background: var(--bg-tertiary);
|
|
1081
|
+
color: var(--text-secondary);
|
|
1082
|
+
font-size: 0.8rem;
|
|
1083
|
+
font-weight: 500;
|
|
1084
|
+
padding: 2px 8px;
|
|
1085
|
+
border-radius: 99px;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/* Tabs */
|
|
1089
|
+
.tabs {
|
|
1090
|
+
display: flex;
|
|
1091
|
+
gap: 4px;
|
|
1092
|
+
background: var(--bg-tertiary);
|
|
1093
|
+
padding: 4px;
|
|
1094
|
+
border-radius: var(--radius);
|
|
1095
|
+
}
|
|
1096
|
+
.tab {
|
|
1097
|
+
padding: 6px 14px;
|
|
1098
|
+
font-size: 0.87rem;
|
|
1099
|
+
font-weight: 500;
|
|
1100
|
+
color: var(--text-secondary);
|
|
1101
|
+
background: transparent;
|
|
1102
|
+
border: none;
|
|
1103
|
+
border-radius: 6px;
|
|
1104
|
+
cursor: pointer;
|
|
1105
|
+
transition: all 0.15s ease;
|
|
1106
|
+
}
|
|
1107
|
+
.tab:hover { color: var(--text-primary); }
|
|
1108
|
+
.tab.active { background: var(--bg-secondary); color: var(--text-primary); box-shadow: var(--shadow-sm); }
|
|
1109
|
+
|
|
1110
|
+
/* Search */
|
|
1111
|
+
.search-box {
|
|
1112
|
+
display: flex;
|
|
1113
|
+
align-items: center;
|
|
1114
|
+
gap: 8px;
|
|
1115
|
+
background: var(--bg-secondary);
|
|
1116
|
+
border: 1px solid var(--border);
|
|
1117
|
+
border-radius: var(--radius);
|
|
1118
|
+
padding: 0 12px;
|
|
1119
|
+
min-width: 240px;
|
|
1120
|
+
}
|
|
1121
|
+
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
|
|
1122
|
+
.search-box input {
|
|
1123
|
+
flex: 1;
|
|
1124
|
+
border: none;
|
|
1125
|
+
background: transparent;
|
|
1126
|
+
padding: 10px 0;
|
|
1127
|
+
font-size: 0.87rem;
|
|
1128
|
+
color: var(--text-primary);
|
|
1129
|
+
outline: none;
|
|
1130
|
+
}
|
|
1131
|
+
.search-box input::placeholder { color: var(--text-muted); }
|
|
1132
|
+
|
|
1133
|
+
/* Table */
|
|
1134
|
+
.table-container {
|
|
1135
|
+
background: var(--bg-secondary);
|
|
1136
|
+
border: 1px solid var(--border);
|
|
1137
|
+
border-radius: var(--radius-lg);
|
|
1138
|
+
overflow: hidden;
|
|
1139
|
+
}
|
|
1140
|
+
table { width: 100%; border-collapse: collapse; }
|
|
1141
|
+
thead { background: var(--bg-tertiary); }
|
|
1142
|
+
th {
|
|
1143
|
+
padding: 12px 16px;
|
|
1144
|
+
font-size: 0.75rem;
|
|
1145
|
+
font-weight: 600;
|
|
1146
|
+
text-transform: uppercase;
|
|
1147
|
+
letter-spacing: 0.04em;
|
|
1148
|
+
color: var(--text-muted);
|
|
1149
|
+
text-align: left;
|
|
1150
|
+
border-bottom: 1px solid var(--border);
|
|
1151
|
+
}
|
|
1152
|
+
td {
|
|
1153
|
+
padding: 14px 16px;
|
|
1154
|
+
font-size: 0.87rem;
|
|
1155
|
+
color: var(--text-secondary);
|
|
1156
|
+
border-bottom: 1px solid var(--border-light);
|
|
1157
|
+
vertical-align: top;
|
|
1158
|
+
}
|
|
1159
|
+
tr:last-child td { border-bottom: none; }
|
|
1160
|
+
tr:hover td { background: var(--bg-tertiary); }
|
|
1161
|
+
td.key { font-family: "SF Mono", Monaco, Consolas, monospace; font-size: 0.82rem; color: var(--accent); }
|
|
1162
|
+
td.message { max-width: 400px; }
|
|
1163
|
+
|
|
1164
|
+
/* Badges */
|
|
1165
|
+
.badge {
|
|
1166
|
+
display: inline-flex;
|
|
1167
|
+
align-items: center;
|
|
1168
|
+
gap: 4px;
|
|
1169
|
+
padding: 4px 10px;
|
|
1170
|
+
font-size: 0.75rem;
|
|
1171
|
+
font-weight: 600;
|
|
1172
|
+
border-radius: 99px;
|
|
1173
|
+
text-transform: capitalize;
|
|
1174
|
+
}
|
|
1175
|
+
.badge.high { background: var(--danger-bg); color: var(--danger); }
|
|
1176
|
+
.badge.medium { background: var(--warning-bg); color: var(--warning); }
|
|
1177
|
+
.badge.low { background: var(--success-bg); color: var(--success); }
|
|
1178
|
+
.badge-type {
|
|
1179
|
+
background: var(--bg-tertiary);
|
|
1180
|
+
color: var(--text-secondary);
|
|
1181
|
+
padding: 3px 8px;
|
|
1182
|
+
font-size: 0.75rem;
|
|
1183
|
+
font-weight: 500;
|
|
1184
|
+
border-radius: 4px;
|
|
1185
|
+
}
|
|
1186
|
+
.locale-tag {
|
|
1187
|
+
background: var(--accent-light);
|
|
1188
|
+
color: var(--accent);
|
|
1189
|
+
padding: 2px 8px;
|
|
1190
|
+
font-size: 0.75rem;
|
|
1191
|
+
font-weight: 600;
|
|
1192
|
+
border-radius: 4px;
|
|
1193
|
+
text-transform: uppercase;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/* Recommendations */
|
|
1197
|
+
.recommendations {
|
|
1198
|
+
display: flex;
|
|
1199
|
+
flex-direction: column;
|
|
1200
|
+
gap: 12px;
|
|
1201
|
+
}
|
|
1202
|
+
.recommendation {
|
|
1203
|
+
display: flex;
|
|
1204
|
+
align-items: flex-start;
|
|
1205
|
+
gap: 14px;
|
|
1206
|
+
padding: 16px 18px;
|
|
1207
|
+
background: var(--bg-secondary);
|
|
1208
|
+
border: 1px solid var(--border);
|
|
1209
|
+
border-radius: var(--radius);
|
|
1210
|
+
transition: border-color 0.15s ease;
|
|
1211
|
+
}
|
|
1212
|
+
.recommendation:hover { border-color: var(--text-muted); }
|
|
1213
|
+
.recommendation .priority {
|
|
1214
|
+
flex-shrink: 0;
|
|
1215
|
+
width: 28px;
|
|
1216
|
+
height: 28px;
|
|
1217
|
+
border-radius: 50%;
|
|
1218
|
+
display: flex;
|
|
1219
|
+
align-items: center;
|
|
1220
|
+
justify-content: center;
|
|
1221
|
+
font-size: 0.75rem;
|
|
1222
|
+
font-weight: 700;
|
|
1223
|
+
}
|
|
1224
|
+
.recommendation .priority.high { background: var(--danger-bg); color: var(--danger); }
|
|
1225
|
+
.recommendation .priority.medium { background: var(--warning-bg); color: var(--warning); }
|
|
1226
|
+
.recommendation .priority.low { background: var(--success-bg); color: var(--success); }
|
|
1227
|
+
.recommendation .content { flex: 1; }
|
|
1228
|
+
.recommendation .title { font-weight: 500; margin-bottom: 2px; }
|
|
1229
|
+
.recommendation .desc { font-size: 0.87rem; color: var(--text-secondary); }
|
|
1230
|
+
|
|
1231
|
+
/* Empty state */
|
|
1232
|
+
.empty-state {
|
|
1233
|
+
padding: 48px 24px;
|
|
1234
|
+
text-align: center;
|
|
1235
|
+
color: var(--text-muted);
|
|
1236
|
+
}
|
|
1237
|
+
.empty-state svg { margin-bottom: 12px; opacity: 0.5; }
|
|
1238
|
+
.empty-state p { font-size: 0.93rem; }
|
|
1239
|
+
|
|
1240
|
+
/* Locale Grid */
|
|
1241
|
+
.locale-grid {
|
|
1242
|
+
display: grid;
|
|
1243
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
1244
|
+
gap: 12px;
|
|
1245
|
+
}
|
|
1246
|
+
.locale-card {
|
|
1247
|
+
background: var(--bg-secondary);
|
|
1248
|
+
border: 1px solid var(--border);
|
|
1249
|
+
border-radius: var(--radius);
|
|
1250
|
+
padding: 16px;
|
|
1251
|
+
display: flex;
|
|
1252
|
+
flex-direction: column;
|
|
1253
|
+
gap: 12px;
|
|
1254
|
+
}
|
|
1255
|
+
.locale-header {
|
|
1256
|
+
display: flex;
|
|
1257
|
+
align-items: center;
|
|
1258
|
+
justify-content: space-between;
|
|
1259
|
+
}
|
|
1260
|
+
.locale-name { font-weight: 600; font-size: 1rem; }
|
|
1261
|
+
.locale-stats {
|
|
1262
|
+
display: grid;
|
|
1263
|
+
grid-template-columns: repeat(3, 1fr);
|
|
1264
|
+
gap: 8px;
|
|
1265
|
+
text-align: center;
|
|
1266
|
+
}
|
|
1267
|
+
.locale-stat { display: flex; flex-direction: column; gap: 2px; }
|
|
1268
|
+
.locale-stat-value { font-weight: 600; font-size: 1.1rem; }
|
|
1269
|
+
.locale-stat-label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; }
|
|
1270
|
+
.locale-bar {
|
|
1271
|
+
height: 4px;
|
|
1272
|
+
background: var(--border);
|
|
1273
|
+
border-radius: 2px;
|
|
1274
|
+
overflow: hidden;
|
|
1275
|
+
}
|
|
1276
|
+
.locale-bar-fill {
|
|
1277
|
+
height: 100%;
|
|
1278
|
+
background: var(--success);
|
|
1279
|
+
border-radius: 2px;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/* Footer */
|
|
1283
|
+
.footer {
|
|
1284
|
+
margin-top: 48px;
|
|
1285
|
+
padding: 24px 0;
|
|
1286
|
+
border-top: 1px solid var(--border);
|
|
1287
|
+
text-align: center;
|
|
1288
|
+
color: var(--text-muted);
|
|
1289
|
+
font-size: 0.82rem;
|
|
1290
|
+
}
|
|
1291
|
+
.footer a { color: var(--accent); text-decoration: none; }
|
|
1292
|
+
.footer a:hover { text-decoration: underline; }
|
|
1293
|
+
|
|
1294
|
+
/* Print */
|
|
1295
|
+
@media print {
|
|
1296
|
+
.header { position: static; }
|
|
1297
|
+
.btn, .search-box, .tabs { display: none !important; }
|
|
1298
|
+
.main { padding: 0; }
|
|
1299
|
+
body { background: white; }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/* Responsive */
|
|
1303
|
+
@media (max-width: 768px) {
|
|
1304
|
+
.header-inner { flex-wrap: wrap; }
|
|
1305
|
+
.score-hero { grid-template-columns: 1fr; text-align: center; }
|
|
1306
|
+
.score-visual { margin: 0 auto; }
|
|
1307
|
+
.score-breakdown { justify-content: center; }
|
|
1308
|
+
.section-header { flex-direction: column; align-items: flex-start; }
|
|
1309
|
+
.search-box { width: 100%; min-width: auto; }
|
|
1310
|
+
}
|
|
1311
|
+
</style>
|
|
1312
|
+
</head>
|
|
1313
|
+
<body>
|
|
1314
|
+
<header class="header">
|
|
1315
|
+
<div class="header-inner">
|
|
1316
|
+
<div class="logo">
|
|
1317
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1318
|
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
1319
|
+
</svg>
|
|
1320
|
+
<span>${analysis.config.report.title}</span>
|
|
1321
|
+
</div>
|
|
1322
|
+
<div class="header-meta">
|
|
1323
|
+
<span>
|
|
1324
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1325
|
+
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
|
1326
|
+
</svg>
|
|
1327
|
+
${date}
|
|
1328
|
+
</span>
|
|
1329
|
+
<span>
|
|
1330
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1331
|
+
<circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
1332
|
+
</svg>
|
|
1333
|
+
${summary.locales.length} locales
|
|
1334
|
+
</span>
|
|
1335
|
+
</div>
|
|
1336
|
+
<div class="header-actions">
|
|
1337
|
+
<button class="btn" onclick="window.print()">
|
|
1338
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1339
|
+
<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>
|
|
1340
|
+
</svg>
|
|
1341
|
+
Print
|
|
1342
|
+
</button>
|
|
1343
|
+
</div>
|
|
1344
|
+
</div>
|
|
1345
|
+
</header>
|
|
1346
|
+
|
|
1347
|
+
<main class="main">
|
|
1348
|
+
<!-- Score Hero -->
|
|
1349
|
+
<div class="score-hero">
|
|
1350
|
+
<div class="score-visual">
|
|
1351
|
+
<div class="score-ring">
|
|
1352
|
+
<svg width="100" height="100" viewBox="0 0 100 100">
|
|
1353
|
+
<circle class="bg" cx="50" cy="50" r="45"/>
|
|
1354
|
+
<circle class="progress" cx="50" cy="50" r="45"/>
|
|
1355
|
+
</svg>
|
|
1356
|
+
<div class="score-value">${score.total}</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
<span class="score-label">${score.level}</span>
|
|
1359
|
+
</div>
|
|
1360
|
+
<div class="score-details">
|
|
1361
|
+
<h1 class="score-title">Internationalization Health</h1>
|
|
1362
|
+
<p class="score-subtitle">
|
|
1363
|
+
${score.total >= 80 ? "Your i18n setup is in great shape. Keep up the good work!" : score.total >= 50 ? "There are some issues that need attention. Review the recommendations below." : "Critical issues detected. Immediate action is recommended."}
|
|
1364
|
+
</p>
|
|
1365
|
+
<div class="score-breakdown">
|
|
1366
|
+
<div class="breakdown-item">
|
|
1367
|
+
<span class="breakdown-value danger">${highCount}</span>
|
|
1368
|
+
<span class="breakdown-label">Critical</span>
|
|
1369
|
+
</div>
|
|
1370
|
+
<div class="breakdown-item">
|
|
1371
|
+
<span class="breakdown-value warning">${mediumCount}</span>
|
|
1372
|
+
<span class="breakdown-label">Warnings</span>
|
|
1373
|
+
</div>
|
|
1374
|
+
<div class="breakdown-item">
|
|
1375
|
+
<span class="breakdown-value success">${lowCount}</span>
|
|
1376
|
+
<span class="breakdown-label">Info</span>
|
|
1377
|
+
</div>
|
|
1378
|
+
</div>
|
|
1379
|
+
</div>
|
|
1380
|
+
</div>
|
|
1381
|
+
|
|
1382
|
+
<!-- Stats Grid -->
|
|
1383
|
+
<div class="stats-grid">
|
|
1384
|
+
<div class="stat-card">
|
|
1385
|
+
<div class="stat-header">
|
|
1386
|
+
<span class="stat-label">Total Keys</span>
|
|
1387
|
+
<div class="stat-icon blue">
|
|
1388
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1389
|
+
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
|
1390
|
+
</svg>
|
|
1391
|
+
</div>
|
|
1392
|
+
</div>
|
|
1393
|
+
<span class="stat-value">${summary.totalKeys.toLocaleString()}</span>
|
|
1394
|
+
</div>
|
|
1395
|
+
<div class="stat-card">
|
|
1396
|
+
<div class="stat-header">
|
|
1397
|
+
<span class="stat-label">Missing Keys</span>
|
|
1398
|
+
<div class="stat-icon red">
|
|
1399
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1400
|
+
<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>
|
|
1401
|
+
</svg>
|
|
1402
|
+
</div>
|
|
1403
|
+
</div>
|
|
1404
|
+
<span class="stat-value">${summary.missingKeys.toLocaleString()}</span>
|
|
1405
|
+
</div>
|
|
1406
|
+
<div class="stat-card">
|
|
1407
|
+
<div class="stat-header">
|
|
1408
|
+
<span class="stat-label">Unused Keys</span>
|
|
1409
|
+
<div class="stat-icon yellow">
|
|
1410
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1411
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
1412
|
+
</svg>
|
|
1413
|
+
</div>
|
|
1414
|
+
</div>
|
|
1415
|
+
<span class="stat-value">${summary.unusedKeys.toLocaleString()}</span>
|
|
1416
|
+
</div>
|
|
1417
|
+
<div class="stat-card">
|
|
1418
|
+
<div class="stat-header">
|
|
1419
|
+
<span class="stat-label">Placeholder Issues</span>
|
|
1420
|
+
<div class="stat-icon yellow">
|
|
1421
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1422
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/>
|
|
1423
|
+
</svg>
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
<span class="stat-value">${summary.placeholderIssues.toLocaleString()}</span>
|
|
1427
|
+
</div>
|
|
1428
|
+
<div class="stat-card">
|
|
1429
|
+
<div class="stat-header">
|
|
1430
|
+
<span class="stat-label">Dynamic Usages</span>
|
|
1431
|
+
<div class="stat-icon blue">
|
|
1432
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1433
|
+
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
|
1434
|
+
</svg>
|
|
1435
|
+
</div>
|
|
1436
|
+
</div>
|
|
1437
|
+
<span class="stat-value">${summary.dynamicWarnings.toLocaleString()}</span>
|
|
1438
|
+
</div>
|
|
1439
|
+
<div class="stat-card">
|
|
1440
|
+
<div class="stat-header">
|
|
1441
|
+
<span class="stat-label">Total Issues</span>
|
|
1442
|
+
<div class="stat-icon ${summary.totalIssues === 0 ? "green" : "red"}">
|
|
1443
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1444
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
|
1445
|
+
</svg>
|
|
1446
|
+
</div>
|
|
1447
|
+
</div>
|
|
1448
|
+
<span class="stat-value">${summary.totalIssues.toLocaleString()}</span>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
|
|
1452
|
+
<!-- Locale Overview -->
|
|
1453
|
+
<section class="section">
|
|
1454
|
+
<div class="section-header">
|
|
1455
|
+
<h2 class="section-title">
|
|
1456
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1457
|
+
<circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
1458
|
+
</svg>
|
|
1459
|
+
Locale Overview
|
|
1460
|
+
<span class="count">${summary.locales.length}</span>
|
|
1461
|
+
</h2>
|
|
1462
|
+
</div>
|
|
1463
|
+
<div class="locale-grid">
|
|
1464
|
+
${analysis.translationFiles.map((file) => {
|
|
1465
|
+
const missing = analysis.issues.filter(
|
|
1466
|
+
(issue) => issue.type === "missing" && issue.locale === file.locale
|
|
1467
|
+
).length;
|
|
1468
|
+
const extra = analysis.issues.filter(
|
|
1469
|
+
(issue) => issue.type === "extra" && issue.locale === file.locale
|
|
1470
|
+
).length;
|
|
1471
|
+
const coverage = file.flat.size > 0 ? Math.round((file.flat.size - missing) / file.flat.size * 100) : 100;
|
|
1472
|
+
return `
|
|
1473
|
+
<div class="locale-card">
|
|
1474
|
+
<div class="locale-header">
|
|
1475
|
+
<span class="locale-name">${file.locale.toUpperCase()}</span>
|
|
1476
|
+
<span class="locale-tag">${coverage}%</span>
|
|
1477
|
+
</div>
|
|
1478
|
+
<div class="locale-bar">
|
|
1479
|
+
<div class="locale-bar-fill" style="width: ${coverage}%; background: ${coverage >= 90 ? "var(--success)" : coverage >= 70 ? "var(--warning)" : "var(--danger)"}"></div>
|
|
1480
|
+
</div>
|
|
1481
|
+
<div class="locale-stats">
|
|
1482
|
+
<div class="locale-stat">
|
|
1483
|
+
<span class="locale-stat-value">${file.flat.size}</span>
|
|
1484
|
+
<span class="locale-stat-label">Keys</span>
|
|
1485
|
+
</div>
|
|
1486
|
+
<div class="locale-stat">
|
|
1487
|
+
<span class="locale-stat-value" style="color: var(--danger)">${missing}</span>
|
|
1488
|
+
<span class="locale-stat-label">Missing</span>
|
|
1489
|
+
</div>
|
|
1490
|
+
<div class="locale-stat">
|
|
1491
|
+
<span class="locale-stat-value" style="color: var(--warning)">${extra}</span>
|
|
1492
|
+
<span class="locale-stat-label">Extra</span>
|
|
1493
|
+
</div>
|
|
1494
|
+
</div>
|
|
1495
|
+
</div>`;
|
|
1496
|
+
}).join("")}
|
|
1497
|
+
</div>
|
|
1498
|
+
</section>
|
|
1499
|
+
|
|
1500
|
+
<!-- Issues Table -->
|
|
1501
|
+
<section class="section">
|
|
1502
|
+
<div class="section-header">
|
|
1503
|
+
<h2 class="section-title">
|
|
1504
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1505
|
+
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
1506
|
+
</svg>
|
|
1507
|
+
Issues
|
|
1508
|
+
<span class="count">${issues.length}</span>
|
|
1509
|
+
</h2>
|
|
1510
|
+
<div class="tabs" id="severity-tabs">
|
|
1511
|
+
<button class="tab active" data-filter="all">All</button>
|
|
1512
|
+
<button class="tab" data-filter="high">Critical (${highCount})</button>
|
|
1513
|
+
<button class="tab" data-filter="medium">Warning (${mediumCount})</button>
|
|
1514
|
+
<button class="tab" data-filter="low">Info (${lowCount})</button>
|
|
1515
|
+
</div>
|
|
1516
|
+
</div>
|
|
1517
|
+
<div class="table-container">
|
|
1518
|
+
${issues.length > 0 ? `
|
|
1519
|
+
<table id="issues-table">
|
|
1520
|
+
<thead>
|
|
1521
|
+
<tr>
|
|
1522
|
+
<th style="width: 100px">Severity</th>
|
|
1523
|
+
<th style="width: 100px">Type</th>
|
|
1524
|
+
<th style="width: 80px">Locale</th>
|
|
1525
|
+
<th>Key</th>
|
|
1526
|
+
<th class="message">Message</th>
|
|
1527
|
+
</tr>
|
|
1528
|
+
</thead>
|
|
1529
|
+
<tbody>
|
|
1530
|
+
${issues.map((issue) => `
|
|
1531
|
+
<tr data-severity="${issue.severity}">
|
|
1532
|
+
<td><span class="badge ${issue.severity}">${issue.severity === "high" ? "Critical" : issue.severity === "medium" ? "Warning" : "Info"}</span></td>
|
|
1533
|
+
<td><span class="badge-type">${issue.type}</span></td>
|
|
1534
|
+
<td>${issue.locale ? `<span class="locale-tag">${issue.locale}</span>` : "-"}</td>
|
|
1535
|
+
<td class="key">${issue.key ?? "-"}</td>
|
|
1536
|
+
<td class="message">${issue.message}</td>
|
|
1537
|
+
</tr>`).join("")}
|
|
1538
|
+
</tbody>
|
|
1539
|
+
</table>` : `
|
|
1540
|
+
<div class="empty-state">
|
|
1541
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1542
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
|
1543
|
+
</svg>
|
|
1544
|
+
<p>No issues detected. Your i18n setup looks great!</p>
|
|
1545
|
+
</div>`}
|
|
1546
|
+
</div>
|
|
1547
|
+
</section>
|
|
1548
|
+
|
|
1549
|
+
<!-- Recommendations -->
|
|
1550
|
+
<section class="section">
|
|
1551
|
+
<div class="section-header">
|
|
1552
|
+
<h2 class="section-title">
|
|
1553
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1554
|
+
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
1555
|
+
</svg>
|
|
1556
|
+
Recommendations
|
|
1557
|
+
<span class="count">${score.notes.length || 1}</span>
|
|
1558
|
+
</h2>
|
|
1559
|
+
</div>
|
|
1560
|
+
<div class="recommendations">
|
|
1561
|
+
${score.notes.length ? score.notes.map((note, index) => {
|
|
1562
|
+
const priority = index === 0 ? "high" : index === 1 ? "medium" : "low";
|
|
1563
|
+
const priorityNum = index + 1;
|
|
1564
|
+
return `
|
|
1565
|
+
<div class="recommendation">
|
|
1566
|
+
<span class="priority ${priority}">${priorityNum}</span>
|
|
1567
|
+
<div class="content">
|
|
1568
|
+
<p class="title">${note}</p>
|
|
1569
|
+
</div>
|
|
1570
|
+
</div>`;
|
|
1571
|
+
}).join("") : `
|
|
1572
|
+
<div class="recommendation">
|
|
1573
|
+
<span class="priority low">\u2713</span>
|
|
1574
|
+
<div class="content">
|
|
1575
|
+
<p class="title">No major issues detected</p>
|
|
1576
|
+
<p class="desc">Your internationalization setup is well configured. Continue monitoring for any new issues.</p>
|
|
1577
|
+
</div>
|
|
1578
|
+
</div>`}
|
|
1579
|
+
</div>
|
|
1580
|
+
</section>
|
|
1581
|
+
</main>
|
|
1582
|
+
|
|
1583
|
+
<footer class="footer">
|
|
1584
|
+
Generated by <a href="#">i18n-checker</a> \u2022 ${analysis.summary.totalIssues} issue(s) detected across ${summary.locales.length} locale(s)
|
|
1585
|
+
</footer>
|
|
1586
|
+
|
|
1587
|
+
<script>
|
|
1588
|
+
// Tab filtering
|
|
1589
|
+
document.querySelectorAll('#severity-tabs .tab').forEach(tab => {
|
|
1590
|
+
tab.addEventListener('click', () => {
|
|
1591
|
+
document.querySelectorAll('#severity-tabs .tab').forEach(t => t.classList.remove('active'));
|
|
1592
|
+
tab.classList.add('active');
|
|
1593
|
+
|
|
1594
|
+
const filter = tab.dataset.filter;
|
|
1595
|
+
document.querySelectorAll('#issues-table tbody tr').forEach(row => {
|
|
1596
|
+
if (filter === 'all' || row.dataset.severity === filter) {
|
|
1597
|
+
row.style.display = '';
|
|
1598
|
+
} else {
|
|
1599
|
+
row.style.display = 'none';
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
</script>
|
|
1605
|
+
</body>
|
|
1606
|
+
</html>`;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/utils/fs.ts
|
|
1610
|
+
var import_promises = __toESM(require("fs/promises"));
|
|
1611
|
+
var import_path4 = __toESM(require("path"));
|
|
1612
|
+
async function writeFileSafe(filePath, content) {
|
|
1613
|
+
await import_promises.default.mkdir(import_path4.default.dirname(filePath), { recursive: true });
|
|
1614
|
+
await import_promises.default.writeFile(filePath, content, "utf8");
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/core/fix.ts
|
|
1618
|
+
var import_fs4 = __toESM(require("fs"));
|
|
1619
|
+
|
|
1620
|
+
// src/utils/sort.ts
|
|
1621
|
+
function sortObjectDeep(value) {
|
|
1622
|
+
if (Array.isArray(value)) {
|
|
1623
|
+
return value.map(sortObjectDeep);
|
|
1624
|
+
}
|
|
1625
|
+
if (value && typeof value === "object") {
|
|
1626
|
+
const entries = Object.entries(value).sort(
|
|
1627
|
+
([a], [b]) => a.localeCompare(b)
|
|
1628
|
+
);
|
|
1629
|
+
const sorted = {};
|
|
1630
|
+
for (const [key, val] of entries) {
|
|
1631
|
+
sorted[key] = sortObjectDeep(val);
|
|
1632
|
+
}
|
|
1633
|
+
return sorted;
|
|
1634
|
+
}
|
|
1635
|
+
return value;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/core/fix.ts
|
|
1639
|
+
async function applyFixes(options) {
|
|
1640
|
+
const { analysis } = options;
|
|
1641
|
+
const sourceLang = options.sourceLang ?? analysis.config.locales.sourceLang;
|
|
1642
|
+
const sourceFile = analysis.translationFiles.find((file) => file.locale === sourceLang);
|
|
1643
|
+
if (!sourceFile) {
|
|
1644
|
+
throw new Error(`Source language ${sourceLang} not found`);
|
|
1645
|
+
}
|
|
1646
|
+
const sourceKeys = new Set(sourceFile.flat.keys());
|
|
1647
|
+
const usedKeys = new Set(
|
|
1648
|
+
analysis.codeUsages.filter((usage) => !usage.dynamic).map((usage) => usage.key)
|
|
1649
|
+
);
|
|
1650
|
+
const changedFiles = [];
|
|
1651
|
+
for (const file of analysis.translationFiles) {
|
|
1652
|
+
const flat = new Map(flattenJson(file.content));
|
|
1653
|
+
for (const key of sourceKeys) {
|
|
1654
|
+
if (!flat.has(key)) {
|
|
1655
|
+
flat.set(key, getStrategyValue(options.strategy, sourceFile.flat.get(key), analysis));
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (options.removeUnused) {
|
|
1659
|
+
for (const key of Array.from(flat.keys())) {
|
|
1660
|
+
if (!usedKeys.has(key)) {
|
|
1661
|
+
flat.delete(key);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
let nextContent = unflattenJson(flat);
|
|
1666
|
+
if (options.sort) {
|
|
1667
|
+
nextContent = sortObjectDeep(nextContent);
|
|
1668
|
+
}
|
|
1669
|
+
const serialized = JSON.stringify(nextContent, null, 2);
|
|
1670
|
+
if (!options.dryRun) {
|
|
1671
|
+
import_fs4.default.writeFileSync(file.path, `${serialized}
|
|
1672
|
+
`, "utf8");
|
|
1673
|
+
}
|
|
1674
|
+
changedFiles.push(file.path);
|
|
1675
|
+
}
|
|
1676
|
+
const summary = options.dryRun ? `Dry-run complete. ${changedFiles.length} files would be updated.` : `Updated ${changedFiles.length} locale files.`;
|
|
1677
|
+
return {
|
|
1678
|
+
summary,
|
|
1679
|
+
changedFiles
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
function getStrategyValue(strategy, sourceValue, analysis) {
|
|
1683
|
+
switch (strategy) {
|
|
1684
|
+
case "empty":
|
|
1685
|
+
return "";
|
|
1686
|
+
case "source":
|
|
1687
|
+
return typeof sourceValue === "string" ? sourceValue : "";
|
|
1688
|
+
case "todo":
|
|
1689
|
+
default:
|
|
1690
|
+
return analysis.config.fix.placeholder ?? "TODO_TRANSLATE";
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/config/normalize.ts
|
|
1695
|
+
function normalizeCliOptions(options) {
|
|
1696
|
+
const listify = (value) => {
|
|
1697
|
+
if (Array.isArray(value)) {
|
|
1698
|
+
return value.filter(Boolean);
|
|
1699
|
+
}
|
|
1700
|
+
if (typeof value === "string" && value.length) {
|
|
1701
|
+
return [value];
|
|
1702
|
+
}
|
|
1703
|
+
return [];
|
|
1704
|
+
};
|
|
1705
|
+
return {
|
|
1706
|
+
configPath: typeof options.config === "string" ? options.config : void 0,
|
|
1707
|
+
source: listify(options.src),
|
|
1708
|
+
localesDir: typeof options.locales === "string" ? options.locales : void 0,
|
|
1709
|
+
sourceLang: typeof options["source-lang"] === "string" ? options["source-lang"] : void 0,
|
|
1710
|
+
format: typeof options.format === "string" ? options.format : "pretty",
|
|
1711
|
+
failOnError: Boolean(options["fail-on-error"]),
|
|
1712
|
+
verbose: Boolean(options.verbose),
|
|
1713
|
+
ignore: listify(options.ignore),
|
|
1714
|
+
framework: typeof options.framework === "string" ? options.framework : "auto",
|
|
1715
|
+
includeDynamicWarnings: Boolean(options["include-dynamic-warnings"]),
|
|
1716
|
+
showOnlyScore: false
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/config/autoDetect.ts
|
|
1721
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1722
|
+
var import_path5 = __toESM(require("path"));
|
|
1723
|
+
var LOCALE_DIR_CANDIDATES = [
|
|
1724
|
+
"src/i18n",
|
|
1725
|
+
"src/locales",
|
|
1726
|
+
"src/assets/i18n",
|
|
1727
|
+
"locales",
|
|
1728
|
+
"i18n",
|
|
1729
|
+
"public/locales"
|
|
1730
|
+
];
|
|
1731
|
+
var SOURCE_DIR_CANDIDATES = ["src", "app", "apps", "."];
|
|
1732
|
+
var COMMON_PREFIXES = ["trad.", "t.", "i18n.", "translation."];
|
|
1733
|
+
function autoDetect(projectRoot) {
|
|
1734
|
+
const localesDir = LOCALE_DIR_CANDIDATES.map((candidate) => import_path5.default.join(projectRoot, candidate)).find((candidate) => import_fs5.default.existsSync(candidate));
|
|
1735
|
+
const sourceDirs = SOURCE_DIR_CANDIDATES.map((candidate) => import_path5.default.join(projectRoot, candidate)).filter((candidate) => import_fs5.default.existsSync(candidate)).map((candidate) => import_path5.default.relative(projectRoot, candidate) || ".");
|
|
1736
|
+
const localeInfo = localesDir ? detectLocales(localesDir) : null;
|
|
1737
|
+
const prefix = detectPrefix(projectRoot) ?? null;
|
|
1738
|
+
return {
|
|
1739
|
+
projectRoot,
|
|
1740
|
+
sourceDirs: sourceDirs.length ? sourceDirs : ["src"],
|
|
1741
|
+
localesDir: localesDir ? import_path5.default.relative(projectRoot, localesDir) : null,
|
|
1742
|
+
locales: localeInfo?.locales ?? [],
|
|
1743
|
+
fileName: localeInfo?.fileName ?? "trad",
|
|
1744
|
+
extension: localeInfo?.extension ?? ".json",
|
|
1745
|
+
prefix
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
function detectLocales(localesDir) {
|
|
1749
|
+
const entries = import_fs5.default.readdirSync(localesDir, { withFileTypes: true });
|
|
1750
|
+
const fileEntries = entries.filter((entry) => entry.isFile());
|
|
1751
|
+
if (fileEntries.length) {
|
|
1752
|
+
const extensions = new Set(fileEntries.map((entry) => import_path5.default.extname(entry.name)));
|
|
1753
|
+
const extension = extensions.has(".json") ? ".json" : import_path5.default.extname(fileEntries[0].name) || ".json";
|
|
1754
|
+
const locales = fileEntries.filter((entry) => entry.name.endsWith(extension)).map((entry) => import_path5.default.basename(entry.name, extension));
|
|
1755
|
+
return {
|
|
1756
|
+
locales,
|
|
1757
|
+
fileName: "",
|
|
1758
|
+
extension
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
const dirEntries = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
1762
|
+
if (!dirEntries.length) {
|
|
1763
|
+
return { locales: [], fileName: "trad", extension: ".json" };
|
|
1764
|
+
}
|
|
1765
|
+
const sampleLocale = dirEntries[0];
|
|
1766
|
+
const samplePath = import_path5.default.join(localesDir, sampleLocale);
|
|
1767
|
+
const files = import_fs5.default.readdirSync(samplePath).filter((file) => file.endsWith(".json"));
|
|
1768
|
+
const first = files[0] ?? "trad.json";
|
|
1769
|
+
return {
|
|
1770
|
+
locales: dirEntries,
|
|
1771
|
+
fileName: import_path5.default.basename(first, import_path5.default.extname(first)),
|
|
1772
|
+
extension: import_path5.default.extname(first) || ".json"
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function detectPrefix(projectRoot) {
|
|
1776
|
+
const sampleFiles = [];
|
|
1777
|
+
const scanDirs = ["src", "app", "apps"];
|
|
1778
|
+
for (const dir of scanDirs) {
|
|
1779
|
+
const full = import_path5.default.join(projectRoot, dir);
|
|
1780
|
+
if (import_fs5.default.existsSync(full) && import_fs5.default.statSync(full).isDirectory()) {
|
|
1781
|
+
sampleFiles.push(...collectSampleFiles(full, 20));
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
for (const file of sampleFiles) {
|
|
1785
|
+
const content = import_fs5.default.readFileSync(file, "utf8");
|
|
1786
|
+
for (const prefix of COMMON_PREFIXES) {
|
|
1787
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1788
|
+
const regex = new RegExp(`(["'\`])${escaped}[A-Za-z0-9_.-]+\\1`, "g");
|
|
1789
|
+
if (regex.test(content)) {
|
|
1790
|
+
return prefix;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
function collectSampleFiles(dir, limit) {
|
|
1797
|
+
const results = [];
|
|
1798
|
+
const stack = [dir];
|
|
1799
|
+
while (stack.length && results.length < limit) {
|
|
1800
|
+
const current = stack.pop();
|
|
1801
|
+
if (!current) continue;
|
|
1802
|
+
const entries = import_fs5.default.readdirSync(current, { withFileTypes: true });
|
|
1803
|
+
for (const entry of entries) {
|
|
1804
|
+
if (results.length >= limit) break;
|
|
1805
|
+
if (entry.isDirectory()) {
|
|
1806
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
stack.push(import_path5.default.join(current, entry.name));
|
|
1810
|
+
} else if (entry.isFile()) {
|
|
1811
|
+
if (/\.(ts|tsx|js|jsx)$/i.test(entry.name)) {
|
|
1812
|
+
results.push(import_path5.default.join(current, entry.name));
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
return results;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/utils/prompt.ts
|
|
1821
|
+
async function promptText(rl, options) {
|
|
1822
|
+
const suffix = options.defaultValue ? ` (${options.defaultValue})` : "";
|
|
1823
|
+
const raw = await rl.question(`${options.question}${suffix}: `);
|
|
1824
|
+
const value = raw.trim() || options.defaultValue || "";
|
|
1825
|
+
if (!value && !options.optional) {
|
|
1826
|
+
return promptText(rl, options);
|
|
1827
|
+
}
|
|
1828
|
+
return value;
|
|
1829
|
+
}
|
|
1830
|
+
async function promptConfirm(rl, question, defaultValue = true) {
|
|
1831
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
1832
|
+
const raw = await rl.question(`${question} (${hint}): `);
|
|
1833
|
+
const normalized = raw.trim().toLowerCase();
|
|
1834
|
+
if (!normalized) {
|
|
1835
|
+
return defaultValue;
|
|
1836
|
+
}
|
|
1837
|
+
return ["y", "yes"].includes(normalized);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// src/cli.ts
|
|
1841
|
+
var cli = (0, import_cac.cac)("i18n-checker");
|
|
1842
|
+
function withCommonOptions(command) {
|
|
1843
|
+
return command.option("--config <path>", "Path to config file").option("--src <path>", "Source directory or glob (repeatable)", { default: [] }).option("--locales <path>", "Locales root directory", { default: "" }).option("--source-lang <code>", "Reference language").option("--ignore <pattern>", "Ignore glob (repeatable)", { default: [] }).option("--verbose", "Verbose output");
|
|
1844
|
+
}
|
|
1845
|
+
function handleError(error) {
|
|
1846
|
+
console.error(import_chalk2.default.redBright("i18n-checker failed"));
|
|
1847
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1848
|
+
process.exitCode = 1;
|
|
1849
|
+
}
|
|
1850
|
+
withCommonOptions(
|
|
1851
|
+
cli.command("check", "Analyze and print i18n issues").option("--format <type>", "pretty|json", { default: "pretty" }).option("--fail-on-error", "Exit code 1 on issues").option(
|
|
1852
|
+
"--framework <name>",
|
|
1853
|
+
"auto|i18next|next-intl|react-intl|angular",
|
|
1854
|
+
{ default: "auto" }
|
|
1855
|
+
).option("--include-dynamic-warnings", "Show dynamic key warnings")
|
|
1856
|
+
).action(async (options) => {
|
|
1857
|
+
try {
|
|
1858
|
+
const config = await loadConfig(options.config);
|
|
1859
|
+
const cliOptions = normalizeCliOptions(options);
|
|
1860
|
+
const analysis = await analyzeProject({ config, cliOptions });
|
|
1861
|
+
const output = cliOptions.format === "json" ? formatJsonReport(analysis) : formatConsoleReport(analysis, cliOptions);
|
|
1862
|
+
console.log(output);
|
|
1863
|
+
if (cliOptions.failOnError && analysis.summary.totalIssues > 0) {
|
|
1864
|
+
process.exitCode = 1;
|
|
1865
|
+
}
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
handleError(error);
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
withCommonOptions(
|
|
1871
|
+
cli.command("report", "Generate a premium HTML report").option("--output <path>", "Output HTML file", { default: "i18n-report.html" }).option("--open", "Open report after generation")
|
|
1872
|
+
).action(async (options) => {
|
|
1873
|
+
try {
|
|
1874
|
+
const config = await loadConfig(options.config);
|
|
1875
|
+
const cliOptions = normalizeCliOptions(options);
|
|
1876
|
+
const analysis = await analyzeProject({ config, cliOptions });
|
|
1877
|
+
const html = generateHtmlReport(analysis);
|
|
1878
|
+
await writeFileSafe(options.output, html);
|
|
1879
|
+
console.log(import_chalk2.default.green(`Report written to ${options.output}`));
|
|
1880
|
+
if (options.open) {
|
|
1881
|
+
await Promise.resolve().then(() => (init_open(), open_exports)).then((module2) => module2.openFile(options.output));
|
|
1882
|
+
}
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
handleError(error);
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
withCommonOptions(
|
|
1888
|
+
cli.command("score", "Compute and print i18n quality score").option("--format <type>", "pretty|json", { default: "pretty" })
|
|
1889
|
+
).action(async (options) => {
|
|
1890
|
+
try {
|
|
1891
|
+
const config = await loadConfig(options.config);
|
|
1892
|
+
const cliOptions = normalizeCliOptions(options);
|
|
1893
|
+
const analysis = await analyzeProject({ config, cliOptions });
|
|
1894
|
+
const score = computeScore(analysis);
|
|
1895
|
+
if (options.format === "json") {
|
|
1896
|
+
console.log(JSON.stringify(score, null, 2));
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
console.log(formatConsoleReport(analysis, { showOnlyScore: true, score }));
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
handleError(error);
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
withCommonOptions(
|
|
1905
|
+
cli.command("sync", "Synchronize locale files from a source language").option("--strategy <mode>", "empty|source|todo", { default: "todo" }).option("--dry-run", "Preview changes without writing").option("--sort", "Sort keys")
|
|
1906
|
+
).action(async (options) => {
|
|
1907
|
+
try {
|
|
1908
|
+
const config = await loadConfig(options.config);
|
|
1909
|
+
const cliOptions = normalizeCliOptions(options);
|
|
1910
|
+
const analysis = await analyzeProject({ config, cliOptions });
|
|
1911
|
+
const result = await applyFixes({
|
|
1912
|
+
analysis,
|
|
1913
|
+
strategy: options.strategy,
|
|
1914
|
+
sourceLang: options.sourceLang,
|
|
1915
|
+
dryRun: !!options.dryRun,
|
|
1916
|
+
sort: !!options.sort,
|
|
1917
|
+
removeUnused: false
|
|
1918
|
+
});
|
|
1919
|
+
console.log(result.summary);
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
handleError(error);
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
withCommonOptions(
|
|
1925
|
+
cli.command("fix", "Apply safe automatic fixes").option("--strategy <mode>", "empty|source|todo", { default: "todo" }).option("--dry-run", "Preview changes without writing").option("--sort", "Sort keys").option("--remove-unused", "Remove unused keys")
|
|
1926
|
+
).action(async (options) => {
|
|
1927
|
+
try {
|
|
1928
|
+
const config = await loadConfig(options.config);
|
|
1929
|
+
const cliOptions = normalizeCliOptions(options);
|
|
1930
|
+
const analysis = await analyzeProject({ config, cliOptions });
|
|
1931
|
+
const result = await applyFixes({
|
|
1932
|
+
analysis,
|
|
1933
|
+
strategy: options.strategy,
|
|
1934
|
+
sourceLang: options.sourceLang,
|
|
1935
|
+
dryRun: !!options.dryRun,
|
|
1936
|
+
sort: !!options.sort,
|
|
1937
|
+
removeUnused: !!options.removeUnused
|
|
1938
|
+
});
|
|
1939
|
+
console.log(result.summary);
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
handleError(error);
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
cli.help();
|
|
1945
|
+
cli.version("0.2.0");
|
|
1946
|
+
cli.command("init", "Interactive setup to generate config").option("--output <path>", "Config output file", { default: "i18n-checker.config.js" }).action(async (options) => {
|
|
1947
|
+
try {
|
|
1948
|
+
const readline = await import("readline/promises");
|
|
1949
|
+
const rl = readline.createInterface({
|
|
1950
|
+
input: process.stdin,
|
|
1951
|
+
output: process.stdout
|
|
1952
|
+
});
|
|
1953
|
+
console.log(import_chalk2.default.cyan("Let\u2019s set up i18n-checker."));
|
|
1954
|
+
console.log(import_chalk2.default.gray("Press Enter to accept defaults.\n"));
|
|
1955
|
+
const detected = autoDetect(process.cwd());
|
|
1956
|
+
const projectRoot = await promptText(rl, {
|
|
1957
|
+
question: "Project root",
|
|
1958
|
+
defaultValue: detected.projectRoot
|
|
1959
|
+
});
|
|
1960
|
+
const localesDir = await promptText(rl, {
|
|
1961
|
+
question: "Locales directory",
|
|
1962
|
+
defaultValue: detected.localesDir ?? "src/i18n"
|
|
1963
|
+
});
|
|
1964
|
+
const sourceDirs = await promptText(rl, {
|
|
1965
|
+
question: "Source directories (comma separated)",
|
|
1966
|
+
defaultValue: detected.sourceDirs.join(",")
|
|
1967
|
+
});
|
|
1968
|
+
const sourceLang = await promptText(rl, {
|
|
1969
|
+
question: "Source language",
|
|
1970
|
+
defaultValue: detected.locales[0] ?? "en"
|
|
1971
|
+
});
|
|
1972
|
+
const fileName = await promptText(rl, {
|
|
1973
|
+
question: "Translation file name (without extension)",
|
|
1974
|
+
defaultValue: detected.fileName || "trad"
|
|
1975
|
+
});
|
|
1976
|
+
const extension = await promptText(rl, {
|
|
1977
|
+
question: "Translation file extension",
|
|
1978
|
+
defaultValue: detected.extension || ".json"
|
|
1979
|
+
});
|
|
1980
|
+
const prefix = await promptText(rl, {
|
|
1981
|
+
question: "Key prefix in code",
|
|
1982
|
+
defaultValue: detected.prefix ?? "trad."
|
|
1983
|
+
});
|
|
1984
|
+
const functions = await promptText(rl, {
|
|
1985
|
+
question: "Translation functions (comma separated)",
|
|
1986
|
+
defaultValue: "t,i18n.t,translate.instant,this.translate.instant"
|
|
1987
|
+
});
|
|
1988
|
+
const outputPath = await promptText(rl, {
|
|
1989
|
+
question: "Config output path",
|
|
1990
|
+
defaultValue: options.output
|
|
1991
|
+
});
|
|
1992
|
+
const confirm = await promptConfirm(rl, "Write config file now?", true);
|
|
1993
|
+
if (!confirm) {
|
|
1994
|
+
rl.close();
|
|
1995
|
+
console.log("Aborted.");
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
const configContent = `module.exports = {
|
|
1999
|
+
projectRoot: ${JSON.stringify(projectRoot)},
|
|
2000
|
+
source: ${JSON.stringify(sourceDirs.split(",").map((s) => s.trim()).filter(Boolean))},
|
|
2001
|
+
locales: {
|
|
2002
|
+
dir: ${JSON.stringify(localesDir)},
|
|
2003
|
+
extension: ${JSON.stringify(extension)},
|
|
2004
|
+
fileName: ${JSON.stringify(fileName)},
|
|
2005
|
+
sourceLang: ${JSON.stringify(sourceLang)},
|
|
2006
|
+
},
|
|
2007
|
+
patterns: {
|
|
2008
|
+
prefix: ${JSON.stringify(prefix)},
|
|
2009
|
+
functions: ${JSON.stringify(functions.split(",").map((s) => s.trim()).filter(Boolean))},
|
|
2010
|
+
},
|
|
2011
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/coverage/**'],
|
|
2012
|
+
rules: {
|
|
2013
|
+
detectEmpty: true,
|
|
2014
|
+
detectNull: true,
|
|
2015
|
+
detectSameAsKey: true,
|
|
2016
|
+
detectUnused: true,
|
|
2017
|
+
detectDuplicateTranslations: true,
|
|
2018
|
+
detectPlaceholderMismatch: true,
|
|
2019
|
+
detectStructureMismatch: true,
|
|
2020
|
+
},
|
|
2021
|
+
report: {
|
|
2022
|
+
title: 'i18n Quality Report',
|
|
2023
|
+
darkMode: true,
|
|
2024
|
+
},
|
|
2025
|
+
fix: {
|
|
2026
|
+
strategy: 'todo',
|
|
2027
|
+
placeholder: 'TODO_TRANSLATE',
|
|
2028
|
+
sort: true,
|
|
2029
|
+
},
|
|
2030
|
+
};
|
|
2031
|
+
`;
|
|
2032
|
+
await writeFileSafe(outputPath, configContent);
|
|
2033
|
+
console.log(import_chalk2.default.green(`Config written to ${outputPath}`));
|
|
2034
|
+
const useDefault = await promptConfirm(
|
|
2035
|
+
rl,
|
|
2036
|
+
"Use this config by default (no --config needed)?",
|
|
2037
|
+
true
|
|
2038
|
+
);
|
|
2039
|
+
rl.close();
|
|
2040
|
+
if (!useDefault) {
|
|
2041
|
+
console.log(import_chalk2.default.yellow("Config will not be auto-loaded by default."));
|
|
2042
|
+
console.log(
|
|
2043
|
+
`Use it like this: ${import_chalk2.default.cyan(`i18n-checker check --config ${outputPath}`)}`
|
|
2044
|
+
);
|
|
2045
|
+
} else {
|
|
2046
|
+
console.log(import_chalk2.default.green("Config will be auto-loaded by default."));
|
|
2047
|
+
}
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
handleError(error);
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
cli.parse();
|