@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 +4 -3
- package/dist/cli.js +330 -28
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +325 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 === "
|
|
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
|
-
|
|
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>${
|
|
1367
|
-
` <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 ? "" :
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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
|
|
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
|
}
|