@katerynakhar/i18n-keeper 0.16.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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +666 -0
  3. package/dist/apply.d.ts +66 -0
  4. package/dist/apply.js +254 -0
  5. package/dist/apply.js.map +1 -0
  6. package/dist/check.d.ts +5 -0
  7. package/dist/check.js +326 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +525 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/formats/error.d.ts +12 -0
  13. package/dist/formats/error.js +19 -0
  14. package/dist/formats/error.js.map +1 -0
  15. package/dist/formats/flatten.d.ts +6 -0
  16. package/dist/formats/flatten.js +26 -0
  17. package/dist/formats/flatten.js.map +1 -0
  18. package/dist/formats/json-write.d.ts +7 -0
  19. package/dist/formats/json-write.js +55 -0
  20. package/dist/formats/json-write.js.map +1 -0
  21. package/dist/formats/json.d.ts +6 -0
  22. package/dist/formats/json.js +21 -0
  23. package/dist/formats/json.js.map +1 -0
  24. package/dist/formats/php-write.d.ts +16 -0
  25. package/dist/formats/php-write.js +130 -0
  26. package/dist/formats/php-write.js.map +1 -0
  27. package/dist/formats/php.d.ts +44 -0
  28. package/dist/formats/php.js +356 -0
  29. package/dist/formats/php.js.map +1 -0
  30. package/dist/formats/po-write.d.ts +3 -0
  31. package/dist/formats/po-write.js +200 -0
  32. package/dist/formats/po-write.js.map +1 -0
  33. package/dist/formats/po.d.ts +30 -0
  34. package/dist/formats/po.js +202 -0
  35. package/dist/formats/po.js.map +1 -0
  36. package/dist/formats/write.d.ts +29 -0
  37. package/dist/formats/write.js +19 -0
  38. package/dist/formats/write.js.map +1 -0
  39. package/dist/formats/yaml-write.d.ts +12 -0
  40. package/dist/formats/yaml-write.js +42 -0
  41. package/dist/formats/yaml-write.js.map +1 -0
  42. package/dist/formats/yaml.d.ts +18 -0
  43. package/dist/formats/yaml.js +51 -0
  44. package/dist/formats/yaml.js.map +1 -0
  45. package/dist/glossary.d.ts +38 -0
  46. package/dist/glossary.js +140 -0
  47. package/dist/glossary.js.map +1 -0
  48. package/dist/lengths.d.ts +37 -0
  49. package/dist/lengths.js +195 -0
  50. package/dist/lengths.js.map +1 -0
  51. package/dist/mcp.d.ts +2 -0
  52. package/dist/mcp.js +374 -0
  53. package/dist/mcp.js.map +1 -0
  54. package/dist/memory.d.ts +72 -0
  55. package/dist/memory.js +162 -0
  56. package/dist/memory.js.map +1 -0
  57. package/dist/placeholders.d.ts +15 -0
  58. package/dist/placeholders.js +103 -0
  59. package/dist/placeholders.js.map +1 -0
  60. package/dist/plurals.d.ts +59 -0
  61. package/dist/plurals.js +280 -0
  62. package/dist/plurals.js.map +1 -0
  63. package/dist/report.d.ts +2 -0
  64. package/dist/report.js +126 -0
  65. package/dist/report.js.map +1 -0
  66. package/dist/scan.d.ts +23 -0
  67. package/dist/scan.js +250 -0
  68. package/dist/scan.js.map +1 -0
  69. package/dist/translate.d.ts +93 -0
  70. package/dist/translate.js +369 -0
  71. package/dist/translate.js.map +1 -0
  72. package/dist/types.d.ts +88 -0
  73. package/dist/types.js +50 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/version.d.ts +1 -0
  76. package/dist/version.js +28 -0
  77. package/dist/version.js.map +1 -0
  78. package/package.json +77 -0
  79. package/src/apply.ts +334 -0
  80. package/src/check.ts +468 -0
  81. package/src/cli.ts +637 -0
  82. package/src/formats/error.ts +19 -0
  83. package/src/formats/flatten.ts +38 -0
  84. package/src/formats/json-write.ts +66 -0
  85. package/src/formats/json.ts +27 -0
  86. package/src/formats/php-write.ts +146 -0
  87. package/src/formats/php.ts +414 -0
  88. package/src/formats/po-write.ts +230 -0
  89. package/src/formats/po.ts +233 -0
  90. package/src/formats/write.ts +41 -0
  91. package/src/formats/yaml-write.ts +53 -0
  92. package/src/formats/yaml.ts +62 -0
  93. package/src/glossary.ts +191 -0
  94. package/src/lengths.ts +214 -0
  95. package/src/mcp.ts +451 -0
  96. package/src/memory.ts +227 -0
  97. package/src/placeholders.ts +123 -0
  98. package/src/plurals.ts +320 -0
  99. package/src/report.ts +162 -0
  100. package/src/scan.ts +280 -0
  101. package/src/translate.ts +486 -0
  102. package/src/types.ts +136 -0
  103. package/src/version.ts +27 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Placeholder extraction across the interpolation syntaxes that actually collide
