champollion 0.3.4 → 0.4.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 (132) hide show
  1. package/README.md +41 -26
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +632 -125
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +15 -9
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
package/lib/po.js ADDED
@@ -0,0 +1,1187 @@
1
+ /**
2
+ * gettext catalogs (.po / .pot) — read into the flat key → value map the
3
+ * sync engine diffs and translates, and write back without disturbing
4
+ * anything sync did not translate.
5
+ *
6
+ * KEYS. An entry's key is its msgid. An entry with a context is keyed
7
+ * `msgctxt + "\u0004" + msgid` — gettext's own encoding (the string a .mo
8
+ * file stores and pgettext() looks up), so it cannot collide with any
9
+ * msgid and does not clash with the `<ns>::<key>` namespace separator.
10
+ *
11
+ * VALUES.
12
+ * source (.pot, or the source-language .po)
13
+ * msgstr when filled, else msgid — a .pot and `makemessages -l en`
14
+ * both leave msgstr empty, so the msgid IS the English text.
15
+ * target
16
+ * msgstr. An empty msgstr, or an entry flagged `fuzzy`, is
17
+ * UNTRANSLATED: it is absent from the map, so sync translates it.
18
+ *
19
+ * PLURALS. An entry with msgid_plural is ONE key whose value is an ICU
20
+ * plural message, e.g.
21
+ *
22
+ * msgid "One file" msgid_plural "%d files"
23
+ * → "{n, plural, one {One file} other {%d files}}"
24
+ *
25
+ * The model translates the whole message at once (each CLDR category of
26
+ * the target language — Polish one/few/many/other), the ICU structure gate
27
+ * protects it, and the writer maps categories onto the target's msgstr[i]
28
+ * through its Plural-Forms header: each index takes the CLDR category of
29
+ * the numbers that select it (Russian "n%10==1 && n%100!=11 ? 0 : …" →
30
+ * [one, few, many]). Branch text is copied verbatim — nothing inside it is
31
+ * interpreted — so %d, {name} and # survive byte for byte.
32
+ *
33
+ * PLURAL-FORMS. The target's own header is used when it has a valid one
34
+ * (Django `makemessages`, `msginit` and `pybabel init` all write it). A
35
+ * target without one (a catalog champollion creates) gets the header GNU
36
+ * msginit writes for its language (shared/gettext-plural-forms.json —
37
+ * French "nplurals=2; plural=(n > 1);"), so its msgstr[] slots are the ones
38
+ * gettext and Django select. A CLDR-derived French header (nplurals=3, for
39
+ * the rare "many" of 1 000 000) left every French plural carrying a
40
+ * permanent "write them, then delete this line" comment (Round 6, Django
41
+ * persona). A language msginit has no entry for gets a header derived from
42
+ * CLDR: the categories Intl.PluralRules gives integers, in CLDR order, and
43
+ * an expression synthesized from them and VERIFIED against
44
+ * Intl.PluralRules over 0…3000 and large probes. A language whose integer
45
+ * rules cannot be expressed that way fails loud with the msginit command to
46
+ * run instead — a guessed expression would pick the wrong form at runtime.
47
+ *
48
+ * LOSSLESS. Every entry keeps its raw text. Writing re-emits an entry
49
+ * byte-for-byte unless sync changed its value; a changed entry keeps its
50
+ * translator comments, takes the source's references, extracted comments
51
+ * and flags, loses `fuzzy` (and the `#|` previous-msgid lines that only
52
+ * mean something on a fuzzy entry). Entries the source no longer has and
53
+ * obsolete `#~` entries are kept, at the end, as they were.
54
+ *
55
+ * LIMITS (documented): UTF-8 catalogs only (others fail loud with the
56
+ * msgconv command); a plural msgid whose braces do not balance cannot be
57
+ * written as an ICU message and is reported, not translated.
58
+ */
59
+
60
+ import fs from 'node:fs';
61
+ import { parseMessage } from './icu-structure.js';
62
+ import { pluralCategoriesFor } from './plurals.js';
63
+
64
+ /** msginit's Plural-Forms per language (shared/gettext-plural-forms.json), read once. */
65
+ let gettextForms = null;
66
+ function gettextPluralTable() {
67
+ if (gettextForms === null) {
68
+ const file = new URL('../shared/gettext-plural-forms.json', import.meta.url);
69
+ // Fail loud: a missing table would silently give every new catalog the
70
+ // CLDR header instead of the one gettext writes.
71
+ gettextForms = JSON.parse(fs.readFileSync(file, 'utf-8')).forms;
72
+ }
73
+ return gettextForms;
74
+ }
75
+
76
+ /**
77
+ * The Plural-Forms header GNU msginit writes for `locale` (its built-in
78
+ * table, looked up as msginit does: the full code, then the language), or
79
+ * null when msginit has none.
80
+ *
81
+ * @param {string} locale - "fr", "pt-BR", "pt_BR"
82
+ * @returns {{ nplurals: number, expression: string, evaluate: Function }|null}
83
+ */
84
+ function gettextPluralForms(locale) {
85
+ if (!locale) return null;
86
+ const parts = String(locale).replace(/-/g, '_').split('_');
87
+ const lang = parts[0].toLowerCase();
88
+ const region = parts.slice(1).find(p => /^[A-Za-z]{2}$|^\d{3}$/.test(p));
89
+ const table = gettextPluralTable();
90
+ const value = (region && table[`${lang}_${region.toUpperCase()}`]) || table[lang] || null;
91
+ return value ? parsePluralForms(value) : null;
92
+ }
93
+
94
+ /** gettext's msgctxt/msgid separator (EOT). */
95
+ const PO_CONTEXT_SEPARATOR = '\u0004';
96
+
97
+ /**
98
+ * A key as a report shows it: quoted, with the invisible context separator
99
+ * as "␄" (what `--redo keys:` accepts back) — JSON.stringify printed it as
100
+ * the six characters `\u0004`, which nothing accepts.
101
+ */
102
+ function visibleKey(key) {
103
+ return `"${String(key).replace(/\u0004/g, '\u2404').replace(/\n/g, '\\n')}"`;
104
+ }
105
+
106
+ /** The ICU variable name a plural entry is written with. */
107
+ const PO_PLURAL_ARG = 'n';
108
+
109
+ const CLDR_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'];
110
+
111
+ // -----------------------------------------------------------------
112
+ // Strings
113
+ // -----------------------------------------------------------------
114
+
115
+ function unescapeC(body) {
116
+ let out = '';
117
+ for (let i = 0; i < body.length; i++) {
118
+ const ch = body[i];
119
+ if (ch !== '\\') { out += ch; continue; }
120
+ const next = body[++i];
121
+ switch (next) {
122
+ case 'n': out += '\n'; break;
123
+ case 't': out += '\t'; break;
124
+ case 'r': out += '\r'; break;
125
+ case 'a': out += '\x07'; break;
126
+ case 'b': out += '\b'; break;
127
+ case 'f': out += '\f'; break;
128
+ case 'v': out += '\v'; break;
129
+ case '\\': out += '\\'; break;
130
+ case '"': out += '"'; break;
131
+ case "'": out += "'"; break;
132
+ case '?': out += '?'; break;
133
+ case 'x': {
134
+ const m = /^[0-9a-fA-F]+/.exec(body.slice(i + 1));
135
+ if (m) { out += String.fromCharCode(parseInt(m[0], 16)); i += m[0].length; } else out += 'x';
136
+ break;
137
+ }
138
+ default:
139
+ if (next >= '0' && next <= '7') {
140
+ const m = /^[0-7]{1,3}/.exec(body.slice(i));
141
+ out += String.fromCharCode(parseInt(m[0], 8));
142
+ i += m[0].length - 1;
143
+ } else if (next !== undefined) {
144
+ out += next;
145
+ }
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
151
+ function escapeC(str) {
152
+ return str
153
+ .replace(/\\/g, '\\\\')
154
+ .replace(/"/g, '\\"')
155
+ .replace(/\n/g, '\\n')
156
+ .replace(/\t/g, '\\t')
157
+ .replace(/\r/g, '\\r');
158
+ }
159
+
160
+ /** Body of a `"..."` token (the quotes stripped), or null. */
161
+ function quoted(text) {
162
+ const t = text.trim();
163
+ if (t.length < 2 || t[0] !== '"' || t[t.length - 1] !== '"') return null;
164
+ return t.slice(1, -1);
165
+ }
166
+
167
+ /**
168
+ * Lines for `keyword "value"` in gettext's own style: one line, or — when
169
+ * the value has embedded newlines — an empty first string and one line per
170
+ * newline-terminated chunk.
171
+ */
172
+ function formatField(keyword, value) {
173
+ const inner = value.slice(0, -1);
174
+ if (!inner.includes('\n')) return [`${keyword} "${escapeC(value)}"`];
175
+ const lines = [`${keyword} ""`];
176
+ const chunks = value.split(/(?<=\n)/);
177
+ for (const chunk of chunks) lines.push(`"${escapeC(chunk)}"`);
178
+ return lines;
179
+ }
180
+
181
+ // -----------------------------------------------------------------
182
+ // Parser
183
+ // -----------------------------------------------------------------
184
+
185
+ /**
186
+ * Parse a PO/POT file.
187
+ *
188
+ * @param {string} text - File content (BOM already stripped)
189
+ * @param {string} [filePath] - For error messages
190
+ * @returns {{ lines: string[], eol: string, entries: PoEntry[], header: PoEntry|null }}
191
+ *
192
+ * @typedef {object} PoEntry
193
+ * @property {number} start - First line index (inclusive)
194
+ * @property {number} end - Last line index (inclusive)
195
+ * @property {'header'|'entry'|'obsolete'|'comment'} kind
196
+ * @property {string[]} translatorComments - Raw `# …` lines
197
+ * @property {string[]} extractedComments - Text of `#. …` lines
198
+ * @property {string[]} references - Raw `#: …` lines
199
+ * @property {string[]} flags - From `#, …` lines
200
+ * @property {string|null} msgctxt
201
+ * @property {string|null} msgid
202
+ * @property {string|null} msgidPlural
203
+ * @property {string[]} msgstr - msgstr, or msgstr[0..n]
204
+ * @property {object} fieldLines - keyword → [start, end] raw line range
205
+ */
206
+ function parsePO(text, filePath = '') {
207
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
208
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
209
+ const entries = [];
210
+ let cur = null;
211
+ let field = null; // { name, index } — the field continuation strings extend
212
+
213
+ const begin = (i) => {
214
+ cur = {
215
+ start: i, end: i, kind: 'entry',
216
+ translatorComments: [], extractedComments: [], references: [], flags: [], previous: [],
217
+ msgctxt: null, msgid: null, msgidPlural: null, msgstr: [],
218
+ fieldLines: {}, obsolete: false, seenKeyword: false, seenMsgid: false,
219
+ };
220
+ entries.push(cur);
221
+ field = null;
222
+ };
223
+ const where = (i) => `${filePath ? `${filePath}:` : 'line '}${i + 1}`;
224
+
225
+ const setField = (name, index, value, i) => {
226
+ if (name === 'msgstr') {
227
+ cur.msgstr[index] = (cur.msgstr[index] || '') + value;
228
+ } else if (name === 'msgctxt') {
229
+ cur.msgctxt = (cur.msgctxt || '') + value;
230
+ } else if (name === 'msgid') {
231
+ cur.msgid = (cur.msgid || '') + value;
232
+ } else if (name === 'msgid_plural') {
233
+ cur.msgidPlural = (cur.msgidPlural || '') + value;
234
+ }
235
+ const fl = name === 'msgstr' ? `msgstr[${index}]` : name;
236
+ if (!cur.fieldLines[fl]) cur.fieldLines[fl] = [i, i];
237
+ else cur.fieldLines[fl][1] = i;
238
+ cur.end = i;
239
+ };
240
+
241
+ const keyword = (content, i, obsolete) => {
242
+ const m = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?\s+(".*")\s*$/.exec(content);
243
+ if (!m) throw new Error(`${where(i)}: cannot parse "${content.slice(0, 60)}"`);
244
+ const [, name, idx, str] = m;
245
+ // A new msgctxt/msgid after the previous entry's msgid starts a new entry.
246
+ if (!cur || ((name === 'msgctxt' || name === 'msgid') && cur.seenMsgid)
247
+ || (name === 'msgctxt' && cur.msgctxt !== null)) {
248
+ begin(i);
249
+ }
250
+ if (obsolete) cur.obsolete = true;
251
+ cur.seenKeyword = true;
252
+ if (name === 'msgid') cur.seenMsgid = true;
253
+ const body = quoted(str);
254
+ if (body === null) throw new Error(`${where(i)}: malformed string ${str.slice(0, 60)}`);
255
+ field = { name, index: idx === undefined ? 0 : Number(idx) };
256
+ setField(name, field.index, unescapeC(body), i);
257
+ };
258
+
259
+ for (let i = 0; i < lines.length; i++) {
260
+ const line = lines[i];
261
+ const t = line.trim();
262
+ if (t === '') {
263
+ if (cur && cur.seenKeyword) { cur = null; field = null; }
264
+ continue;
265
+ }
266
+ if (t.startsWith('#~')) {
267
+ const content = t.slice(2).trim();
268
+ if (content.startsWith('|') || content === '') {
269
+ if (!cur || cur.seenKeyword) begin(i);
270
+ cur.obsolete = true;
271
+ cur.end = i;
272
+ continue;
273
+ }
274
+ if (content.startsWith('"')) {
275
+ if (!cur || !field) throw new Error(`${where(i)}: string continuation without a keyword`);
276
+ const body = quoted(content);
277
+ if (body === null) throw new Error(`${where(i)}: malformed string`);
278
+ setField(field.name, field.index, unescapeC(body), i);
279
+ continue;
280
+ }
281
+ keyword(content, i, true);
282
+ continue;
283
+ }
284
+ if (t.startsWith('#')) {
285
+ if (!cur || cur.seenKeyword) begin(i);
286
+ cur.end = i;
287
+ field = null;
288
+ if (t.startsWith('#.')) cur.extractedComments.push(t.slice(2).trim());
289
+ else if (t.startsWith('#:')) cur.references.push(line);
290
+ else if (t.startsWith('#,')) {
291
+ for (const f of t.slice(2).split(',')) if (f.trim()) cur.flags.push(f.trim());
292
+ } else if (t.startsWith('#|')) cur.previous.push(line);
293
+ else cur.translatorComments.push(line);
294
+ continue;
295
+ }
296
+ if (t.startsWith('"')) {
297
+ if (!cur || !field) throw new Error(`${where(i)}: string continuation without a keyword`);
298
+ const body = quoted(t);
299
+ if (body === null) throw new Error(`${where(i)}: malformed string ${t.slice(0, 60)}`);
300
+ setField(field.name, field.index, unescapeC(body), i);
301
+ continue;
302
+ }
303
+ keyword(t, i, false);
304
+ }
305
+
306
+ let header = null;
307
+ for (const e of entries) {
308
+ if (!e.seenKeyword) e.kind = 'comment';
309
+ else if (e.obsolete) e.kind = 'obsolete';
310
+ else if (e.msgid === '' && e.msgctxt === null) {
311
+ if (!header) { e.kind = 'header'; header = e; }
312
+ }
313
+ if (e.kind === 'entry' && e.msgid === null) {
314
+ throw new Error(`${where(e.start)}: entry without msgid`);
315
+ }
316
+ }
317
+ return { lines, eol, entries, header };
318
+ }
319
+
320
+ /** The flat-map key of an entry. */
321
+ function entryKey(e) {
322
+ return e.msgctxt !== null ? `${e.msgctxt}${PO_CONTEXT_SEPARATOR}${e.msgid}` : e.msgid;
323
+ }
324
+
325
+ /** An entry's exact original text. */
326
+ function rawText(parsed, e) {
327
+ return parsed.lines.slice(e.start, e.end + 1).join('\n');
328
+ }
329
+
330
+ /** Header fields in order: [[name, value], …]. */
331
+ function headerFields(header) {
332
+ if (!header) return [];
333
+ const out = [];
334
+ for (const line of (header.msgstr[0] || '').split('\n')) {
335
+ if (!line.trim()) continue;
336
+ const i = line.indexOf(':');
337
+ if (i < 0) continue;
338
+ out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function headerField(header, name) {
344
+ const f = headerFields(header).find(([k]) => k.toLowerCase() === name.toLowerCase());
345
+ return f ? f[1] : null;
346
+ }
347
+
348
+ /** Refuse a non-UTF-8 catalog: this module reads and writes UTF-8. */
349
+ function assertUtf8(header, filePath) {
350
+ const ct = headerField(header, 'Content-Type');
351
+ const m = ct && /charset\s*=\s*([^;\s]+)/i.exec(ct);
352
+ if (!m) return;
353
+ const cs = m[1].toUpperCase();
354
+ if (cs === 'UTF-8' || cs === 'UTF8' || cs === 'CHARSET') return;
355
+ throw new Error(
356
+ `${filePath || 'PO file'}: charset ${m[1]} — champollion reads and writes UTF-8 catalogs. `
357
+ + `Convert it first: msgconv --to-code=UTF-8 -o "${filePath || 'file.po'}" "${filePath || 'file.po'}"`);
358
+ }
359
+
360
+ // -----------------------------------------------------------------
361
+ // Plural-Forms
362
+ // -----------------------------------------------------------------
363
+
364
+ /**
365
+ * Compile a gettext plural expression ("n%10==1 && n%100!=11 ? 0 : 1") into
366
+ * a function. The grammar is gettext's (plural-exp.y): ?:, ||, &&, == !=,
367
+ * < > <= >=, + -, * / %, unary !, parentheses, the variable n and
368
+ * non-negative integers. No eval.
369
+ *
370
+ * @param {string} expression
371
+ * @returns {(n: number) => number}
372
+ */
373
+ function compilePluralExpression(expression) {
374
+ const tokens = [];
375
+ const re = /\s*(n|\d+|\?|:|\|\||&&|==|!=|<=|>=|<|>|\+|-|\*|\/|%|!|\(|\))/y;
376
+ let pos = 0;
377
+ const src = String(expression).trim().replace(/;$/, '');
378
+ while (pos < src.length) {
379
+ re.lastIndex = pos;
380
+ const m = re.exec(src);
381
+ if (!m) throw new Error(`unexpected "${src.slice(pos, pos + 10)}" in plural expression`);
382
+ tokens.push(m[1]);
383
+ pos = re.lastIndex;
384
+ while (pos < src.length && /\s/.test(src[pos])) pos++;
385
+ }
386
+ let i = 0;
387
+ const peek = () => tokens[i];
388
+ const take = (t) => {
389
+ if (tokens[i] !== t) throw new Error(`expected "${t}" in plural expression, got "${tokens[i] ?? 'end'}"`);
390
+ i++;
391
+ };
392
+ const bool = (v) => (v ? 1 : 0);
393
+
394
+ function ternary() {
395
+ const cond = or();
396
+ if (peek() === '?') {
397
+ i++;
398
+ const a = ternary();
399
+ take(':');
400
+ const b = ternary();
401
+ return (n) => (cond(n) ? a(n) : b(n));
402
+ }
403
+ return cond;
404
+ }
405
+ function binary(next, ops) {
406
+ return function level() {
407
+ let left = next();
408
+ while (ops[peek()]) {
409
+ const op = ops[tokens[i++]];
410
+ const right = next();
411
+ const l = left;
412
+ left = (n) => op(l(n), right(n));
413
+ }
414
+ return left;
415
+ };
416
+ }
417
+ function unary() {
418
+ if (peek() === '!') { i++; const v = unary(); return (n) => bool(!v(n)); }
419
+ return primary();
420
+ }
421
+ function primary() {
422
+ const t = tokens[i++];
423
+ if (t === undefined) throw new Error('plural expression ends early');
424
+ if (t === '(') { const v = ternary(); take(')'); return v; }
425
+ if (t === 'n') return (n) => n;
426
+ if (/^\d+$/.test(t)) { const k = Number(t); return () => k; }
427
+ throw new Error(`unexpected "${t}" in plural expression`);
428
+ }
429
+ const mul = binary(unary, {
430
+ '*': (a, b) => a * b,
431
+ '/': (a, b) => (b === 0 ? 0 : Math.trunc(a / b)),
432
+ '%': (a, b) => (b === 0 ? 0 : a % b),
433
+ });
434
+ const add = binary(mul, { '+': (a, b) => a + b, '-': (a, b) => a - b });
435
+ const rel = binary(add, {
436
+ '<': (a, b) => bool(a < b), '>': (a, b) => bool(a > b),
437
+ '<=': (a, b) => bool(a <= b), '>=': (a, b) => bool(a >= b),
438
+ });
439
+ const eq = binary(rel, { '==': (a, b) => bool(a === b), '!=': (a, b) => bool(a !== b) });
440
+ const and = binary(eq, { '&&': (a, b) => bool(a && b) });
441
+ const or = binary(and, { '||': (a, b) => bool(a || b) });
442
+
443
+ const fn = ternary();
444
+ if (i !== tokens.length) throw new Error(`unexpected "${tokens[i]}" in plural expression`);
445
+ return (n) => Number(fn(n));
446
+ }
447
+
448
+ /**
449
+ * Parse a `Plural-Forms:` header value. Template placeholders
450
+ * ("nplurals=INTEGER; plural=EXPRESSION;") and invalid expressions are null.
451
+ *
452
+ * @param {string|null} value
453
+ * @returns {{ nplurals: number, expression: string, evaluate: Function }|null}
454
+ */
455
+ function parsePluralForms(value) {
456
+ if (!value) return null;
457
+ const m = /nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(.+?)\s*;?\s*$/.exec(value);
458
+ if (!m) return null;
459
+ const nplurals = Number(m[1]);
460
+ if (!(nplurals >= 1)) return null;
461
+ try {
462
+ const evaluate = compilePluralExpression(m[2]);
463
+ for (const n of [0, 1, 2, 3, 5, 11, 21, 101, 1000000]) {
464
+ const k = evaluate(n);
465
+ if (!Number.isInteger(k) || k < 0 || k >= nplurals) return null;
466
+ }
467
+ return { nplurals, expression: m[2].trim(), evaluate };
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ /** Integers probed beyond the exhaustive range (CLDR rules on n%1000000, …). */
474
+ const LARGE_PROBES = [];
475
+ for (const k of [1, 2, 3, 5, 10, 21, 100]) {
476
+ for (const r of [0, 1, 2, 3, 5, 11, 12, 21, 100, 101]) LARGE_PROBES.push(k * 1000000 + r);
477
+ }
478
+ LARGE_PROBES.push(10000, 10001, 100000, 100001, 1000000000);
479
+
480
+ /** Render "v==a || (v>=b && v<=c)" for a sorted list of integers. */
481
+ function renderSet(v, values) {
482
+ const runs = [];
483
+ for (const x of values) {
484
+ const last = runs[runs.length - 1];
485
+ if (last && x === last[1] + 1) last[1] = x; else runs.push([x, x]);
486
+ }
487
+ const parts = runs.map(([a, b]) => {
488
+ if (a === b) return `${v}==${a}`;
489
+ return a === 0 ? `${v}<=${b}` : `(${v}>=${a} && ${v}<=${b})`;
490
+ });
491
+ return parts.length === 1 ? parts[0] : `(${parts.join(' || ')})`;
492
+ }
493
+
494
+ /** Shortest condition on n%100 (or n%10 with exceptions) for a residue set. */
495
+ function renderResidues(residues) {
496
+ const direct = renderSet('n%100', residues);
497
+ const set = new Set(residues);
498
+ const digits = [];
499
+ for (let a = 0; a < 10; a++) {
500
+ let c = 0;
501
+ for (let r = a; r < 100; r += 10) if (set.has(r)) c++;
502
+ if (c > 5) digits.push(a);
503
+ }
504
+ if (digits.length === 0) return direct;
505
+ const base = [];
506
+ for (let r = 0; r < 100; r++) if (digits.includes(r % 10)) base.push(r);
507
+ const except = base.filter(r => !set.has(r));
508
+ const plus = residues.filter(r => !digits.includes(r % 10));
509
+ let viaDigits = renderSet('n%10', digits);
510
+ if (except.length > 0) {
511
+ const set = renderSet('n%100', except);
512
+ const not = except.length === 1 ? `n%100!=${except[0]}` : (set.startsWith('(') ? `!${set}` : `!(${set})`);
513
+ viaDigits = `(${viaDigits} && ${not})`;
514
+ }
515
+ if (plus.length > 0) viaDigits = `(${viaDigits} || ${renderSet('n%100', plus)})`;
516
+ return viaDigits.length < direct.length ? viaDigits : direct;
517
+ }
518
+
519
+ /**
520
+ * A Plural-Forms header for `locale`, derived from CLDR (Intl.PluralRules)
521
+ * and verified, or null when CLDR has no rules for the locale or its
522
+ * integer rules do not reduce to small-number exceptions + a period of 100
523
+ * (+ the n%1000000 rule some languages use for "millions of").
524
+ *
525
+ * @param {string} locale
526
+ * @returns {{ nplurals: number, expression: string, categories: string[], evaluate: Function }|null}
527
+ */
528
+ function synthesizePluralForms(locale) {
529
+ if (!pluralCategoriesFor(locale)) return null;
530
+ let rules;
531
+ try { rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { return null; }
532
+ const LIMIT = 3100;
533
+ const cat = [];
534
+ for (let n = 0; n < LIMIT; n++) cat.push(rules.select(n));
535
+ const seen = new Set(cat);
536
+ for (const n of LARGE_PROBES) seen.add(rules.select(n));
537
+ const categories = CLDR_ORDER.filter(c => seen.has(c));
538
+ if (categories.length === 1) {
539
+ return { nplurals: 1, expression: '0', categories, evaluate: () => 0 };
540
+ }
541
+ const index = (c) => categories.indexOf(c);
542
+ const fallback = categories.includes('other') ? 'other' : categories[categories.length - 1];
543
+
544
+ // Periodic (mod 100) from T on.
545
+ let T = 0;
546
+ for (let n = 0; n + 100 < LIMIT; n++) if (cat[n] !== cat[n + 100]) T = n + 1;
547
+ if (T > 200) return null;
548
+ const P = [];
549
+ for (let r = 0; r < 100; r++) P.push(cat[200 + r]);
550
+
551
+ const million = rules.select(1000000);
552
+ const special = million !== P[0] ? million : null;
553
+
554
+ const predicate = (c, guard) => {
555
+ const parts = [];
556
+ const small = [];
557
+ for (let n = 0; n < T; n++) if (cat[n] === c) small.push(n);
558
+ if (small.length > 0) parts.push(renderSet('n', small));
559
+ const residues = [];
560
+ for (let r = 0; r < 100; r++) if (P[r] === c) residues.push(r);
561
+ if (residues.length > 0) {
562
+ const res = renderResidues(residues);
563
+ parts.push(guard && T > 0 ? `(n>=${T} && ${res})` : res);
564
+ }
565
+ if (parts.length === 0) return null;
566
+ return parts.length === 1 ? parts[0] : `(${parts.join(' || ')})`;
567
+ };
568
+
569
+ const build = (guard) => {
570
+ let expr = String(index(fallback));
571
+ for (const c of [...categories].reverse()) {
572
+ if (c === fallback) continue;
573
+ const p = predicate(c, guard);
574
+ if (p) expr = `${p} ? ${index(c)} : ${expr}`;
575
+ }
576
+ if (special) expr = `(n!=0 && n%1000000==0) ? ${index(special)} : ${expr}`;
577
+ return expr;
578
+ };
579
+
580
+ for (const guard of [false, true]) {
581
+ const expression = build(guard);
582
+ let evaluate;
583
+ try { evaluate = compilePluralExpression(expression); } catch { continue; }
584
+ let ok = true;
585
+ for (let n = 0; n < LIMIT && ok; n++) if (evaluate(n) !== index(cat[n])) ok = false;
586
+ for (const n of LARGE_PROBES) if (ok && evaluate(n) !== index(rules.select(n))) ok = false;
587
+ if (ok) return { nplurals: categories.length, expression, categories, evaluate };
588
+ }
589
+ return null;
590
+ }
591
+
592
+ /**
593
+ * The ICU selector each msgstr index stands for: the CLDR category most of
594
+ * the numbers that select the index carry in `locale`. Ties and repeats
595
+ * fall back to an exact `=N` selector (N = the smallest number selecting
596
+ * the index), so every index is addressable.
597
+ *
598
+ * @param {number} nplurals
599
+ * @param {(n: number) => number} evaluate
600
+ * @param {string} locale
601
+ * @returns {string[]}
602
+ */
603
+ function pluralIndexSelectors(nplurals, evaluate, locale) {
604
+ let rules = null;
605
+ if (pluralCategoriesFor(locale)) {
606
+ try { rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { rules = null; }
607
+ }
608
+ const byIndex = Array.from({ length: nplurals }, () => ({ count: 0, min: null, cats: new Map() }));
609
+ const samples = [];
610
+ for (let n = 0; n <= 1000; n++) samples.push(n);
611
+ samples.push(...LARGE_PROBES);
612
+ for (const n of samples) {
613
+ const k = evaluate(n);
614
+ if (!(k >= 0 && k < nplurals)) continue;
615
+ const slot = byIndex[k];
616
+ slot.count++;
617
+ if (slot.min === null) slot.min = n;
618
+ const c = rules ? rules.select(n) : (n === 1 ? 'one' : 'other');
619
+ slot.cats.set(c, (slot.cats.get(c) || 0) + 1);
620
+ }
621
+ const best = byIndex.map((slot) => {
622
+ let top = null;
623
+ for (const [c, k] of slot.cats) if (!top || k > top[1]) top = [c, k];
624
+ return top ? top[0] : null;
625
+ });
626
+ const selectors = new Array(nplurals).fill(null);
627
+ const order = byIndex.map((s, i) => i).sort((a, b) => byIndex[b].count - byIndex[a].count);
628
+ const taken = new Set();
629
+ for (const i of order) {
630
+ const c = best[i];
631
+ if (c && !taken.has(c)) { selectors[i] = c; taken.add(c); continue; }
632
+ const min = byIndex[i].min;
633
+ selectors[i] = min === null ? `=${-1 - i}` : `=${min}`;
634
+ }
635
+ return selectors;
636
+ }
637
+
638
+ /**
639
+ * How plural entries of a TARGET catalog map onto ICU categories: from its
640
+ * own valid Plural-Forms header, else from the header msginit writes for
641
+ * the language, else from CLDR.
642
+ *
643
+ * @returns {{ nplurals: number, expression: string, selectors: string[], fromHeader: boolean }|null}
644
+ */
645
+ function targetPluralInfo(header, locale) {
646
+ const fromHeader = parsePluralForms(headerField(header, 'Plural-Forms'));
647
+ if (fromHeader) {
648
+ return {
649
+ nplurals: fromHeader.nplurals, expression: fromHeader.expression,
650
+ selectors: pluralIndexSelectors(fromHeader.nplurals, fromHeader.evaluate, locale),
651
+ fromHeader: true,
652
+ };
653
+ }
654
+ const conventional = gettextPluralForms(locale);
655
+ if (conventional) {
656
+ return {
657
+ nplurals: conventional.nplurals, expression: conventional.expression,
658
+ selectors: pluralIndexSelectors(conventional.nplurals, conventional.evaluate, locale),
659
+ fromHeader: false,
660
+ };
661
+ }
662
+ const synth = locale ? synthesizePluralForms(locale) : null;
663
+ if (!synth) return null;
664
+ return { nplurals: synth.nplurals, expression: synth.expression, selectors: synth.categories, fromHeader: false };
665
+ }
666
+
667
+ /**
668
+ * Keys of a TARGET catalog's entries flagged `fuzzy` — read as untranslated
669
+ * (absent from the flat map), so sync re-translates them; the log names them
670
+ * as fuzzy, not missing (Round 6, Django persona: an entry `makemessages`
671
+ * marked fuzzy after its msgid changed was reported "missing").
672
+ *
673
+ * @param {string} text - Catalog content
674
+ * @returns {Set<string>}
675
+ */
676
+ function poFuzzyKeys(text) {
677
+ const out = new Set();
678
+ if (typeof text !== 'string' || !text.trim()) return out;
679
+ let parsed;
680
+ try { parsed = parsePO(text); } catch { return out; }
681
+ for (const e of parsed.entries) {
682
+ if (e.kind === 'entry' && e.flags.includes('fuzzy') && e.msgid) out.add(entryKey(e));
683
+ }
684
+ return out;
685
+ }
686
+
687
+ /**
688
+ * The CLDR plural categories a catalog has a msgstr[] slot for — its own
689
+ * header's, else the header a new catalog gets — or null when no plural
690
+ * rules are known. A form without a slot cannot be written, so it is never
691
+ * asked again for, marked or reported as missing (French "many" in a
692
+ * two-form catalog).
693
+ *
694
+ * @param {string|null} text - Catalog content (null: a catalog not yet created)
695
+ * @param {string} locale
696
+ * @returns {string[]|null}
697
+ */
698
+ function poPluralSlots(text, locale) {
699
+ let header = null;
700
+ if (typeof text === 'string' && text.trim()) {
701
+ try { header = parsePO(text).header; } catch { header = null; }
702
+ }
703
+ const info = targetPluralInfo(header, locale || headerField(header, 'Language') || null);
704
+ if (!info) return null;
705
+ const slots = info.selectors.filter(c => c && !c.startsWith('='));
706
+ // ICU's `other` always has a home: the last form when no index takes it.
707
+ if (!slots.includes('other')) slots.push('other');
708
+ return slots;
709
+ }
710
+
711
+ function pluralFormsUnavailable(locale, filePath) {
712
+ return new Error(
713
+ `${filePath || 'PO file'}: no usable Plural-Forms header, and CLDR's plural rules for "${locale}" cannot be `
714
+ + 'turned into a gettext expression automatically. Add the header yourself — e.g. '
715
+ + `\`msginit --locale=${locale} --input=<template>.pot --output=${filePath || '<file>.po'}\` writes it — and sync again.`);
716
+ }
717
+
718
+ // -----------------------------------------------------------------
719
+ // ICU plural values
720
+ // -----------------------------------------------------------------
721
+
722
+ /**
723
+ * "{n, plural, one {…} other {…}}" from selectors and form texts.
724
+ *
725
+ * ICU requires `other`; a language whose integer forms do not include it
726
+ * (Russian: one/few/many — CLDR's `other` is for fractions) gets the LAST
727
+ * form as `other`, gettext's general plural.
728
+ */
729
+ function formsToICU(selectors, forms) {
730
+ const branches = [];
731
+ forms.forEach((text, i) => {
732
+ if (selectors[i] && !selectors[i].startsWith('=-')) branches.push(`${selectors[i]} {${text}}`);
733
+ });
734
+ if (!selectors.includes('other') && forms.length > 0) branches.push(`other {${forms[forms.length - 1]}}`);
735
+ return `{${PO_PLURAL_ARG}, plural, ${branches.join(' ')}}`;
736
+ }
737
+
738
+ /** Does this ICU text parse back into exactly one plural argument? */
739
+ function icuPluralBranches(value) {
740
+ const parsed = parseMessage(value, { apostrophes: 'literal' });
741
+ if (!parsed.ok) return null;
742
+ const meaningful = parsed.nodes.filter(n => !(n.type === 'text' && n.value.trim() === ''));
743
+ if (meaningful.length !== 1 || meaningful[0].type !== 'branching') return null;
744
+ return new Map(meaningful[0].options.map(o => [o.selector, o.raw]));
745
+ }
746
+
747
+ /**
748
+ * msgstr[0..nplurals-1] from a translated ICU plural value. Branch text is
749
+ * copied verbatim. Returns null when the value is not a plural message.
750
+ */
751
+ function icuToForms(value, info, locale) {
752
+ const mapped = icuToFormsWithCopies(value, info, locale);
753
+ return mapped ? mapped.forms : null;
754
+ }
755
+
756
+ /**
757
+ * icuToForms, plus the forms that had to REPEAT the `other` branch because
758
+ * the translation has no branch for their CLDR category (a Russian message
759
+ * with only one/other fills few and many from other). msgfmt needs every
760
+ * msgstr[i], so the repeat is written — and marked (PO_COPIED_FORMS_MARK),
761
+ * never passed off as a translation of that form. An index with no integer
762
+ * counts (Django's Russian fraction form) takes `other` by right: CLDR's
763
+ * `other` IS that form, so it is not a copy.
764
+ *
765
+ * @returns {{ forms: string[], copied: Array<{ index: number, category: string }> }|null}
766
+ */
767
+ function icuToFormsWithCopies(value, info, locale) {
768
+ const branches = icuPluralBranches(value);
769
+ if (!branches) return null;
770
+ let rules = null;
771
+ try { if (pluralCategoriesFor(locale)) rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { rules = null; }
772
+ const forms = [];
773
+ const copied = [];
774
+ for (let i = 0; i < info.nplurals; i++) {
775
+ const sel = info.selectors[i];
776
+ let text = branches.get(sel);
777
+ if (text === undefined && sel && sel.startsWith('=') && rules) {
778
+ const k = Number(sel.slice(1));
779
+ if (k >= 0) text = branches.get(rules.select(k));
780
+ }
781
+ if (text === undefined) {
782
+ text = branches.get('other');
783
+ if (text !== undefined && sel && sel !== 'other' && !sel.startsWith('=')) copied.push({ index: i, category: sel });
784
+ }
785
+ if (text === undefined) return null;
786
+ forms.push(text);
787
+ }
788
+ return { forms, copied };
789
+ }
790
+
791
+ /**
792
+ * The translator comment a catalog entry carries while some of its plural
793
+ * forms only repeat `other`: Poedit, Weblate and msgmerge keep `# ` comments
794
+ * and show them to the reviewer, and `champollion verify` reads it back
795
+ * (poPluralFindings) — in CI too, where there is no translation cache.
796
+ * A sync that re-translates the entry replaces the line.
797
+ */
798
+ const PO_COPIED_FORMS_MARK = '# champollion:';
799
+
800
+ function copiedFormsComment(copied) {
801
+ const which = copied.map(c => `${c.category} (msgstr[${c.index}])`).join(', ');
802
+ return `${PO_COPIED_FORMS_MARK} plural form(s) ${which} were not supplied by the translation — `
803
+ + 'they repeat the "other" form; write them, then delete this line';
804
+ }
805
+
806
+ /**
807
+ * Plural findings in a TARGET catalog that the flat map cannot show:
808
+ * - copied: forms marked as repeating `other` (see PO_COPIED_FORMS_MARK)
809
+ * whose text still repeats it (all marked forms identical — and equal
810
+ * to the `other` form when the catalog has one). A reviewer who wrote
811
+ * real forms has fixed it, comment or not.
812
+ * - extraForms: an entry with more msgstr[n] than nplurals (msgfmt
813
+ * rejects it; the reader treats the entry as untranslated).
814
+ *
815
+ * @param {string} text - Catalog content
816
+ * @param {{ locale?: string|null, filePath?: string }} [options]
817
+ * @returns {{ copied: Array<{ key: string, categories: string[] }>,
818
+ * extraForms: Array<{ key: string, forms: number, nplurals: number, categories: string[] }> }}
819
+ */
820
+ function poPluralFindings(text, { locale = null, filePath = '' } = {}) {
821
+ const parsed = parsePO(text, filePath);
822
+ const lang = locale || headerField(parsed.header, 'Language') || null;
823
+ const info = targetPluralInfo(parsed.header, lang);
824
+ const copied = [];
825
+ const extraForms = [];
826
+ if (!info) return { copied, extraForms };
827
+ for (const e of parsed.entries) {
828
+ if (e.kind !== 'entry' || e.msgidPlural === null || e.flags.includes('fuzzy')) continue;
829
+ const key = entryKey(e);
830
+ if (e.msgstr.length > info.nplurals) {
831
+ extraForms.push({ key, forms: e.msgstr.length, nplurals: info.nplurals, categories: info.selectors.filter(c => c && !c.startsWith('=')) });
832
+ continue;
833
+ }
834
+ const mark = e.translatorComments.find(l => l.startsWith(PO_COPIED_FORMS_MARK));
835
+ if (!mark || e.msgstr.length !== info.nplurals) continue;
836
+ const marked = [...mark.matchAll(/(\w+) \(msgstr\[(\d+)\]\)/g)].map(m => ({ category: m[1], index: Number(m[2]) }))
837
+ .filter(m => m.index < e.msgstr.length);
838
+ if (marked.length === 0) continue;
839
+ const texts = marked.map(m => e.msgstr[m.index]);
840
+ const otherIndex = info.selectors.indexOf('other');
841
+ const stillCopied = texts.every(t => t === texts[0])
842
+ && (otherIndex < 0 || marked.some(m => m.index === otherIndex) || e.msgstr[otherIndex] === texts[0]);
843
+ if (stillCopied) copied.push({ key, categories: marked.map(m => m.category) });
844
+ }
845
+ return { copied, extraForms };
846
+ }
847
+
848
+ // -----------------------------------------------------------------
849
+ // Read
850
+ // -----------------------------------------------------------------
851
+
852
+ /**
853
+ * Read a catalog into the flat map (see the module header for the rules)
854
+ * plus per-key translator context for the prompt (msgctxt, `#.` comments).
855
+ *
856
+ * @param {string} text - File content
857
+ * @param {{ role?: 'source'|'target', locale?: string|null, filePath?: string }} [options]
858
+ * @returns {{ flat: object, context: object }}
859
+ */
860
+ function readPO(text, { role = 'target', locale = null, filePath = '' } = {}) {
861
+ const parsed = parsePO(text, filePath);
862
+ assertUtf8(parsed.header, filePath);
863
+ const lang = locale || headerField(parsed.header, 'Language') || null;
864
+ const flat = {};
865
+ const context = {};
866
+ let info;
867
+ const plural = () => {
868
+ if (info === undefined) info = targetPluralInfo(parsed.header, lang);
869
+ return info;
870
+ };
871
+
872
+ for (const e of parsed.entries) {
873
+ if (e.kind !== 'entry') continue;
874
+ const key = entryKey(e);
875
+ if (Object.prototype.hasOwnProperty.call(flat, key)) {
876
+ console.warn(` [WARN] Duplicate gettext entry ${visibleKey(key)} in ${filePath} — later entry wins.`);
877
+ }
878
+ const fuzzy = e.flags.includes('fuzzy');
879
+
880
+ if (role === 'source') {
881
+ const notes = [];
882
+ if (e.msgctxt) notes.push(`Context (msgctxt): ${e.msgctxt}`);
883
+ if (e.extractedComments.length > 0) notes.push(e.extractedComments.join(' '));
884
+ if (e.msgidPlural === null) {
885
+ flat[key] = (!fuzzy && e.msgstr[0]) ? e.msgstr[0] : e.msgid;
886
+ } else {
887
+ let value = null;
888
+ const forms = e.msgstr;
889
+ const filled = !fuzzy && forms.length > 0 && forms.every(f => f);
890
+ const own = filled ? parsePluralForms(headerField(parsed.header, 'Plural-Forms')) : null;
891
+ if (own && own.nplurals === forms.length) {
892
+ value = formsToICU(pluralIndexSelectors(own.nplurals, own.evaluate, lang || 'en'), forms);
893
+ } else {
894
+ value = formsToICU(['one', 'other'], [e.msgid, e.msgidPlural]);
895
+ }
896
+ if (!icuPluralBranches(value)) {
897
+ console.warn(` [WARN] ${filePath}: plural entry ${visibleKey(key)} has unbalanced braces — it cannot be `
898
+ + 'written as an ICU plural message and is NOT translated. Translate it by hand.');
899
+ continue;
900
+ }
901
+ flat[key] = value;
902
+ notes.push('gettext plural message, written as an ICU plural: give every plural category the target language needs.');
903
+ }
904
+ if (notes.length > 0) context[key] = notes.join(' — ');
905
+ continue;
906
+ }
907
+
908
+ // Target: fuzzy and empty msgstr are untranslated.
909
+ if (fuzzy) continue;
910
+ if (e.msgidPlural === null) {
911
+ if (e.msgstr[0]) flat[key] = e.msgstr[0];
912
+ continue;
913
+ }
914
+ const forms = e.msgstr;
915
+ if (forms.length === 0 || forms.some(f => !f)) continue;
916
+ const pi = plural();
917
+ if (!pi) {
918
+ if (!lang) {
919
+ throw new Error(`${filePath}: plural entries need a "Language:" or "Plural-Forms:" header to be read.`);
920
+ }
921
+ throw pluralFormsUnavailable(lang, filePath);
922
+ }
923
+ // A form count that disagrees with Plural-Forms is not a usable
924
+ // translation (msgfmt rejects it) — re-translate it.
925
+ if (forms.length !== pi.nplurals) continue;
926
+ flat[key] = formsToICU(pi.selectors, forms);
927
+ }
928
+ return { flat, context };
929
+ }
930
+
931
+ // -----------------------------------------------------------------
932
+ // Write
933
+ // -----------------------------------------------------------------
934
+
935
+ /**
936
+ * Serialize a target catalog.
937
+ *
938
+ * @param {object} options
939
+ * @param {object} options.flat - Target key → value map (sync's edited data)
940
+ * @param {string|null} options.sourceText - The template (source .po/.pot);
941
+ * null = the target is its own template (xliff import, autofix)
942
+ * @param {string|null} options.targetText - Existing target content, or null
943
+ * @param {string} options.locale - Target locale code
944
+ * @param {string} [options.filePath]
945
+ * @param {string|null} [options.projectName] - A NEW catalog's
946
+ * Project-Id-Version when the template carries no real one (see
947
+ * newHeaderFields)
948
+ * @param {Date} [options.now] - A new catalog's PO-Revision-Date
949
+ * @returns {string}
950
+ */
951
+ function writePO({ flat, sourceText, targetText, locale, filePath = '', projectName = null, now = new Date() }) {
952
+ const tgt = targetText ? parsePO(targetText, filePath) : null;
953
+ const src = sourceText ? parsePO(sourceText, filePath) : tgt;
954
+ if (tgt) assertUtf8(tgt.header, filePath);
955
+ // Callers without a locale (xliff import, autofix) rely on the header.
956
+ locale = locale || headerField(tgt?.header || null, 'Language') || null;
957
+ const eol = (tgt || src)?.eol || '\n';
958
+ const blocks = [];
959
+
960
+ const tgtByKey = new Map();
961
+ for (const e of tgt ? tgt.entries : []) if (e.kind === 'entry') tgtByKey.set(entryKey(e), e);
962
+ const srcEntries = src ? src.entries.filter(e => e.kind === 'entry') : [];
963
+ const hasPlural = srcEntries.some(e => e.msgidPlural !== null)
964
+ || Object.values(flat).some(v => typeof v === 'string' && v.startsWith(`{${PO_PLURAL_ARG}, plural,`));
965
+
966
+ let info = targetPluralInfo(tgt?.header || null, locale);
967
+ if (!info && hasPlural) throw pluralFormsUnavailable(locale, filePath);
968
+
969
+ // ── Header ──
970
+ const header = tgt?.header || null;
971
+ if (header) {
972
+ const fields = headerFields(header);
973
+ let changed = false;
974
+ const pf = fields.find(([k]) => k.toLowerCase() === 'plural-forms');
975
+ if (info && !info.fromHeader) {
976
+ const value = `nplurals=${info.nplurals}; plural=${info.expression};`;
977
+ if (pf) pf[1] = value; else fields.push(['Plural-Forms', value]);
978
+ changed = true;
979
+ }
980
+ const ct = fields.find(([k]) => k.toLowerCase() === 'content-type');
981
+ if (ct && /charset\s*=\s*CHARSET/i.test(ct[1])) {
982
+ ct[1] = ct[1].replace(/charset\s*=\s*CHARSET/i, 'charset=UTF-8');
983
+ changed = true;
984
+ }
985
+ if (!changed) blocks.push(rawText(tgt, header));
986
+ else {
987
+ const out = [];
988
+ for (let i = header.start; i < (header.fieldLines.msgid?.[0] ?? header.start); i++) out.push(tgt.lines[i]);
989
+ out.push('msgid ""', 'msgstr ""');
990
+ for (const [k, v] of fields) out.push(`"${escapeC(`${k}: ${v}\n`)}"`);
991
+ blocks.push(out.join('\n'));
992
+ }
993
+ } else {
994
+ const fields = newHeaderFields({ srcHeader: src?.header || null, locale, info, projectName, now });
995
+ blocks.push(['msgid ""', 'msgstr ""', ...fields.map(([k, v]) => `"${escapeC(`${k}: ${v}\n`)}"`)].join('\n'));
996
+ }
997
+ if (!info) info = { nplurals: 2, expression: '(n != 1)', selectors: ['one', 'other'], fromHeader: false };
998
+
999
+ // What the reader returns for an existing target entry — "unchanged" is
1000
+ // judged against exactly this.
1001
+ const existingValue = (e) => {
1002
+ if (e.flags.includes('fuzzy')) return undefined;
1003
+ if (e.msgidPlural === null) return e.msgstr[0] || undefined;
1004
+ if (e.msgstr.length !== info.nplurals || e.msgstr.some(f => !f)) return undefined;
1005
+ return formsToICU(info.selectors, e.msgstr);
1006
+ };
1007
+
1008
+ const fieldRaw = (parsed, e, name) => {
1009
+ const range = e.fieldLines[name];
1010
+ return range ? parsed.lines.slice(range[0], range[1] + 1) : null;
1011
+ };
1012
+
1013
+ const render = (s, e, value) => {
1014
+ const out = [];
1015
+ // Our own "repeats other" line is re-decided for the new value below.
1016
+ if (e) out.push(...e.translatorComments.filter(l => !l.startsWith(PO_COPIED_FORMS_MARK)));
1017
+ const mapped = s.msgidPlural !== null && value !== null ? icuToFormsWithCopies(value, info, locale) : null;
1018
+ if (mapped && mapped.copied.length > 0) out.push(copiedFormsComment(mapped.copied));
1019
+ for (const c of s.extractedComments) out.push(`#. ${c}`);
1020
+ out.push(...s.references);
1021
+ const flags = [...new Set([...s.flags, ...(e ? e.flags : [])])]
1022
+ .filter(f => !(f === 'fuzzy' && value !== null));
1023
+ if (flags.length > 0) out.push(`#, ${flags.join(', ')}`);
1024
+ if (value === null && e) out.push(...e.previous);
1025
+ const srcParsed = s === e ? tgt : src;
1026
+ for (const name of ['msgctxt', 'msgid', 'msgid_plural']) {
1027
+ const raw = fieldRaw(srcParsed, s, name);
1028
+ if (raw) out.push(...raw);
1029
+ else if (name === 'msgctxt' && s.msgctxt !== null) out.push(...formatField('msgctxt', s.msgctxt));
1030
+ else if (name === 'msgid') out.push(...formatField('msgid', s.msgid));
1031
+ else if (name === 'msgid_plural' && s.msgidPlural !== null) out.push(...formatField('msgid_plural', s.msgidPlural));
1032
+ }
1033
+ if (s.msgidPlural === null) {
1034
+ out.push(...formatField('msgstr', value ?? ''));
1035
+ } else {
1036
+ const forms = value === null ? new Array(info.nplurals).fill('') : mapped.forms;
1037
+ for (let i = 0; i < info.nplurals; i++) out.push(...formatField(`msgstr[${i}]`, forms[i]));
1038
+ }
1039
+ return out.join('\n');
1040
+ };
1041
+
1042
+ const srcKeys = new Set();
1043
+ for (const s of srcEntries) {
1044
+ const key = entryKey(s);
1045
+ srcKeys.add(key);
1046
+ const e = tgtByKey.get(key) || null;
1047
+ const wanted = flat[key];
1048
+ const have = e ? existingValue(e) : undefined;
1049
+ if (wanted === undefined || wanted === have) {
1050
+ blocks.push(e ? rawText(tgt, e) : render(s, null, null));
1051
+ continue;
1052
+ }
1053
+ if (s.msgidPlural !== null && !icuToForms(wanted, info, locale)) {
1054
+ console.warn(` [WARN] ${filePath}: ${visibleKey(key)} — the translation is not an ICU plural message; left untranslated.`);
1055
+ blocks.push(e ? rawText(tgt, e) : render(s, null, null));
1056
+ continue;
1057
+ }
1058
+ blocks.push(render(s, e, wanted));
1059
+ }
1060
+
1061
+ // Keys sync added that no template entry carries (xliff import of a new
1062
+ // key): plain singular entries.
1063
+ for (const [key, value] of Object.entries(flat)) {
1064
+ if (srcKeys.has(key) || typeof value !== 'string') continue;
1065
+ if (tgtByKey.has(key) && src !== tgt) continue;
1066
+ const sep = key.indexOf(PO_CONTEXT_SEPARATOR);
1067
+ const out = [];
1068
+ if (sep >= 0) out.push(...formatField('msgctxt', key.slice(0, sep)));
1069
+ out.push(...formatField('msgid', sep >= 0 ? key.slice(sep + 1) : key));
1070
+ out.push(...formatField('msgstr', value));
1071
+ blocks.push(out.join('\n'));
1072
+ }
1073
+
1074
+ // Target entries the template no longer has, obsolete entries and loose
1075
+ // comment blocks: kept exactly as they were.
1076
+ if (tgt && src !== tgt) {
1077
+ for (const e of tgt.entries) {
1078
+ if (e.kind === 'header') continue;
1079
+ if (e.kind === 'entry' && srcKeys.has(entryKey(e))) continue;
1080
+ blocks.push(rawText(tgt, e));
1081
+ }
1082
+ } else if (tgt) {
1083
+ for (const e of tgt.entries) {
1084
+ if (e.kind === 'obsolete' || e.kind === 'comment') blocks.push(rawText(tgt, e));
1085
+ }
1086
+ }
1087
+
1088
+ return blocks.join('\n\n').split('\n').join(eol) + eol;
1089
+ }
1090
+
1091
+ /** xgettext's template value for Project-Id-Version (msgfmt -c: "still has the initial default value"). */
1092
+ const PO_TEMPLATE_PROJECT_ID = 'PACKAGE VERSION';
1093
+
1094
+ /** "YYYY-MM-DD HH:MM+ZZZZ" in local time — the stamp gettext's tools write. */
1095
+ function poTimestamp(date) {
1096
+ const pad = (n) => String(Math.trunc(Math.abs(n))).padStart(2, '0');
1097
+ const offset = -date.getTimezoneOffset();
1098
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
1099
+ + `${pad(date.getHours())}:${pad(date.getMinutes())}`
1100
+ + `${offset < 0 ? '-' : '+'}${pad(offset / 60)}${pad(offset % 60)}`;
1101
+ }
1102
+
1103
+ /**
1104
+ * The header of a catalog champollion CREATES (init --langs, or sync for a
1105
+ * locale with no catalog yet). An existing catalog's header is never
1106
+ * rewritten — only a placeholder Plural-Forms/charset in it is filled.
1107
+ *
1108
+ * It used to carry five fields, and `msgfmt -c` warned about four missing
1109
+ * ones (Project-Id-Version, PO-Revision-Date, Last-Translator,
1110
+ * Language-Team; synthetic Django persona, 2026-10). The fields and values
1111
+ * are what `msginit --no-translator` writes — gettext's own non-interactive
1112
+ * way to start a catalog, whose output `msgfmt -c` accepts:
1113
+ * - Project-Id-Version, Report-Msgid-Bugs-To, POT-Creation-Date: copied
1114
+ * from the template (msginit copies them from the .pot). xgettext's
1115
+ * "PACKAGE VERSION" placeholder is replaced, as msginit replaces it,
1116
+ * by the project's name (its folder). No template date → no
1117
+ * POT-Creation-Date: there was no .pot to date.
1118
+ * - PO-Revision-Date: when the file was made.
1119
+ * - Last-Translator "Automatically generated", Language-Team "none": no
1120
+ * person has worked on it (msginit's own values for this case — not a
1121
+ * made-up name, and not FULL NAME <EMAIL@ADDRESS>, which msgfmt -c
1122
+ * reports as an unfilled template).
1123
+ *
1124
+ * @returns {Array<[string, string]>}
1125
+ */
1126
+ function newHeaderFields({ srcHeader, locale, info, projectName, now }) {
1127
+ const fromSource = (name) => headerField(srcHeader, name);
1128
+ const projectId = fromSource('Project-Id-Version');
1129
+ const fields = [
1130
+ ['Project-Id-Version', projectId && projectId !== PO_TEMPLATE_PROJECT_ID
1131
+ ? projectId : (projectName || PO_TEMPLATE_PROJECT_ID)],
1132
+ ['Report-Msgid-Bugs-To', fromSource('Report-Msgid-Bugs-To') ?? ''],
1133
+ ];
1134
+ const potDate = fromSource('POT-Creation-Date');
1135
+ if (potDate) fields.push(['POT-Creation-Date', potDate]);
1136
+ fields.push(
1137
+ ['PO-Revision-Date', poTimestamp(now instanceof Date ? now : new Date())],
1138
+ ['Last-Translator', 'Automatically generated'],
1139
+ ['Language-Team', 'none'],
1140
+ );
1141
+ if (locale) fields.push(['Language', locale]);
1142
+ fields.push(
1143
+ ['MIME-Version', '1.0'],
1144
+ ['Content-Type', 'text/plain; charset=UTF-8'],
1145
+ ['Content-Transfer-Encoding', '8bit'],
1146
+ );
1147
+ if (info) fields.push(['Plural-Forms', `nplurals=${info.nplurals}; plural=${info.expression};`]);
1148
+ return fields;
1149
+ }
1150
+
1151
+ /**
1152
+ * The content of a new, empty target catalog: a header only.
1153
+ *
1154
+ * @param {string} locale
1155
+ * @param {{ sourceText?: string|null, projectName?: string|null, now?: Date }} [options] -
1156
+ * sourceText: the template the catalog will mirror (its header fields are
1157
+ * carried over — see newHeaderFields); no entries are copied, sync fills them
1158
+ * @returns {string}
1159
+ */
1160
+ function emptyPO(locale, { sourceText = null, projectName = null, now = new Date() } = {}) {
1161
+ const header = sourceText ? parsePO(sourceText).header : null;
1162
+ const info = targetPluralInfo(null, locale);
1163
+ const fields = newHeaderFields({ srcHeader: header, locale, info, projectName, now });
1164
+ return ['msgid ""', 'msgstr ""', ...fields.map(([k, v]) => `"${escapeC(`${k}: ${v}\n`)}"`)].join('\n') + '\n';
1165
+ }
1166
+
1167
+ export {
1168
+ PO_CONTEXT_SEPARATOR,
1169
+ PO_PLURAL_ARG,
1170
+ parsePO,
1171
+ readPO,
1172
+ writePO,
1173
+ emptyPO,
1174
+ entryKey,
1175
+ headerField,
1176
+ parsePluralForms,
1177
+ compilePluralExpression,
1178
+ synthesizePluralForms,
1179
+ pluralIndexSelectors,
1180
+ targetPluralInfo,
1181
+ gettextPluralForms,
1182
+ poPluralSlots,
1183
+ poFuzzyKeys,
1184
+ poPluralFindings,
1185
+ icuToFormsWithCopies,
1186
+ PO_COPIED_FORMS_MARK,
1187
+ };