@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.
- package/LICENSE +21 -0
- package/README.md +666 -0
- package/dist/apply.d.ts +66 -0
- package/dist/apply.js +254 -0
- package/dist/apply.js.map +1 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +326 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +525 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/error.d.ts +12 -0
- package/dist/formats/error.js +19 -0
- package/dist/formats/error.js.map +1 -0
- package/dist/formats/flatten.d.ts +6 -0
- package/dist/formats/flatten.js +26 -0
- package/dist/formats/flatten.js.map +1 -0
- package/dist/formats/json-write.d.ts +7 -0
- package/dist/formats/json-write.js +55 -0
- package/dist/formats/json-write.js.map +1 -0
- package/dist/formats/json.d.ts +6 -0
- package/dist/formats/json.js +21 -0
- package/dist/formats/json.js.map +1 -0
- package/dist/formats/php-write.d.ts +16 -0
- package/dist/formats/php-write.js +130 -0
- package/dist/formats/php-write.js.map +1 -0
- package/dist/formats/php.d.ts +44 -0
- package/dist/formats/php.js +356 -0
- package/dist/formats/php.js.map +1 -0
- package/dist/formats/po-write.d.ts +3 -0
- package/dist/formats/po-write.js +200 -0
- package/dist/formats/po-write.js.map +1 -0
- package/dist/formats/po.d.ts +30 -0
- package/dist/formats/po.js +202 -0
- package/dist/formats/po.js.map +1 -0
- package/dist/formats/write.d.ts +29 -0
- package/dist/formats/write.js +19 -0
- package/dist/formats/write.js.map +1 -0
- package/dist/formats/yaml-write.d.ts +12 -0
- package/dist/formats/yaml-write.js +42 -0
- package/dist/formats/yaml-write.js.map +1 -0
- package/dist/formats/yaml.d.ts +18 -0
- package/dist/formats/yaml.js +51 -0
- package/dist/formats/yaml.js.map +1 -0
- package/dist/glossary.d.ts +38 -0
- package/dist/glossary.js +140 -0
- package/dist/glossary.js.map +1 -0
- package/dist/lengths.d.ts +37 -0
- package/dist/lengths.js +195 -0
- package/dist/lengths.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +374 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +162 -0
- package/dist/memory.js.map +1 -0
- package/dist/placeholders.d.ts +15 -0
- package/dist/placeholders.js +103 -0
- package/dist/placeholders.js.map +1 -0
- package/dist/plurals.d.ts +59 -0
- package/dist/plurals.js +280 -0
- package/dist/plurals.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +126 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +23 -0
- package/dist/scan.js +250 -0
- package/dist/scan.js.map +1 -0
- package/dist/translate.d.ts +93 -0
- package/dist/translate.js +369 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/apply.ts +334 -0
- package/src/check.ts +468 -0
- package/src/cli.ts +637 -0
- package/src/formats/error.ts +19 -0
- package/src/formats/flatten.ts +38 -0
- package/src/formats/json-write.ts +66 -0
- package/src/formats/json.ts +27 -0
- package/src/formats/php-write.ts +146 -0
- package/src/formats/php.ts +414 -0
- package/src/formats/po-write.ts +230 -0
- package/src/formats/po.ts +233 -0
- package/src/formats/write.ts +41 -0
- package/src/formats/yaml-write.ts +53 -0
- package/src/formats/yaml.ts +62 -0
- package/src/glossary.ts +191 -0
- package/src/lengths.ts +214 -0
- package/src/mcp.ts +451 -0
- package/src/memory.ts +227 -0
- package/src/placeholders.ts +123 -0
- package/src/plurals.ts +320 -0
- package/src/report.ts +162 -0
- package/src/scan.ts +280 -0
- package/src/translate.ts +486 -0
- package/src/types.ts +136 -0
- package/src/version.ts +27 -0
package/src/check.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import {
|
|
2
|
+
containsTerm,
|
|
3
|
+
expectationsFor,
|
|
4
|
+
missingVerbatim,
|
|
5
|
+
type Glossary,
|
|
6
|
+
} from './glossary.js';
|
|
7
|
+
import {
|
|
8
|
+
allowanceFor,
|
|
9
|
+
limitFor,
|
|
10
|
+
measurable,
|
|
11
|
+
renderedWidth,
|
|
12
|
+
type Limits,
|
|
13
|
+
} from './lengths.js';
|
|
14
|
+
import { judge, type Memory } from './memory.js';
|
|
15
|
+
import { diffPlaceholders, extractPlaceholders } from './placeholders.js';
|
|
16
|
+
import {
|
|
17
|
+
categoriesFor,
|
|
18
|
+
pipeSegments,
|
|
19
|
+
pluralGroups,
|
|
20
|
+
scanIcu,
|
|
21
|
+
splitPluralSuffix,
|
|
22
|
+
type Category,
|
|
23
|
+
} from './plurals.js';
|
|
24
|
+
import { loadBundle, sourceIsMsgid } from './scan.js';
|
|
25
|
+
import type { Config, Finding, LocaleBundle, LocaleStat, Report, RuleId } from './types.js';
|
|
26
|
+
|
|
27
|
+
const HAS_LETTER = /\p{L}/u;
|
|
28
|
+
|
|
29
|
+
/** gettext plural forms are indexed, not named: base.0, base.1, base.2. */
|
|
30
|
+
const PLURAL_INDEX = /^(.*)\.(\d+)$/;
|
|
31
|
+
|
|
32
|
+
function pluralIndexBase(key: string): string | null {
|
|
33
|
+
const match = PLURAL_INDEX.exec(key);
|
|
34
|
+
return match ? match[1]! : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function push(
|
|
38
|
+
findings: Finding[],
|
|
39
|
+
config: Config,
|
|
40
|
+
rule: RuleId,
|
|
41
|
+
locale: string,
|
|
42
|
+
key: string,
|
|
43
|
+
file: string,
|
|
44
|
+
detail: string,
|
|
45
|
+
): void {
|
|
46
|
+
const setting = config.rules[rule];
|
|
47
|
+
if (setting === 'off') return;
|
|
48
|
+
findings.push({ rule, severity: setting, locale, key, file, detail });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function list(categories: Iterable<string>): string {
|
|
52
|
+
return [...categories].join('/');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** ICU plural branches must cover the categories the target language actually has. */
|
|
56
|
+
function checkIcuCategories(
|
|
57
|
+
config: Config,
|
|
58
|
+
findings: Finding[],
|
|
59
|
+
locale: string,
|
|
60
|
+
categories: Set<string> | null,
|
|
61
|
+
key: string,
|
|
62
|
+
file: string,
|
|
63
|
+
value: string,
|
|
64
|
+
): void {
|
|
65
|
+
const scan = scanIcu(value);
|
|
66
|
+
|
|
67
|
+
if (scan.error) {
|
|
68
|
+
push(findings, config, 'icu_syntax_error', locale, key, file, scan.error);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!categories) return;
|
|
72
|
+
|
|
73
|
+
for (const block of scan.blocks) {
|
|
74
|
+
const absent = [...categories].filter((c) => !block.categories.has(c));
|
|
75
|
+
if (absent.length > 0) {
|
|
76
|
+
push(findings, config, 'plural_missing_category', locale, key, file,
|
|
77
|
+
`${locale} needs ${list(categories)}, has ${list(block.categories) || 'none'}`);
|
|
78
|
+
}
|
|
79
|
+
const extra = [...block.categories].filter((c) => !categories.has(c));
|
|
80
|
+
if (extra.length > 0) {
|
|
81
|
+
push(findings, config, 'plural_extra_category', locale, key, file,
|
|
82
|
+
`${list(extra)} is not a plural category in ${locale}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The plural categories a target uses where the source had a plain string, or
|
|
89
|
+
* null when the expansion is something else.
|
|
90
|
+
*/
|
|
91
|
+
function pluralExpansion(
|
|
92
|
+
target: LocaleBundle,
|
|
93
|
+
key: string,
|
|
94
|
+
categories: Set<string> | null,
|
|
95
|
+
): Set<string> | null {
|
|
96
|
+
if (!categories) return null;
|
|
97
|
+
const prefix = `${key}.`;
|
|
98
|
+
const used = new Set<string>();
|
|
99
|
+
|
|
100
|
+
for (const candidate of target.leaves.keys()) {
|
|
101
|
+
if (!candidate.startsWith(prefix)) continue;
|
|
102
|
+
const rest = candidate.slice(prefix.length);
|
|
103
|
+
if (rest.includes('.') || !categories.has(rest)) return null;
|
|
104
|
+
used.add(rest);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return used.size > 0 ? used : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkLocale(
|
|
111
|
+
config: Config,
|
|
112
|
+
source: LocaleBundle,
|
|
113
|
+
target: LocaleBundle,
|
|
114
|
+
findings: Finding[],
|
|
115
|
+
memory: Memory | null,
|
|
116
|
+
glossary: Glossary | null,
|
|
117
|
+
limits: Limits | null,
|
|
118
|
+
): LocaleStat {
|
|
119
|
+
const before = findings.length;
|
|
120
|
+
for (const bad of target.unreadable) {
|
|
121
|
+
push(findings, config, 'unreadable_file', target.locale, bad.file, bad.file, bad.message);
|
|
122
|
+
}
|
|
123
|
+
const ignore = new Set(config.ignoreIdentical.map((s) => s.toLowerCase()));
|
|
124
|
+
const structuralPrefixes: string[] = [];
|
|
125
|
+
|
|
126
|
+
// Plural forms differ per language, so a key present in the source is not
|
|
127
|
+
// automatically required here, and a key absent from the source is not
|
|
128
|
+
// automatically dead.
|
|
129
|
+
const categories = categoriesFor(target.locale);
|
|
130
|
+
const sourceGroups = pluralGroups(source.leaves.keys());
|
|
131
|
+
const targetGroups = pluralGroups(target.leaves.keys());
|
|
132
|
+
const laravel = config.placeholderSyntaxes.includes('laravel');
|
|
133
|
+
|
|
134
|
+
let translated = 0;
|
|
135
|
+
let missing = 0;
|
|
136
|
+
let orphan = 0;
|
|
137
|
+
let stale = 0;
|
|
138
|
+
let notApplicable = 0;
|
|
139
|
+
|
|
140
|
+
// Only collected when the rule is on: one map per locale of source string ->
|
|
141
|
+
// the distinct translations it received.
|
|
142
|
+
const trackWording = config.rules.inconsistent_translation !== 'off';
|
|
143
|
+
const wordings = new Map<string, Map<string, string>>();
|
|
144
|
+
|
|
145
|
+
for (const [key, sourceLeaf] of source.leaves) {
|
|
146
|
+
const targetLeaf = target.leaves.get(key);
|
|
147
|
+
|
|
148
|
+
if (!targetLeaf) {
|
|
149
|
+
const indexBase = pluralIndexBase(key);
|
|
150
|
+
if (indexBase && (source.plurals.has(indexBase) || target.plurals.has(indexBase))) {
|
|
151
|
+
// A catalogue carries as many forms as its own language needs.
|
|
152
|
+
notApplicable++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const suffix = splitPluralSuffix(key);
|
|
157
|
+
if (suffix && sourceGroups.has(suffix.base) && categories && !categories.has(suffix.category)) {
|
|
158
|
+
// e.g. item_one has no counterpart in Japanese, which only has `other`.
|
|
159
|
+
notApplicable++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const file = target.files[0] ?? target.locale;
|
|
164
|
+
if (target.containers.has(key)) {
|
|
165
|
+
structuralPrefixes.push(key + '.');
|
|
166
|
+
// Arabic turning one English string into zero/one/two/few/many is
|
|
167
|
+
// correct pluralisation, not a structural disagreement — so it is
|
|
168
|
+
// judged as a plural group instead.
|
|
169
|
+
const expanded = pluralExpansion(target, key, categories);
|
|
170
|
+
if (expanded) {
|
|
171
|
+
const absent = [...categories!].filter((c) => !expanded.has(c));
|
|
172
|
+
if (absent.length > 0) {
|
|
173
|
+
push(findings, config, 'plural_missing_category', target.locale, `${key}.*`, file,
|
|
174
|
+
`${target.locale} needs ${list(categories!)}, has ${list(expanded)}`);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
push(findings, config, 'structure_mismatch', target.locale, key, file,
|
|
179
|
+
'value in source, object in target');
|
|
180
|
+
} else {
|
|
181
|
+
missing++;
|
|
182
|
+
push(findings, config, 'missing_key', target.locale, key, file, 'not translated');
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (targetLeaf.value.trim() === '' && sourceLeaf.value.trim() !== '') {
|
|
188
|
+
// An empty string is not a translation. gettext spells that as an empty
|
|
189
|
+
// msgstr and i18next as "", and both mean the same as no key at all —
|
|
190
|
+
// so they are one finding, not two ideas wearing different names.
|
|
191
|
+
missing++;
|
|
192
|
+
push(findings, config, 'missing_key', target.locale, key, targetLeaf.file,
|
|
193
|
+
'present but empty');
|
|
194
|
+
// Nothing further can be true of an empty string: it has no placeholders
|
|
195
|
+
// to have lost, no plural forms to be missing, no width to overflow.
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
translated++;
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
targetLeaf.value === sourceLeaf.value &&
|
|
202
|
+
HAS_LETTER.test(sourceLeaf.value) &&
|
|
203
|
+
!ignore.has(sourceLeaf.value.toLowerCase())
|
|
204
|
+
) {
|
|
205
|
+
push(findings, config, 'identical_to_source', target.locale, key, targetLeaf.file,
|
|
206
|
+
`identical to source: "${truncate(sourceLeaf.value)}"`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sourcePlaceholders = extractPlaceholders(sourceLeaf.value, config.placeholderSyntaxes);
|
|
210
|
+
const targetPlaceholders = extractPlaceholders(targetLeaf.value, config.placeholderSyntaxes);
|
|
211
|
+
const { missing: lost, extra } = diffPlaceholders(sourcePlaceholders, targetPlaceholders);
|
|
212
|
+
|
|
213
|
+
if (lost.length > 0) {
|
|
214
|
+
push(findings, config, 'placeholder_missing', target.locale, key, targetLeaf.file,
|
|
215
|
+
`${lost.join(', ')} lost`);
|
|
216
|
+
}
|
|
217
|
+
if (extra.length > 0) {
|
|
218
|
+
push(findings, config, 'placeholder_extra', target.locale, key, targetLeaf.file,
|
|
219
|
+
`${extra.join(', ')} not in source`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (glossary) {
|
|
223
|
+
const lost = missingVerbatim(glossary, sourceLeaf.value, targetLeaf.value);
|
|
224
|
+
if (lost.length > 0) {
|
|
225
|
+
push(findings, config, 'dnt_violation', target.locale, key, targetLeaf.file,
|
|
226
|
+
`${lost.join(', ')} must stay verbatim`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const { term, accepted } of expectationsFor(glossary, target.locale, sourceLeaf.value)) {
|
|
230
|
+
const satisfied = accepted.some((form) =>
|
|
231
|
+
containsTerm(targetLeaf.value, form, term.match, term.caseSensitive),
|
|
232
|
+
);
|
|
233
|
+
if (!satisfied) {
|
|
234
|
+
push(findings, config, 'glossary_violation', target.locale, key, targetLeaf.file,
|
|
235
|
+
`"${term.source}" should be ${accepted.map((f) => `"${f}"`).join(' or ')}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (trackWording && HAS_LETTER.test(sourceLeaf.value) && sourceLeaf.value.length >= 4) {
|
|
241
|
+
let variants = wordings.get(sourceLeaf.value);
|
|
242
|
+
if (!variants) {
|
|
243
|
+
variants = new Map();
|
|
244
|
+
wordings.set(sourceLeaf.value, variants);
|
|
245
|
+
}
|
|
246
|
+
if (!variants.has(targetLeaf.value)) variants.set(targetLeaf.value, key);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
checkLength(
|
|
250
|
+
config, findings, target.locale, key, targetLeaf.file, limits, laravel,
|
|
251
|
+
targetLeaf.value, sourceLeaf.value,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
checkIcuCategories(
|
|
255
|
+
config, findings, target.locale, categories, key, targetLeaf.file, targetLeaf.value,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Laravel selects a segment by position or explicit range, which is its own
|
|
259
|
+
// mechanism, not CLDR. The one thing that is unambiguously wrong is losing
|
|
260
|
+
// the selection entirely: trans_choice would then return one string for
|
|
261
|
+
// every count.
|
|
262
|
+
if (laravel && pipeSegments(sourceLeaf.value) > 1 && pipeSegments(targetLeaf.value) === 1) {
|
|
263
|
+
push(findings, config, 'plural_selector_lost', target.locale, key, targetLeaf.file,
|
|
264
|
+
'source selects between forms with |, translation has a single form');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const verdict = judge(memory?.entries[target.locale]?.[key], sourceLeaf.value, targetLeaf.value);
|
|
268
|
+
if (targetLeaf.fuzzy) {
|
|
269
|
+
// gettext already tracks this; no translation memory needed.
|
|
270
|
+
stale++;
|
|
271
|
+
push(findings, config, 'stale', target.locale, key, targetLeaf.file,
|
|
272
|
+
'marked fuzzy in the catalogue');
|
|
273
|
+
} else if (verdict.stale) {
|
|
274
|
+
stale++;
|
|
275
|
+
push(findings, config, 'stale', target.locale, key, targetLeaf.file,
|
|
276
|
+
'source changed after this translation was recorded');
|
|
277
|
+
} else if (memory && !verdict.tracked) {
|
|
278
|
+
push(findings, config, 'untracked', target.locale, key, targetLeaf.file,
|
|
279
|
+
'no memory entry; run sync');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const [key, leaf] of target.leaves) {
|
|
284
|
+
if (source.leaves.has(key)) continue;
|
|
285
|
+
// Already reported as a structure mismatch one level up; not a separate orphan.
|
|
286
|
+
if (structuralPrefixes.some((prefix) => key.startsWith(prefix))) continue;
|
|
287
|
+
|
|
288
|
+
const indexBase = pluralIndexBase(key);
|
|
289
|
+
if (indexBase && (source.plurals.has(indexBase) || target.plurals.has(indexBase))) continue;
|
|
290
|
+
|
|
291
|
+
const suffix = splitPluralSuffix(key);
|
|
292
|
+
if (suffix && sourceGroups.has(suffix.base) && categories?.has(suffix.category)) {
|
|
293
|
+
// e.g. item_few exists in Polish and not in English. That is the point.
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (source.containers.has(key)) {
|
|
298
|
+
push(findings, config, 'structure_mismatch', target.locale, key, leaf.file,
|
|
299
|
+
'object in source, value in target');
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
orphan++;
|
|
303
|
+
push(findings, config, 'orphan_key', target.locale, key, leaf.file, 'not in source locale');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// gettext states its own form count in the catalogue header, so this one is
|
|
307
|
+
// internally checkable without consulting CLDR at all.
|
|
308
|
+
if (target.nplurals !== null) {
|
|
309
|
+
for (const [base, forms] of target.plurals) {
|
|
310
|
+
if (forms === target.nplurals) continue;
|
|
311
|
+
push(findings, config, 'plural_missing_category', target.locale, base,
|
|
312
|
+
target.files[0] ?? target.locale,
|
|
313
|
+
`header declares nplurals=${target.nplurals}, entry has ${forms}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Suffix-key plural groups, checked per group rather than per key.
|
|
318
|
+
if (categories) {
|
|
319
|
+
for (const base of sourceGroups.keys()) {
|
|
320
|
+
const have = targetGroups.get(base);
|
|
321
|
+
if (!have) continue; // the whole group is missing; already reported above
|
|
322
|
+
|
|
323
|
+
// Named the way the project writes it: item_one for i18next, item.one for
|
|
324
|
+
// Rails and Symfony.
|
|
325
|
+
const label = `${base}${have.separator}*`;
|
|
326
|
+
|
|
327
|
+
const absent = [...categories].filter((c) => !have.categories.has(c as Category));
|
|
328
|
+
if (absent.length > 0) {
|
|
329
|
+
push(findings, config, 'plural_missing_category', target.locale, label,
|
|
330
|
+
target.files[0] ?? target.locale,
|
|
331
|
+
`${target.locale} needs ${list(categories)}, has ${list(have.categories)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const extra = [...have.categories].filter((c) => !categories.has(c));
|
|
335
|
+
if (extra.length > 0) {
|
|
336
|
+
push(findings, config, 'plural_extra_category', target.locale, label,
|
|
337
|
+
target.files[0] ?? target.locale,
|
|
338
|
+
`${list(extra)} is not a plural category in ${target.locale}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const [sourceValue, variants] of wordings) {
|
|
344
|
+
if (variants.size < 2) continue;
|
|
345
|
+
const keys = [...variants.values()];
|
|
346
|
+
push(findings, config, 'inconsistent_translation', target.locale, keys[0]!,
|
|
347
|
+
target.files[0] ?? target.locale,
|
|
348
|
+
`"${truncate(sourceValue)}" is translated ${variants.size} ways, also at ${keys.slice(1).join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const added = findings.slice(before);
|
|
352
|
+
const applicable = source.leaves.size - notApplicable;
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
locale: target.locale,
|
|
356
|
+
sourceKeys: source.leaves.size,
|
|
357
|
+
translated,
|
|
358
|
+
// Keys that the target language has no grammatical use for are left out of
|
|
359
|
+
// the denominator, so a complete Japanese locale still reads as 100%.
|
|
360
|
+
coverage: applicable <= 0 ? 1 : translated / applicable,
|
|
361
|
+
missing,
|
|
362
|
+
orphan,
|
|
363
|
+
stale,
|
|
364
|
+
errors: added.filter((f) => f.severity === 'error').length,
|
|
365
|
+
warnings: added.filter((f) => f.severity === 'warning').length,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Text is measured in display columns and only where measuring means something:
|
|
371
|
+
* an ICU plural holds every branch at once but shows one, and Laravel's pipes
|
|
372
|
+
* select between forms, so only the widest segment can ever appear.
|
|
373
|
+
*/
|
|
374
|
+
function checkLength(
|
|
375
|
+
config: Config,
|
|
376
|
+
findings: Finding[],
|
|
377
|
+
locale: string,
|
|
378
|
+
key: string,
|
|
379
|
+
file: string,
|
|
380
|
+
limits: Limits | null,
|
|
381
|
+
laravel: boolean,
|
|
382
|
+
value: string,
|
|
383
|
+
sourceValue: string | null,
|
|
384
|
+
): void {
|
|
385
|
+
if (!measurable(value)) return;
|
|
386
|
+
const width = renderedWidth(value, laravel);
|
|
387
|
+
|
|
388
|
+
const max = limits ? limitFor(limits, key) : null;
|
|
389
|
+
if (max !== null && width > max) {
|
|
390
|
+
push(findings, config, 'length_over_max', locale, key, file,
|
|
391
|
+
`${width} columns, limit ${max}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (sourceValue === null || !measurable(sourceValue)) return;
|
|
395
|
+
const sourceWidth = renderedWidth(sourceValue, laravel);
|
|
396
|
+
if (sourceWidth === 0) return;
|
|
397
|
+
|
|
398
|
+
const allowed = allowanceFor(sourceWidth);
|
|
399
|
+
if (width > sourceWidth * allowed) {
|
|
400
|
+
const percent = Math.round((width / sourceWidth) * 100);
|
|
401
|
+
push(findings, config, 'length_overflow', locale, key, file,
|
|
402
|
+
`${width} columns vs ${sourceWidth} in source — ${percent}%, allowance ${Math.round(allowed * 100)}%`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A broken or oversized source string is broken for every language. */
|
|
407
|
+
function checkSource(
|
|
408
|
+
config: Config,
|
|
409
|
+
source: LocaleBundle,
|
|
410
|
+
findings: Finding[],
|
|
411
|
+
limits: Limits | null,
|
|
412
|
+
): void {
|
|
413
|
+
for (const bad of source.unreadable) {
|
|
414
|
+
push(findings, config, 'unreadable_file', source.locale, bad.file, bad.file, bad.message);
|
|
415
|
+
}
|
|
416
|
+
const categories = categoriesFor(source.locale);
|
|
417
|
+
const laravel = config.placeholderSyntaxes.includes('laravel');
|
|
418
|
+
for (const [key, leaf] of source.leaves) {
|
|
419
|
+
checkIcuCategories(
|
|
420
|
+
config, findings, source.locale, categories, key, leaf.file, leaf.value,
|
|
421
|
+
);
|
|
422
|
+
checkLength(config, findings, source.locale, key, leaf.file, limits, laravel, leaf.value, null);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function truncate(value: string, max = 40): string {
|
|
427
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const SEVERITY_ORDER = { error: 0, warning: 1 } as const;
|
|
431
|
+
|
|
432
|
+
export function check(
|
|
433
|
+
config: Config,
|
|
434
|
+
memory: Memory | null = null,
|
|
435
|
+
glossary: Glossary | null = null,
|
|
436
|
+
limits: Limits | null = null,
|
|
437
|
+
): Report {
|
|
438
|
+
const source = loadBundle(config, config.sourceLocale);
|
|
439
|
+
const findings: Finding[] = [];
|
|
440
|
+
const stats: LocaleStat[] = [];
|
|
441
|
+
|
|
442
|
+
checkSource(config, source, findings, limits);
|
|
443
|
+
|
|
444
|
+
for (const locale of config.locales) {
|
|
445
|
+
if (locale === config.sourceLocale) continue;
|
|
446
|
+
stats.push(
|
|
447
|
+
checkLocale(config, source, loadBundle(config, locale), findings, memory, glossary, limits),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
findings.sort(
|
|
452
|
+
(a, b) =>
|
|
453
|
+
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
|
454
|
+
a.locale.localeCompare(b.locale) ||
|
|
455
|
+
a.key.localeCompare(b.key) ||
|
|
456
|
+
a.rule.localeCompare(b.rule),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
localesDir: config.localesDir,
|
|
461
|
+
sourceLocale: config.sourceLocale,
|
|
462
|
+
sourceKeys: source.leaves.size,
|
|
463
|
+
sourceFromMsgid: sourceIsMsgid(source),
|
|
464
|
+
memoryLoaded: memory !== null,
|
|
465
|
+
stats,
|
|
466
|
+
findings,
|
|
467
|
+
};
|
|
468
|
+
}
|