@verbaly/compiler 0.26.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/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);
@@ -387,7 +387,7 @@ function resolveConfig(config = {}) {
387
387
  const sourceLocale = config.sourceLocale ?? "en";
388
388
  const locales = /* @__PURE__ */ new Set([sourceLocale, ...config.locales ?? []]);
389
389
  if (existsSync(dir)) {
390
- for (const file of readdirSync(dir)) if (file.endsWith(".json") && file !== `en-XA.json`) locales.add(file.slice(0, -5));
390
+ for (const file of readdirSync(dir)) if (file.endsWith(".json") && !file.startsWith(".") && file !== `en-XA.json`) locales.add(file.slice(0, -5));
391
391
  }
392
392
  return {
393
393
  root,
@@ -1083,6 +1083,282 @@ function readDeps(root) {
1083
1083
  }
1084
1084
  }
1085
1085
  //#endregion
1086
+ //#region src/drafts.ts
1087
+ const DRAFTS_FILE = ".verbaly-drafts.json";
1088
+ function draftsPath(cfg) {
1089
+ return join(cfg.dir, DRAFTS_FILE);
1090
+ }
1091
+ function loadDrafts(cfg) {
1092
+ let content;
1093
+ try {
1094
+ content = readFileSync(draftsPath(cfg), "utf8");
1095
+ } catch {
1096
+ return {};
1097
+ }
1098
+ try {
1099
+ return JSON.parse(content.replace(/^\uFEFF/, ""));
1100
+ } catch (error) {
1101
+ throw new Error(`[verbaly] ${draftsPath(cfg)} is not valid JSON, fix or delete the file`, { cause: error });
1102
+ }
1103
+ }
1104
+ function serializeDrafts(drafts) {
1105
+ const sorted = {};
1106
+ for (const locale of Object.keys(drafts).sort()) {
1107
+ const keys = [...new Set(drafts[locale])].sort();
1108
+ if (keys.length) sorted[locale] = keys;
1109
+ }
1110
+ return JSON.stringify(sorted, null, 2) + "\n";
1111
+ }
1112
+ function saveDrafts(cfg, drafts) {
1113
+ const serialized = serializeDrafts(drafts);
1114
+ const path = draftsPath(cfg);
1115
+ try {
1116
+ if (readFileSync(path, "utf8") === serialized) return;
1117
+ } catch {
1118
+ mkdirSync(cfg.dir, { recursive: true });
1119
+ }
1120
+ writeFileSync(path, serialized);
1121
+ }
1122
+ function markDrafts(drafts, locale, keys) {
1123
+ if (!keys.length) return;
1124
+ drafts[locale] = [.../* @__PURE__ */ new Set([...drafts[locale] ?? [], ...keys])];
1125
+ }
1126
+ function clearDrafts(drafts, locale, keys) {
1127
+ if (!drafts[locale]) return;
1128
+ if (!keys) {
1129
+ delete drafts[locale];
1130
+ return;
1131
+ }
1132
+ const drop = new Set(keys);
1133
+ drafts[locale] = drafts[locale].filter((key) => !drop.has(key));
1134
+ if (!drafts[locale].length) delete drafts[locale];
1135
+ }
1136
+ function effectiveDrafts(drafts, catalogs) {
1137
+ const out = {};
1138
+ for (const [locale, keys] of Object.entries(drafts)) {
1139
+ const catalog = catalogs[locale] ?? {};
1140
+ const live = keys.filter((key) => catalog[key]);
1141
+ if (live.length) out[locale] = live;
1142
+ }
1143
+ return out;
1144
+ }
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
1086
1362
  //#region src/mobile.ts
1087
1363
  function androidValuesDir(locale, sourceLocale) {
1088
1364
  if (locale === sourceLocale) return "values";
@@ -1122,6 +1398,97 @@ function iosText(text) {
1122
1398
  return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1123
1399
  }
1124
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
1125
1492
  //#region src/translate.ts
1126
1493
  async function translateCatalogs(cfg, catalogs, provider, options = {}) {
1127
1494
  const batchSize = options.batchSize ?? 20;
@@ -1143,10 +1510,12 @@ async function translateCatalogs(cfg, catalogs, provider, options = {}) {
1143
1510
  for (let i = 0; i < missing.length; i += batchSize) {
1144
1511
  const keys = missing.slice(i, i + batchSize);
1145
1512
  const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
1513
+ const origins = options.origins ? Object.fromEntries(keys.filter((key) => options.origins[key]).map((key) => [key, options.origins[key]])) : void 0;
1146
1514
  const out = await provider({
1147
1515
  sourceLocale: cfg.sourceLocale,
1148
1516
  targetLocale: locale,
1149
- messages
1517
+ messages,
1518
+ origins
1150
1519
  });
1151
1520
  for (const key of keys) {
1152
1521
  const text = out[key];
@@ -1201,8 +1570,8 @@ function exportCatalogs(cfg, catalogs, options = {}) {
1201
1570
  }));
1202
1571
  const untranslated = all.filter((entry) => !entry.target);
1203
1572
  const entries = options.missing ? untranslated : all;
1204
- const path = join(dir, `${locale}.${format === "csv" ? "csv" : "xlf"}`);
1205
- 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));
1206
1575
  files.push({
1207
1576
  locale,
1208
1577
  path,
@@ -1290,7 +1659,14 @@ function parseExchangeFile(file, localeOverride) {
1290
1659
  locale: localeOverride ?? basename(file).replace(/\.csv$/i, ""),
1291
1660
  entries: parseCsv(content)
1292
1661
  };
1293
- 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.`);
1294
1670
  }
1295
1671
  function toXliff(sourceLocale, locale, entries) {
1296
1672
  const units = entries.map(({ key, source, target, location }) => [
@@ -1301,8 +1677,8 @@ function toXliff(sourceLocale, locale, entries) {
1301
1677
  " </notes>"
1302
1678
  ] : [],
1303
1679
  ` <segment state="${target ? "translated" : "initial"}">`,
1304
- ` <source>${escapeXml(source)}</source>`,
1305
- ` <target>${escapeXml(target)}</target>`,
1680
+ ` <source>${messageToInline(source)}</source>`,
1681
+ ` <target>${messageToInline(target)}</target>`,
1306
1682
  " </segment>",
1307
1683
  " </unit>"
1308
1684
  ].join("\n")).join("\n");
@@ -1323,30 +1699,16 @@ function parseXliff(content) {
1323
1699
  const id = /\bid\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
1324
1700
  if (!id) continue;
1325
1701
  const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
1326
- entries[unescapeXml(id)] = target === void 0 ? "" : unescapeXml(stripCdata(target));
1702
+ entries[unescapeXml(id)] = target === void 0 ? "" : inlineToMessage(stripCdata(target));
1327
1703
  }
1328
1704
  return {
1329
1705
  locale,
1330
1706
  entries
1331
1707
  };
1332
1708
  }
1333
- function escapeXml(text) {
1334
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1335
- }
1336
1709
  function stripCdata(text) {
1337
1710
  return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
1338
1711
  }
1339
- function unescapeXml(text) {
1340
- return text.replace(/&(lt|gt|amp|quot|apos|#x?[0-9a-fA-F]+);/g, (_, entity) => {
1341
- if (entity === "lt") return "<";
1342
- if (entity === "gt") return ">";
1343
- if (entity === "amp") return "&";
1344
- if (entity === "quot") return "\"";
1345
- if (entity === "apos") return "'";
1346
- const code = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
1347
- return String.fromCodePoint(code);
1348
- });
1349
- }
1350
1712
  function toCsv(entries) {
1351
1713
  return [
1352
1714
  "key,source,target,location",
@@ -1519,7 +1881,8 @@ async function renderSite(cfg, options = {}) {
1519
1881
  const attribute = options.attribute ?? cfg.render.attribute;
1520
1882
  const baseUrl = (options.baseUrl ?? cfg.render.baseUrl)?.replace(/\/+$/, "");
1521
1883
  const wantHreflang = (options.hreflang ?? cfg.render.hreflang ?? true) && baseUrl !== void 0;
1522
- 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;
1523
1886
  const clean = options.clean ?? cfg.render.clean ?? false;
1524
1887
  const catalogs = loadCatalogs(cfg);
1525
1888
  if (clean) {
@@ -1562,7 +1925,7 @@ async function renderSite(cfg, options = {}) {
1562
1925
  writeFileSync(out, result.html);
1563
1926
  }
1564
1927
  }
1565
- 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));
1566
1929
  return {
1567
1930
  files: files.length,
1568
1931
  locales: [...locales],
@@ -1670,9 +2033,10 @@ function decodeEntities(text) {
1670
2033
  }
1671
2034
  //#endregion
1672
2035
  //#region src/status.ts
1673
- function status(cfg, catalogs, registry) {
2036
+ function status(cfg, catalogs, registry, drafts = {}) {
1674
2037
  const source = catalogs[cfg.sourceLocale] ?? {};
1675
2038
  const needed = /* @__PURE__ */ new Set([...registry.messages().keys(), ...Object.keys(source)]);
2039
+ const live = effectiveDrafts(drafts, catalogs);
1676
2040
  const locales = [];
1677
2041
  for (const locale of cfg.locales) {
1678
2042
  if (locale === cfg.sourceLocale) continue;
@@ -1681,7 +2045,8 @@ function status(cfg, catalogs, registry) {
1681
2045
  locales.push({
1682
2046
  locale,
1683
2047
  translated,
1684
- total: needed.size
2048
+ total: needed.size,
2049
+ drafts: live[locale]?.length ?? 0
1685
2050
  });
1686
2051
  }
1687
2052
  return {
@@ -1696,10 +2061,11 @@ function formatStatusResult(result) {
1696
2061
  lines.push(" no target locales (add locales to your config)");
1697
2062
  return lines.join("\n");
1698
2063
  }
1699
- for (const { locale, translated, total } of result.locales) {
2064
+ for (const { locale, translated, total, drafts } of result.locales) {
1700
2065
  const pct = total === 0 ? 100 : Math.floor(translated / total * 100);
1701
2066
  const mark = translated === total ? " ✓" : "";
1702
- lines.push(` ${locale}: ${translated}/${total} translated (${pct}%)${mark}`);
2067
+ const draftNote = drafts > 0 ? `, ${drafts} unreviewed` : "";
2068
+ lines.push(` ${locale}: ${translated}/${total} translated (${pct}%${draftNote})${mark}`);
1703
2069
  }
1704
2070
  return lines.join("\n");
1705
2071
  }
@@ -1969,8 +2335,9 @@ Usage:
1969
2335
  verbaly status translation coverage per locale, at a glance
1970
2336
  verbaly check verify translations are complete (CI)
1971
2337
  verbaly translate fill missing translations via a provider (default: claude)
1972
- verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
1973
- verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
2338
+ verbaly review list machine translations awaiting review (--approve marks them reviewed)
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
1974
2341
  verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
1975
2342
  verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
1976
2343
 
@@ -1983,10 +2350,12 @@ Options:
1983
2350
  --watch keep extracting as source files change (extract)
1984
2351
  --write apply the rewrites instead of only reporting (wrap)
1985
2352
  --json machine-readable output (status)
2353
+ --drafts also fail on unreviewed machine translations (check)
2354
+ --approve mark listed drafts as reviewed (review)
1986
2355
  --reporter <name> failure format: text (default) or github annotations (check)
1987
2356
  --model <id> model override for the claude provider (translate)
1988
2357
  --dry-run list what would happen, write nothing (translate, import, extract)
1989
- --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)
1990
2359
  --out <path> export directory (export, default: verbaly-export)
1991
2360
  --missing export only untranslated entries (export)
1992
2361
  --overwrite replace existing translations on import (import)
@@ -2013,6 +2382,8 @@ async function runCli(args = process.argv.slice(2)) {
2013
2382
  watch: { type: "boolean" },
2014
2383
  write: { type: "boolean" },
2015
2384
  json: { type: "boolean" },
2385
+ drafts: { type: "boolean" },
2386
+ approve: { type: "boolean" },
2016
2387
  reporter: { type: "string" },
2017
2388
  model: { type: "string" },
2018
2389
  locale: { type: "string" },
@@ -2131,7 +2502,7 @@ async function runCli(args = process.argv.slice(2)) {
2131
2502
  }
2132
2503
  if (command === "status") {
2133
2504
  const registry = await extractProject(cfg);
2134
- const result = status(cfg, loadCatalogs(cfg), registry);
2505
+ const result = status(cfg, loadCatalogs(cfg), registry, loadDrafts(cfg));
2135
2506
  console.log(values.json ? JSON.stringify(result) : formatStatusResult(result));
2136
2507
  return;
2137
2508
  }
@@ -2143,11 +2514,20 @@ async function runCli(args = process.argv.slice(2)) {
2143
2514
  return;
2144
2515
  }
2145
2516
  const registry = await extractProject(cfg);
2146
- const result = check(cfg, loadCatalogs(cfg), registry);
2147
- if (result.ok) {
2517
+ const catalogs = loadCatalogs(cfg);
2518
+ const result = check(cfg, catalogs, registry);
2519
+ const unreviewed = values.drafts ? effectiveDrafts(loadDrafts(cfg), catalogs) : {};
2520
+ const draftKeys = Object.entries(unreviewed);
2521
+ if (result.ok && draftKeys.length === 0) {
2148
2522
  console.log("[verbaly] all translations complete ✓");
2149
2523
  return;
2150
2524
  }
2525
+ if (result.ok && draftKeys.length > 0) {
2526
+ for (const [locale, keys] of draftKeys) console.error(` [${locale}] ${keys.length} unreviewed: ${keys.join(", ")}`);
2527
+ console.error("[verbaly] check failed: machine translations awaiting review (run verbaly review --approve)");
2528
+ process.exitCode = 1;
2529
+ return;
2530
+ }
2151
2531
  if (reporter === "github") {
2152
2532
  for (const line of githubCheckAnnotations(result, registry, cfg.root)) console.error(line);
2153
2533
  console.error(`[verbaly] check failed: ${result.missing.length} missing, ${result.unknown.length} unknown`);
@@ -2160,7 +2540,8 @@ async function runCli(args = process.argv.slice(2)) {
2160
2540
  const result = await translateCatalogs(cfg, catalogs, await resolveProvider(cfg, values.model), {
2161
2541
  locales: values.locales?.split(","),
2162
2542
  batchSize: cfg.translate.batchSize,
2163
- dryRun: values["dry-run"]
2543
+ dryRun: values["dry-run"],
2544
+ origins: values["dry-run"] ? void 0 : await collectOrigins(cfg)
2164
2545
  });
2165
2546
  if (values["dry-run"]) {
2166
2547
  const entries = Object.entries(result.pending);
@@ -2171,23 +2552,53 @@ async function runCli(args = process.argv.slice(2)) {
2171
2552
  for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.length} missing: ${keys.join(", ")}`);
2172
2553
  return;
2173
2554
  }
2555
+ const drafts = loadDrafts(cfg);
2174
2556
  for (const locale of Object.keys(result.translated)) {
2175
2557
  writeCatalog(cfg, locale, catalogs[locale] ?? {});
2176
- console.log(` ${locale}: +${result.translated[locale].length} translated`);
2558
+ markDrafts(drafts, locale, result.translated[locale]);
2559
+ console.log(` ${locale}: +${result.translated[locale].length} translated (draft)`);
2177
2560
  }
2561
+ if (Object.keys(result.translated).length > 0) saveDrafts(cfg, drafts);
2178
2562
  for (const [locale, keys] of Object.entries(result.invalid)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
2179
2563
  if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) console.log("[verbaly] nothing to translate ✓");
2180
2564
  return;
2181
2565
  }
2566
+ if (command === "review") {
2567
+ const catalogs = loadCatalogs(cfg);
2568
+ const drafts = loadDrafts(cfg);
2569
+ const live = effectiveDrafts(drafts, catalogs);
2570
+ const targets = values.locale ? { [values.locale]: live[values.locale] ?? [] } : live;
2571
+ const entries = Object.entries(targets).filter(([, keys]) => keys.length);
2572
+ if (entries.length === 0) {
2573
+ console.log("[verbaly] no machine translations awaiting review ✓");
2574
+ return;
2575
+ }
2576
+ if (values.approve) {
2577
+ let count = 0;
2578
+ for (const [locale, keys] of entries) {
2579
+ clearDrafts(drafts, locale, keys);
2580
+ count += keys.length;
2581
+ console.log(` ${locale}: ${keys.length} approved`);
2582
+ }
2583
+ saveDrafts(cfg, drafts);
2584
+ console.log(`[verbaly] ${count} translations marked reviewed ✓`);
2585
+ return;
2586
+ }
2587
+ const total = entries.reduce((sum, [, keys]) => sum + keys.length, 0);
2588
+ console.log(`[verbaly] ${total} machine translations awaiting review (--approve to accept)`);
2589
+ for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.join(", ")}`);
2590
+ return;
2591
+ }
2182
2592
  if (command === "export") {
2183
2593
  const format = values.format ?? "xliff";
2184
2594
  if (![
2185
2595
  "xliff",
2186
2596
  "csv",
2597
+ "po",
2187
2598
  "android-xml",
2188
2599
  "ios-strings"
2189
2600
  ].includes(format)) {
2190
- 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`);
2191
2602
  process.exitCode = 1;
