@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 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 # write translator-ready XLIFF 2.0 / CSV files per locale
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
- return JSON.parse(readFileSync(catalogPath(cfg, locale), "utf8"));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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-ready files per locale (XLIFF 2.0 or CSV)
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 csv (export)
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 (format !== "xliff" && format !== "csv") {
1688
- console.error(`[verbaly] unknown format "${values.format}", use xliff or csv`);
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} untranslated) → ${file.path}`);
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") {