3
+ * in the wild. Patterns are applied in a fixed order and every match is masked
4
+ * out of the working copy, so `{{name}}` is never also counted as `{name}`.
5
+ */
6
+
7
+ interface PatternSpec {
8
+ re: RegExp;
9
+ label: (m: RegExpExecArray) => string;
10
+ }
11
+
12
+ const PATTERNS: Record<string, PatternSpec> = {
13
+ // {{ name }} — mustache / i18next default
14
+ mustache: {
15
+ re: /\{\{\s*[#/^&]?\s*([\w.$-]+)\s*\}\}/g,
16
+ label: (m) => `{{${m[1]}}}`,
17
+ },
18
+ // %{name} — ruby i18n
19
+ ruby: {
20
+ re: /%\{\s*([\w.$-]+)\s*\}/g,
21
+ label: (m) => `%{${m[1]}}`,
22
+ },
23
+ // {count, plural, ...} — ICU complex argument; matched by its head so that
24
+ // nested braces in the sub-messages do not hide the argument name.
25
+ icu_complex: {
26
+ re: /\{\s*([\w.$-]+)\s*,\s*(plural|select|selectordinal|number|date|time)\b/g,
27
+ label: (m) => `{${m[1]},${m[2]}}`,
28
+ },
29
+ // {name} — ICU simple argument
30
+ icu: {
31
+ re: /\{\s*([\w.$-]+)\s*\}/g,
32
+ label: (m) => `{${m[1]}}`,
33
+ },
34
+ // %s, %d, %1$s — printf
35
+ printf: {
36
+ re: /%(?:(\d+)\$)?([sdifux])/g,
37
+ label: (m) => (m[1] ? `%${m[1]}$${m[2]}` : `%${m[2]}`),
38
+ },
39
+ // <0>...</0> — react-i18next <Trans> indices
40
+ tag: {
41
+ re: /<\/?(\d+)>/g,
42
+ label: (m) => m[0],
43
+ },
44
+ // :name — Laravel. Off by default: it false-positives on prose like
45
+ // "Warning:Important". Enabled automatically for PHP locale files.
46
+ // Laravel treats :name, :Name and :NAME as one placeholder rendered with
47
+ // different capitalisation, so they normalise to one label here.
48
+ laravel: {
49
+ re: /(?<![\w:]):([a-zA-Z][\w]*)/g,
50
+ label: (m) => `:${m[1]!.toLowerCase()}`,
51
+ },
52
+ };
53
+
54
+ /** Longest / most specific syntaxes first, so masking works. */
55
+ const ORDER = ['mustache', 'ruby', 'icu_complex', 'icu', 'printf', 'tag', 'laravel'];
56
+
57
+ export const DEFAULT_SYNTAXES = ['mustache', 'ruby', 'icu_complex', 'icu', 'printf', 'tag'];
58
+ export const ALL_SYNTAXES = ORDER;
59
+
60
+ const MASK = '\u0000';
61
+
62
+ function maskSpans(text: string, spans: Array<[number, number]>): string {
63
+ const chars = [...text];
64
+ for (const [start, end] of spans) {
65
+ for (let i = start; i < end && i < chars.length; i++) chars[i] = MASK;
66
+ }
67
+ return chars.join('');
68
+ }
69
+
70
+ /** Returns a multiset of placeholder labels: `{name}` twice is not the same as once. */
71
+ export function extractPlaceholders(text: string, syntaxes: string[]): Map<string, number> {
72
+ const counts = new Map<string, number>();
73
+ let masked = text;
74
+
75
+ for (const id of ORDER) {
76
+ if (!syntaxes.includes(id)) continue;
77
+ const spec = PATTERNS[id];
78
+ if (!spec) continue;
79
+
80
+ const re = new RegExp(spec.re.source, spec.re.flags);
81
+ const spans: Array<[number, number]> = [];
82
+ let m: RegExpExecArray | null;
83
+
84
+ while ((m = re.exec(masked)) !== null) {
85
+ if (m[0].length === 0) {
86
+ re.lastIndex++;
87
+ continue;
88
+ }
89
+ const label = spec.label(m);
90
+ counts.set(label, (counts.get(label) ?? 0) + 1);
91
+ spans.push([m.index, m.index + m[0].length]);
92
+ }
93
+
94
+ if (spans.length > 0) masked = maskSpans(masked, spans);
95
+ }
96
+
97
+ return counts;
98
+ }
99
+
100
+ export interface PlaceholderDiff {
101
+ missing: string[];
102
+ extra: string[];
103
+ }
104
+
105
+ /** What the target lost from the source, and what it invented. */
106
+ export function diffPlaceholders(
107
+ source: Map<string, number>,
108
+ target: Map<string, number>,
109
+ ): PlaceholderDiff {
110
+ const missing: string[] = [];
111
+ const extra: string[] = [];
112
+
113
+ for (const [label, count] of source) {
114
+ const got = target.get(label) ?? 0;
115
+ if (got < count) missing.push(count - got > 1 ? `${label} x${count - got}` : label);
116
+ }
117
+ for (const [label, count] of target) {
118
+ const had = source.get(label) ?? 0;
119
+ if (count > had) extra.push(count - had > 1 ? `${label} x${count - had}` : label);
120
+ }
121
+
122
+ return { missing, extra };
123
+ }
package/src/plurals.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * CLDR plural categories, taken from the ICU data already in the runtime rather
3
+ * than a hand-maintained table: the table would go stale, and this is the same
4
+ * data the application itself will use at runtime.
5
+ */
6
+
7
+ export const CATEGORY_NAMES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const;
8
+ export type Category = (typeof CATEGORY_NAMES)[number];
9
+
10
+ const CATEGORIES = new Set<string>(CATEGORY_NAMES);
11
+ const cache = new Map<string, Set<string> | null>();
12
+
13
+ export function isCategory(name: string): name is Category {
14
+ return CATEGORIES.has(name);
15
+ }
16
+
17
+ /**
18
+ * Categories CLDR defines for a locale, or null when we cannot tell.
19
+ *
20
+ * Intl.PluralRules silently falls back to the system locale for tags it does
21
+ * not know — asking for "zz" on a Russian machine reports four categories — so
22
+ * a resolved language subtag that does not match the request means "unknown"
23
+ * rather than a wrong answer.
24
+ */
25
+ export function categoriesFor(locale: string): Set<string> | null {
26
+ const cached = cache.get(locale);
27
+ if (cached !== undefined) return cached;
28
+
29
+ const tag = locale.replace(/_/g, '-');
30
+ let result: Set<string> | null = null;
31
+
32
+ try {
33
+ const resolved = new Intl.PluralRules(tag, { type: 'cardinal' }).resolvedOptions();
34
+ const asked = tag.split('-')[0]!.toLowerCase();
35
+ const got = resolved.locale.split('-')[0]!.toLowerCase();
36
+ if (asked === got && resolved.pluralCategories.length > 0) {
37
+ result = new Set(resolved.pluralCategories);
38
+ }
39
+ } catch {
40
+ result = null;
41
+ }
42
+
43
+ cache.set(locale, result);
44
+ return result;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // ICU MessageFormat
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface PluralBlock {
52
+ arg: string;
53
+ type: 'plural' | 'selectordinal';
54
+ /** Keyword selectors: one, few, other … */
55
+ categories: Set<string>;
56
+ /** Exact selectors: =0, =1 … which are not CLDR categories. */
57
+ exact: Set<string>;
58
+ }
59
+
60
+ export interface IcuScan {
61
+ blocks: PluralBlock[];
62
+ error: string | null;
63
+ }
64
+
65
+ const PLURAL_INTENT = /\{\s*[\w.$-]+\s*,\s*(?:plural|selectordinal)\b/;
66
+
67
+ /** Whether the string is trying to be an ICU plural at all. */
68
+ export function looksLikeIcuPlural(text: string): boolean {
69
+ return PLURAL_INTENT.test(text);
70
+ }
71
+
72
+ class IcuScanner {
73
+ private pos = 0;
74
+ readonly blocks: PluralBlock[] = [];
75
+
76
+ constructor(private readonly text: string) {}
77
+
78
+ private get done(): boolean {
79
+ return this.pos >= this.text.length;
80
+ }
81
+
82
+ private fail(message: string): never {
83
+ throw new Error(`${message} at offset ${this.pos}`);
84
+ }
85
+
86
+ private skipWs(): void {
87
+ while (!this.done && /\s/.test(this.text[this.pos]!)) this.pos++;
88
+ }
89
+
90
+ private readIdent(): string {
91
+ const start = this.pos;
92
+ while (!this.done && /[\w.$-]/.test(this.text[this.pos]!)) this.pos++;
93
+ return this.text.slice(start, this.pos);
94
+ }
95
+
96
+ /** ICU quoting: '{', '}' and '#' start a literal run; '' is one apostrophe. */
97
+ private skipQuote(): void {
98
+ const next = this.text[this.pos + 1];
99
+ if (next === "'") {
100
+ this.pos += 2;
101
+ return;
102
+ }
103
+ if (next === '{' || next === '}' || next === '#') {
104
+ const close = this.text.indexOf("'", this.pos + 1);
105
+ this.pos = close === -1 ? this.text.length : close + 1;
106
+ return;
107
+ }
108
+ this.pos++;
109
+ }
110
+
111
+ /** Consumes a message body; with `nested`, stops after the matching brace. */
112
+ private scanMessage(nested: boolean): void {
113
+ while (!this.done) {
114
+ const ch = this.text[this.pos]!;
115
+ if (ch === "'") {
116
+ this.skipQuote();
117
+ continue;
118
+ }
119
+ if (ch === '}') {
120
+ if (!nested) this.fail('Unexpected }');
121
+ this.pos++;
122
+ return;
123
+ }
124
+ if (ch === '{') {
125
+ this.scanArgument();
126
+ continue;
127
+ }
128
+ this.pos++;
129
+ }
130
+ if (nested) this.fail('Unclosed {');
131
+ }
132
+
133
+ /** Consumes `{...}` of any argument type, starting at the opening brace. */
134
+ private scanArgument(): void {
135
+ this.pos++; // {
136
+ this.skipWs();
137
+ const name = this.readIdent();
138
+ this.skipWs();
139
+
140
+ if (this.done) this.fail('Unclosed {');
141
+ if (this.text[this.pos] === '}') {
142
+ this.pos++;
143
+ return;
144
+ }
145
+ if (this.text[this.pos] !== ',') this.fail('Expected , or } in argument');
146
+ this.pos++;
147
+ this.skipWs();
148
+
149
+ const type = this.readIdent();
150
+ this.skipWs();
151
+
152
+ if (this.done) this.fail('Unclosed {');
153
+ if (this.text[this.pos] === '}') {
154
+ this.pos++;
155
+ return;
156
+ }
157
+ if (this.text[this.pos] !== ',') this.fail(`Expected , or } after ${type || 'argument type'}`);
158
+ this.pos++;
159
+
160
+ if (type === 'plural' || type === 'selectordinal') {
161
+ this.scanPluralBody(name, type);
162
+ } else {
163
+ this.scanOpaqueBody();
164
+ }
165
+ }
166
+
167
+ /** For select/number/date: keep the braces balanced, ignore the content. */
168
+ private scanOpaqueBody(): void {
169
+ let depth = 1;
170
+ while (!this.done) {
171
+ const ch = this.text[this.pos]!;
172
+ if (ch === "'") {
173
+ this.skipQuote();
174
+ continue;
175
+ }
176
+ if (ch === '{') depth++;
177
+ if (ch === '}') {
178
+ depth--;
179
+ this.pos++;
180
+ if (depth === 0) return;
181
+ continue;
182
+ }
183
+ this.pos++;
184
+ }
185
+ this.fail('Unclosed {');
186
+ }
187
+
188
+ private scanPluralBody(arg: string, type: 'plural' | 'selectordinal'): void {
189
+ const block: PluralBlock = { arg, type, categories: new Set(), exact: new Set() };
190
+
191
+ for (;;) {
192
+ this.skipWs();
193
+ if (this.done) this.fail('Unclosed plural');
194
+
195
+ if (this.text[this.pos] === '}') {
196
+ this.pos++;
197
+ break;
198
+ }
199
+
200
+ if (this.text.startsWith('offset:', this.pos)) {
201
+ this.pos += 'offset:'.length;
202
+ this.skipWs();
203
+ while (!this.done && /[\d.-]/.test(this.text[this.pos]!)) this.pos++;
204
+ continue;
205
+ }
206
+
207
+ if (this.text[this.pos] === '=') {
208
+ this.pos++;
209
+ const start = this.pos;
210
+ while (!this.done && /[\d.]/.test(this.text[this.pos]!)) this.pos++;
211
+ if (this.pos === start) this.fail('Expected a number after =');
212
+ block.exact.add(`=${this.text.slice(start, this.pos)}`);
213
+ } else {
214
+ const keyword = this.readIdent();
215
+ if (!keyword) this.fail('Expected a plural selector');
216
+ block.categories.add(keyword);
217
+ }
218
+
219
+ this.skipWs();
220
+ if (this.text[this.pos] !== '{') this.fail('Expected { after a plural selector');
221
+ this.pos++;
222
+ this.scanMessage(true);
223
+ }
224
+
225
+ this.blocks.push(block);
226
+ }
227
+
228
+ run(): void {
229
+ this.scanMessage(false);
230
+ }
231
+ }
232
+
233
+ export function scanIcu(text: string): IcuScan {
234
+ const scanner = new IcuScanner(text);
235
+ try {
236
+ scanner.run();
237
+ return { blocks: scanner.blocks, error: null };
238
+ } catch (err) {
239
+ return {
240
+ blocks: scanner.blocks,
241
+ // Only meaningful for strings that meant to be ICU; i18next's {{name}}
242
+ // is not ICU and must not be reported as broken ICU.
243
+ error: looksLikeIcuPlural(text) ? (err instanceof Error ? err.message : String(err)) : null,
244
+ };
245
+ }
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // i18next suffix keys: item_one, item_few, item_other
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export interface SuffixKey {
253
+ base: string;
254
+ category: Category;
255
+ separator: '_' | '.';
256
+ }
257
+
258
+ export interface PluralGroup {
259
+ categories: Set<Category>;
260
+ /** Kept so a finding can name the keys as the project actually writes them. */
261
+ separator: '_' | '.';
262
+ }
263
+
264
+ /** i18next writes item_one; Rails and Symfony nest it as item.one. */
265
+ export function splitPluralSuffix(key: string): SuffixKey | null {
266
+ for (const separator of ['_', '.'] as const) {
267
+ const at = key.lastIndexOf(separator);
268
+ if (at <= 0) continue;
269
+ const suffix = key.slice(at + 1);
270
+ if (isCategory(suffix)) return { base: key.slice(0, at), category: suffix, separator };
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Groups keys like item_one / item_other into base -> categories.
277
+ *
278
+ * A group must carry `other`, which every CLDR plural set has. Without that
279
+ * test, Rails' `restrict_dependent_destroy.has_one` / `has_many` — ActiveRecord
280
+ * association names — read as a plural group and were asked to grow the forms
281
+ * their language requires.
282
+ */
283
+ export function pluralGroups(keys: Iterable<string>): Map<string, PluralGroup> {
284
+ const groups = new Map<string, PluralGroup>();
285
+ for (const key of keys) {
286
+ const split = splitPluralSuffix(key);
287
+ if (!split) continue;
288
+ let group = groups.get(split.base);
289
+ if (!group) {
290
+ group = { categories: new Set(), separator: split.separator };
291
+ groups.set(split.base, group);
292
+ }
293
+ group.categories.add(split.category);
294
+ }
295
+
296
+ for (const [base, group] of groups) {
297
+ if (!group.categories.has('other')) groups.delete(base);
298
+ }
299
+ return groups;
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Laravel pipe selection — deliberately not CLDR
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Laravel picks a segment by position or by explicit {0} / [1,*] ranges, which
308
+ * is its own mechanism and not CLDR. All we can honestly compare is the segment
309
+ * count, so that is all this reports.
310
+ */
311
+ export function pipeSegments(text: string): number {
312
+ let count = 1;
313
+ let depth = 0;
314
+ for (const ch of text) {
315
+ if (ch === '{' || ch === '[') depth++;
316
+ else if (ch === '}' || ch === ']') depth = Math.max(0, depth - 1);
317
+ else if (ch === '|' && depth === 0) count++;
318
+ }
319
+ return count;
320
+ }
package/src/report.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { relative } from 'node:path';
2
+ import type { Finding, LocaleStat, Report } from './types.js';
3
+
4
+ const useColor =
5
+ process.stdout.isTTY === true && !process.env['NO_COLOR'] && process.env['TERM'] !== 'dumb';
6
+
7
+ const paint = (code: string) => (s: string) =>
8
+ useColor ? `\u001b[${code}m${s}\u001b[0m` : s;
9
+ const dim = paint('2');
10
+ const bold = paint('1');
11
+ const red = paint('31');
12
+ const yellow = paint('33');
13
+ const green = paint('32');
14
+
15
+ function pad(s: string, width: number, align: 'left' | 'right' = 'left'): string {
16
+ const gap = ' '.repeat(Math.max(0, width - s.length));
17
+ return align === 'right' ? gap + s : s + gap;
18
+ }
19
+
20
+ function colorCoverage(value: number, text: string): string {
21
+ if (value >= 0.99) return green(text);
22
+ if (value >= 0.9) return yellow(text);
23
+ return red(text);
24
+ }
25
+
26
+ interface Column {
27
+ header: string;
28
+ align: 'left' | 'right';
29
+ value: (s: LocaleStat) => string;
30
+ color?: (s: LocaleStat, text: string) => string;
31
+ }
32
+
33
+ function columns(report: Report): Column[] {
34
+ const cols: Column[] = [
35
+ { header: 'locale', align: 'left', value: (s) => s.locale },
36
+ {
37
+ header: 'coverage',
38
+ align: 'right',
39
+ value: (s) => `${(s.coverage * 100).toFixed(1)}%`,
40
+ color: (s, text) => colorCoverage(s.coverage, text),
41
+ },
42
+ { header: 'missing', align: 'right', value: (s) => String(s.missing) },
43
+ { header: 'orphan', align: 'right', value: (s) => String(s.orphan) },
44
+ ];
45
+
46
+ // Without a translation memory there is nothing to compare against, so the
47
+ // column would only ever print zeroes.
48
+ if (report.memoryLoaded) {
49
+ cols.push({
50
+ header: 'stale',
51
+ align: 'right',
52
+ value: (s) => String(s.stale),
53
+ color: (s, text) => (s.stale > 0 ? yellow(text) : text),
54
+ });
55
+ }
56
+
57
+ cols.push(
58
+ {
59
+ header: 'errors',
60
+ align: 'right',
61
+ value: (s) => String(s.errors),
62
+ color: (s, text) => (s.errors > 0 ? red(text) : text),
63
+ },
64
+ {
65
+ header: 'warnings',
66
+ align: 'right',
67
+ value: (s) => String(s.warnings),
68
+ color: (s, text) => (s.warnings > 0 ? yellow(text) : text),
69
+ },
70
+ );
71
+
72
+ return cols;
73
+ }
74
+
75
+ function renderTable(report: Report): string[] {
76
+ const cols = columns(report);
77
+ const cells = report.stats.map((s) => cols.map((c) => c.value(s)));
78
+ const widths = cols.map((c, i) =>
79
+ Math.max(c.header.length, ...cells.map((row) => (row[i] ?? '').length)),
80
+ );
81
+
82
+ const lines = [dim(cols.map((c, i) => pad(c.header, widths[i]!, c.align)).join(' '))];
83
+
84
+ for (const [rowIndex, row] of cells.entries()) {
85
+ const stat = report.stats[rowIndex]!;
86
+ lines.push(
87
+ cols
88
+ .map((c, i) => {
89
+ const text = pad(row[i] ?? '', widths[i]!, c.align);
90
+ return c.color ? c.color(stat, text) : text;
91
+ })
92
+ .join(' '),
93
+ );
94
+ }
95
+
96
+ return lines;
97
+ }
98
+
99
+ function renderFindings(findings: Finding[], limit: number): string[] {
100
+ if (findings.length === 0) return [];
101
+
102
+ const shown = findings.slice(0, limit);
103
+ const localeWidth = Math.max(...shown.map((f) => f.locale.length));
104
+ const keyWidth = Math.min(36, Math.max(...shown.map((f) => f.key.length)));
105
+ const ruleWidth = Math.max(...shown.map((f) => f.rule.length));
106
+
107
+ const lines: string[] = [];
108
+ let lastSeverity = '';
109
+
110
+ for (const f of shown) {
111
+ if (f.severity !== lastSeverity) {
112
+ lines.push('');
113
+ lines.push(f.severity === 'error' ? red(bold('errors')) : yellow(bold('warnings')));
114
+ lastSeverity = f.severity;
115
+ }
116
+ const key = f.key.length > keyWidth ? `…${f.key.slice(-(keyWidth - 1))}` : f.key;
117
+ lines.push(
118
+ ' ' +
119
+ [pad(f.locale, localeWidth), pad(key, keyWidth), dim(pad(f.rule, ruleWidth)), f.detail].join(
120
+ ' ',
121
+ ),
122
+ );
123
+ }
124
+
125
+ if (findings.length > shown.length) {
126
+ lines.push(dim(` … ${findings.length - shown.length} more (use --limit)`));
127
+ }
128
+
129
+ return lines;
130
+ }
131
+
132
+ export function renderReport(report: Report, root: string, limit: number): string {
133
+ const errors = report.findings.filter((f) => f.severity === 'error').length;
134
+ const warnings = report.findings.length - errors;
135
+
136
+ const head = [
137
+ `${bold('i18n check')}`,
138
+ `source: ${bold(report.sourceLocale)}${report.sourceFromMsgid ? dim(' (msgid)') : ''}`,
139
+ `${report.sourceKeys} keys`,
140
+ dim(relative(root, report.localesDir) || report.localesDir),
141
+ ];
142
+ if (!report.memoryLoaded) head.push(dim('no memory'));
143
+
144
+ const lines = [
145
+ head.join(` ${dim('·')} `),
146
+ '',
147
+ ...renderTable(report),
148
+ ...renderFindings(report.findings, limit),
149
+ '',
150
+ ];
151
+
152
+ if (report.findings.length === 0) {
153
+ lines.push(green('✓ no issues'));
154
+ } else {
155
+ const parts = [];
156
+ if (errors > 0) parts.push(red(`${errors} error${errors === 1 ? '' : 's'}`));
157
+ if (warnings > 0) parts.push(yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`));
158
+ lines.push(parts.join(dim(' · ')));
159
+ }
160
+
161
+ return lines.join('\n');
162
+ }