@verbaly/compiler 0.27.0 → 0.28.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/README.md CHANGED
@@ -26,8 +26,8 @@ npx verbaly extract --prune # drop orphaned keys
26
26
  npx verbaly status # translation coverage per locale, at a glance
27
27
  npx verbaly check # exit 1 if anything is missing (CI)
28
28
  npx verbaly translate # fill missing translations via Claude (or your provider)
29
- npx verbaly export # translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
30
- npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV files
29
+ npx verbaly export # translator files (XLIFF 2.0, CSV, gettext PO) or mobile resources (Android, iOS)
30
+ npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV/PO files
31
31
  npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
32
32
  npx verbaly render # pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
33
33
  ```
@@ -51,10 +51,11 @@ Catalogs are **flat JSON**: most TMS platforms (Crowdin, Lokalise, Phrase, …)
51
51
  ```bash
52
52
  npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
53
53
  npx verbaly export --format csv # spreadsheet-friendly: key,source,target,location
54
+ npx verbaly export --format po # gettext PO (msgctxt = key, works with any PO editor)
54
55
  npx verbaly import verbaly-export/es.xlf # fill the catalog back
55
56
  ```
56
57
 
57
- `export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). Every entry carries **where the text lives in your source** (XLIFF `location` notes, a `location` column in CSV), so translators and TMS tools see the context instead of guessing it. `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
58
+ `export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). Every entry carries **where the text lives in your source** (XLIFF `location` notes, a `location` column in CSV, `#:` comments in PO), so translators and TMS tools see the context instead of guessing it. In XLIFF, `{params}` and rich tags travel as **protected inline codes with semantic ids** (`<ph id="name"/>`, `<pc id="em">`), so TMS editors show them as untouchable chips instead of editable raw syntax. `import` reads XLIFF 2.0/1.2, CSV or PO back (PO entries flagged `fuzzy` count as untranslated) and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
58
59
 
59
60
  ## 📱 Mobile resources
60
61
 
package/dist/cli.js CHANGED
@@ -331,7 +331,7 @@ function pseudoLocalize(message) {
331
331
  }
332
332
  const ch = message[i];
