@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/dist/apply.d.ts
ADDED
|
@@ -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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"}
|
package/dist/check.d.ts
ADDED
|
@@ -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
|