@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,66 @@
1
+ import type { Glossary } from './glossary.js';
2
+ import { type Limits } from './lengths.js';
3
+ import { type Memory } from './memory.js';
4
+ import type { Config, LocaleBundle } from './types.js';
5
+ import { type Proposal } from './translate.js';
6
+ /**
7
+ * Applying accepted translations to locale files.
8
+ *
9
+ * Each format has its own writer, and none of them re-serialises a parsed tree:
10
+ * comments, quote styles, anchors and translator notes have to survive, so the
11
+ * writers edit text in place. What this module decides is which file a key
12
+ * belongs to, and whether a file that does not exist yet can be created — which
13
+ * only JSON and PHP can, since a YAML file's shape and a gettext header cannot
14
+ * be invented.
15
+ */
16
+ export declare class ApplyError extends Error {
17
+ }
18
+ export interface Destination {
19
+ file: string;
20
+ /** The key as that file spells it, with any file-level namespace removed. */
21
+ key: string;
22
+ /** The source-locale file this one mirrors, used when creating it. */
23
+ sourceFile: string;
24
+ }
25
+ /** Where a translation for `key` belongs, or null when the format is unknown. */
26
+ export declare function destinationFor(config: Config, source: LocaleBundle, locale: string, key: string): Destination | null;
27
+ export interface ApplyResult {
28
+ written: number;
29
+ files: string[];
30
+ /** Translations that could not be written, and why. */
31
+ skipped: Array<{
32
+ locale: string;
33
+ key: string;
34
+ reason: string;
35
+ }>;
36
+ }
37
+ export declare function applyProposals(config: Config, source: LocaleBundle, proposals: Proposal[]): ApplyResult;
38
+ export declare const SAVE_VERSION = 1;
39
+ export interface SavedRun {
40
+ version: number;
41
+ model: string;
42
+ sourceLocale: string;
43
+ createdAt: string;
44
+ aborted: string | null;
45
+ proposals: Proposal[];
46
+ }
47
+ export declare function saveRun(file: string, run: SavedRun): void;
48
+ export declare function loadRun(file: string): SavedRun;
49
+ export interface Recheck {
50
+ ready: Proposal[];
51
+ dropped: Array<{
52
+ proposal: Proposal;
53
+ reason: string;
54
+ }>;
55
+ }
56
+ /**
57
+ * The gate runs again at apply time.
58
+ *
59
+ * A saved file can be days old and is editable by hand, so nothing is written
60
+ * on the strength of a check made earlier against files that may since have
61
+ * moved. A proposal whose source string has changed is dropped rather than
62
+ * applied to text it was not written for.
63
+ */
64
+ export declare function recheck(config: Config, source: LocaleBundle, glossary: Glossary | null, limits: Limits | null, proposals: Proposal[]): Recheck;
65
+ /** Machine output is recorded unreviewed, whichever command wrote it. */
66
+ export declare function recordMachine(memory: Memory, proposals: Proposal[], now: string): void;
package/dist/apply.js ADDED
@@ -0,0 +1,254 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { limitFor } from './lengths.js';
4
+ import { hashValue } from './memory.js';
5
+ import { writeJsonLocale } from './formats/json-write.js';
6
+ import { blankPhpFile, writePhpLocale } from './formats/php-write.js';
7
+ import { writePoLocale } from './formats/po-write.js';
8
+ import { writeYamlLocale } from './formats/yaml-write.js';
9
+ import { validate } from './translate.js';
10
+ /**
11
+ * Applying accepted translations to locale files.
12
+ *
13
+ * Each format has its own writer, and none of them re-serialises a parsed tree:
14
+ * comments, quote styles, anchors and translator notes have to survive, so the
15
+ * writers edit text in place. What this module decides is which file a key
16
+ * belongs to, and whether a file that does not exist yet can be created — which
17
+ * only JSON and PHP can, since a YAML file's shape and a gettext header cannot
18
+ * be invented.
19
+ */
20
+ export class ApplyError extends Error {
21
+ }
22
+ const WRITERS = new Map([
23
+ ['.json', writeJsonLocale],
24
+ ['.php', writePhpLocale],
25
+ ['.yaml', writeYamlLocale],
26
+ ['.yml', writeYamlLocale],
27
+ ['.pot', writePoLocale],
28
+ ['.po', writePoLocale],
29
+ ]);
30
+ /**
31
+ * An empty file for a locale that has none yet, or null when its shape cannot
32
+ * be known. A YAML file depends on whether the project nests under a locale
33
+ * root, and a gettext catalogue needs a header declaring the language's own
34
+ * plural rules — neither can be invented.
35
+ */
36
+ function blankFileFor(extension, sourceFile) {
37
+ if (extension === '.json')
38
+ return '{}\n';
39
+ if (extension === '.php') {
40
+ const source = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf8') : null;
41
+ return blankPhpFile(source);
42
+ }
43
+ return null;
44
+ }
45
+ function extensionOf(file) {
46
+ const at = file.lastIndexOf('.');
47
+ return at === -1 ? '' : file.slice(at).toLowerCase();
48
+ }
49
+ function namespaceOf(config, sourceFile) {
50
+ const localeRoot = join(config.localesDir, config.sourceLocale);
51
+ return relative(localeRoot, sourceFile)
52
+ .replace(/\.[^.]+$/, '')
53
+ .split(sep)
54
+ .filter((part) => part !== 'LC_MESSAGES')
55
+ .join('.');
56
+ }
57
+ /**
58
+ * Which source file a key belongs to.
59
+ *
60
+ * Not every key the target needs exists in the source: a plural form only the
61
+ * target language has — Polish `items.few` against English `one`/`other` — has
62
+ * no source leaf at all. Such a key still belongs wherever its neighbours live,
63
+ * so the lookup walks up to the nearest prefix that does exist.
64
+ */
65
+ function sourceFileFor(source, key) {
66
+ const leaf = source.leaves.get(key);
67
+ if (leaf)
68
+ return leaf.file;
69
+ const parts = key.split('.');
70
+ for (let depth = parts.length - 1; depth > 0; depth--) {
71
+ const prefix = `${parts.slice(0, depth).join('.')}.`;
72
+ for (const [candidate, candidateLeaf] of source.leaves) {
73
+ if (candidate.startsWith(prefix))
74
+ return candidateLeaf.file;
75
+ }
76
+ }
77
+ // i18next spells plurals as item_few rather than item.few.
78
+ const underscore = key.lastIndexOf('_');
79
+ if (underscore > 0) {
80
+ const base = `${key.slice(0, underscore)}_`;
81
+ for (const [candidate, candidateLeaf] of source.leaves) {
82
+ if (candidate.startsWith(base))
83
+ return candidateLeaf.file;
84
+ }
85
+ }
86
+ return source.files[0] ?? null;
87
+ }
88
+ /** Where a translation for `key` belongs, or null when the format is unknown. */
89
+ export function destinationFor(config, source, locale, key) {
90
+ const from = sourceFileFor(source, key);
91
+ if (!from)
92
+ return null;
93
+ const extension = extensionOf(from);
94
+ if (!WRITERS.has(extension))
95
+ return null;
96
+ if (config.layout === 'flat') {
97
+ return { file: join(config.localesDir, `${locale}${extension}`), key, sourceFile: from };
98
+ }
99
+ const localeRoot = join(config.localesDir, config.sourceLocale);
100
+ const withinLocale = relative(localeRoot, from);
101
+ const namespace = namespaceOf(config, from);
102
+ const inner = namespace && key.startsWith(`${namespace}.`) ? key.slice(namespace.length + 1) : key;
103
+ return { file: join(config.localesDir, locale, withinLocale), key: inner, sourceFile: from };
104
+ }
105
+ export function applyProposals(config, source, proposals) {
106
+ const result = { written: 0, files: [], skipped: [] };
107
+ const byFile = new Map();
108
+ for (const proposal of proposals) {
109
+ if (!proposal.accepted)
110
+ continue;
111
+ const destination = destinationFor(config, source, proposal.locale, proposal.key);
112
+ if (!destination) {
113
+ result.skipped.push({
114
+ locale: proposal.locale,
115
+ key: proposal.key,
116
+ reason: 'no writer for this format',
117
+ });
118
+ continue;
119
+ }
120
+ const bucket = byFile.get(destination.file);
121
+ const edit = { key: destination.key, value: proposal.value };
122
+ if (bucket)
123
+ bucket.edits.push(edit);
124
+ else {
125
+ byFile.set(destination.file, {
126
+ locale: proposal.locale,
127
+ sourceFile: destination.sourceFile,
128
+ edits: [edit],
129
+ });
130
+ }
131
+ }
132
+ for (const [file, { locale, sourceFile, edits }] of byFile) {
133
+ const extension = extensionOf(file);
134
+ const writer = WRITERS.get(extension);
135
+ let content;
136
+ if (existsSync(file)) {
137
+ content = readFileSync(file, 'utf8');
138
+ }
139
+ else {
140
+ const blank = blankFileFor(extension, sourceFile);
141
+ if (blank === null) {
142
+ for (const edit of edits) {
143
+ result.skipped.push({
144
+ locale,
145
+ key: edit.key,
146
+ reason: `${file} does not exist and this format cannot be created from nothing`,
147
+ });
148
+ }
149
+ continue;
150
+ }
151
+ content = blank;
152
+ }
153
+ let outcome;
154
+ try {
155
+ outcome = writer(content, file, locale, edits);
156
+ }
157
+ catch (err) {
158
+ throw new ApplyError(`Cannot write ${file}\n ${err instanceof Error ? err.message : String(err)}`);
159
+ }
160
+ for (const skip of outcome.skipped) {
161
+ result.skipped.push({ locale, key: skip.key, reason: skip.reason });
162
+ }
163
+ mkdirSync(dirname(file), { recursive: true });
164
+ writeFileSync(file, outcome.content);
165
+ result.files.push(file);
166
+ result.written += edits.length - outcome.skipped.length;
167
+ }
168
+ return result;
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Saved runs
172
+ // ---------------------------------------------------------------------------
173
+ export const SAVE_VERSION = 1;
174
+ export function saveRun(file, run) {
175
+ mkdirSync(dirname(file), { recursive: true });
176
+ writeFileSync(file, `${JSON.stringify(run, null, 2)}\n`);
177
+ }
178
+ export function loadRun(file) {
179
+ if (!existsSync(file))
180
+ throw new ApplyError(`Proposals file not found: ${file}`);
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
184
+ }
185
+ catch (err) {
186
+ throw new ApplyError(`Cannot read ${file}\n ${err instanceof Error ? err.message : String(err)}`);
187
+ }
188
+ const run = parsed;
189
+ if (!run || run.version !== SAVE_VERSION) {
190
+ throw new ApplyError(`Unrecognised proposals file ${file} (expected version ${SAVE_VERSION})`);
191
+ }
192
+ if (!Array.isArray(run.proposals))
193
+ throw new ApplyError(`${file} has no proposals array`);
194
+ return {
195
+ version: SAVE_VERSION,
196
+ model: run.model ?? 'unknown',
197
+ sourceLocale: run.sourceLocale ?? '',
198
+ createdAt: run.createdAt ?? '',
199
+ aborted: run.aborted ?? null,
200
+ proposals: run.proposals,
201
+ };
202
+ }
203
+ /**
204
+ * The gate runs again at apply time.
205
+ *
206
+ * A saved file can be days old and is editable by hand, so nothing is written
207
+ * on the strength of a check made earlier against files that may since have
208
+ * moved. A proposal whose source string has changed is dropped rather than
209
+ * applied to text it was not written for.
210
+ */
211
+ export function recheck(config, source, glossary, limits, proposals) {
212
+ const ready = [];
213
+ const dropped = [];
214
+ for (const proposal of proposals) {
215
+ if (!proposal.accepted) {
216
+ dropped.push({ proposal, reason: 'was rejected when proposed' });
217
+ continue;
218
+ }
219
+ const leaf = source.leaves.get(proposal.key);
220
+ if (!leaf) {
221
+ dropped.push({ proposal, reason: 'the key is no longer in the source locale' });
222
+ continue;
223
+ }
224
+ if (leaf.value !== proposal.source) {
225
+ dropped.push({ proposal, reason: 'the source string changed after the proposal was made' });
226
+ continue;
227
+ }
228
+ const problems = validate(config, {
229
+ source: leaf.value,
230
+ locale: proposal.locale,
231
+ maxWidth: limits ? limitFor(limits, proposal.key) : null,
232
+ }, proposal.value, glossary);
233
+ if (problems.length > 0) {
234
+ dropped.push({ proposal, reason: problems.join('; ') });
235
+ continue;
236
+ }
237
+ ready.push(proposal);
238
+ }
239
+ return { ready, dropped };
240
+ }
241
+ /** Machine output is recorded unreviewed, whichever command wrote it. */
242
+ export function recordMachine(memory, proposals, now) {
243
+ for (const proposal of proposals) {
244
+ const byKey = (memory.entries[proposal.locale] ??= {});
245
+ byKey[proposal.key] = {
246
+ sourceHash: hashValue(proposal.source),
247
+ value: proposal.value,
248
+ origin: 'machine',
249
+ reviewed: false,
250
+ updatedAt: now,
251
+ };
252
+ }
253
+ }
254
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import { type Glossary } from './glossary.js';
2
+ import { type Limits } from './lengths.js';
3
+ import { type Memory } from './memory.js';
4
+ import type { Config, Report } from './types.js';
5
+ export declare function check(config: Config, memory?: Memory | null, glossary?: Glossary | null, limits?: Limits | null): Report;
package/dist/check.js ADDED
@@ -0,0 +1,326 @@
1
+ import { containsTerm, expectationsFor, missingVerbatim, } from './glossary.js';
2
+ import { allowanceFor, limitFor, measurable, renderedWidth, } from './lengths.js';
3
+ import { judge } from './memory.js';
4
+ import { diffPlaceholders, extractPlaceholders } from './placeholders.js';
5
+ import { categoriesFor, pipeSegments, pluralGroups, scanIcu, splitPluralSuffix, } from './plurals.js';
6
+ import { loadBundle, sourceIsMsgid } from './scan.js';
7
+ const HAS_LETTER = /\p{L}/u;
8
+ /** gettext plural forms are indexed, not named: base.0, base.1, base.2. */
9
+ const PLURAL_INDEX = /^(.*)\.(\d+)$/;
10
+ function pluralIndexBase(key) {
11
+ const match = PLURAL_INDEX.exec(key);
12
+ return match ? match[1] : null;
13
+ }
14
+ function push(findings, config, rule, locale, key, file, detail) {
15
+ const setting = config.rules[rule];
16
+ if (setting === 'off')
17
+ return;
18
+ findings.push({ rule, severity: setting, locale, key, file, detail });
19
+ }
20
+ function list(categories) {
21
+ return [...categories].join('/');
22
+ }
23
+ /** ICU plural branches must cover the categories the target language actually has. */
24
+ function checkIcuCategories(config, findings, locale, categories, key, file, value) {
25
+ const scan = scanIcu(value);
26
+ if (scan.error) {
27
+ push(findings, config, 'icu_syntax_error', locale, key, file, scan.error);
28
+ return;
29
+ }
30
+ if (!categories)
31
+ return;
32
+ for (const block of scan.blocks) {
33
+ const absent = [...categories].filter((c) => !block.categories.has(c));
34
+ if (absent.length > 0) {
35
+ push(findings, config, 'plural_missing_category', locale, key, file, `${locale} needs ${list(categories)}, has ${list(block.categories) || 'none'}`);
36
+ }
37
+ const extra = [...block.categories].filter((c) => !categories.has(c));
38
+ if (extra.length > 0) {
39
+ push(findings, config, 'plural_extra_category', locale, key, file, `${list(extra)} is not a plural category in ${locale}`);
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * The plural categories a target uses where the source had a plain string, or
45
+ * null when the expansion is something else.
46
+ */
47
+ function pluralExpansion(target, key, categories) {
48
+ if (!categories)
49
+ return null;
50
+ const prefix = `${key}.`;
51
+ const used = new Set();
52
+ for (const candidate of target.leaves.keys()) {
53
+ if (!candidate.startsWith(prefix))
54
+ continue;
55
+ const rest = candidate.slice(prefix.length);
56
+ if (rest.includes('.') || !categories.has(rest))
57
+ return null;
58
+ used.add(rest);
59
+ }
60
+ return used.size > 0 ? used : null;
61
+ }
62
+ function checkLocale(config, source, target, findings, memory, glossary, limits) {
63
+ const before = findings.length;
64
+ for (const bad of target.unreadable) {
65
+ push(findings, config, 'unreadable_file', target.locale, bad.file, bad.file, bad.message);
66
+ }
67
+ const ignore = new Set(config.ignoreIdentical.map((s) => s.toLowerCase()));
68
+ const structuralPrefixes = [];
69
+ // Plural forms differ per language, so a key present in the source is not
70
+ // automatically required here, and a key absent from the source is not
71
+ // automatically dead.
72
+ const categories = categoriesFor(target.locale);
73
+ const sourceGroups = pluralGroups(source.leaves.keys());
74
+ const targetGroups = pluralGroups(target.leaves.keys());
75
+ const laravel = config.placeholderSyntaxes.includes('laravel');
76
+ let translated = 0;
77
+ let missing = 0;
78
+ let orphan = 0;
79
+ let stale = 0;
80
+ let notApplicable = 0;
81
+ // Only collected when the rule is on: one map per locale of source string ->
82
+ // the distinct translations it received.
83
+ const trackWording = config.rules.inconsistent_translation !== 'off';
84
+ const wordings = new Map();
85
+ for (const [key, sourceLeaf] of source.leaves) {
86
+ const targetLeaf = target.leaves.get(key);
87
+ if (!targetLeaf) {
88
+ const indexBase = pluralIndexBase(key);
89
+ if (indexBase && (source.plurals.has(indexBase) || target.plurals.has(indexBase))) {
90
+ // A catalogue carries as many forms as its own language needs.
91
+ notApplicable++;
92
+ continue;
93
+ }
94
+ const suffix = splitPluralSuffix(key);
95
+ if (suffix && sourceGroups.has(suffix.base) && categories && !categories.has(suffix.category)) {
96
+ // e.g. item_one has no counterpart in Japanese, which only has `other`.
97
+ notApplicable++;
98
+ continue;
99
+ }
100
+ const file = target.files[0] ?? target.locale;
101
+ if (target.containers.has(key)) {
102
+ structuralPrefixes.push(key + '.');
103
+ // Arabic turning one English string into zero/one/two/few/many is
104
+ // correct pluralisation, not a structural disagreement — so it is
105
+ // judged as a plural group instead.
106
+ const expanded = pluralExpansion(target, key, categories);
107
+ if (expanded) {
108
+ const absent = [...categories].filter((c) => !expanded.has(c));
109
+ if (absent.length > 0) {
110
+ push(findings, config, 'plural_missing_category', target.locale, `${key}.*`, file, `${target.locale} needs ${list(categories)}, has ${list(expanded)}`);
111
+ }
112
+ continue;
113
+ }
114
+ push(findings, config, 'structure_mismatch', target.locale, key, file, 'value in source, object in target');
115
+ }
116
+ else {
117
+ missing++;
118
+ push(findings, config, 'missing_key', target.locale, key, file, 'not translated');
119
+ }
120
+ continue;
121
+ }
122
+ if (targetLeaf.value.trim() === '' && sourceLeaf.value.trim() !== '') {
123
+ // An empty string is not a translation. gettext spells that as an empty
124
+ // msgstr and i18next as "", and both mean the same as no key at all —
125
+ // so they are one finding, not two ideas wearing different names.
126
+ missing++;
127
+ push(findings, config, 'missing_key', target.locale, key, targetLeaf.file, 'present but empty');
128
+ // Nothing further can be true of an empty string: it has no placeholders
129
+ // to have lost, no plural forms to be missing, no width to overflow.
130
+ continue;
131
+ }
132
+ translated++;
133
+ if (targetLeaf.value === sourceLeaf.value &&
134
+ HAS_LETTER.test(sourceLeaf.value) &&
135
+ !ignore.has(sourceLeaf.value.toLowerCase())) {
136
+ push(findings, config, 'identical_to_source', target.locale, key, targetLeaf.file, `identical to source: "${truncate(sourceLeaf.value)}"`);
137
+ }
138
+ const sourcePlaceholders = extractPlaceholders(sourceLeaf.value, config.placeholderSyntaxes);
139
+ const targetPlaceholders = extractPlaceholders(targetLeaf.value, config.placeholderSyntaxes);
140
+ const { missing: lost, extra } = diffPlaceholders(sourcePlaceholders, targetPlaceholders);
141
+ if (lost.length > 0) {
142
+ push(findings, config, 'placeholder_missing', target.locale, key, targetLeaf.file, `${lost.join(', ')} lost`);
143
+ }
144
+ if (extra.length > 0) {
145
+ push(findings, config, 'placeholder_extra', target.locale, key, targetLeaf.file, `${extra.join(', ')} not in source`);
146
+ }
147
+ if (glossary) {
148
+ const lost = missingVerbatim(glossary, sourceLeaf.value, targetLeaf.value);
149
+ if (lost.length > 0) {
150
+ push(findings, config, 'dnt_violation', target.locale, key, targetLeaf.file, `${lost.join(', ')} must stay verbatim`);
151
+ }
152
+ for (const { term, accepted } of expectationsFor(glossary, target.locale, sourceLeaf.value)) {
153
+ const satisfied = accepted.some((form) => containsTerm(targetLeaf.value, form, term.match, term.caseSensitive));
154
+ if (!satisfied) {
155
+ push(findings, config, 'glossary_violation', target.locale, key, targetLeaf.file, `"${term.source}" should be ${accepted.map((f) => `"${f}"`).join(' or ')}`);
156
+ }
157
+ }
158
+ }
159
+ if (trackWording && HAS_LETTER.test(sourceLeaf.value) && sourceLeaf.value.length >= 4) {
160
+ let variants = wordings.get(sourceLeaf.value);
161
+ if (!variants) {
162
+ variants = new Map();
163
+ wordings.set(sourceLeaf.value, variants);
164
+ }
165
+ if (!variants.has(targetLeaf.value))
166
+ variants.set(targetLeaf.value, key);
167
+ }
168
+ checkLength(config, findings, target.locale, key, targetLeaf.file, limits, laravel, targetLeaf.value, sourceLeaf.value);
169
+ checkIcuCategories(config, findings, target.locale, categories, key, targetLeaf.file, targetLeaf.value);
170
+ // Laravel selects a segment by position or explicit range, which is its own
171
+ // mechanism, not CLDR. The one thing that is unambiguously wrong is losing
172
+ // the selection entirely: trans_choice would then return one string for
173
+ // every count.
174
+ if (laravel && pipeSegments(sourceLeaf.value) > 1 && pipeSegments(targetLeaf.value) === 1) {
175
+ push(findings, config, 'plural_selector_lost', target.locale, key, targetLeaf.file, 'source selects between forms with |, translation has a single form');
176
+ }
177
+ const verdict = judge(memory?.entries[target.locale]?.[key], sourceLeaf.value, targetLeaf.value);
178
+ if (targetLeaf.fuzzy) {
179
+ // gettext already tracks this; no translation memory needed.
180
+ stale++;
181
+ push(findings, config, 'stale', target.locale, key, targetLeaf.file, 'marked fuzzy in the catalogue');
182
+ }
183
+ else if (verdict.stale) {
184
+ stale++;
185
+ push(findings, config, 'stale', target.locale, key, targetLeaf.file, 'source changed after this translation was recorded');
186
+ }
187
+ else if (memory && !verdict.tracked) {
188
+ push(findings, config, 'untracked', target.locale, key, targetLeaf.file, 'no memory entry; run sync');
189
+ }
190
+ }
191
+ for (const [key, leaf] of target.leaves) {
192
+ if (source.leaves.has(key))
193
+ continue;
194
+ // Already reported as a structure mismatch one level up; not a separate orphan.
195
+ if (structuralPrefixes.some((prefix) => key.startsWith(prefix)))
196
+ continue;
197
+ const indexBase = pluralIndexBase(key);
198
+ if (indexBase && (source.plurals.has(indexBase) || target.plurals.has(indexBase)))
199
+ continue;
200
+ const suffix = splitPluralSuffix(key);
201
+ if (suffix && sourceGroups.has(suffix.base) && categories?.has(suffix.category)) {
202
+ // e.g. item_few exists in Polish and not in English. That is the point.
203
+ continue;
204
+ }
205
+ if (source.containers.has(key)) {
206
+ push(findings, config, 'structure_mismatch', target.locale, key, leaf.file, 'object in source, value in target');
207
+ continue;
208
+ }
209
+ orphan++;
210
+ push(findings, config, 'orphan_key', target.locale, key, leaf.file, 'not in source locale');
211
+ }
212
+ // gettext states its own form count in the catalogue header, so this one is
213
+ // internally checkable without consulting CLDR at all.
214
+ if (target.nplurals !== null) {
215
+ for (const [base, forms] of target.plurals) {
216
+ if (forms === target.nplurals)
217
+ continue;
218
+ push(findings, config, 'plural_missing_category', target.locale, base, target.files[0] ?? target.locale, `header declares nplurals=${target.nplurals}, entry has ${forms}`);
219
+ }
220
+ }
221
+ // Suffix-key plural groups, checked per group rather than per key.
222
+ if (categories) {
223
+ for (const base of sourceGroups.keys()) {
224
+ const have = targetGroups.get(base);
225
+ if (!have)
226
+ continue; // the whole group is missing; already reported above
227
+ // Named the way the project writes it: item_one for i18next, item.one for
228
+ // Rails and Symfony.
229
+ const label = `${base}${have.separator}*`;
230
+ const absent = [...categories].filter((c) => !have.categories.has(c));
231
+ if (absent.length > 0) {
232
+ push(findings, config, 'plural_missing_category', target.locale, label, target.files[0] ?? target.locale, `${target.locale} needs ${list(categories)}, has ${list(have.categories)}`);
233
+ }
234
+ const extra = [...have.categories].filter((c) => !categories.has(c));
235
+ if (extra.length > 0) {
236
+ push(findings, config, 'plural_extra_category', target.locale, label, target.files[0] ?? target.locale, `${list(extra)} is not a plural category in ${target.locale}`);
237
+ }
238
+ }
239
+ }
240
+ for (const [sourceValue, variants] of wordings) {
241
+ if (variants.size < 2)
242
+ continue;
243
+ const keys = [...variants.values()];
244
+ push(findings, config, 'inconsistent_translation', target.locale, keys[0], target.files[0] ?? target.locale, `"${truncate(sourceValue)}" is translated ${variants.size} ways, also at ${keys.slice(1).join(', ')}`);
245
+ }
246
+ const added = findings.slice(before);
247
+ const applicable = source.leaves.size - notApplicable;
248
+ return {
249
+ locale: target.locale,
250
+ sourceKeys: source.leaves.size,
251
+ translated,
252
+ // Keys that the target language has no grammatical use for are left out of
253
+ // the denominator, so a complete Japanese locale still reads as 100%.
254
+ coverage: applicable <= 0 ? 1 : translated / applicable,
255
+ missing,
256
+ orphan,
257
+ stale,
258
+ errors: added.filter((f) => f.severity === 'error').length,
259
+ warnings: added.filter((f) => f.severity === 'warning').length,
260
+ };
261
+ }
262
+ /**
263
+ * Text is measured in display columns and only where measuring means something:
264
+ * an ICU plural holds every branch at once but shows one, and Laravel's pipes
265
+ * select between forms, so only the widest segment can ever appear.
266
+ */
267
+ function checkLength(config, findings, locale, key, file, limits, laravel, value, sourceValue) {
268
+ if (!measurable(value))
269
+ return;
270
+ const width = renderedWidth(value, laravel);
271
+ const max = limits ? limitFor(limits, key) : null;
272
+ if (max !== null && width > max) {
273
+ push(findings, config, 'length_over_max', locale, key, file, `${width} columns, limit ${max}`);
274
+ }
275
+ if (sourceValue === null || !measurable(sourceValue))
276
+ return;
277
+ const sourceWidth = renderedWidth(sourceValue, laravel);
278
+ if (sourceWidth === 0)
279
+ return;
280
+ const allowed = allowanceFor(sourceWidth);
281
+ if (width > sourceWidth * allowed) {
282
+ const percent = Math.round((width / sourceWidth) * 100);
283
+ push(findings, config, 'length_overflow', locale, key, file, `${width} columns vs ${sourceWidth} in source — ${percent}%, allowance ${Math.round(allowed * 100)}%`);
284
+ }
285
+ }
286
+ /** A broken or oversized source string is broken for every language. */
287
+ function checkSource(config, source, findings, limits) {
288
+ for (const bad of source.unreadable) {
289
+ push(findings, config, 'unreadable_file', source.locale, bad.file, bad.file, bad.message);
290
+ }
291
+ const categories = categoriesFor(source.locale);
292
+ const laravel = config.placeholderSyntaxes.includes('laravel');
293
+ for (const [key, leaf] of source.leaves) {
294
+ checkIcuCategories(config, findings, source.locale, categories, key, leaf.file, leaf.value);
295
+ checkLength(config, findings, source.locale, key, leaf.file, limits, laravel, leaf.value, null);
296
+ }
297
+ }
298
+ function truncate(value, max = 40) {
299
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
300
+ }
301
+ const SEVERITY_ORDER = { error: 0, warning: 1 };
302
+ export function check(config, memory = null, glossary = null, limits = null) {
303
+ const source = loadBundle(config, config.sourceLocale);
304
+ const findings = [];
305
+ const stats = [];
306
+ checkSource(config, source, findings, limits);
307
+ for (const locale of config.locales) {
308
+ if (locale === config.sourceLocale)
309
+ continue;
310
+ stats.push(checkLocale(config, source, loadBundle(config, locale), findings, memory, glossary, limits));
311
+ }
312
+ findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
313
+ a.locale.localeCompare(b.locale) ||
314
+ a.key.localeCompare(b.key) ||
315
+ a.rule.localeCompare(b.rule));
316
+ return {
317
+ localesDir: config.localesDir,
318
+ sourceLocale: config.sourceLocale,
319
+ sourceKeys: source.leaves.size,
320
+ sourceFromMsgid: sourceIsMsgid(source),
321
+ memoryLoaded: memory !== null,
322
+ stats,
323
+ findings,
324
+ };
325
+ }
326
+ //# sourceMappingURL=check.js.map