2192
2603
  return;
2193
2604
  }
@@ -2225,11 +2636,18 @@ async function runCli(args = process.argv.slice(2)) {
2225
2636
  overwrite: values.overwrite,
2226
2637
  dryRun: values["dry-run"]
2227
2638
  });
2639
+ const drafts = loadDrafts(cfg);
2640
+ let draftsChanged = false;
2228
2641
  for (const [locale, keys] of Object.entries(result.imported)) {
2229
- if (!values["dry-run"]) writeCatalog(cfg, locale, catalogs[locale] ?? {});
2642
+ if (!values["dry-run"]) {
2643
+ writeCatalog(cfg, locale, catalogs[locale] ?? {});
2644
+ clearDrafts(drafts, locale, keys);
2645
+ draftsChanged = true;
2646
+ }
2230
2647
  const verb = values["dry-run"] ? "would import" : "imported";
2231
2648
  console.log(` ${locale}: +${keys.length} ${verb}`);
2232
2649
  }
2650
+ if (draftsChanged) saveDrafts(cfg, drafts);
2233
2651
  for (const [locale, keys] of Object.entries(result.skipped)) console.log(` ${locale}: ${keys.length} already translated, kept (use --overwrite to replace)`);
2234
2652
  for (const [locale, keys] of Object.entries(result.rejected)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
2235
2653
  for (const [locale, keys] of Object.entries(result.unknown)) console.warn(` ${locale}: ${keys.length} unknown keys ignored (not in the source catalog): ${keys.join(", ")}`);
@@ -2277,8 +2695,9 @@ const COMMAND_FLAGS = {
2277
2695
  ],
2278
2696
  wrap: ["write"],
2279
2697
  status: ["json"],
2280
- check: ["reporter"],
2698
+ check: ["reporter", "drafts"],
2281
2699
  translate: ["model", "dry-run"],
2700
+ review: ["approve", "locale"],
2282
2701
  export: [
2283
2702
  "format",
2284
2703
  "out",
@@ -2317,7 +2736,7 @@ async function collectOrigins(cfg) {
2317
2736
  async function resolveProvider(cfg, model) {
2318
2737
  const configured = cfg.translate.provider;
2319
2738
  if (typeof configured === "function") return configured;
2320
- const { claudeProvider } = await import("./claude-C-ETlqsW.js");
2739
+ const { claudeProvider } = await import("./claude-h9WA0ppg.js");
2321
2740
  return claudeProvider({ model: model ?? cfg.translate.model });
2322
2741
  }
2323
2742
  //#endregion