glotfile 0.8.6 → 0.8.7

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
@@ -6,6 +6,8 @@ web UI, and you export to whatever locale formats your apps consume — no SaaS,
6
6
  no hosted database, nothing leaves your machine except the AI calls you choose
7
7
  to make.
8
8
 
9
+ **[glotfile.dev](https://glotfile.dev)** · [Docs](https://glotfile.dev/docs/) · [npm](https://www.npmjs.com/package/glotfile)
10
+
9
11
  - **One source of truth** — every string and translation lives in `glotfile.json`, committed alongside your code. Versioning, review, and rollback come from git. For large catalogs, run `glotfile split` to store the catalog as a `glotfile/` directory with one file per locale — so a one-locale change is a one-file `git diff` instead of a multi-megabyte one. The in-app experience is identical.
10
12
  - **Local web UI** — run one command, edit in the browser, changes save straight back to the file.
11
13
  - **AI translation** — fill in missing languages with Anthropic, OpenAI, AWS Bedrock, OpenRouter, or a local Ollama model, using per-key context, a glossary, and screenshots.
@@ -19,21 +21,19 @@ to make.
19
21
 
20
22
  ## Getting started
21
23
 
22
- Glotfile is designed to run with no install via `npx glotfile`. It's pre-1.0 and
23
- not yet published to npm, so for now run it from a checkout of this repo:
24
+ Glotfile runs with no install via `npx`. It's pre-1.0, so expect rough edges:
24
25
 
25
26
  ```bash
26
- npm install
27
- npm run build # builds the UI + server into dist/
28
- node bin/glotfile.js # same as the `glotfile` command
27
+ npx glotfile
29
28
  ```
30
29
 
31
30
  That starts a local server bound to `127.0.0.1`, opens your browser, and — if
32
31
  there's no `glotfile.json` in the current directory yet — starts from sensible
33
32
  defaults and writes the file as soon as you make your first edit.
34
33
 
35
- > Working on Glotfile itself? `npm run dev` runs the Vite UI with hot-reload and
36
- > the server side-by-side.
34
+ > Working on Glotfile itself? Clone the repo, then `npm install` and `npm run dev`
35
+ > to run the Vite UI with hot-reload alongside the server. `npm run build` followed
36
+ > by `node bin/glotfile.js` runs the built CLI exactly like the published `glotfile`.
37
37
 
38
38
  ---
39
39
 
@@ -959,17 +959,57 @@ function extractPlaceholders(value) {
959
959
  function isIcuPluralOrSelect(value) {
960
960
  return ICU_PLURAL_SELECT.test(value);
961
961
  }
962
+ function withLiterals(value, convertGap, emitLiteral) {
963
+ let out = "";
964
+ let gap = "";
965
+ const flushGap = () => {
966
+ if (gap) {
967
+ out += convertGap(gap);
968
+ gap = "";
969
+ }
970
+ };
971
+ for (let i = 0; i < value.length; ) {
972
+ if (value[i] === "'") {
973
+ const next = value[i + 1];
974
+ if (next === "'") {
975
+ gap += "'";
976
+ i += 2;
977
+ continue;
978
+ }
979
+ if (next === "{" || next === "}" || next === "#" || next === "|") {
980
+ flushGap();
981
+ let j = i + 1;
982
+ while (j < value.length && value[j] !== "'") j++;
983
+ out += emitLiteral(value.slice(i + 1, j));
984
+ i = j < value.length ? j + 1 : j;
985
+ continue;
986
+ }
987
+ }
988
+ gap += value[i];
989
+ i++;
990
+ }
991
+ flushGap();
992
+ return out;
993
+ }
994
+ function extractLiterals(value) {
995
+ const out = [];
996
+ withLiterals(value, () => "", (lit) => {
997
+ out.push(lit);
998
+ return "";
999
+ });
1000
+ return out;
1001
+ }
962
1002
  function toLaravel(value) {
963
1003
  if (isIcuPluralOrSelect(value)) return value;
964
- return value.replace(/\{(\w+)\}/g, ":$1");
1004
+ return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
965
1005
  }
966
1006
  function toI18next(value) {
967
1007
  if (isIcuPluralOrSelect(value)) return value;
968
- return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
1008
+ return withLiterals(value, (gap) => gap.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}"), (lit) => lit);
969
1009
  }
970
1010
  function toRuby(value) {
971
1011
  if (isIcuPluralOrSelect(value)) return value;
972
- return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
1012
+ return withLiterals(value, (gap) => gap.replace(/(?<!%)\{(\w+)\}/g, "%{$1}"), (lit) => lit);
973
1013
  }
974
1014
  function placeholdersMatch(source, translation) {
975
1015
  const a = extractPlaceholders(source).sort();
@@ -1186,6 +1226,20 @@ var init_laravel_php = __esm({
1186
1226
  message: "laravel-php cannot represent ICU plural/select; written unconverted"
1187
1227
  });
1188
1228
  }
1229
+ if (raw) {
1230
+ const names = new Set(extractPlaceholders(raw));
1231
+ for (const m of raw.matchAll(/:([a-zA-Z][a-zA-Z0-9_]*)/g)) {
1232
+ if (names.has(m[1])) {
1233
+ warnings.push({
1234
+ code: "lossy-literal",
1235
+ key,
1236
+ locale,
1237
+ message: `literal ":${m[1]}" collides with the :${m[1]} placeholder; Laravel will interpolate both`
1238
+ });
1239
+ break;
1240
+ }
1241
+ }
1242
+ }
1189
1243
  (tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
1190
1244
  }
1191
1245
  }
@@ -1277,6 +1331,9 @@ var init_i18next_json = __esm({
1277
1331
  if (raw && isIcuPluralOrSelect(raw)) {
1278
1332
  warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
1279
1333
  }
1334
+ if (raw && extractLiterals(raw).some((lit) => /\{\{\w+\}\}/.test(lit))) {
1335
+ warnings.push({ code: "lossy-literal", key, locale, message: "i18next will interpolate a literal containing {{\u2026}}; i18next has no escape for it" });
1336
+ }
1280
1337
  if (setNested(obj, segments, toI18next(raw))) collided.add(key);
1281
1338
  }
1282
1339
  files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
@@ -1295,7 +1352,9 @@ function poString(s) {
1295
1352
  return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
1296
1353
  }
1297
1354
  function toGettext(body, arg) {
1298
- return body.split(`{${arg}}`).join("%d");
1355
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
1356
+ if (isIcuPluralOrSelect(body)) return gap(body);
1357
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
1299
1358
  }
1300
1359
  var DEFAULT_LOCALE_CASE4, gettextPo;
1301
1360
  var init_gettext_po = __esm({
@@ -1304,6 +1363,7 @@ var init_gettext_po = __esm({
1304
1363
  init_adapters();
1305
1364
  init_options();
1306
1365
  init_plurals();
1366
+ init_placeholders();
1307
1367
  DEFAULT_LOCALE_CASE4 = "lower-hyphen";
1308
1368
  gettextPo = {
1309
1369
  name: "gettext-po",
@@ -1362,8 +1422,10 @@ var init_gettext_po = __esm({
1362
1422
  blocks.push(
1363
1423
  [
1364
1424
  `msgctxt ${poString(key)}`,
1365
- `msgid ${poString(src?.value ?? "")}`,
1366
- `msgstr ${poString(lv?.value ?? "")}`
1425
+ // Scalar keys carry no count arg; toGettext still strips literal
1426
+ // spans and escapes a literal % to %%.
1427
+ `msgid ${poString(toGettext(src?.value ?? "", ""))}`,
1428
+ `msgstr ${poString(toGettext(lv?.value ?? "", ""))}`
1367
1429
  ].join("\n")
1368
1430
  );
1369
1431
  }
@@ -1389,7 +1451,9 @@ function xmlEscape(s) {
1389
1451
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1390
1452
  }
1391
1453
  function toApple(body, arg) {
1392
- return body.split(`{${arg}}`).join("%d");
1454
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
1455
+ if (isIcuPluralOrSelect(body)) return gap(body);
1456
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
1393
1457
  }
1394
1458
  var DEFAULT_LOCALE_CASE5, appleStringsdict;
1395
1459
  var init_apple_stringsdict = __esm({
@@ -1398,6 +1462,7 @@ var init_apple_stringsdict = __esm({
1398
1462
  init_adapters();
1399
1463
  init_options();
1400
1464
  init_schema();
1465
+ init_placeholders();
1401
1466
  DEFAULT_LOCALE_CASE5 = "lower-hyphen";
1402
1467
  appleStringsdict = {
1403
1468
  name: "apple-stringsdict",
@@ -1461,12 +1526,18 @@ var init_apple_stringsdict = __esm({
1461
1526
  function escape(s) {
1462
1527
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
1463
1528
  }
1529
+ function toApple2(value) {
1530
+ const gap = (text) => text.replace(/%/g, "%%");
1531
+ if (isIcuPluralOrSelect(value)) return gap(value);
1532
+ return withLiterals(value, gap, (lit) => lit.replace(/%/g, "%%"));
1533
+ }
1464
1534
  var DEFAULT_LOCALE_CASE6, appleStrings;
1465
1535
  var init_apple_strings = __esm({
1466
1536
  "src/server/adapters/apple-strings.ts"() {
1467
1537
  "use strict";
1468
1538
  init_adapters();
1469
1539
  init_options();
1540
+ init_placeholders();
1470
1541
  DEFAULT_LOCALE_CASE6 = "bcp47-hyphen";
1471
1542
  appleStrings = {
1472
1543
  name: "apple-strings",
@@ -1493,7 +1564,7 @@ var init_apple_strings = __esm({
1493
1564
  if (entry.plural) continue;
1494
1565
  const value = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1495
1566
  if (value === null) continue;
1496
- lines.push(`"${escape(key)}" = "${escape(value)}";`);
1567
+ lines.push(`"${escape(key)}" = "${escape(toApple2(value))}";`);
1497
1568
  }
1498
1569
  const contents = lines.length ? lines.join("\n") + "\n" : "";
1499
1570
  files.push({
@@ -1508,6 +1579,10 @@ var init_apple_strings = __esm({
1508
1579
  });
1509
1580
 
1510
1581
  // src/server/adapters/vue-i18n-json.ts
1582
+ function toVueI18n(value) {
1583
+ if (isIcuPluralOrSelect(value)) return value;
1584
+ return withLiterals(value, (gap) => gap, (content) => `{'${content}'}`);
1585
+ }
1511
1586
  var DEFAULT_LOCALE_CASE7, vueI18nJson;
1512
1587
  var init_vue_i18n_json = __esm({
1513
1588
  "src/server/adapters/vue-i18n-json.ts"() {
@@ -1544,7 +1619,7 @@ var init_vue_i18n_json = __esm({
1544
1619
  if (entry.plural) {
1545
1620
  const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1546
1621
  if (!forms) continue;
1547
- const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
1622
+ const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map(toVueI18n);
1548
1623
  flat[key] = parts.join(" | ");
1549
1624
  } else {
1550
1625
  const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
@@ -1557,7 +1632,7 @@ var init_vue_i18n_json = __esm({
1557
1632
  message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
1558
1633
  });
1559
1634
  }
1560
- flat[key] = raw;
1635
+ flat[key] = toVueI18n(raw);
1561
1636
  }
1562
1637
  }
1563
1638
  let payload = flat;
@@ -1594,27 +1669,30 @@ function angularXMeta(placeholders, name) {
1594
1669
  return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
1595
1670
  }
1596
1671
  function renderInterpolations(text, ids, placeholders) {
1597
- let out = "";
1598
- let last = 0;
1599
- for (const m of text.matchAll(/\{(\w+)\}/g)) {
1600
- const name = m[1];
1601
- out += xmlEscape2(text.slice(last, m.index));
1602
- const meta = angularXMeta(placeholders, name);
1603
- if (meta) {
1604
- const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
1605
- const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
1606
- out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
1607
- } else {
1608
- let id = ids.get(name);
1609
- if (id === void 0) {
1610
- id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1611
- ids.set(name, id);
1672
+ const convertGap = (gap) => {
1673
+ let out = "";
1674
+ let last = 0;
1675
+ for (const m of gap.matchAll(/\{(\w+)\}/g)) {
1676
+ const name = m[1];
1677
+ out += xmlEscape2(gap.slice(last, m.index));
1678
+ const meta = angularXMeta(placeholders, name);
1679
+ if (meta) {
1680
+ const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
1681
+ const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
1682
+ out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
1683
+ } else {
1684
+ let id = ids.get(name);
1685
+ if (id === void 0) {
1686
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1687
+ ids.set(name, id);
1688
+ }
1689
+ out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
1612
1690
  }
1613
- out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
1691
+ last = m.index + m[0].length;
1614
1692
  }
1615
- last = m.index + m[0].length;
1616
- }
1617
- return out + xmlEscape2(text.slice(last));
1693
+ return out + xmlEscape2(gap.slice(last));
1694
+ };
1695
+ return withLiterals(text, convertGap, (lit) => xmlEscape2(`'${lit}'`));
1618
1696
  }
1619
1697
  function renderPluralIcu(forms, ids, placeholders) {
1620
1698
  const cats = [
@@ -4065,7 +4143,7 @@ function extractPrefixes(content, scanner) {
4065
4143
  result.sort((a, b) => a.line - b.line || a.col - b.col);
4066
4144
  return result;
4067
4145
  }
4068
- function extractLiterals(content) {
4146
+ function extractLiterals2(content) {
4069
4147
  const starts = lineStartOffsets(content);
4070
4148
  const result = [];
4071
4149
  const seen = /* @__PURE__ */ new Set();
@@ -4166,7 +4244,7 @@ function runScan(projectRoot, opts, existing) {
4166
4244
  size,
4167
4245
  refs: extractRefs(content, scanner, opts),
4168
4246
  prefixes: extractPrefixes(content, scanner),
4169
- literals: extractLiterals(content)
4247
+ literals: extractLiterals2(content)
4170
4248
  };
4171
4249
  }
4172
4250
  saveUsageCache(projectRoot, cache2);
@@ -4554,6 +4632,9 @@ var init_flatten = __esm({
4554
4632
  // src/server/import/parsers/vue-i18n-json.ts
4555
4633
  import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4556
4634
  import { join as join7 } from "path";
4635
+ function fromVueI18n(value) {
4636
+ return value.replace(/\{'([^']*)'\}/g, "'$1'");
4637
+ }
4557
4638
  var LOCALE_RE2, vueI18nJson2;
4558
4639
  var init_vue_i18n_json2 = __esm({
4559
4640
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -4580,7 +4661,7 @@ var init_vue_i18n_json2 = __esm({
4580
4661
  }
4581
4662
  if (!locales.includes(locale)) locales.push(locale);
4582
4663
  for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
4583
- (keys[key] ??= { values: {} }).values[locale] = value;
4664
+ (keys[key] ??= { values: {} }).values[locale] = fromVueI18n(value);
4584
4665
  }
4585
4666
  }
4586
4667
  return { locales, keys, warnings };
@@ -4590,8 +4671,14 @@ var init_vue_i18n_json2 = __esm({
4590
4671
  });
4591
4672
 
4592
4673
  // src/server/import/placeholders.ts
4674
+ function markBareBracesLiteral(value) {
4675
+ return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
4676
+ }
4593
4677
  function laravelToCanonical(value) {
4594
- return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4678
+ return markBareBracesLiteral(value).replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4679
+ }
4680
+ function railsToCanonical(value) {
4681
+ return markBareBracesLiteral(value).replace(/%\{(\w+)\}/g, "{$1}");
4595
4682
  }
4596
4683
  var init_placeholders2 = __esm({
4597
4684
  "src/server/import/placeholders.ts"() {
@@ -4753,6 +4840,9 @@ function localeFromLproj(dir) {
4753
4840
  if (!m) return null;
4754
4841
  return LOCALE_RE4.test(m[1]) ? m[1] : null;
4755
4842
  }
4843
+ function printfToCanonical(s) {
4844
+ return s.replace(/%%/g, "%");
4845
+ }
4756
4846
  function unescape(body) {
4757
4847
  return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
4758
4848
  const c = esc[0];
@@ -4878,7 +4968,7 @@ var init_apple_strings2 = __esm({
4878
4968
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4879
4969
  }
4880
4970
  for (const { key, value } of parseStrings(text, file, warnings)) {
4881
- (keys[key] ??= { values: {} }).values[locale] = value;
4971
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical(value);
4882
4972
  }
4883
4973
  }
4884
4974
  return { locales, keys, warnings };
@@ -5015,6 +5105,24 @@ function unescapePo(s) {
5015
5105
  (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
5016
5106
  );
5017
5107
  }
5108
+ function printfToCanonical2(s, arg) {
5109
+ let out = "";
5110
+ for (let i = 0; i < s.length; ) {
5111
+ if (s[i] === "%" && s[i + 1] === "%") {
5112
+ out += "%";
5113
+ i += 2;
5114
+ continue;
5115
+ }
5116
+ if (arg && s[i] === "%" && s[i + 1] === "d") {
5117
+ out += `{${arg}}`;
5118
+ i += 2;
5119
+ continue;
5120
+ }
5121
+ out += s[i];
5122
+ i++;
5123
+ }
5124
+ return out;
5125
+ }
5018
5126
  function parseEntries(text) {
5019
5127
  const entries = [];
5020
5128
  let cur = null;
@@ -5143,13 +5251,13 @@ var init_gettext_po2 = __esm({
5143
5251
  );
5144
5252
  continue;
5145
5253
  }
5146
- forms[cat] = body.split("%d").join("{count}");
5254
+ forms[cat] = printfToCanonical2(body, "count");
5147
5255
  }
5148
5256
  if (!forms.other) continue;
5149
5257
  (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
5150
5258
  } else {
5151
5259
  if (!entry.msgstr) continue;
5152
- (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
5260
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical2(entry.msgstr, "");
5153
5261
  }
5154
5262
  }
5155
5263
  }
@@ -5259,9 +5367,6 @@ var init_i18next_json2 = __esm({
5259
5367
  // src/server/import/parsers/rails-yaml.ts
5260
5368
  import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5261
5369
  import { join as join14 } from "path";
5262
- function fromRuby(value) {
5263
- return value.replace(/%\{(\w+)\}/g, "{$1}");
5264
- }
5265
5370
  function makeNode() {
5266
5371
  return /* @__PURE__ */ Object.create(null);
5267
5372
  }
@@ -5447,7 +5552,7 @@ function synthesizeIcu(forms, file, key, warnings) {
5447
5552
  `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5448
5553
  );
5449
5554
  }
5450
- parts.push(`${cat} {${fromRuby(body)}}`);
5555
+ parts.push(`${cat} {${railsToCanonical(body)}}`);
5451
5556
  }
5452
5557
  return `{count, plural, ${parts.join(" ")}}`;
5453
5558
  }
@@ -5456,6 +5561,7 @@ var init_rails_yaml2 = __esm({
5456
5561
  "src/server/import/parsers/rails-yaml.ts"() {
5457
5562
  "use strict";
5458
5563
  init_schema();
5564
+ init_placeholders2();
5459
5565
  LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5460
5566
  CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5461
5567
  railsYaml2 = {
@@ -5472,7 +5578,7 @@ var init_rails_yaml2 = __esm({
5472
5578
  for (const [k, v] of Object.entries(node)) {
5473
5579
  const key = prefix ? `${prefix}.${k}` : k;
5474
5580
  if (typeof v === "string") {
5475
- if (v !== "") addValue(key, locale, fromRuby(v));
5581
+ if (v !== "") addValue(key, locale, railsToCanonical(v));
5476
5582
  continue;
5477
5583
  }
5478
5584
  const forms = asPluralForms(v);
@@ -5598,6 +5704,24 @@ function parsePlistDict(xml) {
5598
5704
  if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
5599
5705
  return tag.selfClosing ? {} : readDict();
5600
5706
  }
5707
+ function printfToCanonical3(body, token, arg) {
5708
+ let out = "";
5709
+ for (let i = 0; i < body.length; ) {
5710
+ if (body[i] === "%" && body[i + 1] === "%") {
5711
+ out += "%";
5712
+ i += 2;
5713
+ continue;
5714
+ }
5715
+ if (body.startsWith(token, i)) {
5716
+ out += `{${arg}}`;
5717
+ i += token.length;
5718
+ continue;
5719
+ }
5720
+ out += body[i];
5721
+ i++;
5722
+ }
5723
+ return out;
5724
+ }
5601
5725
  function entryToIcu(key, entry, file, warnings) {
5602
5726
  const warn = (msg) => {
5603
5727
  warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
@@ -5626,7 +5750,7 @@ function entryToIcu(key, entry, file, warnings) {
5626
5750
  for (const cat of PLURAL_CATEGORIES) {
5627
5751
  const body = varDict[cat];
5628
5752
  if (typeof body !== "string") continue;
5629
- forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
5753
+ forms[cat] = prefix + printfToCanonical3(body, token, arg) + suffix;
5630
5754
  }
5631
5755
  if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5632
5756
  return formsToIcu(arg, forms);