@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/apply.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
import type { Glossary } from './glossary.js';
|
|
4
|
+
import { limitFor, type Limits } from './lengths.js';
|
|
5
|
+
import { hashValue, type Memory } from './memory.js';
|
|
6
|
+
import type { Config, LocaleBundle } from './types.js';
|
|
7
|
+
import { writeJsonLocale } from './formats/json-write.js';
|
|
8
|
+
import { blankPhpFile, writePhpLocale } from './formats/php-write.js';
|
|
9
|
+
import { writePoLocale } from './formats/po-write.js';
|
|
10
|
+
import { writeYamlLocale } from './formats/yaml-write.js';
|
|
11
|
+
import type { Edit, LocaleWriter } from './formats/write.js';
|
|
12
|
+
import { validate, type Proposal } from './translate.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applying accepted translations to locale files.
|
|
16
|
+
*
|
|
17
|
+
* Each format has its own writer, and none of them re-serialises a parsed tree:
|
|
18
|
+
* comments, quote styles, anchors and translator notes have to survive, so the
|
|
19
|
+
* writers edit text in place. What this module decides is which file a key
|
|
20
|
+
* belongs to, and whether a file that does not exist yet can be created — which
|
|
21
|
+
* only JSON and PHP can, since a YAML file's shape and a gettext header cannot
|
|
22
|
+
* be invented.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class ApplyError extends Error {}
|
|
26
|
+
|
|
27
|
+
export interface Destination {
|
|
28
|
+
file: string;
|
|
29
|
+
/** The key as that file spells it, with any file-level namespace removed. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** The source-locale file this one mirrors, used when creating it. */
|
|
32
|
+
sourceFile: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WRITERS = new Map<string, LocaleWriter>([
|
|
36
|
+
['.json', writeJsonLocale],
|
|
37
|
+
['.php', writePhpLocale],
|
|
38
|
+
['.yaml', writeYamlLocale],
|
|
39
|
+
['.yml', writeYamlLocale],
|
|
40
|
+
['.pot', writePoLocale],
|
|
41
|
+
['.po', writePoLocale],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An empty file for a locale that has none yet, or null when its shape cannot
|
|
46
|
+
* be known. A YAML file depends on whether the project nests under a locale
|
|
47
|
+
* root, and a gettext catalogue needs a header declaring the language's own
|
|
48
|
+
* plural rules — neither can be invented.
|
|
49
|
+
*/
|
|
50
|
+
function blankFileFor(extension: string, sourceFile: string): string | null {
|
|
51
|
+
if (extension === '.json') return '{}\n';
|
|
52
|
+
if (extension === '.php') {
|
|
53
|
+
const source = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf8') : null;
|
|
54
|
+
return blankPhpFile(source);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extensionOf(file: string): string {
|
|
60
|
+
const at = file.lastIndexOf('.');
|
|
61
|
+
return at === -1 ? '' : file.slice(at).toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function namespaceOf(config: Config, sourceFile: string): string {
|
|
65
|
+
const localeRoot = join(config.localesDir, config.sourceLocale);
|
|
66
|
+
return relative(localeRoot, sourceFile)
|
|
67
|
+
.replace(/\.[^.]+$/, '')
|
|
68
|
+
.split(sep)
|
|
69
|
+
.filter((part) => part !== 'LC_MESSAGES')
|
|
70
|
+
.join('.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which source file a key belongs to.
|
|
75
|
+
*
|
|
76
|
+
* Not every key the target needs exists in the source: a plural form only the
|
|
77
|
+
* target language has — Polish `items.few` against English `one`/`other` — has
|
|
78
|
+
* no source leaf at all. Such a key still belongs wherever its neighbours live,
|
|
79
|
+
* so the lookup walks up to the nearest prefix that does exist.
|
|
80
|
+
*/
|
|
81
|
+
function sourceFileFor(source: LocaleBundle, key: string): string | null {
|
|
82
|
+
const leaf = source.leaves.get(key);
|
|
83
|
+
if (leaf) return leaf.file;
|
|
84
|
+
|
|
85
|
+
const parts = key.split('.');
|
|
86
|
+
for (let depth = parts.length - 1; depth > 0; depth--) {
|
|
87
|
+
const prefix = `${parts.slice(0, depth).join('.')}.`;
|
|
88
|
+
for (const [candidate, candidateLeaf] of source.leaves) {
|
|
89
|
+
if (candidate.startsWith(prefix)) return candidateLeaf.file;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// i18next spells plurals as item_few rather than item.few.
|
|
94
|
+
const underscore = key.lastIndexOf('_');
|
|
95
|
+
if (underscore > 0) {
|
|
96
|
+
const base = `${key.slice(0, underscore)}_`;
|
|
97
|
+
for (const [candidate, candidateLeaf] of source.leaves) {
|
|
98
|
+
if (candidate.startsWith(base)) return candidateLeaf.file;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return source.files[0] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Where a translation for `key` belongs, or null when the format is unknown. */
|
|
106
|
+
export function destinationFor(
|
|
107
|
+
config: Config,
|
|
108
|
+
source: LocaleBundle,
|
|
109
|
+
locale: string,
|
|
110
|
+
key: string,
|
|
111
|
+
): Destination | null {
|
|
112
|
+
const from = sourceFileFor(source, key);
|
|
113
|
+
if (!from) return null;
|
|
114
|
+
|
|
115
|
+
const extension = extensionOf(from);
|
|
116
|
+
if (!WRITERS.has(extension)) return null;
|
|
117
|
+
|
|
118
|
+
if (config.layout === 'flat') {
|
|
119
|
+
return { file: join(config.localesDir, `${locale}${extension}`), key, sourceFile: from };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const localeRoot = join(config.localesDir, config.sourceLocale);
|
|
123
|
+
const withinLocale = relative(localeRoot, from);
|
|
124
|
+
const namespace = namespaceOf(config, from);
|
|
125
|
+
const inner = namespace && key.startsWith(`${namespace}.`) ? key.slice(namespace.length + 1) : key;
|
|
126
|
+
|
|
127
|
+
return { file: join(config.localesDir, locale, withinLocale), key: inner, sourceFile: from };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ApplyResult {
|
|
131
|
+
written: number;
|
|
132
|
+
files: string[];
|
|
133
|
+
/** Translations that could not be written, and why. */
|
|
134
|
+
skipped: Array<{ locale: string; key: string; reason: string }>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function applyProposals(
|
|
138
|
+
config: Config,
|
|
139
|
+
source: LocaleBundle,
|
|
140
|
+
proposals: Proposal[],
|
|
141
|
+
): ApplyResult {
|
|
142
|
+
const result: ApplyResult = { written: 0, files: [], skipped: [] };
|
|
143
|
+
const byFile = new Map<string, { locale: string; sourceFile: string; edits: Edit[] }>();
|
|
144
|
+
|
|
145
|
+
for (const proposal of proposals) {
|
|
146
|
+
if (!proposal.accepted) continue;
|
|
147
|
+
|
|
148
|
+
const destination = destinationFor(config, source, proposal.locale, proposal.key);
|
|
149
|
+
if (!destination) {
|
|
150
|
+
result.skipped.push({
|
|
151
|
+
locale: proposal.locale,
|
|
152
|
+
key: proposal.key,
|
|
153
|
+
reason: 'no writer for this format',
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bucket = byFile.get(destination.file);
|
|
159
|
+
const edit: Edit = { key: destination.key, value: proposal.value };
|
|
160
|
+
if (bucket) bucket.edits.push(edit);
|
|
161
|
+
else {
|
|
162
|
+
byFile.set(destination.file, {
|
|
163
|
+
locale: proposal.locale,
|
|
164
|
+
sourceFile: destination.sourceFile,
|
|
165
|
+
edits: [edit],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const [file, { locale, sourceFile, edits }] of byFile) {
|
|
171
|
+
const extension = extensionOf(file);
|
|
172
|
+
const writer = WRITERS.get(extension)!;
|
|
173
|
+
|
|
174
|
+
let content: string;
|
|
175
|
+
if (existsSync(file)) {
|
|
176
|
+
content = readFileSync(file, 'utf8');
|
|
177
|
+
} else {
|
|
178
|
+
const blank = blankFileFor(extension, sourceFile);
|
|
179
|
+
if (blank === null) {
|
|
180
|
+
for (const edit of edits) {
|
|
181
|
+
result.skipped.push({
|
|
182
|
+
locale,
|
|
183
|
+
key: edit.key,
|
|
184
|
+
reason: `${file} does not exist and this format cannot be created from nothing`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
content = blank;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let outcome;
|
|
193
|
+
try {
|
|
194
|
+
outcome = writer(content, file, locale, edits);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
throw new ApplyError(
|
|
197
|
+
`Cannot write ${file}\n ${err instanceof Error ? err.message : String(err)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const skip of outcome.skipped) {
|
|
202
|
+
result.skipped.push({ locale, key: skip.key, reason: skip.reason });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
206
|
+
writeFileSync(file, outcome.content);
|
|
207
|
+
result.files.push(file);
|
|
208
|
+
result.written += edits.length - outcome.skipped.length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Saved runs
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export const SAVE_VERSION = 1;
|
|
219
|
+
|
|
220
|
+
export interface SavedRun {
|
|
221
|
+
version: number;
|
|
222
|
+
model: string;
|
|
223
|
+
sourceLocale: string;
|
|
224
|
+
createdAt: string;
|
|
225
|
+
aborted: string | null;
|
|
226
|
+
proposals: Proposal[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function saveRun(file: string, run: SavedRun): void {
|
|
230
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
231
|
+
writeFileSync(file, `${JSON.stringify(run, null, 2)}\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function loadRun(file: string): SavedRun {
|
|
235
|
+
if (!existsSync(file)) throw new ApplyError(`Proposals file not found: ${file}`);
|
|
236
|
+
|
|
237
|
+
let parsed: unknown;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw new ApplyError(
|
|
242
|
+
`Cannot read ${file}\n ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const run = parsed as Partial<SavedRun> | null;
|
|
247
|
+
if (!run || run.version !== SAVE_VERSION) {
|
|
248
|
+
throw new ApplyError(`Unrecognised proposals file ${file} (expected version ${SAVE_VERSION})`);
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(run.proposals)) throw new ApplyError(`${file} has no proposals array`);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
version: SAVE_VERSION,
|
|
254
|
+
model: run.model ?? 'unknown',
|
|
255
|
+
sourceLocale: run.sourceLocale ?? '',
|
|
256
|
+
createdAt: run.createdAt ?? '',
|
|
257
|
+
aborted: run.aborted ?? null,
|
|
258
|
+
proposals: run.proposals,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface Recheck {
|
|
263
|
+
ready: Proposal[];
|
|
264
|
+
dropped: Array<{ proposal: Proposal; reason: string }>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The gate runs again at apply time.
|
|
269
|
+
*
|
|
270
|
+
* A saved file can be days old and is editable by hand, so nothing is written
|
|
271
|
+
* on the strength of a check made earlier against files that may since have
|
|
272
|
+
* moved. A proposal whose source string has changed is dropped rather than
|
|
273
|
+
* applied to text it was not written for.
|
|
274
|
+
*/
|
|
275
|
+
export function recheck(
|
|
276
|
+
config: Config,
|
|
277
|
+
source: LocaleBundle,
|
|
278
|
+
glossary: Glossary | null,
|
|
279
|
+
limits: Limits | null,
|
|
280
|
+
proposals: Proposal[],
|
|
281
|
+
): Recheck {
|
|
282
|
+
const ready: Proposal[] = [];
|
|
283
|
+
const dropped: Array<{ proposal: Proposal; reason: string }> = [];
|
|
284
|
+
|
|
285
|
+
for (const proposal of proposals) {
|
|
286
|
+
if (!proposal.accepted) {
|
|
287
|
+
dropped.push({ proposal, reason: 'was rejected when proposed' });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const leaf = source.leaves.get(proposal.key);
|
|
292
|
+
if (!leaf) {
|
|
293
|
+
dropped.push({ proposal, reason: 'the key is no longer in the source locale' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (leaf.value !== proposal.source) {
|
|
297
|
+
dropped.push({ proposal, reason: 'the source string changed after the proposal was made' });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const problems = validate(
|
|
302
|
+
config,
|
|
303
|
+
{
|
|
304
|
+
source: leaf.value,
|
|
305
|
+
locale: proposal.locale,
|
|
306
|
+
maxWidth: limits ? limitFor(limits, proposal.key) : null,
|
|
307
|
+
},
|
|
308
|
+
proposal.value,
|
|
309
|
+
glossary,
|
|
310
|
+
);
|
|
311
|
+
if (problems.length > 0) {
|
|
312
|
+
dropped.push({ proposal, reason: problems.join('; ') });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
ready.push(proposal);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { ready, dropped };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Machine output is recorded unreviewed, whichever command wrote it. */
|
|
323
|
+
export function recordMachine(memory: Memory, proposals: Proposal[], now: string): void {
|
|
324
|
+
for (const proposal of proposals) {
|
|
325
|
+
const byKey = (memory.entries[proposal.locale] ??= {});
|
|
326
|
+
byKey[proposal.key] = {
|
|
327
|
+
sourceHash: hashValue(proposal.source),
|
|
328
|
+
value: proposal.value,
|
|
329
|
+
origin: 'machine',
|
|
330
|
+
reviewed: false,
|
|
331
|
+
updatedAt: now,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|