@verbaly/compiler 0.21.0 → 0.22.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 +12 -1
- package/dist/cli.js +91 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +76 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npx verbaly extract # sync catalogs + types
|
|
|
24
24
|
npx verbaly check # exit 1 if anything is missing (CI)
|
|
25
25
|
npx verbaly extract --prune # drop orphaned keys
|
|
26
26
|
npx verbaly translate # fill missing translations via Claude (or your provider)
|
|
27
|
-
npx verbaly export #
|
|
27
|
+
npx verbaly export # translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
|
|
28
28
|
npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV files
|
|
29
29
|
npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
|
|
30
30
|
npx verbaly render # pre-fill data-verbaly HTML per locale (SSG — kills the FOUC)
|
|
@@ -54,6 +54,17 @@ npx verbaly import verbaly-export/es.xlf # fill the catalog back
|
|
|
54
54
|
|
|
55
55
|
`export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). `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.
|
|
56
56
|
|
|
57
|
+
## 📱 Mobile resources
|
|
58
|
+
|
|
59
|
+
The same catalogs can ship to a companion mobile app as drop-in native resources:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx verbaly export --format android-xml # verbaly-export/values-<locale>/strings.xml (drop into res/)
|
|
63
|
+
npx verbaly export --format ios-strings # verbaly-export/<locale>.lproj/Localizable.strings (drop into Xcode)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Your source locale becomes the platform default (`values/strings.xml`, `en.lproj`), and untranslated keys are skipped so the app falls back to it natively instead of showing empty text. Keys are sanitized to valid Android resource names (`hero.title` → `hero_title`; a collision fails loudly), values keep Verbaly's `{name}` syntax. Export-only by design: translations flow from your catalogs to the app.
|
|
67
|
+
|
|
57
68
|
## 📄 Static rendering (SSG)
|
|
58
69
|
|
|
59
70
|
`verbaly render` walks your built site (`dist/` by default, `--site <path>` to change) and pre-fills every `data-verbaly` element **per locale** using the real runtime — plurals, `Intl` formatting, `data-verbaly-args`, attribute translation and `data-verbaly-rich` (same whitelist, XSS-safe). The source locale is filled in place; every other locale is mirrored to `dist/<locale>/…` with `<html lang>` set. Static HTML ships already translated — **no flash of untranslated content** — and the runtime attributes stay put, so client-side locale switching keeps working.
|
package/dist/cli.js
CHANGED
|
@@ -13,11 +13,17 @@ function catalogPath(cfg, locale) {
|
|
|
13
13
|
return join(cfg.dir, `${locale}.json`);
|
|
14
14
|
}
|
|
15
15
|
function readCatalog(cfg, locale) {
|
|
16
|
+
let content;
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
+
content = readFileSync(catalogPath(cfg, locale), "utf8");
|
|
18
19
|
} catch {
|
|
19
20
|
return {};
|
|
20
21
|
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(content.replace(/^\uFEFF/, ""));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`[verbaly] ${catalogPath(cfg, locale)} is not valid JSON, fix or delete the file`, { cause: error });
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
function loadCatalogs(cfg) {
|
|
23
29
|
const catalogs = {};
|
|
@@ -1006,6 +1012,45 @@ function readDeps(root) {
|
|
|
1006
1012
|
}
|
|
1007
1013
|
}
|
|
1008
1014
|
//#endregion
|
|
1015
|
+
//#region src/mobile.ts
|
|
1016
|
+
function androidValuesDir(locale, sourceLocale) {
|
|
1017
|
+
if (locale === sourceLocale) return "values";
|
|
1018
|
+
const parts = locale.split("-");
|
|
1019
|
+
if (parts.length === 1) return `values-${locale}`;
|
|
1020
|
+
if (parts.length === 2 && /^[a-zA-Z]{2}$/.test(parts[1])) return `values-${parts[0]}-r${parts[1].toUpperCase()}`;
|
|
1021
|
+
return `values-b+${parts.join("+")}`;
|
|
1022
|
+
}
|
|
1023
|
+
function toAndroidXml(entries) {
|
|
1024
|
+
const names = /* @__PURE__ */ new Map();
|
|
1025
|
+
return [
|
|
1026
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
1027
|
+
"<resources>",
|
|
1028
|
+
...entries.map(({ key, text }) => {
|
|
1029
|
+
const name = androidName(key);
|
|
1030
|
+
const clash = names.get(name);
|
|
1031
|
+
if (clash !== void 0) throw new Error(`[verbaly] android-xml: keys "${clash}" and "${key}" both become resource name "${name}", rename one of them.`);
|
|
1032
|
+
names.set(name, key);
|
|
1033
|
+
return ` <string name="${name}">${androidText(text)}</string>`;
|
|
1034
|
+
}),
|
|
1035
|
+
"</resources>",
|
|
1036
|
+
""
|
|
1037
|
+
].join("\n");
|
|
1038
|
+
}
|
|
1039
|
+
function toIosStrings(entries) {
|
|
1040
|
+
return [...entries.map(({ key, text }) => `"${iosText(key)}" = "${iosText(text)}";`), ""].join("\n");
|
|
1041
|
+
}
|
|
1042
|
+
function androidName(key) {
|
|
1043
|
+
const name = key.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1044
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
1045
|
+
}
|
|
1046
|
+
function androidText(text) {
|
|
1047
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1048
|
+
return /^[@?]/.test(escaped) ? `\\${escaped}` : escaped;
|
|
1049
|
+
}
|
|
1050
|
+
function iosText(text) {
|
|
1051
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1052
|
+
}
|
|
1053
|
+
//#endregion
|
|
1009
1054
|
//#region src/translate.ts
|
|
1010
1055
|
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
1011
1056
|
const batchSize = options.batchSize ?? 20;
|
|
@@ -1064,11 +1109,15 @@ function sameMembers(a, b) {
|
|
|
1064
1109
|
}
|
|
1065
1110
|
//#endregion
|
|
1066
1111
|
//#region src/exchange.ts
|
|
1112
|
+
function isMobileFormat(format) {
|
|
1113
|
+
return format === "android-xml" || format === "ios-strings";
|
|
1114
|
+
}
|
|
1067
1115
|
function exportCatalogs(cfg, catalogs, options = {}) {
|
|
1068
1116
|
const format = options.format ?? "xliff";
|
|
1069
1117
|
const dir = resolve(cfg.root, options.out ?? "verbaly-export");
|
|
1070
1118
|
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1071
1119
|
const targets = targetLocales(cfg, options.locales);
|
|
1120
|
+
if (isMobileFormat(format)) return exportMobile(cfg, catalogs, format, dir, source, targets);
|
|
1072
1121
|
const files = [];
|
|
1073
1122
|
mkdirSync(dir, { recursive: true });
|
|
1074
1123
|
for (const locale of targets) {
|
|
@@ -1095,6 +1144,31 @@ function exportCatalogs(cfg, catalogs, options = {}) {
|
|
|
1095
1144
|
files
|
|
1096
1145
|
};
|
|
1097
1146
|
}
|
|
1147
|
+
function exportMobile(cfg, catalogs, format, dir, source, targets) {
|
|
1148
|
+
const keys = Object.keys(source).filter((key) => source[key]).sort();
|
|
1149
|
+
const files = [];
|
|
1150
|
+
for (const locale of [cfg.sourceLocale, ...targets]) {
|
|
1151
|
+
const catalog = locale === cfg.sourceLocale ? source : catalogs[locale] ?? {};
|
|
1152
|
+
const entries = keys.map((key) => ({
|
|
1153
|
+
key,
|
|
1154
|
+
text: catalog[key] ?? ""
|
|
1155
|
+
})).filter((entry) => entry.text);
|
|
1156
|
+
const path = join(dir, format === "android-xml" ? join(androidValuesDir(locale, cfg.sourceLocale), "strings.xml") : join(`${locale}.lproj`, "Localizable.strings"));
|
|
1157
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1158
|
+
writeFileSync(path, format === "android-xml" ? toAndroidXml(entries) : toIosStrings(entries));
|
|
1159
|
+
files.push({
|
|
1160
|
+
locale,
|
|
1161
|
+
path,
|
|
1162
|
+
total: entries.length,
|
|
1163
|
+
untranslated: keys.length - entries.length
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
format,
|
|
1168
|
+
dir,
|
|
1169
|
+
files
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1098
1172
|
function importCatalogs(cfg, catalogs, files, options = {}) {
|
|
1099
1173
|
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1100
1174
|
const result = {
|
|
@@ -1527,7 +1601,7 @@ Usage:
|
|
|
1527
1601
|
verbaly extract scan sources, update catalogs and types
|
|
1528
1602
|
verbaly check verify translations are complete (CI)
|
|
1529
1603
|
verbaly translate fill missing translations via a provider (default: claude)
|
|
1530
|
-
verbaly export write translator
|
|
1604
|
+
verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
|
|
1531
1605
|
verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
|
|
1532
1606
|
verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
|
|
1533
1607
|
verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
|
|
@@ -1540,7 +1614,7 @@ Options:
|
|
|
1540
1614
|
--prune drop keys no longer referenced (extract)
|
|
1541
1615
|
--model <id> model override for the claude provider (translate)
|
|
1542
1616
|
--dry-run list what would happen, write nothing (translate, import, extract)
|
|
1543
|
-
--format <f> export format: xliff (default) or
|
|
1617
|
+
--format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
|
|
1544
1618
|
--out <path> export directory (export, default: verbaly-export)
|
|
1545
1619
|
--missing export only untranslated entries (export)
|
|
1546
1620
|
--overwrite replace existing translations on import (import)
|
|
@@ -1684,8 +1758,18 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1684
1758
|
}
|
|
1685
1759
|
if (command === "export") {
|
|
1686
1760
|
const format = values.format ?? "xliff";
|
|
1687
|
-
if (
|
|
1688
|
-
|
|
1761
|
+
if (![
|
|
1762
|
+
"xliff",
|
|
1763
|
+
"csv",
|
|
1764
|
+
"android-xml",
|
|
1765
|
+
"ios-strings"
|
|
1766
|
+
].includes(format)) {
|
|
1767
|
+
console.error(`[verbaly] unknown format "${values.format}", use xliff, csv, android-xml or ios-strings`);
|
|
1768
|
+
process.exitCode = 1;
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (values.missing && isMobileFormat(format)) {
|
|
1772
|
+
console.error(`[verbaly] --missing is for translator formats (xliff, csv): ${format} already skips untranslated keys so the app falls back to the source locale`);
|
|
1689
1773
|
process.exitCode = 1;
|
|
1690
1774
|
return;
|
|
1691
1775
|
}
|
|
@@ -1699,8 +1783,9 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1699
1783
|
console.log("[verbaly] no target locales to export (add locales to your config)");
|
|
1700
1784
|
return;
|
|
1701
1785
|
}
|
|
1786
|
+
const note = isMobileFormat(result.format) ? "untranslated skipped" : "untranslated";
|
|
1702
1787
|
console.log(`[verbaly] exported ${result.files.length} locales (${result.format}) → ${result.dir}`);
|
|
1703
|
-
for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated}
|
|
1788
|
+
for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated} ${note}) → ${file.path}`);
|
|
1704
1789
|
return;
|
|
1705
1790
|
}
|
|
1706
1791
|
if (command === "import") {
|