glotfile 0.8.6 → 0.8.8

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,9 +6,11 @@ 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
- - **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.
13
+ - **AI translation** — fill in missing languages with Anthropic, OpenAI, AWS Bedrock, OpenRouter, Claude Code, or a local Ollama model, using per-key context, a glossary, and screenshots.
12
14
  - **Export anywhere** — generate Flutter ARB, Laravel PHP, i18next JSON, gettext `.po`, and Apple `.stringsdict` from the same source.
13
15
 
14
16
  ---
@@ -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
 
@@ -181,11 +181,11 @@ in the project directory. For example:
181
181
  ANTHROPIC_API_KEY=sk-ant-...
182
182
  ```
183
183
 
184
- Five providers are supported — Anthropic (default), OpenAI, AWS Bedrock
185
- (Amazon Nova, Claude, and Meta Llama), OpenRouter, and Ollama (local,
186
- no API key needed). For the full setup of
184
+ Six providers are supported — Anthropic (default), OpenAI, AWS Bedrock
185
+ (Amazon Nova, Claude, and Meta Llama), OpenRouter, Claude Code, and Ollama
186
+ (local, no API key needed). For the full setup of
187
187
  each — required env vars, model ids, regions, and the optional SDKs to install — see
188
- **[docs/ai-providers.md](docs/ai-providers.md)**.
188
+ **[AI Providers](https://glotfile.dev/docs/ai-translation/ai-providers/)**.
189
189
 
190
190
  What the translator does for you:
191
191
 
@@ -959,17 +959,60 @@ 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
+ }
1002
+ function quotedLiterals(value) {
1003
+ return extractLiterals(value).map((content) => `'${content}'`);
1004
+ }
962
1005
  function toLaravel(value) {
963
1006
  if (isIcuPluralOrSelect(value)) return value;
964
- return value.replace(/\{(\w+)\}/g, ":$1");
1007
+ return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
965
1008
  }
966
1009
  function toI18next(value) {
967
1010
  if (isIcuPluralOrSelect(value)) return value;
968
- return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
1011
+ return withLiterals(value, (gap) => gap.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}"), (lit) => lit);
969
1012
  }
970
1013
  function toRuby(value) {
971
1014
  if (isIcuPluralOrSelect(value)) return value;
972
- return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
1015
+ return withLiterals(value, (gap) => gap.replace(/(?<!%)\{(\w+)\}/g, "%{$1}"), (lit) => lit);
973
1016
  }
974
1017
  function placeholdersMatch(source, translation) {
975
1018
  const a = extractPlaceholders(source).sort();
@@ -1186,6 +1229,20 @@ var init_laravel_php = __esm({
1186
1229
  message: "laravel-php cannot represent ICU plural/select; written unconverted"
1187
1230
  });
1188
1231
  }
1232
+ if (raw) {
1233
+ const names = new Set(extractPlaceholders(raw));
1234
+ for (const m of raw.matchAll(/:([a-zA-Z][a-zA-Z0-9_]*)/g)) {
1235
+ if (names.has(m[1])) {
1236
+ warnings.push({
1237
+ code: "lossy-literal",
1238
+ key,
1239
+ locale,
1240
+ message: `literal ":${m[1]}" collides with the :${m[1]} placeholder; Laravel will interpolate both`
1241
+ });
1242
+ break;
1243
+ }
1244
+ }
1245
+ }
1189
1246
  (tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
1190
1247
  }
1191
1248
  }
@@ -1277,6 +1334,9 @@ var init_i18next_json = __esm({
1277
1334
  if (raw && isIcuPluralOrSelect(raw)) {
1278
1335
  warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
1279
1336
  }
1337
+ if (raw && extractLiterals(raw).some((lit) => /\{\{\w+\}\}/.test(lit))) {
1338
+ warnings.push({ code: "lossy-literal", key, locale, message: "i18next will interpolate a literal containing {{\u2026}}; i18next has no escape for it" });
1339
+ }
1280
1340
  if (setNested(obj, segments, toI18next(raw))) collided.add(key);
1281
1341
  }
1282
1342
  files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
@@ -1295,7 +1355,9 @@ function poString(s) {
1295
1355
  return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
1296
1356
  }
1297
1357
  function toGettext(body, arg) {
1298
- return body.split(`{${arg}}`).join("%d");
1358
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
1359
+ if (isIcuPluralOrSelect(body)) return gap(body);
1360
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
1299
1361
  }
1300
1362
  var DEFAULT_LOCALE_CASE4, gettextPo;
1301
1363
  var init_gettext_po = __esm({
@@ -1304,6 +1366,7 @@ var init_gettext_po = __esm({
1304
1366
  init_adapters();
1305
1367
  init_options();
1306
1368
  init_plurals();
1369
+ init_placeholders();
1307
1370
  DEFAULT_LOCALE_CASE4 = "lower-hyphen";
1308
1371
  gettextPo = {
1309
1372
  name: "gettext-po",
@@ -1362,8 +1425,10 @@ var init_gettext_po = __esm({
1362
1425
  blocks.push(
1363
1426
  [
1364
1427
  `msgctxt ${poString(key)}`,
1365
- `msgid ${poString(src?.value ?? "")}`,
1366
- `msgstr ${poString(lv?.value ?? "")}`
1428
+ // Scalar keys carry no count arg; toGettext still strips literal
1429
+ // spans and escapes a literal % to %%.
1430
+ `msgid ${poString(toGettext(src?.value ?? "", ""))}`,
1431
+ `msgstr ${poString(toGettext(lv?.value ?? "", ""))}`
1367
1432
  ].join("\n")
1368
1433
  );
1369
1434
  }
@@ -1389,7 +1454,9 @@ function xmlEscape(s) {
1389
1454
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1390
1455
  }
1391
1456
  function toApple(body, arg) {
1392
- return body.split(`{${arg}}`).join("%d");
1457
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
1458
+ if (isIcuPluralOrSelect(body)) return gap(body);
1459
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
1393
1460
  }
1394
1461
  var DEFAULT_LOCALE_CASE5, appleStringsdict;
1395
1462
  var init_apple_stringsdict = __esm({
@@ -1398,6 +1465,7 @@ var init_apple_stringsdict = __esm({
1398
1465
  init_adapters();
1399
1466
  init_options();
1400
1467
  init_schema();
1468
+ init_placeholders();
1401
1469
  DEFAULT_LOCALE_CASE5 = "lower-hyphen";
1402
1470
  appleStringsdict = {
1403
1471
  name: "apple-stringsdict",
@@ -1461,12 +1529,18 @@ var init_apple_stringsdict = __esm({
1461
1529
  function escape(s) {
1462
1530
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
1463
1531
  }
1532
+ function toApple2(value) {
1533
+ const gap = (text) => text.replace(/%/g, "%%");
1534
+ if (isIcuPluralOrSelect(value)) return gap(value);
1535
+ return withLiterals(value, gap, (lit) => lit.replace(/%/g, "%%"));
1536
+ }
1464
1537
  var DEFAULT_LOCALE_CASE6, appleStrings;
1465
1538
  var init_apple_strings = __esm({
1466
1539
  "src/server/adapters/apple-strings.ts"() {
1467
1540
  "use strict";
1468
1541
  init_adapters();
1469
1542
  init_options();
1543
+ init_placeholders();
1470
1544
  DEFAULT_LOCALE_CASE6 = "bcp47-hyphen";
1471
1545
  appleStrings = {
1472
1546
  name: "apple-strings",
@@ -1493,7 +1567,7 @@ var init_apple_strings = __esm({
1493
1567
  if (entry.plural) continue;
1494
1568
  const value = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1495
1569
  if (value === null) continue;
1496
- lines.push(`"${escape(key)}" = "${escape(value)}";`);
1570
+ lines.push(`"${escape(key)}" = "${escape(toApple2(value))}";`);
1497
1571
  }
1498
1572
  const contents = lines.length ? lines.join("\n") + "\n" : "";
1499
1573
  files.push({
@@ -1508,6 +1582,10 @@ var init_apple_strings = __esm({
1508
1582
  });
1509
1583
 
1510
1584
  // src/server/adapters/vue-i18n-json.ts
1585
+ function toVueI18n(value) {
1586
+ if (isIcuPluralOrSelect(value)) return value;
1587
+ return withLiterals(value, (gap) => gap, (content) => `{'${content}'}`);
1588
+ }
1511
1589
  var DEFAULT_LOCALE_CASE7, vueI18nJson;
1512
1590
  var init_vue_i18n_json = __esm({
1513
1591
  "src/server/adapters/vue-i18n-json.ts"() {
@@ -1544,7 +1622,7 @@ var init_vue_i18n_json = __esm({
1544
1622
  if (entry.plural) {
1545
1623
  const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1546
1624
  if (!forms) continue;
1547
- const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
1625
+ const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map(toVueI18n);
1548
1626
  flat[key] = parts.join(" | ");
1549
1627
  } else {
1550
1628
  const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
@@ -1557,7 +1635,7 @@ var init_vue_i18n_json = __esm({
1557
1635
  message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
1558
1636
  });
1559
1637
  }
1560
- flat[key] = raw;
1638
+ flat[key] = toVueI18n(raw);
1561
1639
  }
1562
1640
  }
1563
1641
  let payload = flat;
@@ -1594,27 +1672,30 @@ function angularXMeta(placeholders, name) {
1594
1672
  return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
1595
1673
  }
1596
1674
  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);
1675
+ const convertGap = (gap) => {
1676
+ let out = "";
1677
+ let last = 0;
1678
+ for (const m of gap.matchAll(/\{(\w+)\}/g)) {
1679
+ const name = m[1];
1680
+ out += xmlEscape2(gap.slice(last, m.index));
1681
+ const meta = angularXMeta(placeholders, name);
1682
+ if (meta) {
1683
+ const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
1684
+ const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
1685
+ out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
1686
+ } else {
1687
+ let id = ids.get(name);
1688
+ if (id === void 0) {
1689
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1690
+ ids.set(name, id);
1691
+ }
1692
+ out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
1612
1693
  }
1613
- out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
1694
+ last = m.index + m[0].length;
1614
1695
  }
1615
- last = m.index + m[0].length;
1616
- }
1617
- return out + xmlEscape2(text.slice(last));
1696
+ return out + xmlEscape2(gap.slice(last));
1697
+ };
1698
+ return withLiterals(text, convertGap, (lit) => xmlEscape2(`'${lit}'`));
1618
1699
  }
1619
1700
  function renderPluralIcu(forms, ids, placeholders) {
1620
1701
  const cats = [
@@ -1992,10 +2073,11 @@ function buildSystemPrompt(hasPluralItems) {
1992
2073
  "You are a professional software localization engine for a UI string catalog.",
1993
2074
  "Your goal: translate each source UI string into its target locale accurately and idiomatically, as a native speaker would phrase it in a real app interface.",
1994
2075
  "",
1995
- "You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
2076
+ "You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, an optional `literals` list, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
1996
2077
  "",
1997
2078
  "Hard rules:",
1998
2079
  "- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
2080
+ "- Reproduce every entry of the item's `literals` array EXACTLY, including its surrounding apostrophes (e.g. '{{visitor}}', '{name}'). These are app-managed literal tokens, not prose: translate the words around them, but never translate, rename, unquote, or drop them. The apostrophes are required \u2014 a result with bare {{visitor}} instead of '{{visitor}}' is wrong.",
1999
2081
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
2000
2082
  "- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
2001
2083
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
@@ -2024,6 +2106,7 @@ function buildBatchPrompt(reqs) {
2024
2106
  // Wrap in braces so the model sees "{site}" not "site" — makes the visual
2025
2107
  // connection to the source string obvious and reduces rename errors.
2026
2108
  placeholders: r.placeholders.map((p) => `{${p}}`),
2109
+ ...r.literals?.length ? { literals: r.literals } : {},
2027
2110
  ...r.glossary?.length ? { glossary: r.glossary } : {},
2028
2111
  hasScreenshot: r.image !== void 0
2029
2112
  };
@@ -3084,6 +3167,7 @@ function selectRequests(state, opts) {
3084
3167
  const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
3085
3168
  if (opts.onlyMissing && complete) continue;
3086
3169
  const glossary = relevantGlossary(other, locale, state.glossary);
3170
+ const literals = quotedLiterals(other);
3087
3171
  reqs.push({
3088
3172
  id: String(id++),
3089
3173
  key,
@@ -3093,6 +3177,7 @@ function selectRequests(state, opts) {
3093
3177
  targetLocale: locale,
3094
3178
  maxLength: entry.maxLength,
3095
3179
  placeholders: extractPlaceholders(other),
3180
+ ...literals.length ? { literals } : {},
3096
3181
  ...glossary.length ? { glossary } : {},
3097
3182
  plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
3098
3183
  });
@@ -3105,6 +3190,7 @@ function selectRequests(state, opts) {
3105
3190
  const existing = entry.values[locale]?.value;
3106
3191
  if (opts.onlyMissing && existing) continue;
3107
3192
  const glossary = relevantGlossary(source, locale, state.glossary);
3193
+ const literals = quotedLiterals(source);
3108
3194
  reqs.push({
3109
3195
  id: String(id++),
3110
3196
  key,
@@ -3114,6 +3200,7 @@ function selectRequests(state, opts) {
3114
3200
  targetLocale: locale,
3115
3201
  maxLength: entry.maxLength,
3116
3202
  placeholders: extractPlaceholders(source),
3203
+ ...literals.length ? { literals } : {},
3117
3204
  ...glossary.length ? { glossary } : {}
3118
3205
  });
3119
3206
  }
@@ -3605,7 +3692,8 @@ function buildContextSystemPrompt() {
3605
3692
  "- Do NOT restate the source string itself.",
3606
3693
  "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
3607
3694
  "- Keep it under 500 characters.",
3608
- "- If no code snippets are available, infer from the key path and source value."
3695
+ "- If no code snippets are available, infer from the key path and source value.",
3696
+ "- Tokens: a source may contain interpolation placeholders ({name}, {{name}}, :name, %s) and ICU-apostrophe-quoted LITERAL tokens (e.g. '{{visitor}}', '{name}') that the app fills at runtime. Any provided `literals` are literal tokens, NOT plain placeholders. If you reference a token, write it EXACTLY as it appears in the source \u2014 keep apostrophe-quoted literals quoted, and never relabel a quoted literal as a placeholder or strip its quotes. The translation engine needs these to survive verbatim, so a note may simply remind translators to reproduce them exactly."
3609
3697
  ].join("\n");
3610
3698
  }
3611
3699
  function buildContextBatchPrompt(reqs) {
@@ -3617,7 +3705,14 @@ function buildContextBatchPrompt(reqs) {
3617
3705
  ${s.lines}
3618
3706
  \`\`\``;