333
333
  if (ch === "{") {
334
- const end = matchBrace(message, i);
334
+ const end = matchBrace$1(message, i);
335
335
  out += message.slice(i, end);
336
336
  i = end;
337
337
  continue;
@@ -355,7 +355,7 @@ function pseudoLocalize(message) {
355
355
  const pad = "~".repeat(Math.ceil(letters / 3));
356
356
  return `⟦${out}${pad ? " " + pad : ""}⟧`;
357
357
  }
358
- function matchBrace(message, start) {
358
+ function matchBrace$1(message, start) {
359
359
  let depth = 0;
360
360
  for (let i = start; i < message.length; i++) {
361
361
  const two = message.slice(i, i + 2);
@@ -1143,6 +1143,222 @@ function effectiveDrafts(drafts, catalogs) {
1143
1143
  return out;
1144
1144
  }
1145
1145
  //#endregion
1146
+ //#region src/inline.ts
1147
+ const OPEN_TAG = /^<([a-zA-Z][a-zA-Z0-9-]*)\s*(\/?)>/;
1148
+ function messageToInline(text) {
1149
+ return renderParts(parseParts(text, 0).parts, /* @__PURE__ */ new Map());
1150
+ }
1151
+ function inlineToMessage(xml) {
1152
+ let out = "";
1153
+ let text = "";
1154
+ let i = 0;
1155
+ const flush = () => {
1156
+ out += unescapeXml(text);
1157
+ text = "";
1158
+ };
1159
+ while (i < xml.length) {
1160
+ if (xml[i] === "<") {
1161
+ const slice = xml.slice(i);
1162
+ const ph = /^<ph\b([^>]*?)\/\s*>/.exec(slice) ?? /^<ph\b([^>]*)>\s*<\/ph\s*>/.exec(slice);
1163
+ if (ph) {
1164
+ flush();
1165
+ out += attr(ph[1], "disp") ?? `{${attr(ph[1], "id") ?? "ph"}}`;
1166
+ i += ph[0].length;
1167
+ continue;
1168
+ }
1169
+ const pc = /^<pc\b([^>]*)>/.exec(slice);
1170
+ if (pc) {
1171
+ const close = findPcClose(xml, i + pc[0].length);
1172
+ if (close) {
1173
+ flush();
1174
+ const id = attr(pc[1], "id") ?? "pc";
1175
+ out += attr(pc[1], "dispStart") ?? `<${id}>`;
1176
+ out += inlineToMessage(xml.slice(i + pc[0].length, close.innerEnd));
1177
+ out += attr(pc[1], "dispEnd") ?? `</${id}>`;
1178
+ i = close.end;
1179
+ continue;
1180
+ }
1181
+ }
1182
+ }
1183
+ text += xml[i];
1184
+ i += 1;
1185
+ }
1186
+ flush();
1187
+ return out;
1188
+ }
1189
+ function escapeXml(text) {
1190
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1191
+ }
1192
+ function unescapeXml(text) {
1193
+ return text.replace(/&(lt|gt|amp|quot|apos|#x?[0-9a-fA-F]+);/g, (_, entity) => {
1194
+ if (entity === "lt") return "<";
1195
+ if (entity === "gt") return ">";
1196
+ if (entity === "amp") return "&";
1197
+ if (entity === "quot") return "\"";
1198
+ if (entity === "apos") return "'";
1199
+ const code = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
1200
+ return String.fromCodePoint(code);
1201
+ });
1202
+ }
1203
+ function parseParts(text, pos, closeTag) {
1204
+ const parts = [];
1205
+ let plain = "";
1206
+ const flush = () => {
1207
+ if (plain) parts.push({
1208
+ kind: "text",
1209
+ text: plain
1210
+ });
1211
+ plain = "";
1212
+ };
1213
+ let i = pos;
1214
+ while (i < text.length) {
1215
+ const pair = text[i] + (text[i + 1] ?? "");
1216
+ if (pair === "{{" || pair === "}}") {
1217
+ plain += pair;
1218
+ i += 2;
1219
+ continue;
1220
+ }
1221
+ if (text[i] === "{") {
1222
+ const end = matchBrace(text, i);
1223
+ if (end === -1) {
1224
+ plain += "{";
1225
+ i += 1;
1226
+ continue;
1227
+ }
1228
+ const src = text.slice(i, end + 1);
1229
+ if (hasTopLevelPipe(src)) plain += src;
1230
+ else {
1231
+ flush();
1232
+ parts.push({
1233
+ kind: "ph",
1234
+ name: paramName(src),
1235
+ src
1236
+ });
1237
+ }
1238
+ i = end + 1;
1239
+ continue;
1240
+ }
1241
+ if (text[i] === "<") {
1242
+ if (closeTag && text.startsWith(`</${closeTag}>`, i)) {
1243
+ flush();
1244
+ return {
1245
+ parts,
1246
+ end: i + closeTag.length + 3,
1247
+ closed: true
1248
+ };
1249
+ }
1250
+ const open = OPEN_TAG.exec(text.slice(i));
1251
+ if (open) {
1252
+ const [openSrc, name, selfClose] = open;
1253
+ if (selfClose) {
1254
+ flush();
1255
+ parts.push({
1256
+ kind: "ph",
1257
+ name: idSafe(name),
1258
+ src: openSrc
1259
+ });
1260
+ i += openSrc.length;
1261
+ continue;
1262
+ }
1263
+ const inner = parseParts(text, i + openSrc.length, name);
1264
+ if (inner.closed) {
1265
+ flush();
1266
+ parts.push({
1267
+ kind: "pc",
1268
+ name: idSafe(name),
1269
+ openSrc,
1270
+ closeSrc: `</${name}>`,
1271
+ children: inner.parts
1272
+ });
1273
+ i = inner.end;
1274
+ continue;
1275
+ }
1276
+ }
1277
+ plain += "<";
1278
+ i += 1;
1279
+ continue;
1280
+ }
1281
+ plain += text[i];
1282
+ i += 1;
1283
+ }
1284
+ flush();
1285
+ return {
1286
+ parts,
1287
+ end: i,
1288
+ closed: false
1289
+ };
1290
+ }
1291
+ function matchBrace(text, start) {
1292
+ let depth = 0;
1293
+ for (let i = start; i < text.length; i++) {
1294
+ const pair = text[i] + (text[i + 1] ?? "");
1295
+ if (pair === "{{" || pair === "}}") {
1296
+ i += 1;
1297
+ continue;
1298
+ }
1299
+ if (text[i] === "{") depth += 1;
1300
+ else if (text[i] === "}") {
1301
+ depth -= 1;
1302
+ if (depth === 0) return i;
1303
+ }
1304
+ }
1305
+ return -1;
1306
+ }
1307
+ function hasTopLevelPipe(src) {
1308
+ let depth = 0;
1309
+ for (let i = 0; i < src.length; i++) {
1310
+ const pair = src[i] + (src[i + 1] ?? "");
1311
+ if (pair === "{{" || pair === "}}" || pair === "||") {
1312
+ i += 1;
1313
+ continue;
1314
+ }
1315
+ if (src[i] === "{") depth += 1;
1316
+ else if (src[i] === "}") depth -= 1;
1317
+ else if (src[i] === "|" && depth === 1) return true;
1318
+ }
1319
+ return false;
1320
+ }
1321
+ function paramName(src) {
1322
+ const name = /^\{\s*([^{}|:\s]+)/.exec(src)?.[1];
1323
+ return name ? idSafe(name) : "ph";
1324
+ }
1325
+ function idSafe(name) {
1326
+ return name.replace(/[^a-zA-Z0-9._-]/g, "_") || "ph";
1327
+ }
1328
+ function renderParts(parts, taken) {
1329
+ let out = "";
1330
+ for (const part of parts) if (part.kind === "text") out += escapeXml(part.text);
1331
+ else if (part.kind === "ph") out += `<ph id="${uniqueId(part.name, taken)}" disp="${escapeXml(part.src)}"/>`;
1332
+ else {
1333
+ const id = uniqueId(part.name, taken);
1334
+ out += `<pc id="${id}" dispStart="${escapeXml(part.openSrc)}" dispEnd="${escapeXml(part.closeSrc)}">`;
1335
+ out += renderParts(part.children, taken);
1336
+ out += "</pc>";
1337
+ }
1338
+ return out;
1339
+ }
1340
+ function uniqueId(name, taken) {
1341
+ const count = taken.get(name) ?? 0;
1342
+ taken.set(name, count + 1);
1343
+ return count === 0 ? name : `${name}${count + 1}`;
1344
+ }
1345
+ function findPcClose(xml, from) {
1346
+ const tags = /<pc\b[^>]*>|<\/pc\s*>/g;
1347
+ tags.lastIndex = from;
1348
+ let depth = 1;
1349
+ for (let match = tags.exec(xml); match; match = tags.exec(xml)) {
1350
+ depth += match[0].startsWith("</") ? -1 : 1;
1351
+ if (depth === 0) return {
1352
+ innerEnd: match.index,
1353
+ end: tags.lastIndex
1354
+ };
1355
+ }
1356
+ }
1357
+ function attr(attrs, name) {
1358
+ const value = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(attrs)?.[1];
1359
+ return value === void 0 ? void 0 : unescapeXml(value);
1360
+ }
1361
+ //#endregion
1146
1362
  //#region src/mobile.ts
1147
1363
  function androidValuesDir(locale, sourceLocale) {
1148
1364
  if (locale === sourceLocale) return "values";
@@ -1182,6 +1398,97 @@ function iosText(text) {
1182
1398
  return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1183
1399
  }
1184
1400
  //#endregion
1401
+ //#region src/po.ts
1402
+ function toPo(sourceLocale, locale, entries) {
1403
+ return [
1404
+ [
1405
+ "msgid \"\"",
1406
+ "msgstr \"\"",
1407
+ `${poString("Project-Id-Version: verbaly\n")}`,
1408
+ `${poString("MIME-Version: 1.0\n")}`,
1409
+ `${poString("Content-Type: text/plain; charset=UTF-8\n")}`,
1410
+ `${poString("Content-Transfer-Encoding: 8bit\n")}`,
1411
+ `${poString(`Language: ${locale}\n`)}`,
1412
+ `${poString(`X-Source-Language: ${sourceLocale}\n`)}`
1413
+ ].join("\n"),
1414
+ ...entries.map(({ key, source, target, location }) => [
1415
+ ...location?.length ? [`#: ${location.join(" ")}`] : [],
1416
+ `msgctxt ${poString(key)}`,
1417
+ `msgid ${poString(source)}`,
1418
+ `msgstr ${poString(target)}`
1419
+ ].join("\n")),
1420
+ ""
1421
+ ].join("\n\n");
1422
+ }
1423
+ function parsePo(content) {
1424
+ const entries = {};
1425
+ let locale;
1426
+ let msgctxt;
1427
+ let msgid;
1428
+ let msgstr;
1429
+ let fuzzy = false;
1430
+ let field;
1431
+ const finish = () => {
1432
+ if (msgid === "" && msgctxt === void 0) {
1433
+ const lang = /^Language:[ \t]*(.+?)[ \t]*$/m.exec(msgstr ?? "")?.[1];
1434
+ if (lang) locale = lang;
1435
+ } else if (msgctxt !== void 0 && msgid !== void 0) entries[msgctxt] = fuzzy ? "" : msgstr ?? "";
1436
+ msgctxt = msgid = msgstr = field = void 0;
1437
+ fuzzy = false;
1438
+ };
1439
+ for (const raw of content.replace(/^\uFEFF/, "").split(/\r\n|[\r\n]/)) {
1440
+ const line = raw.trim();
1441
+ if (line === "") {
1442
+ finish();
1443
+ continue;
1444
+ }
1445
+ if (line.startsWith("#")) {
1446
+ if (/^#,.*\bfuzzy\b/.test(line)) fuzzy = true;
1447
+ continue;
1448
+ }
1449
+ const keyword = /^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?)\s+(.*)$/.exec(line);
1450
+ if (keyword) {
1451
+ const [, name, rest] = keyword;
1452
+ const value = poUnquote(rest);
1453
+ if (value === void 0) continue;
1454
+ if (name === "msgctxt") {
1455
+ msgctxt = value;
1456
+ field = "msgctxt";
1457
+ } else if (name === "msgid") {
1458
+ msgid = value;
1459
+ field = "msgid";
1460
+ } else if (name === "msgstr" || name === "msgstr[0]") {
1461
+ msgstr = value;
1462
+ field = "msgstr";
1463
+ } else field = "other";
1464
+ continue;
1465
+ }
1466
+ const continuation = poUnquote(line);
1467
+ if (continuation === void 0 || field === void 0 || field === "other") continue;
1468
+ if (field === "msgctxt") msgctxt = (msgctxt ?? "") + continuation;
1469
+ else if (field === "msgid") msgid = (msgid ?? "") + continuation;
1470
+ else msgstr = (msgstr ?? "") + continuation;
1471
+ }
1472
+ finish();
1473
+ return {
1474
+ locale,
1475
+ entries
1476
+ };
1477
+ }
1478
+ function poString(text) {
1479
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r")}"`;
1480
+ }
1481
+ function poUnquote(text) {
1482
+ const match = /^"((?:[^"\\]|\\.)*)"$/.exec(text);
1483
+ if (!match) return void 0;
1484
+ return match[1].replace(/\\(.)/g, (_, ch) => {
1485
+ if (ch === "n") return "\n";
1486
+ if (ch === "t") return " ";
1487
+ if (ch === "r") return "\r";
1488
+ return ch;
1489
+ });
1490
+ }
1491
+ //#endregion
1185
1492
  //#region src/translate.ts
1186
1493
  async function translateCatalogs(cfg, catalogs, provider, options = {}) {
1187
1494
  const batchSize = options.batchSize ?? 20;
@@ -1263,8 +1570,8 @@ function exportCatalogs(cfg, catalogs, options = {}) {
1263
1570
  }));
1264
1571
  const untranslated = all.filter((entry) => !entry.target);
1265
1572
  const entries = options.missing ? untranslated : all;
1266
- const path = join(dir, `${locale}.${format === "csv" ? "csv" : "xlf"}`);
1267
- writeFileSync(path, format === "csv" ? toCsv(entries) : toXliff(cfg.sourceLocale, locale, entries));
1573
+ const path = join(dir, `${locale}.${format === "xliff" ? "xlf" : format}`);
1574
+ writeFileSync(path, format === "csv" ? toCsv(entries) : format === "po" ? toPo(cfg.sourceLocale, locale, entries) : toXliff(cfg.sourceLocale, locale, entries));
1268
1575
  files.push({
1269
1576
  locale,
1270
1577
  path,
@@ -1352,7 +1659,14 @@ function parseExchangeFile(file, localeOverride) {
1352
1659
  locale: localeOverride ?? basename(file).replace(/\.csv$/i, ""),
1353
1660
  entries: parseCsv(content)
1354
1661
  };
1355
- throw new Error(`[verbaly] ${file}: unsupported format, expected .xlf, .xliff or .csv.`);
1662
+ if (/\.po$/i.test(file)) {
1663
+ const parsed = parsePo(content);
1664
+ return {
1665
+ locale: localeOverride ?? parsed.locale ?? basename(file).replace(/\.po$/i, ""),
1666
+ entries: parsed.entries
1667
+ };
1668
+ }
1669
+ throw new Error(`[verbaly] ${file}: unsupported format, expected .xlf, .xliff, .csv or .po.`);
1356
1670
  }
1357
1671
  function toXliff(sourceLocale, locale, entries) {
1358
1672
  const units = entries.map(({ key, source, target, location }) => [
@@ -1363,8 +1677,8 @@ function toXliff(sourceLocale, locale, entries) {
1363
1677
  " </notes>"
1364
1678
  ] : [],
1365
1679
  ` <segment state="${target ? "translated" : "initial"}">`,
1366
- ` <source>${escapeXml(source)}</source>`,
1367
- ` <target>${escapeXml(target)}</target>`,
1680
+ ` <source>${messageToInline(source)}</source>`,
1681
+ ` <target>${messageToInline(target)}</target>`,
1368
1682
  " </segment>",
1369
1683
  " </unit>"
1370
1684
  ].join("\n")).join("\n");
@@ -1385,30 +1699,16 @@ function parseXliff(content) {
1385
1699
  const id = /\bid\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
1386
1700
  if (!id) continue;
1387
1701
  const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
1388
- entries[unescapeXml(id)] = target === void 0 ? "" : unescapeXml(stripCdata(target));
1702
+ entries[unescapeXml(id)] = target === void 0 ? "" : inlineToMessage(stripCdata(target));
1389
1703
  }
1390
1704
  return {
1391
1705
  locale,
1392
1706
  entries
1393
1707
  };
1394
1708
  }
1395
- function escapeXml(text) {
1396
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1397
- }
1398
1709
  function stripCdata(text) {
1399
1710
  return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
1400
1711
  }
1401
- function unescapeXml(text) {
1402
- return text.replace(/&(lt|gt|amp|quot|apos|#x?[0-9a-fA-F]+);/g, (_, entity) => {
1403
- if (entity === "lt") return "<";
1404
- if (entity === "gt") return ">";
1405
- if (entity === "amp") return "&";
1406
- if (entity === "quot") return "\"";
1407
- if (entity === "apos") return "'";
1408
- const code = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
1409
- return String.fromCodePoint(code);
1410
- });
1411
- }
1412
1712
  function toCsv(entries) {
1413
1713
  return [
1414
1714
  "key,source,target,location",
@@ -1581,7 +1881,8 @@ async function renderSite(cfg, options = {}) {
1581
1881
  const attribute = options.attribute ?? cfg.render.attribute;
1582
1882
  const baseUrl = (options.baseUrl ?? cfg.render.baseUrl)?.replace(/\/+$/, "");
1583
1883
  const wantHreflang = (options.hreflang ?? cfg.render.hreflang ?? true) && baseUrl !== void 0;
1584
- const wantSitemap = (options.sitemap ?? cfg.render.sitemap ?? false) && baseUrl !== void 0;
1884
+ const sitemap = options.sitemap ?? cfg.render.sitemap ?? false;
1885
+ const wantSitemap = sitemap !== false && baseUrl !== void 0;
1585
1886
  const clean = options.clean ?? cfg.render.clean ?? false;
1586
1887
  const catalogs = loadCatalogs(cfg);
1587
1888
  if (clean) {
@@ -1624,7 +1925,7 @@ async function renderSite(cfg, options = {}) {
1624
1925
  writeFileSync(out, result.html);
1625
1926
  }
1626
1927
  }
1627
- if (wantSitemap && urls.length) writeFileSync(join(site, typeof wantSitemap === "string" ? wantSitemap : "sitemap-i18n.xml"), buildSitemap(urls));
1928
+ if (wantSitemap && urls.length) writeFileSync(join(site, typeof sitemap === "string" ? sitemap : "sitemap-i18n.xml"), buildSitemap(urls));
1628
1929
  return {
1629
1930
  files: files.length,
1630
1931
  locales: [...locales],
@@ -2035,8 +2336,8 @@ Usage:
2035
2336
  verbaly check verify translations are complete (CI)
2036
2337
  verbaly translate fill missing translations via a provider (default: claude)
2037
2338
  verbaly review list machine translations awaiting review (--approve marks them reviewed)
2038
- verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
2039
- verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
2339
+ verbaly export write translator files (XLIFF 2.0, CSV, gettext PO) or mobile resources (Android, iOS)
2340
+ verbaly import <files…> fill catalogs back from translated XLIFF/CSV/PO files
2040
2341
  verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
2041
2342
  verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
2042
2343
 
@@ -2054,7 +2355,7 @@ Options:
2054
2355
  --reporter <name> failure format: text (default) or github annotations (check)
2055
2356
  --model <id> model override for the claude provider (translate)
2056
2357
  --dry-run list what would happen, write nothing (translate, import, extract)
2057
- --format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
2358
+ --format <f> export format: xliff (default), csv, po, android-xml or ios-strings (export)
2058
2359
  --out <path> export directory (export, default: verbaly-export)
2059
2360
  --missing export only untranslated entries (export)
2060
2361
  --overwrite replace existing translations on import (import)
@@ -2293,10 +2594,11 @@ async function runCli(args = process.argv.slice(2)) {
2293
2594
  if (![
2294
2595
  "xliff",
2295
2596
  "csv",
2597
+ "po",
2296
2598
  "android-xml",
2297
2599
  "ios-strings"
2298
2600
  ].includes(format)) {
2299
- console.error(`[verbaly] unknown format "${values.format}", use xliff, csv, android-xml or ios-strings`);
2601
+ console.error(`[verbaly] unknown format "${values.format}", use xliff, csv, po, android-xml or ios-strings`);
2300
2602
  process.exitCode = 1;
2301
2603
  return;
2302
2604
  }