ai-localize-reporting 2.0.0 → 2.0.3
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/CHANGELOG.md +47 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1983 -83
- package/dist/index.mjs +1983 -83
- package/package.json +2 -2
- package/preview/fix-exports.js +147 -0
- package/preview/fix-preview-exports.js +166 -0
- package/preview/generate-preview.ts +105 -0
- package/preview/make-preview.cjs +564 -0
- package/preview/make-preview.mjs +398 -0
- package/preview/report-preview.html +831 -0
- package/src/cli-reporter.ts +597 -25
- package/src/html-reporter.ts +1674 -58
- package/tsconfig.json +6 -1
package/src/cli-reporter.ts
CHANGED
|
@@ -1,29 +1,601 @@
|
|
|
1
1
|
import type { Report } from "ai-localize-shared";
|
|
2
2
|
|
|
3
|
+
// ─── Terminal helpers ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/** ANSI escape sequences — inline so the package stays dependency-free. */
|
|
6
|
+
const A = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
bold: '\x1b[1m',
|
|
9
|
+
dim: '\x1b[2m',
|
|
10
|
+
italic: '\x1b[3m',
|
|
11
|
+
underline: '\x1b[4m',
|
|
12
|
+
// foreground
|
|
13
|
+
black: '\x1b[30m',
|
|
14
|
+
red: '\x1b[31m',
|
|
15
|
+
green: '\x1b[32m',
|
|
16
|
+
yellow: '\x1b[33m',
|
|
17
|
+
blue: '\x1b[34m',
|
|
18
|
+
magenta: '\x1b[35m',
|
|
19
|
+
cyan: '\x1b[36m',
|
|
20
|
+
white: '\x1b[37m',
|
|
21
|
+
// bright foreground
|
|
22
|
+
brightRed: '\x1b[91m',
|
|
23
|
+
brightGreen: '\x1b[92m',
|
|
24
|
+
brightYellow: '\x1b[93m',
|
|
25
|
+
brightBlue: '\x1b[94m',
|
|
26
|
+
brightMagenta: '\x1b[95m',
|
|
27
|
+
brightCyan: '\x1b[96m',
|
|
28
|
+
brightWhite: '\x1b[97m',
|
|
29
|
+
// background
|
|
30
|
+
bgRed: '\x1b[41m',
|
|
31
|
+
bgGreen: '\x1b[42m',
|
|
32
|
+
bgYellow: '\x1b[43m',
|
|
33
|
+
bgBlue: '\x1b[44m',
|
|
34
|
+
bgMagenta: '\x1b[45m',
|
|
35
|
+
bgCyan: '\x1b[46m',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function c(color: keyof typeof A, text: string): string {
|
|
39
|
+
return A[color] + text + A.reset;
|
|
40
|
+
}
|
|
41
|
+
function bold(text: string): string { return A.bold + text + A.reset; }
|
|
42
|
+
function dim(text: string): string { return A.dim + text + A.reset; }
|
|
43
|
+
|
|
44
|
+
/** Detect whether the terminal supports colour (respects NO_COLOR env). */
|
|
45
|
+
function supportsColor(): boolean {
|
|
46
|
+
if (process.env.NO_COLOR || process.env.TERM === 'dumb') return false;
|
|
47
|
+
if (process.stdout && !process.stdout.isTTY) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Strips ANSI codes — used when colour is disabled. */
|
|
52
|
+
function strip(text: string): string {
|
|
53
|
+
// eslint-disable-next-line no-control-regex
|
|
54
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns the printable (non-ANSI) length of a string. */
|
|
58
|
+
function visLen(s: string): number {
|
|
59
|
+
return strip(s).length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Pad a string on the right to `width` visible characters. */
|
|
63
|
+
function padEnd(s: string, width: number): string {
|
|
64
|
+
const pad = width - visLen(s);
|
|
65
|
+
return pad > 0 ? s + ' '.repeat(pad) : s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pad a string on the left to `width` visible characters. */
|
|
69
|
+
function padStart(s: string, width: number): string {
|
|
70
|
+
const pad = width - visLen(s);
|
|
71
|
+
return pad > 0 ? ' '.repeat(pad) + s : s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── UI primitives ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Gets the terminal column width, defaulting to 100. */
|
|
77
|
+
function termWidth(): number {
|
|
78
|
+
return Math.min(process.stdout.columns || 100, 120);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Draws a horizontal rule. */
|
|
82
|
+
function hr(char = '─', color: keyof typeof A = 'dim'): string {
|
|
83
|
+
const line = char.repeat(termWidth());
|
|
84
|
+
return supportsColor() ? A[color] + line + A.reset : line;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Centres text within the terminal width. */
|
|
88
|
+
function centre(text: string): string {
|
|
89
|
+
const visible = visLen(text);
|
|
90
|
+
const tw = termWidth();
|
|
91
|
+
if (visible >= tw) return text;
|
|
92
|
+
const pad = Math.floor((tw - visible) / 2);
|
|
93
|
+
return ' '.repeat(pad) + text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Renders a status indicator badge. */
|
|
97
|
+
function badge(text: string, ok: boolean): string {
|
|
98
|
+
return ok
|
|
99
|
+
? c('bgGreen', c('black', ` ${text} `))
|
|
100
|
+
: c('bgRed', c('white', ` ${text} `));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Renders a mini horizontal bar of filled/empty blocks. */
|
|
104
|
+
function miniBar(value: number, max: number, width = 20, color: keyof typeof A = 'cyan'): string {
|
|
105
|
+
const filled = max > 0 ? Math.round((value / max) * width) : 0;
|
|
106
|
+
const empty = width - filled;
|
|
107
|
+
const bar = c(color, '█'.repeat(filled)) + dim('░'.repeat(empty));
|
|
108
|
+
return '[' + bar + ']';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Renders a progress bar for a percentage value. */
|
|
112
|
+
function progressBar(pct: number, width = 24): string {
|
|
113
|
+
const filled = Math.round((pct / 100) * width);
|
|
114
|
+
const empty = width - filled;
|
|
115
|
+
let color: keyof typeof A = 'brightGreen';
|
|
116
|
+
if (pct < 50) color = 'brightRed';
|
|
117
|
+
else if (pct < 80) color = 'brightYellow';
|
|
118
|
+
const bar = c(color, '█'.repeat(filled)) + dim('░'.repeat(empty));
|
|
119
|
+
return '[' + bar + ']';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A small status dot. */
|
|
123
|
+
function dot(ok: boolean, warn?: boolean): string {
|
|
124
|
+
if (ok) return c('brightGreen', '●');
|
|
125
|
+
if (warn) return c('brightYellow', '●');
|
|
126
|
+
return c('brightRed', '●');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/** Formats a number with thousands separators. */
|
|
131
|
+
function fmt(n: number): string {
|
|
132
|
+
return n.toLocaleString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Table renderer ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
interface Column {
|
|
138
|
+
header: string;
|
|
139
|
+
align?: 'left' | 'right' | 'center';
|
|
140
|
+
maxWidth?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderTable(columns: Column[], rows: string[][]): string {
|
|
144
|
+
if (rows.length === 0) return '';
|
|
145
|
+
|
|
146
|
+
// Compute column widths
|
|
147
|
+
const widths = columns.map((col, i) => {
|
|
148
|
+
const headerW = visLen(col.header);
|
|
149
|
+
const maxDataW = rows.reduce((m, row) => Math.max(m, visLen(row[i] ?? '')), 0);
|
|
150
|
+
const w = Math.max(headerW, maxDataW);
|
|
151
|
+
return col.maxWidth ? Math.min(w, col.maxWidth) : w;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const sepLine = '├' + widths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤';
|
|
155
|
+
const topLine = '┌' + widths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐';
|
|
156
|
+
const botLine = '└' + widths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘';
|
|
157
|
+
|
|
158
|
+
function renderRow(cells: string[], isHeader = false): string {
|
|
159
|
+
const parts = cells.map((cell, i) => {
|
|
160
|
+
const w = widths[i];
|
|
161
|
+
const col = columns[i];
|
|
162
|
+
// Truncate if needed
|
|
163
|
+
let s = visLen(cell) > w ? strip(cell).slice(0, w - 1) + '…' : cell;
|
|
164
|
+
if (isHeader) s = bold(strip(s));
|
|
165
|
+
// Align
|
|
166
|
+
if (col?.align === 'right') s = padStart(s, w);
|
|
167
|
+
else if (col?.align === 'center') {
|
|
168
|
+
const p = Math.floor((w - visLen(s)) / 2);
|
|
169
|
+
s = ' '.repeat(p) + s + ' '.repeat(w - visLen(s) - p);
|
|
170
|
+
} else {
|
|
171
|
+
s = padEnd(s, w);
|
|
172
|
+
}
|
|
173
|
+
return ' ' + s + ' ';
|
|
174
|
+
});
|
|
175
|
+
return '│' + parts.join('│') + '│';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const lines: string[] = [];
|
|
179
|
+
lines.push(dim(topLine));
|
|
180
|
+
lines.push(renderRow(columns.map((c) => c.header), true));
|
|
181
|
+
lines.push(dim(sepLine));
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
lines.push(renderRow(row));
|
|
184
|
+
}
|
|
185
|
+
lines.push(dim(botLine));
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Insight computation ──────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
interface CliInsights {
|
|
192
|
+
coveragePct: number;
|
|
193
|
+
totalUniqueKeys: number;
|
|
194
|
+
duplicateTextGroups: number;
|
|
195
|
+
namespaceCount: number;
|
|
196
|
+
topMissingLanguages: Array<{ lang: string; count: number }>;
|
|
197
|
+
topNamespaces: Array<{ ns: string; count: number }>;
|
|
198
|
+
topContexts: Array<{ ctx: string; count: number }>;
|
|
199
|
+
unusedTopKeys: string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function computeCliInsights(report: Report): CliInsights {
|
|
203
|
+
const { details } = report;
|
|
204
|
+
|
|
205
|
+
// Coverage
|
|
206
|
+
const totalUniqueKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
|
|
207
|
+
const coveragePct = totalUniqueKeys > 0
|
|
208
|
+
? Math.round(((totalUniqueKeys - details.missingKeys.length) / totalUniqueKeys) * 100)
|
|
209
|
+
: 100;
|
|
210
|
+
|
|
211
|
+
// Duplicate texts
|
|
212
|
+
const textMap = new Map<string, Set<string>>();
|
|
213
|
+
for (const dt of details.detectedTexts) {
|
|
214
|
+
const t = dt.text.trim();
|
|
215
|
+
if (!textMap.has(t)) textMap.set(t, new Set());
|
|
216
|
+
textMap.get(t)!.add(dt.suggestedKey);
|
|
217
|
+
}
|
|
218
|
+
let duplicateTextGroups = 0;
|
|
219
|
+
for (const [, keys] of textMap) {
|
|
220
|
+
if (keys.size > 1) duplicateTextGroups++;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Namespace distribution
|
|
224
|
+
const nsCounts = new Map<string, number>();
|
|
225
|
+
for (const dt of details.detectedTexts) {
|
|
226
|
+
const ns = dt.suggestedKey.split('.')[0] || 'default';
|
|
227
|
+
nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
const topNamespaces = [...nsCounts.entries()]
|
|
230
|
+
.sort((a, b) => b[1] - a[1])
|
|
231
|
+
.slice(0, 6)
|
|
232
|
+
.map(([ns, count]) => ({ ns, count }));
|
|
233
|
+
|
|
234
|
+
// Context distribution
|
|
235
|
+
const ctxCounts = new Map<string, number>();
|
|
236
|
+
for (const dt of details.detectedTexts) {
|
|
237
|
+
ctxCounts.set(dt.context, (ctxCounts.get(dt.context) || 0) + 1);
|
|
238
|
+
}
|
|
239
|
+
const topContexts = [...ctxCounts.entries()]
|
|
240
|
+
.sort((a, b) => b[1] - a[1])
|
|
241
|
+
.slice(0, 6)
|
|
242
|
+
.map(([ctx, count]) => ({ ctx, count }));
|
|
243
|
+
|
|
244
|
+
// Missing by language
|
|
245
|
+
const byLang = new Map<string, number>();
|
|
246
|
+
for (const mk of details.missingKeys) {
|
|
247
|
+
if (mk.language) byLang.set(mk.language, (byLang.get(mk.language) || 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
const topMissingLanguages = [...byLang.entries()]
|
|
250
|
+
.sort((a, b) => b[1] - a[1])
|
|
251
|
+
.slice(0, 6)
|
|
252
|
+
.map(([lang, count]) => ({ lang, count }));
|
|
253
|
+
|
|
254
|
+
// Unused top keys
|
|
255
|
+
const unusedTopKeys = details.unusedKeysList.slice(0, 5);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
coveragePct, totalUniqueKeys, duplicateTextGroups,
|
|
259
|
+
namespaceCount: nsCounts.size,
|
|
260
|
+
topMissingLanguages, topNamespaces, topContexts, unusedTopKeys,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Section renderers ────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function renderSectionHeader(icon: string, title: string, count?: number, warn = false): string {
|
|
267
|
+
const countStr = count !== undefined
|
|
268
|
+
? ' ' + (count === 0 ? c('brightGreen', `(${count})`) : warn ? c('brightYellow', `(${count})`) : c('brightRed', `(${count})`))
|
|
269
|
+
: '';
|
|
270
|
+
return '\n' + bold(c('brightCyan', icon + ' ' + title)) + countStr;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderKeyValueSection(pairs: Array<[string, string]>, indent = ' '): string {
|
|
274
|
+
const keyWidth = Math.max(...pairs.map(([k]) => k.length)) + 1;
|
|
275
|
+
return pairs.map(([k, v]) =>
|
|
276
|
+
indent + dim(padEnd(k + ':', keyWidth + 1)) + ' ' + v
|
|
277
|
+
).join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
3
282
|
export function printCliSummary(report: Report): void {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
283
|
+
const {
|
|
284
|
+
timestamp, framework, duration, filesScanned,
|
|
285
|
+
hardcodedTexts, localeKeysGenerated: _localeKeysGenerated, unusedKeys, missingTranslations,
|
|
286
|
+
assets, details,
|
|
287
|
+
} = report;
|
|
288
|
+
|
|
289
|
+
const scanDate = new Date(timestamp).toLocaleString();
|
|
290
|
+
const ins = computeCliInsights(report);
|
|
291
|
+
const w = termWidth();
|
|
292
|
+
|
|
293
|
+
const out: string[] = [];
|
|
294
|
+
const ln = (s = '') => out.push(s);
|
|
295
|
+
|
|
296
|
+
// ── Banner ────────────────────────────────────────────────────────────────
|
|
297
|
+
ln();
|
|
298
|
+
ln(hr('═', 'cyan'));
|
|
299
|
+
ln(centre(bold(c('brightCyan', ' 🌐 ai-localize · Localization Report '))));
|
|
300
|
+
ln(hr('═', 'cyan'));
|
|
301
|
+
ln();
|
|
302
|
+
|
|
303
|
+
// ── Meta row ──────────────────────────────────────────────────────────────
|
|
304
|
+
ln(centre(
|
|
305
|
+
dim('Framework: ') + c('brightWhite', framework) +
|
|
306
|
+
dim(' │ ') +
|
|
307
|
+
dim('Scanned: ') + c('brightWhite', scanDate) +
|
|
308
|
+
dim(' │ ') +
|
|
309
|
+
dim('Duration: ') + c('brightWhite', duration + 'ms')
|
|
310
|
+
));
|
|
311
|
+
ln();
|
|
312
|
+
|
|
313
|
+
// ── Summary stat cards (2-col layout) ─────────────────────────────────────
|
|
314
|
+
ln(hr('─'));
|
|
315
|
+
ln(bold(c('white', ' 📊 Summary')));
|
|
316
|
+
ln(hr('─'));
|
|
317
|
+
ln();
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
function statLine(
|
|
321
|
+
icon: string, label: string, value: number | string,
|
|
322
|
+
statusOk: boolean, statusWarn = false,
|
|
323
|
+
hint = ''
|
|
324
|
+
): string {
|
|
325
|
+
const valStr = typeof value === 'number'
|
|
326
|
+
? (statusOk ? c('brightGreen', fmt(value)) : statusWarn ? c('brightYellow', fmt(value)) : c('brightRed', fmt(value)))
|
|
327
|
+
: c('brightWhite', String(value));
|
|
328
|
+
const d = dot(statusOk, !statusOk && statusWarn);
|
|
329
|
+
const left = ' ' + d + ' ' + padEnd(icon + ' ' + label, 26) + ' ' + padStart(valStr, 8);
|
|
330
|
+
const right = hint ? dim(' ← ' + hint) : '';
|
|
331
|
+
return left + right;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
ln(statLine('📁', 'Files Scanned', filesScanned, true, false, 'source files processed'));
|
|
335
|
+
ln(statLine('🔍', 'Hardcoded Texts', hardcodedTexts, hardcodedTexts === 0, hardcodedTexts === 0, 'raw strings not yet in t()'));
|
|
336
|
+
ln(statLine('🔑', 'Unique Keys Generated', ins.totalUniqueKeys, true, false, 'deduplicated locale keys'));
|
|
337
|
+
ln(statLine('❌', 'Missing Translations', missingTranslations, missingTranslations === 0, false, 'absent in target languages'));
|
|
338
|
+
ln(statLine('🗑 ', 'Unused Keys', unusedKeys, unusedKeys === 0, true, 'in locale but not in source'));
|
|
339
|
+
ln(statLine('📋', 'Duplicate Text Groups', ins.duplicateTextGroups, ins.duplicateTextGroups === 0, true, 'same text → multiple keys'));
|
|
340
|
+
ln(statLine('📦', 'Total Assets', assets.totalAssets, true, false, 'static asset references'));
|
|
341
|
+
ln(statLine('☁ ', 'Uploaded to CDN', assets.uploadedAssets, true, false, 'pushed to S3/CloudFront'));
|
|
342
|
+
ln(statLine('🔗', 'Legacy CDN URLs', assets.legacyCdnUrls, assets.legacyCdnUrls === 0, true, 'old CDN refs pending replace'));
|
|
343
|
+
ln();
|
|
344
|
+
|
|
345
|
+
// ── Coverage bar ──────────────────────────────────────────────────────────
|
|
346
|
+
ln(hr('─'));
|
|
347
|
+
const covColor: keyof typeof A = ins.coveragePct >= 80 ? 'brightGreen' : ins.coveragePct >= 50 ? 'brightYellow' : 'brightRed';
|
|
348
|
+
const covLabel = ins.coveragePct === 100 ? '✓ Fully Covered' : ins.coveragePct >= 80 ? '~ Good Coverage' : '✗ Low Coverage';
|
|
349
|
+
ln(
|
|
350
|
+
' ' + bold('🎯 Translation Coverage') + ' ' +
|
|
351
|
+
progressBar(ins.coveragePct) +
|
|
352
|
+
' ' + c(covColor, ins.coveragePct + '%') +
|
|
353
|
+
' ' + dim(covLabel)
|
|
354
|
+
);
|
|
355
|
+
ln();
|
|
356
|
+
|
|
357
|
+
// ── Localization section ───────────────────────────────────────────────────
|
|
358
|
+
if (details.detectedTexts.length > 0) {
|
|
359
|
+
ln(hr('─'));
|
|
360
|
+
ln(renderSectionHeader('🔍', 'Hardcoded Texts', hardcodedTexts, true));
|
|
361
|
+
ln(dim(' Strings detected in source that are not yet wrapped in a translation call.'));
|
|
362
|
+
ln();
|
|
363
|
+
|
|
364
|
+
// Show top 10 with minibar
|
|
365
|
+
const maxCount = hardcodedTexts;
|
|
366
|
+
const topFiles = new Map<string, number>();
|
|
367
|
+
for (const dt of details.detectedTexts) {
|
|
368
|
+
const f = dt.filePath.split('/').slice(-2).join('/');
|
|
369
|
+
topFiles.set(f, (topFiles.get(f) || 0) + 1);
|
|
370
|
+
}
|
|
371
|
+
const topFilesSorted = [...topFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
372
|
+
|
|
373
|
+
if (topFilesSorted.length > 0) {
|
|
374
|
+
ln(' ' + bold('Top files with hardcoded text:'));
|
|
375
|
+
ln();
|
|
376
|
+
for (const [file, cnt] of topFilesSorted) {
|
|
377
|
+
ln(' ' + padEnd(dim(file), 48) + ' ' + miniBar(cnt, maxCount, 16) + ' ' + c('brightYellow', String(cnt)));
|
|
378
|
+
}
|
|
379
|
+
ln();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Sample of detected texts
|
|
383
|
+
const sample = details.detectedTexts.slice(0, 5);
|
|
384
|
+
if (sample.length > 0) {
|
|
385
|
+
ln(' ' + bold('Sample detected strings:'));
|
|
386
|
+
ln();
|
|
387
|
+
const sampleRows = sample.map((t) => [
|
|
388
|
+
c('cyan', t.filePath.split('/').slice(-2).join('/')),
|
|
389
|
+
dim(String(t.line)),
|
|
390
|
+
c('brightWhite', t.text.length > 50 ? t.text.slice(0, 50) + '…' : t.text),
|
|
391
|
+
dim(t.context),
|
|
392
|
+
]);
|
|
393
|
+
ln(renderTable(
|
|
394
|
+
[
|
|
395
|
+
{ header: 'File', maxWidth: 36 },
|
|
396
|
+
{ header: 'Line', align: 'right', maxWidth: 6 },
|
|
397
|
+
{ header: 'Text', maxWidth: 52 },
|
|
398
|
+
{ header: 'Context', maxWidth: 20 },
|
|
399
|
+
],
|
|
400
|
+
sampleRows
|
|
401
|
+
).split('\n').map((l) => ' ' + l).join('\n'));
|
|
402
|
+
if (details.detectedTexts.length > 5) {
|
|
403
|
+
ln();
|
|
404
|
+
ln(' ' + dim(`… and ${details.detectedTexts.length - 5} more. Run `) + c('cyan', 'ai-localize report') + dim(' for the full HTML report.'));
|
|
405
|
+
}
|
|
406
|
+
ln();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Missing translations ───────────────────────────────────────────────────
|
|
411
|
+
if (missingTranslations > 0) {
|
|
412
|
+
ln(hr('─'));
|
|
413
|
+
ln(renderSectionHeader('❌', 'Missing Translations', missingTranslations));
|
|
414
|
+
ln(dim(' Keys present in the default language but absent in one or more target language files.'));
|
|
415
|
+
ln();
|
|
416
|
+
|
|
417
|
+
if (ins.topMissingLanguages.length > 0) {
|
|
418
|
+
const maxMissing = ins.topMissingLanguages[0].count;
|
|
419
|
+
ln(' ' + bold('Missing keys by language:'));
|
|
420
|
+
ln();
|
|
421
|
+
for (const { lang, count } of ins.topMissingLanguages) {
|
|
422
|
+
ln(' ' + padEnd(c('brightMagenta', lang), 18) + ' ' + miniBar(count, maxMissing, 20, 'brightRed') + ' ' + c('brightRed', String(count) + ' key' + (count > 1 ? 's' : '')));
|
|
423
|
+
}
|
|
424
|
+
ln();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Sample missing keys
|
|
428
|
+
const missSample = details.missingKeys.slice(0, 5);
|
|
429
|
+
if (missSample.length > 0) {
|
|
430
|
+
ln(' ' + bold('Sample missing keys:'));
|
|
431
|
+
ln();
|
|
432
|
+
const missRows = missSample.map((e) => [
|
|
433
|
+
c('cyan', e.key.length > 42 ? e.key.slice(0, 42) + '…' : e.key),
|
|
434
|
+
e.language ? c('brightMagenta', e.language) : dim('—'),
|
|
435
|
+
dim(e.filePath ? e.filePath.split('/').slice(-2).join('/') : '—'),
|
|
436
|
+
]);
|
|
437
|
+
ln(renderTable(
|
|
438
|
+
[
|
|
439
|
+
{ header: 'Key', maxWidth: 44 },
|
|
440
|
+
{ header: 'Language', maxWidth: 14 },
|
|
441
|
+
{ header: 'Locale File', maxWidth: 36 },
|
|
442
|
+
],
|
|
443
|
+
missRows
|
|
444
|
+
).split('\n').map((l) => ' ' + l).join('\n'));
|
|
445
|
+
if (details.missingKeys.length > 5) {
|
|
446
|
+
ln();
|
|
447
|
+
ln(' ' + dim(`… and ${details.missingKeys.length - 5} more.`));
|
|
448
|
+
}
|
|
449
|
+
ln();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
ln(' ' + dim('💡 Tip: Run ') + c('cyan', 'ai-localize extract') + dim(' to seed target language files with source values.'));
|
|
453
|
+
ln();
|
|
454
|
+
} else {
|
|
455
|
+
ln(hr('─'));
|
|
456
|
+
ln(' ' + dot(true) + ' ' + bold(c('brightGreen', 'Missing Translations')) + ' ' + c('brightGreen', 'All translations present ✓'));
|
|
457
|
+
ln();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Unused keys ────────────────────────────────────────────────────────────
|
|
461
|
+
if (unusedKeys > 0) {
|
|
462
|
+
ln(hr('─'));
|
|
463
|
+
ln(renderSectionHeader('🗑 ', 'Unused Keys', unusedKeys, true));
|
|
464
|
+
ln(dim(' Translation keys in locale files that are not referenced anywhere in source code.'));
|
|
465
|
+
ln();
|
|
466
|
+
if (ins.unusedTopKeys.length > 0) {
|
|
467
|
+
ln(' ' + bold('Examples:'));
|
|
468
|
+
for (const key of ins.unusedTopKeys) {
|
|
469
|
+
ln(' ' + c('yellow', ' ⚠ ') + c('brightYellow', key));
|
|
470
|
+
}
|
|
471
|
+
if (unusedKeys > ins.unusedTopKeys.length) {
|
|
472
|
+
ln(' ' + dim(` … and ${unusedKeys - ins.unusedTopKeys.length} more.`));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
ln();
|
|
476
|
+
ln(' ' + dim('💡 Tip: Run ') + c('cyan', 'ai-localize cleanup') + dim(' to remove unused keys safely.'));
|
|
477
|
+
ln();
|
|
478
|
+
} else {
|
|
479
|
+
ln(hr('─'));
|
|
480
|
+
ln(' ' + dot(true) + ' ' + bold(c('brightGreen', 'Unused Keys')) + ' ' + c('brightGreen', 'No unused keys — locale files are clean ✓'));
|
|
481
|
+
ln();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Namespace distribution ─────────────────────────────────────────────────
|
|
485
|
+
if (ins.topNamespaces.length > 0) {
|
|
486
|
+
ln(hr('─'));
|
|
487
|
+
ln(renderSectionHeader('📦', 'Namespace Distribution', ins.namespaceCount));
|
|
488
|
+
ln(dim(' How locale keys are distributed across namespaces.'));
|
|
489
|
+
ln();
|
|
490
|
+
const maxNs = ins.topNamespaces[0]?.count ?? 1;
|
|
491
|
+
for (const { ns, count } of ins.topNamespaces) {
|
|
492
|
+
ln(' ' + padEnd(c('brightBlue', ns), 26) + ' ' + miniBar(count, maxNs, 18, 'blue') + ' ' + dim(String(count) + ' key' + (count > 1 ? 's' : '')));
|
|
493
|
+
}
|
|
494
|
+
if (ins.namespaceCount > ins.topNamespaces.length) {
|
|
495
|
+
ln(' ' + dim(` … and ${ins.namespaceCount - ins.topNamespaces.length} more namespaces.`));
|
|
496
|
+
}
|
|
497
|
+
ln();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── AST context distribution ───────────────────────────────────────────────
|
|
501
|
+
if (ins.topContexts.length > 0) {
|
|
502
|
+
ln(hr('─'));
|
|
503
|
+
ln(renderSectionHeader('🏷 ', 'Text Context Distribution', undefined));
|
|
504
|
+
ln(dim(' Where hardcoded strings were detected in the AST.'));
|
|
505
|
+
ln();
|
|
506
|
+
const maxCtx = ins.topContexts[0]?.count ?? 1;
|
|
507
|
+
for (const { ctx, count } of ins.topContexts) {
|
|
508
|
+
ln(' ' + padEnd(c('brightMagenta', ctx), 26) + ' ' + miniBar(count, maxCtx, 18, 'magenta') + ' ' + dim(String(count)));
|
|
509
|
+
}
|
|
510
|
+
ln();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Assets / CDN ───────────────────────────────────────────────────────────
|
|
514
|
+
if (assets.totalAssets > 0 || assets.legacyCdnUrls > 0) {
|
|
515
|
+
ln(hr('─'));
|
|
516
|
+
ln(renderSectionHeader('📦', 'CDN Assets', undefined));
|
|
517
|
+
ln();
|
|
518
|
+
ln(renderKeyValueSection([
|
|
519
|
+
['Total Assets Found', c('brightWhite', fmt(assets.totalAssets))],
|
|
520
|
+
['Uploaded to S3/CDN', c('brightGreen', fmt(assets.uploadedAssets))],
|
|
521
|
+
['URLs Replaced', c('brightGreen', fmt(assets.replacedUrls))],
|
|
522
|
+
['Legacy CDN URLs', assets.legacyCdnUrls > 0
|
|
523
|
+
? c('brightYellow', fmt(assets.legacyCdnUrls)) + dim(' ← run ai-localize replace-cdn')
|
|
524
|
+
: c('brightGreen', '0')],
|
|
525
|
+
]));
|
|
526
|
+
ln();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── AI Insights summary ────────────────────────────────────────────────────
|
|
530
|
+
const hasInsights = ins.duplicateTextGroups > 0 || ins.topMissingLanguages.length > 0 || unusedKeys > 0;
|
|
531
|
+
ln(hr('─'));
|
|
532
|
+
ln(bold(c('brightMagenta', ' 🤖 AI Insights') + dim(' (deterministic analysis)')));
|
|
533
|
+
ln();
|
|
534
|
+
|
|
535
|
+
if (ins.duplicateTextGroups > 0) {
|
|
536
|
+
ln(' ' + c('brightYellow', '⚠ ') + c('yellow', ins.duplicateTextGroups + ' duplicate text group' + (ins.duplicateTextGroups > 1 ? 's' : '')) + dim(' — same string mapped to multiple keys. Consider consolidating.'));
|
|
537
|
+
} else {
|
|
538
|
+
ln(' ' + c('brightGreen', '✓ ') + dim('No duplicate texts detected.'));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (missingTranslations > 0) {
|
|
542
|
+
const langList = ins.topMissingLanguages.slice(0, 3).map((l) => c('brightMagenta', l.lang)).join(', ');
|
|
543
|
+
ln(' ' + c('brightRed', '✗ ') + c('red', missingTranslations + ' missing translation' + (missingTranslations > 1 ? 's' : '')) + dim(' in: ') + langList + (ins.topMissingLanguages.length > 3 ? dim(' + more') : ''));
|
|
544
|
+
} else {
|
|
545
|
+
ln(' ' + c('brightGreen', '✓ ') + dim('All translations present across all languages.'));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (unusedKeys > 0) {
|
|
549
|
+
ln(' ' + c('brightYellow', '⚠ ') + c('yellow', unusedKeys + ' unused key' + (unusedKeys > 1 ? 's' : '')) + dim(' bloating your locale bundles.'));
|
|
550
|
+
} else {
|
|
551
|
+
ln(' ' + c('brightGreen', '✓ ') + dim('No unused keys — locale files are lean.'));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!hasInsights) {
|
|
555
|
+
ln(' ' + c('brightGreen', '✓ ') + dim('No issues detected. Your localization is in great shape!'));
|
|
556
|
+
}
|
|
557
|
+
ln();
|
|
558
|
+
|
|
559
|
+
// ── Footer ─────────────────────────────────────────────────────────────────
|
|
560
|
+
ln(hr('═', 'cyan'));
|
|
561
|
+
|
|
562
|
+
// Coverage badge
|
|
563
|
+
const covBadge = ins.coveragePct === 100
|
|
564
|
+
? badge('COVERAGE 100%', true)
|
|
565
|
+
: ins.coveragePct >= 80
|
|
566
|
+
? c('bgYellow', c('black', ` COVERAGE ${ins.coveragePct}% `))
|
|
567
|
+
: badge(`COVERAGE ${ins.coveragePct}%`, false);
|
|
568
|
+
|
|
569
|
+
// Overall status badge
|
|
570
|
+
const allOk = missingTranslations === 0 && unusedKeys === 0 && hardcodedTexts === 0;
|
|
571
|
+
const overallBadge = allOk ? badge(' PASS ', true) : badge(' ISSUES ', false);
|
|
572
|
+
|
|
573
|
+
ln(centre(overallBadge + ' ' + covBadge));
|
|
574
|
+
ln();
|
|
575
|
+
ln(centre(dim('Generated by ') + bold('ai-localize-core') + dim(' · deterministic, offline-capable i18n tooling')));
|
|
576
|
+
ln(hr('═', 'cyan'));
|
|
577
|
+
ln();
|
|
578
|
+
|
|
579
|
+
// ── Next-step hints ────────────────────────────────────────────────────────
|
|
580
|
+
if (!allOk) {
|
|
581
|
+
ln(bold(' ⚡ Recommended next steps:'));
|
|
582
|
+
ln();
|
|
583
|
+
if (hardcodedTexts > 0) {
|
|
584
|
+
ln('' + c('cyan', '1.') + ' Run ' + c('brightCyan', 'ai-localize full-migrate') + dim(' to wrap hardcoded strings with translation calls.'));
|
|
585
|
+
}
|
|
586
|
+
if (missingTranslations > 0) {
|
|
587
|
+
ln(' ' + c('cyan', '2.') + ' Run ' + c('brightCyan', 'ai-localize extract') + dim(' to seed target language files.'));
|
|
588
|
+
}
|
|
589
|
+
if (unusedKeys > 0) {
|
|
590
|
+
ln(' ' + c('cyan', '3.') + ' Run ' + c('brightCyan', 'ai-localize cleanup') + dim(' to remove unused keys.'));
|
|
591
|
+
}
|
|
592
|
+
if (assets.legacyCdnUrls > 0) {
|
|
593
|
+
ln(' ' + c('cyan', '4.') + ' Run ' + c('brightCyan', 'ai-localize replace-cdn') + dim(' to migrate legacy CDN URLs to CloudFront.'));
|
|
594
|
+
}
|
|
595
|
+
ln();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Print everything
|
|
599
|
+
const output = out.join('\n');
|
|
600
|
+
process.stdout.write(supportsColor() ? output : strip(output));
|
|
29
601
|
}
|