3619
3707
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
3620
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
3708
+ const literals = quotedLiterals(r.source);
3709
+ return {
3710
+ id: r.id,
3711
+ key: r.key,
3712
+ source: r.source,
3713
+ ...literals.length ? { literals } : {},
3714
+ codeSnippets: snippetText
3715
+ };
3621
3716
  });
3622
3717
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
3623
3718
  }
@@ -3655,6 +3750,7 @@ var init_context = __esm({
3655
3750
  "src/server/ai/context.ts"() {
3656
3751
  "use strict";
3657
3752
  init_state();
3753
+ init_placeholders();
3658
3754
  MAX_CONTEXT_LENGTH = 500;
3659
3755
  SNIPPET_WINDOW = 15;
3660
3756
  MAX_SNIPPETS = 3;
@@ -4065,7 +4161,7 @@ function extractPrefixes(content, scanner) {
4065
4161
  result.sort((a, b) => a.line - b.line || a.col - b.col);
4066
4162
  return result;
4067
4163
  }
4068
- function extractLiterals(content) {
4164
+ function extractLiterals2(content) {
4069
4165
  const starts = lineStartOffsets(content);
4070
4166
  const result = [];
4071
4167
  const seen = /* @__PURE__ */ new Set();
@@ -4166,7 +4262,7 @@ function runScan(projectRoot, opts, existing) {
4166
4262
  size,
4167
4263
  refs: extractRefs(content, scanner, opts),
4168
4264
  prefixes: extractPrefixes(content, scanner),
4169
- literals: extractLiterals(content)
4265
+ literals: extractLiterals2(content)
4170
4266
  };
4171
4267
  }
4172
4268
  saveUsageCache(projectRoot, cache2);
@@ -4554,6 +4650,9 @@ var init_flatten = __esm({
4554
4650
  // src/server/import/parsers/vue-i18n-json.ts
4555
4651
  import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4556
4652
  import { join as join7 } from "path";
4653
+ function fromVueI18n(value) {
4654
+ return value.replace(/\{'([^']*)'\}/g, "'$1'");
4655
+ }
4557
4656
  var LOCALE_RE2, vueI18nJson2;
4558
4657
  var init_vue_i18n_json2 = __esm({
4559
4658
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -4580,7 +4679,7 @@ var init_vue_i18n_json2 = __esm({
4580
4679
  }
4581
4680
  if (!locales.includes(locale)) locales.push(locale);
4582
4681
  for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
4583
- (keys[key] ??= { values: {} }).values[locale] = value;
4682
+ (keys[key] ??= { values: {} }).values[locale] = fromVueI18n(value);
4584
4683
  }
4585
4684
  }
4586
4685
  return { locales, keys, warnings };
@@ -4590,8 +4689,14 @@ var init_vue_i18n_json2 = __esm({
4590
4689
  });
4591
4690
 
4592
4691
  // src/server/import/placeholders.ts
4692
+ function markBareBracesLiteral(value) {
4693
+ return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
4694
+ }
4593
4695
  function laravelToCanonical(value) {
4594
- return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4696
+ return markBareBracesLiteral(value).replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4697
+ }
4698
+ function railsToCanonical(value) {
4699
+ return markBareBracesLiteral(value).replace(/%\{(\w+)\}/g, "{$1}");
4595
4700
  }
4596
4701
  var init_placeholders2 = __esm({
4597
4702
  "src/server/import/placeholders.ts"() {
@@ -4753,6 +4858,9 @@ function localeFromLproj(dir) {
4753
4858
  if (!m) return null;
4754
4859
  return LOCALE_RE4.test(m[1]) ? m[1] : null;
4755
4860
  }
4861
+ function printfToCanonical(s) {
4862
+ return s.replace(/%%/g, "%");
4863
+ }
4756
4864
  function unescape(body) {
4757
4865
  return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
4758
4866
  const c = esc[0];
@@ -4878,7 +4986,7 @@ var init_apple_strings2 = __esm({
4878
4986
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4879
4987
  }
4880
4988
  for (const { key, value } of parseStrings(text, file, warnings)) {
4881
- (keys[key] ??= { values: {} }).values[locale] = value;
4989
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical(value);
4882
4990
  }
4883
4991
  }
4884
4992
  return { locales, keys, warnings };
@@ -5015,6 +5123,24 @@ function unescapePo(s) {
5015
5123
  (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
5016
5124
  );
5017
5125
  }
5126
+ function printfToCanonical2(s, arg) {
5127
+ let out = "";
5128
+ for (let i = 0; i < s.length; ) {
5129
+ if (s[i] === "%" && s[i + 1] === "%") {
5130
+ out += "%";
5131
+ i += 2;
5132
+ continue;
5133
+ }
5134
+ if (arg && s[i] === "%" && s[i + 1] === "d") {
5135
+ out += `{${arg}}`;
5136
+ i += 2;
5137
+ continue;
5138
+ }
5139
+ out += s[i];
5140
+ i++;
5141
+ }
5142
+ return out;
5143
+ }
5018
5144
  function parseEntries(text) {
5019
5145
  const entries = [];
5020
5146
  let cur = null;
@@ -5143,13 +5269,13 @@ var init_gettext_po2 = __esm({
5143
5269
  );
5144
5270
  continue;
5145
5271
  }
5146
- forms[cat] = body.split("%d").join("{count}");
5272
+ forms[cat] = printfToCanonical2(body, "count");
5147
5273
  }
5148
5274
  if (!forms.other) continue;
5149
5275
  (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
5150
5276
  } else {
5151
5277
  if (!entry.msgstr) continue;
5152
- (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
5278
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical2(entry.msgstr, "");
5153
5279
  }
5154
5280
  }
5155
5281
  }
@@ -5259,9 +5385,6 @@ var init_i18next_json2 = __esm({
5259
5385
  // src/server/import/parsers/rails-yaml.ts
5260
5386
  import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5261
5387
  import { join as join14 } from "path";
5262
- function fromRuby(value) {
5263
- return value.replace(/%\{(\w+)\}/g, "{$1}");
5264
- }
5265
5388
  function makeNode() {
5266
5389
  return /* @__PURE__ */ Object.create(null);
5267
5390
  }
@@ -5447,7 +5570,7 @@ function synthesizeIcu(forms, file, key, warnings) {
5447
5570
  `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5448
5571
  );
5449
5572
  }
5450
- parts.push(`${cat} {${fromRuby(body)}}`);
5573
+ parts.push(`${cat} {${railsToCanonical(body)}}`);
5451
5574
  }
5452
5575
  return `{count, plural, ${parts.join(" ")}}`;
5453
5576
  }
@@ -5456,6 +5579,7 @@ var init_rails_yaml2 = __esm({
5456
5579
  "src/server/import/parsers/rails-yaml.ts"() {
5457
5580
  "use strict";
5458
5581
  init_schema();
5582
+ init_placeholders2();
5459
5583
  LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5460
5584
  CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5461
5585
  railsYaml2 = {
@@ -5472,7 +5596,7 @@ var init_rails_yaml2 = __esm({
5472
5596
  for (const [k, v] of Object.entries(node)) {
5473
5597
  const key = prefix ? `${prefix}.${k}` : k;
5474
5598
  if (typeof v === "string") {
5475
- if (v !== "") addValue(key, locale, fromRuby(v));
5599
+ if (v !== "") addValue(key, locale, railsToCanonical(v));
5476
5600
  continue;
5477
5601
  }
5478
5602
  const forms = asPluralForms(v);
@@ -5598,6 +5722,24 @@ function parsePlistDict(xml) {
5598
5722
  if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
5599
5723
  return tag.selfClosing ? {} : readDict();
5600
5724
  }
5725
+ function printfToCanonical3(body, token, arg) {
5726
+ let out = "";
5727
+ for (let i = 0; i < body.length; ) {
5728
+ if (body[i] === "%" && body[i + 1] === "%") {
5729
+ out += "%";
5730
+ i += 2;
5731
+ continue;
5732
+ }
5733
+ if (body.startsWith(token, i)) {
5734
+ out += `{${arg}}`;
5735
+ i += token.length;
5736
+ continue;
5737
+ }
5738
+ out += body[i];
5739
+ i++;
5740
+ }
5741
+ return out;
5742
+ }
5601
5743
  function entryToIcu(key, entry, file, warnings) {
5602
5744
  const warn = (msg) => {
5603
5745
  warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
@@ -5626,7 +5768,7 @@ function entryToIcu(key, entry, file, warnings) {
5626
5768
  for (const cat of PLURAL_CATEGORIES) {
5627
5769
  const body = varDict[cat];
5628
5770
  if (typeof body !== "string") continue;
5629
- forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
5771
+ forms[cat] = prefix + printfToCanonical3(body, token, arg) + suffix;
5630
5772
  }
5631
5773
  if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5632
5774
  return formsToIcu(arg, forms);