@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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { poKey, parsePo } from './po.js';
|
|
2
|
+
import type { Edit, WriteOutcome } from './write.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* gettext catalogues, edited line by line.
|
|
6
|
+
*
|
|
7
|
+
* One deliberate choice beyond preserving the file: a translation written here
|
|
8
|
+
* is marked `#, fuzzy`. That flag is gettext's own word for "not reviewed by a
|
|
9
|
+
* person yet", which is exactly what this tool records as `reviewed: false` in
|
|
10
|
+
* the memory. Leaving it off would claim a human had approved the string.
|
|
11
|
+
* It does mean `check` then reports those entries as stale — correctly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ESCAPES = new Map<number, string>([
|
|
15
|
+
[0x0a, '\\n'],
|
|
16
|
+
[0x0d, '\\r'],
|
|
17
|
+
[0x09, '\\t'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function poString(value: string): string {
|
|
21
|
+
let out = '';
|
|
22
|
+
for (const ch of value) {
|
|
23
|
+
const cp = ch.codePointAt(0)!;
|
|
24
|
+
if (ch === '\\' || ch === '"') out += `\\${ch}`;
|
|
25
|
+
else if (cp < 0x20) out += ESCAPES.get(cp) ?? `\\x${cp.toString(16).padStart(2, '0')}`;
|
|
26
|
+
else out += ch;
|
|
27
|
+
}
|
|
28
|
+
return `"${out}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Block {
|
|
32
|
+
/** Index of the `#,` flag line, if the entry has one. */
|
|
33
|
+
flagLine: number | null;
|
|
34
|
+
/** First line of the entry proper, where a flag line can go before it. */
|
|
35
|
+
headLine: number;
|
|
36
|
+
/** msgstr index -> the lines it occupies, [start, end). */
|
|
37
|
+
msgstr: Map<number, { start: number; end: number }>;
|
|
38
|
+
/** True for the catalogue header, which carries no translation. */
|
|
39
|
+
headerLike: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KEYWORD = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?(.*)$/;
|
|
43
|
+
const EMPTY_STRING = /^\s*""\s*$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lines up each entry in the text with the key the parser resolved for it.
|
|
47
|
+
*
|
|
48
|
+
* The parser drops the header entry, so the scanner has to drop it too or every
|
|
49
|
+
* key lands one entry early — which is exactly the bug this shape prevents.
|
|
50
|
+
*/
|
|
51
|
+
function scan(content: string, file: string): { lines: string[]; blocks: Map<string, Block> } {
|
|
52
|
+
const lines = content.split(/\r?\n/);
|
|
53
|
+
const keysInOrder = parsePo(content, file).entries.map(poKey);
|
|
54
|
+
const blocks = new Map<string, Block>();
|
|
55
|
+
|
|
56
|
+
let current: Block | null = null;
|
|
57
|
+
let msgidEmpty = true;
|
|
58
|
+
let inMsgid = false;
|
|
59
|
+
let lastMsgstr: number | null = null;
|
|
60
|
+
let obsolete = false;
|
|
61
|
+
let entryIndex = 0;
|
|
62
|
+
|
|
63
|
+
const close = (): void => {
|
|
64
|
+
if (current && current.msgstr.size > 0 && !obsolete) {
|
|
65
|
+
current.headerLike = msgidEmpty;
|
|
66
|
+
if (!msgidEmpty) {
|
|
67
|
+
const key = keysInOrder[entryIndex];
|
|
68
|
+
if (key !== undefined) blocks.set(key, current);
|
|
69
|
+
entryIndex++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
current = null;
|
|
73
|
+
msgidEmpty = true;
|
|
74
|
+
inMsgid = false;
|
|
75
|
+
lastMsgstr = null;
|
|
76
|
+
obsolete = false;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const [index, raw] of lines.entries()) {
|
|
80
|
+
const line = raw.trim();
|
|
81
|
+
|
|
82
|
+
if (line === '') {
|
|
83
|
+
close();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (line.startsWith('#~')) {
|
|
87
|
+
obsolete = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith('#')) {
|
|
91
|
+
if (line.startsWith('#,')) {
|
|
92
|
+
current ??= { flagLine: null, headLine: index, msgstr: new Map(), headerLike: false };
|
|
93
|
+
current.flagLine = index;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const match = KEYWORD.exec(line);
|
|
99
|
+
if (match) {
|
|
100
|
+
if (!current) current = { flagLine: null, headLine: index, msgstr: new Map(), headerLike: false };
|
|
101
|
+
else if (current.msgstr.size === 0 && current.flagLine === null) {
|
|
102
|
+
current.headLine = Math.min(current.headLine, index);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (match[1] === 'msgstr') {
|
|
106
|
+
const at = match[2] === undefined ? 0 : Number.parseInt(match[2], 10);
|
|
107
|
+
current.msgstr.set(at, { start: index, end: index + 1 });
|
|
108
|
+
lastMsgstr = at;
|
|
109
|
+
inMsgid = false;
|
|
110
|
+
} else {
|
|
111
|
+
if (match[1] === 'msgid') {
|
|
112
|
+
inMsgid = true;
|
|
113
|
+
msgidEmpty = EMPTY_STRING.test(match[3] ?? '');
|
|
114
|
+
} else {
|
|
115
|
+
inMsgid = false;
|
|
116
|
+
}
|
|
117
|
+
lastMsgstr = null;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (line.startsWith('"') && current) {
|
|
123
|
+
if (lastMsgstr !== null) {
|
|
124
|
+
current.msgstr.get(lastMsgstr)!.end = index + 1;
|
|
125
|
+
} else if (inMsgid && !EMPTY_STRING.test(line)) {
|
|
126
|
+
// A msgid spelled across continuation lines is not the empty header.
|
|
127
|
+
msgidEmpty = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
close();
|
|
132
|
+
|
|
133
|
+
return { lines, blocks };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitForm(key: string): { base: string; form: number | null } {
|
|
137
|
+
const match = /^(.*)\.(\d+)$/.exec(key);
|
|
138
|
+
return match
|
|
139
|
+
? { base: match[1]!, form: Number.parseInt(match[2]!, 10) }
|
|
140
|
+
: { base: key, form: null };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A new entry has to be spelled back out as msgctxt + msgid. The key format
|
|
145
|
+
* joins them with `|`, which a msgid could itself contain; splitting at the
|
|
146
|
+
* first one matches how the key was built and is wrong only for a contextless
|
|
147
|
+
* msgid that happens to contain a pipe.
|
|
148
|
+
*/
|
|
149
|
+
function appendEntry(key: string, value: string): string[] {
|
|
150
|
+
const pipe = key.indexOf('|');
|
|
151
|
+
const head =
|
|
152
|
+
pipe > 0
|
|
153
|
+
? [`msgctxt ${poString(key.slice(0, pipe))}`, `msgid ${poString(key.slice(pipe + 1))}`]
|
|
154
|
+
: [`msgid ${poString(key)}`];
|
|
155
|
+
return ['', '#, fuzzy', ...head, `msgstr ${poString(value)}`];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function writePoLocale(
|
|
159
|
+
content: string,
|
|
160
|
+
file: string,
|
|
161
|
+
_locale: string,
|
|
162
|
+
edits: Edit[],
|
|
163
|
+
): WriteOutcome {
|
|
164
|
+
const { lines, blocks } = scan(content, file);
|
|
165
|
+
const skipped: Array<{ key: string; reason: string }> = [];
|
|
166
|
+
|
|
167
|
+
// line index -> replacement lines, or null to drop the line.
|
|
168
|
+
const replaced = new Map<number, string[] | null>();
|
|
169
|
+
const needFuzzy = new Set<Block>();
|
|
170
|
+
const appended: string[] = [];
|
|
171
|
+
|
|
172
|
+
for (const edit of edits) {
|
|
173
|
+
let block = blocks.get(edit.key);
|
|
174
|
+
let form = 0;
|
|
175
|
+
|
|
176
|
+
if (!block) {
|
|
177
|
+
const split = splitForm(edit.key);
|
|
178
|
+
if (split.form !== null) {
|
|
179
|
+
const candidate = blocks.get(split.base);
|
|
180
|
+
if (candidate) {
|
|
181
|
+
block = candidate;
|
|
182
|
+
form = split.form;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!block) {
|
|
188
|
+
appended.push(...appendEntry(edit.key, edit.value));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const span = block.msgstr.get(form);
|
|
193
|
+
if (!span) {
|
|
194
|
+
skipped.push({ key: edit.key, reason: `the entry has no msgstr[${form}]` });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const label = block.msgstr.size > 1 ? `msgstr[${form}]` : 'msgstr';
|
|
199
|
+
replaced.set(span.start, [`${label} ${poString(edit.value)}`]);
|
|
200
|
+
for (let line = span.start + 1; line < span.end; line++) replaced.set(line, null);
|
|
201
|
+
needFuzzy.add(block);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const block of needFuzzy) {
|
|
205
|
+
if (block.flagLine !== null) {
|
|
206
|
+
const flags = lines[block.flagLine]!;
|
|
207
|
+
if (!/\bfuzzy\b/.test(flags)) replaced.set(block.flagLine, [`${flags}, fuzzy`]);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const head: string[] = replaced.get(block.headLine) ?? [lines[block.headLine]!];
|
|
211
|
+
replaced.set(block.headLine, ['#, fuzzy', ...head]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const out: string[] = [];
|
|
215
|
+
for (const [index, line] of lines.entries()) {
|
|
216
|
+
const replacement = replaced.get(index);
|
|
217
|
+
if (replacement === undefined) out.push(line);
|
|
218
|
+
else if (replacement !== null) out.push(...replacement);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (appended.length > 0) {
|
|
222
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
223
|
+
out.push(...appended, '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A catalogue checked out on Windows may use CRLF; writing back with bare
|
|
227
|
+
// newlines would leave the file mixed.
|
|
228
|
+
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
229
|
+
return { content: out.join(newline), skipped };
|
|
230
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { ReadTarget } from '../types.js';
|
|
3
|
+
import { FormatError } from './error.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* gettext .po / .pot.
|
|
7
|
+
*
|
|
8
|
+
* Two things make this format different from the others: the msgid *is* the
|
|
9
|
+
* source text, so a .pot with empty translations is a usable source locale; and
|
|
10
|
+
* the format already has a notion of an outdated translation — the fuzzy flag —
|
|
11
|
+
* which maps onto the same rule the translation memory feeds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class PoParseError extends FormatError {
|
|
15
|
+
constructor(file: string, line: number, message: string) {
|
|
16
|
+
super(file, message, line);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PoEntry {
|
|
21
|
+
context: string | null;
|
|
22
|
+
msgid: string;
|
|
23
|
+
msgidPlural: string | null;
|
|
24
|
+
msgstr: string[];
|
|
25
|
+
fuzzy: boolean;
|
|
26
|
+
line: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PoFile {
|
|
30
|
+
entries: PoEntry[];
|
|
31
|
+
/** From the header's Plural-Forms, when present. */
|
|
32
|
+
nplurals: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SIMPLE_ESCAPES: Record<string, string> = {
|
|
36
|
+
n: '\n',
|
|
37
|
+
t: '\t',
|
|
38
|
+
r: '\r',
|
|
39
|
+
a: '\x07',
|
|
40
|
+
b: '\b',
|
|
41
|
+
f: '\f',
|
|
42
|
+
v: '\v',
|
|
43
|
+
'"': '"',
|
|
44
|
+
'\\': '\\',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function unescape(raw: string): string {
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < raw.length; i++) {
|
|
50
|
+
const ch = raw[i]!;
|
|
51
|
+
if (ch !== '\\') {
|
|
52
|
+
out += ch;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const next = raw[i + 1];
|
|
56
|
+
if (next === undefined) {
|
|
57
|
+
out += ch;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (next in SIMPLE_ESCAPES) {
|
|
61
|
+
out += SIMPLE_ESCAPES[next]!;
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (next === 'x') {
|
|
66
|
+
const hex = /^[0-9A-Fa-f]{1,2}/.exec(raw.slice(i + 2));
|
|
67
|
+
if (hex) {
|
|
68
|
+
out += String.fromCharCode(Number.parseInt(hex[0], 16));
|
|
69
|
+
i += 1 + hex[0].length;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const octal = /^[0-7]{1,3}/.exec(raw.slice(i + 1));
|
|
74
|
+
if (octal) {
|
|
75
|
+
out += String.fromCharCode(Number.parseInt(octal[0], 8) & 0xff);
|
|
76
|
+
i += octal[0].length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
out += next;
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A quoted chunk, as it appears after a keyword or on a continuation line. */
|
|
86
|
+
function readQuoted(file: string, line: number, text: string): string {
|
|
87
|
+
const trimmed = text.trim();
|
|
88
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"') || trimmed.length < 2) {
|
|
89
|
+
throw new PoParseError(file, line, 'Expected a quoted string');
|
|
90
|
+
}
|
|
91
|
+
return unescape(trimmed.slice(1, -1));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const KEYWORD = /^(msgctxt|msgid_plural|msgid|msgstr)(\[(\d+)\])?\s*(.*)$/;
|
|
95
|
+
|
|
96
|
+
export function parsePo(text: string, file: string): PoFile {
|
|
97
|
+
const entries: PoEntry[] = [];
|
|
98
|
+
const lines = text.split(/\r?\n/);
|
|
99
|
+
|
|
100
|
+
// Assignments stay in the loop body rather than in closures, so the compiler
|
|
101
|
+
// can follow whether an entry is open.
|
|
102
|
+
let current: PoEntry | null = null;
|
|
103
|
+
let field: 'msgctxt' | 'msgid' | 'msgid_plural' | null = null;
|
|
104
|
+
let msgstrIndex: number | null = null;
|
|
105
|
+
let fuzzy = false;
|
|
106
|
+
let seenKeyword = false;
|
|
107
|
+
|
|
108
|
+
for (const [index, rawLine] of lines.entries()) {
|
|
109
|
+
const lineNo = index + 1;
|
|
110
|
+
const line = rawLine.trim();
|
|
111
|
+
|
|
112
|
+
// A blank line ends an entry; #~ marks one already removed from the
|
|
113
|
+
// catalogue, so its block is dropped rather than parsed.
|
|
114
|
+
if (line === '' || line.startsWith('#~')) {
|
|
115
|
+
if (current && seenKeyword) entries.push(current);
|
|
116
|
+
current = null;
|
|
117
|
+
field = null;
|
|
118
|
+
msgstrIndex = null;
|
|
119
|
+
fuzzy = false;
|
|
120
|
+
seenKeyword = false;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (line.startsWith('#')) {
|
|
125
|
+
if (line.startsWith('#,')) {
|
|
126
|
+
const flags = line
|
|
127
|
+
.slice(2)
|
|
128
|
+
.split(',')
|
|
129
|
+
.map((f) => f.trim());
|
|
130
|
+
if (flags.includes('fuzzy')) {
|
|
131
|
+
fuzzy = true;
|
|
132
|
+
if (current) current.fuzzy = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const match = KEYWORD.exec(line);
|
|
139
|
+
if (match) {
|
|
140
|
+
if (!current) {
|
|
141
|
+
current = { context: null, msgid: '', msgidPlural: null, msgstr: [], fuzzy, line: lineNo };
|
|
142
|
+
}
|
|
143
|
+
const entry = current;
|
|
144
|
+
const keyword = match[1]!;
|
|
145
|
+
seenKeyword = true;
|
|
146
|
+
const value = readQuoted(file, lineNo, match[4] || '""');
|
|
147
|
+
|
|
148
|
+
if (keyword === 'msgstr') {
|
|
149
|
+
const at = match[3] === undefined ? 0 : Number.parseInt(match[3], 10);
|
|
150
|
+
entry.msgstr[at] = value;
|
|
151
|
+
msgstrIndex = at;
|
|
152
|
+
field = null;
|
|
153
|
+
} else if (keyword === 'msgctxt') {
|
|
154
|
+
entry.context = value;
|
|
155
|
+
field = 'msgctxt';
|
|
156
|
+
msgstrIndex = null;
|
|
157
|
+
} else if (keyword === 'msgid') {
|
|
158
|
+
entry.msgid = value;
|
|
159
|
+
field = 'msgid';
|
|
160
|
+
msgstrIndex = null;
|
|
161
|
+
} else {
|
|
162
|
+
entry.msgidPlural = value;
|
|
163
|
+
field = 'msgid_plural';
|
|
164
|
+
msgstrIndex = null;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (line.startsWith('"')) {
|
|
170
|
+
if (!current) throw new PoParseError(file, lineNo, 'Continuation without a preceding keyword');
|
|
171
|
+
const entry = current;
|
|
172
|
+
const value = readQuoted(file, lineNo, line);
|
|
173
|
+
if (msgstrIndex !== null) entry.msgstr[msgstrIndex] = (entry.msgstr[msgstrIndex] ?? '') + value;
|
|
174
|
+
else if (field === 'msgctxt') entry.context = (entry.context ?? '') + value;
|
|
175
|
+
else if (field === 'msgid') entry.msgid += value;
|
|
176
|
+
else if (field === 'msgid_plural') entry.msgidPlural = (entry.msgidPlural ?? '') + value;
|
|
177
|
+
else throw new PoParseError(file, lineNo, 'Continuation without a preceding keyword');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw new PoParseError(file, lineNo, `Unexpected line: ${line.slice(0, 40)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (current && seenKeyword) entries.push(current);
|
|
185
|
+
|
|
186
|
+
let nplurals: number | null = null;
|
|
187
|
+
const header = entries.find((e) => e.msgid === '' && e.context === null);
|
|
188
|
+
if (header) {
|
|
189
|
+
const declared = /nplurals\s*=\s*(\d+)/.exec(header.msgstr[0] ?? '');
|
|
190
|
+
if (declared) nplurals = Number.parseInt(declared[1]!, 10);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { entries: entries.filter((e) => e.msgid !== '' || e.context !== null), nplurals };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** msgctxt disambiguates an otherwise identical msgid. */
|
|
197
|
+
export function poKey(entry: PoEntry): string {
|
|
198
|
+
return entry.context === null ? entry.msgid : `${entry.context}|${entry.msgid}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function readPoLocale(
|
|
202
|
+
file: string,
|
|
203
|
+
namespace: string,
|
|
204
|
+
_locale: string,
|
|
205
|
+
isSource: boolean,
|
|
206
|
+
target: ReadTarget,
|
|
207
|
+
): void {
|
|
208
|
+
const parsed = parsePo(readFileSync(file, 'utf8'), file);
|
|
209
|
+
if (parsed.nplurals !== null) target.nplurals = parsed.nplurals;
|
|
210
|
+
|
|
211
|
+
for (const entry of parsed.entries) {
|
|
212
|
+
const base = namespace ? `${namespace}.${poKey(entry)}` : poKey(entry);
|
|
213
|
+
|
|
214
|
+
if (entry.msgidPlural === null) {
|
|
215
|
+
// The msgid is the source text by definition, whatever this catalogue
|
|
216
|
+
// has translated it to.
|
|
217
|
+
const value = isSource ? entry.msgid : (entry.msgstr[0] ?? '');
|
|
218
|
+
target.leaves.set(base, { value, kind: 'string', file, fuzzy: entry.fuzzy });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sourceForms = [entry.msgid, entry.msgidPlural];
|
|
223
|
+
const count = Math.max(entry.msgstr.length, isSource ? sourceForms.length : 1);
|
|
224
|
+
target.plurals.set(base, entry.msgstr.length);
|
|
225
|
+
target.containers.add(base);
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < count; i++) {
|
|
228
|
+
const translated = entry.msgstr[i] ?? '';
|
|
229
|
+
const value = isSource ? sourceForms[Math.min(i, 1)]! : translated;
|
|
230
|
+
target.leaves.set(`${base}.${i}`, { value, kind: 'string', file, fuzzy: entry.fuzzy });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing back into a locale file.
|
|
3
|
+
*
|
|
4
|
+
* Reading throws away everything that is not a key or a value: comments,
|
|
5
|
+
* quote styles, blank lines, anchors, translator notes. Writing must not, so
|
|
6
|
+
* none of these writers re-serialise a parsed tree. They edit the text in
|
|
7
|
+
* place — replacing the exact span of one value, or inserting one entry —
|
|
8
|
+
* and leave every byte they did not have a reason to touch.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Edit {
|
|
12
|
+
/** Key relative to this file, with any file-level namespace already removed. */
|
|
13
|
+
key: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WriteOutcome {
|
|
18
|
+
content: string;
|
|
19
|
+
/** Edits the format could not take, and why. */
|
|
20
|
+
skipped: Array<{ key: string; reason: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LocaleWriter = (
|
|
24
|
+
content: string,
|
|
25
|
+
file: string,
|
|
26
|
+
locale: string,
|
|
27
|
+
edits: Edit[],
|
|
28
|
+
) => WriteOutcome;
|
|
29
|
+
|
|
30
|
+
/** Applies text edits from the end backwards, so earlier offsets stay valid. */
|
|
31
|
+
export function spliceAll(
|
|
32
|
+
content: string,
|
|
33
|
+
edits: Array<{ start: number; end: number; text: string }>,
|
|
34
|
+
): string {
|
|
35
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start);
|
|
36
|
+
let out = content;
|
|
37
|
+
for (const edit of ordered) {
|
|
38
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isAlias, isScalar, parseDocument } from 'yaml';
|
|
2
|
+
import { FormatError } from './error.js';
|
|
3
|
+
import { localeRootKey } from './yaml.js';
|
|
4
|
+
import type { Edit, WriteOutcome } from './write.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* YAML is the one format edited through a document model rather than by
|
|
8
|
+
* splicing text, because the library keeps comments, anchors and node styles
|
|
9
|
+
* across a round trip and a hand-rolled splicer would not.
|
|
10
|
+
*
|
|
11
|
+
* Two things are still done by hand. An existing scalar is mutated in place
|
|
12
|
+
* instead of replaced, so a block scalar stays a block scalar and keeps its
|
|
13
|
+
* comments. And an alias is refused outright: writing through `*shared` would
|
|
14
|
+
* silently break every other key that shares the anchor.
|
|
15
|
+
*/
|
|
16
|
+
export function writeYamlLocale(
|
|
17
|
+
content: string,
|
|
18
|
+
file: string,
|
|
19
|
+
locale: string,
|
|
20
|
+
edits: Edit[],
|
|
21
|
+
): WriteOutcome {
|
|
22
|
+
const doc = parseDocument(content);
|
|
23
|
+
if (doc.errors.length > 0) {
|
|
24
|
+
throw new FormatError(file, doc.errors[0]!.message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const root = localeRootKey(doc.toJS(), locale);
|
|
28
|
+
const skipped: Array<{ key: string; reason: string }> = [];
|
|
29
|
+
|
|
30
|
+
for (const edit of edits) {
|
|
31
|
+
const path = (root ? [root] : []).concat(edit.key.split('.'));
|
|
32
|
+
const existing = doc.getIn(path, true);
|
|
33
|
+
|
|
34
|
+
if (isAlias(existing)) {
|
|
35
|
+
skipped.push({ key: edit.key, reason: 'the value is a YAML alias, shared with other keys' });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (isScalar(existing)) {
|
|
39
|
+
// Keeps the node's style — block scalars stay block scalars.
|
|
40
|
+
existing.value = edit.value;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (existing !== undefined && existing !== null) {
|
|
44
|
+
skipped.push({ key: edit.key, reason: 'the key holds a collection, not a string' });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
doc.setIn(path, edit.value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// lineWidth 0 disables re-wrapping, so untouched long lines stay as written.
|
|
52
|
+
return { content: doc.toString({ lineWidth: 0 }), skipped };
|
|
53
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import type { ReadTarget } from '../types.js';
|
|
4
|
+
import { FormatError } from './error.js';
|
|
5
|
+
import { flattenValue } from './flatten.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rails and Symfony locale files.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the PHP parser, this one delegates: reading YAML is not a security
|
|
11
|
+
* question, and the spec is deep enough — anchors, block scalars, implicit
|
|
12
|
+
* typing — that a hand-rolled subset would quietly misread real files.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class YamlParseError extends FormatError {}
|
|
16
|
+
|
|
17
|
+
function normaliseLocale(value: string): string {
|
|
18
|
+
return value.toLowerCase().replace(/_/g, '-');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rails nests the whole file under its locale code. Left in place, every key in
|
|
23
|
+
* en.yml would differ from every key in fr.yml.
|
|
24
|
+
*/
|
|
25
|
+
export function localeRootKey(parsed: unknown, locale: string): string | null {
|
|
26
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
|
27
|
+
|
|
28
|
+
const keys = Object.keys(parsed as Record<string, unknown>);
|
|
29
|
+
if (keys.length !== 1) return null;
|
|
30
|
+
|
|
31
|
+
const root = keys[0]!;
|
|
32
|
+
return normaliseLocale(root) === normaliseLocale(locale) ? root : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stripLocaleRoot(parsed: unknown, locale: string): unknown {
|
|
36
|
+
const root = localeRootKey(parsed, locale);
|
|
37
|
+
return root === null ? parsed : (parsed as Record<string, unknown>)[root];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readYamlLocale(
|
|
41
|
+
file: string,
|
|
42
|
+
namespace: string,
|
|
43
|
+
locale: string,
|
|
44
|
+
_isSource: boolean,
|
|
45
|
+
target: ReadTarget,
|
|
46
|
+
): void {
|
|
47
|
+
let parsed: unknown;
|
|
48
|
+
try {
|
|
49
|
+
parsed = parse(readFileSync(file, 'utf8'));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new YamlParseError(file, err instanceof Error ? err.message : String(err));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (parsed === null || parsed === undefined) return; // an empty document
|
|
55
|
+
|
|
56
|
+
const inner = stripLocaleRoot(parsed, locale);
|
|
57
|
+
if (Array.isArray(inner)) {
|
|
58
|
+
target.skipped.push({ file, reason: 'the file holds a list, not keyed translations' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
flattenValue(inner, namespace, file, target.leaves, target.containers);
|
|
62
|
+
}
|