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.
@@ -1215,6 +1215,116 @@ function runScan(projectRoot, opts, existing) {
1215
1215
  // src/server/ai/context.ts
1216
1216
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
1217
1217
  import { resolve as resolve3 } from "path";
1218
+
1219
+ // src/server/placeholders.ts
1220
+ var ICU_PLURAL_SELECT = /\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/;
1221
+ function withoutQuotedSpans(value) {
1222
+ if (!value.includes("'")) return value;
1223
+ let out = "";
1224
+ for (let i = 0; i < value.length; ) {
1225
+ if (value[i] === "'") {
1226
+ const next = value[i + 1];
1227
+ if (next === "'") {
1228
+ out += " ";
1229
+ i += 2;
1230
+ continue;
1231
+ }
1232
+ if (next === "{" || next === "}" || next === "#" || next === "|") {
1233
+ i += 2;
1234
+ while (i < value.length && value[i] !== "'") i++;
1235
+ i++;
1236
+ continue;
1237
+ }
1238
+ }
1239
+ out += value[i];
1240
+ i++;
1241
+ }
1242
+ return out;
1243
+ }
1244
+ function extractPlaceholders(value) {
1245
+ const scan = withoutQuotedSpans(value);
1246
+ const names = /* @__PURE__ */ new Set();
1247
+ for (const m of scan.matchAll(/\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,/g)) {
1248
+ names.add(m[1]);
1249
+ }
1250
+ if (!isIcuPluralOrSelect(scan)) {
1251
+ for (const m of scan.matchAll(/\{\s*(\w+)\s*\}/g)) names.add(m[1]);
1252
+ }
1253
+ return [...names];
1254
+ }
1255
+ function isIcuPluralOrSelect(value) {
1256
+ return ICU_PLURAL_SELECT.test(value);
1257
+ }
1258
+ function withLiterals(value, convertGap, emitLiteral) {
1259
+ let out = "";
1260
+ let gap = "";
1261
+ const flushGap = () => {
1262
+ if (gap) {
1263
+ out += convertGap(gap);
1264
+ gap = "";
1265
+ }
1266
+ };
1267
+ for (let i = 0; i < value.length; ) {
1268
+ if (value[i] === "'") {
1269
+ const next = value[i + 1];
1270
+ if (next === "'") {
1271
+ gap += "'";
1272
+ i += 2;
1273
+ continue;
1274
+ }
1275
+ if (next === "{" || next === "}" || next === "#" || next === "|") {
1276
+ flushGap();
1277
+ let j = i + 1;
1278
+ while (j < value.length && value[j] !== "'") j++;
1279
+ out += emitLiteral(value.slice(i + 1, j));
1280
+ i = j < value.length ? j + 1 : j;
1281
+ continue;
1282
+ }
1283
+ }
1284
+ gap += value[i];
1285
+ i++;
1286
+ }
1287
+ flushGap();
1288
+ return out;
1289
+ }
1290
+ function extractLiterals2(value) {
1291
+ const out = [];
1292
+ withLiterals(value, () => "", (lit) => {
1293
+ out.push(lit);
1294
+ return "";
1295
+ });
1296
+ return out;
1297
+ }
1298
+ function quotedLiterals(value) {
1299
+ return extractLiterals2(value).map((content) => `'${content}'`);
1300
+ }
1301
+ function toLaravel(value) {
1302
+ if (isIcuPluralOrSelect(value)) return value;
1303
+ return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
1304
+ }
1305
+ function toI18next(value) {
1306
+ if (isIcuPluralOrSelect(value)) return value;
1307
+ return withLiterals(value, (gap) => gap.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}"), (lit) => lit);
1308
+ }
1309
+ function toRuby(value) {
1310
+ if (isIcuPluralOrSelect(value)) return value;
1311
+ return withLiterals(value, (gap) => gap.replace(/(?<!%)\{(\w+)\}/g, "%{$1}"), (lit) => lit);
1312
+ }
1313
+ function placeholdersMatch(source, translation) {
1314
+ const a = extractPlaceholders(source).sort();
1315
+ const b = extractPlaceholders(translation).sort();
1316
+ return a.length === b.length && a.every((x, i) => x === b[i]);
1317
+ }
1318
+ function placeholdersSubset(source, translation) {
1319
+ const allowed = new Set(extractPlaceholders(source));
1320
+ return extractPlaceholders(translation).every((p) => allowed.has(p));
1321
+ }
1322
+ var COUNT_OPTIONAL = /* @__PURE__ */ new Set(["zero", "one", "two"]);
1323
+ function pluralFormPlaceholdersMatch(category, source, form) {
1324
+ return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
1325
+ }
1326
+
1327
+ // src/server/ai/context.ts
1218
1328
  var MAX_CONTEXT_LENGTH = 500;
1219
1329
  var SNIPPET_WINDOW = 15;
1220
1330
  var MAX_SNIPPETS = 3;
@@ -1300,7 +1410,8 @@ function buildContextSystemPrompt() {
1300
1410
  "- Do NOT restate the source string itself.",
1301
1411
  "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
1302
1412
  "- Keep it under 500 characters.",
1303
- "- If no code snippets are available, infer from the key path and source value."
1413
+ "- If no code snippets are available, infer from the key path and source value.",
1414
+ "- 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."
1304
1415
  ].join("\n");
1305
1416
  }
1306
1417
  function buildContextBatchPrompt(reqs) {
@@ -1312,7 +1423,14 @@ function buildContextBatchPrompt(reqs) {
1312
1423
  ${s.lines}
1313
1424
  \`\`\``;
1314
1425
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
1315
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
1426
+ const literals = quotedLiterals(r.source);
1427
+ return {
1428
+ id: r.id,
1429
+ key: r.key,
1430
+ source: r.source,
1431
+ ...literals.length ? { literals } : {},
1432
+ codeSnippets: snippetText
1433
+ };
1316
1434
  });
1317
1435
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
1318
1436
  }
@@ -1476,71 +1594,6 @@ function computeStats(state) {
1476
1594
  };
1477
1595
  }
1478
1596
 
1479
- // src/server/placeholders.ts
1480
- var ICU_PLURAL_SELECT = /\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/;
1481
- function withoutQuotedSpans(value) {
1482
- if (!value.includes("'")) return value;
1483
- let out = "";
1484
- for (let i = 0; i < value.length; ) {
1485
- if (value[i] === "'") {
1486
- const next = value[i + 1];
1487
- if (next === "'") {
1488
- out += " ";
1489
- i += 2;
1490
- continue;
1491
- }
1492
- if (next === "{" || next === "}" || next === "#" || next === "|") {
1493
- i += 2;
1494
- while (i < value.length && value[i] !== "'") i++;
1495
- i++;
1496
- continue;
1497
- }
1498
- }
1499
- out += value[i];
1500
- i++;
1501
- }
1502
- return out;
1503
- }
1504
- function extractPlaceholders(value) {
1505
- const scan = withoutQuotedSpans(value);
1506
- const names = /* @__PURE__ */ new Set();
1507
- for (const m of scan.matchAll(/\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,/g)) {
1508
- names.add(m[1]);
1509
- }
1510
- if (!isIcuPluralOrSelect(scan)) {
1511
- for (const m of scan.matchAll(/\{\s*(\w+)\s*\}/g)) names.add(m[1]);
1512
- }
1513
- return [...names];
1514
- }
1515
- function isIcuPluralOrSelect(value) {
1516
- return ICU_PLURAL_SELECT.test(value);
1517
- }
1518
- function toLaravel(value) {
1519
- if (isIcuPluralOrSelect(value)) return value;
1520
- return value.replace(/\{(\w+)\}/g, ":$1");
1521
- }
1522
- function toI18next(value) {
1523
- if (isIcuPluralOrSelect(value)) return value;
1524
- return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
1525
- }
1526
- function toRuby(value) {
1527
- if (isIcuPluralOrSelect(value)) return value;
1528
- return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
1529
- }
1530
- function placeholdersMatch(source, translation) {
1531
- const a = extractPlaceholders(source).sort();
1532
- const b = extractPlaceholders(translation).sort();
1533
- return a.length === b.length && a.every((x, i) => x === b[i]);
1534
- }
1535
- function placeholdersSubset(source, translation) {
1536
- const allowed = new Set(extractPlaceholders(source));
1537
- return extractPlaceholders(translation).every((p) => allowed.has(p));
1538
- }
1539
- var COUNT_OPTIONAL = /* @__PURE__ */ new Set(["zero", "one", "two"]);
1540
- function pluralFormPlaceholdersMatch(category, source, form) {
1541
- return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
1542
- }
1543
-
1544
1597
  // src/server/glossary.ts
1545
1598
  function contains(haystack, needle, caseSensitive) {
1546
1599
  return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
@@ -2266,6 +2319,20 @@ var laravelPhp = {
2266
2319
  message: "laravel-php cannot represent ICU plural/select; written unconverted"
2267
2320
  });
2268
2321
  }
2322
+ if (raw) {
2323
+ const names = new Set(extractPlaceholders(raw));
2324
+ for (const m of raw.matchAll(/:([a-zA-Z][a-zA-Z0-9_]*)/g)) {
2325
+ if (names.has(m[1])) {
2326
+ warnings.push({
2327
+ code: "lossy-literal",
2328
+ key,
2329
+ locale,
2330
+ message: `literal ":${m[1]}" collides with the :${m[1]} placeholder; Laravel will interpolate both`
2331
+ });
2332
+ break;
2333
+ }
2334
+ }
2335
+ }
2269
2336
  (tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
2270
2337
  }
2271
2338
  }
@@ -2346,6 +2413,9 @@ var i18nextJson = {
2346
2413
  if (raw && isIcuPluralOrSelect(raw)) {
2347
2414
  warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
2348
2415
  }
2416
+ if (raw && extractLiterals2(raw).some((lit) => /\{\{\w+\}\}/.test(lit))) {
2417
+ warnings.push({ code: "lossy-literal", key, locale, message: "i18next will interpolate a literal containing {{\u2026}}; i18next has no escape for it" });
2418
+ }
2349
2419
  if (setNested(obj, segments, toI18next(raw))) collided.add(key);
2350
2420
  }
2351
2421
  files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
@@ -2362,7 +2432,9 @@ function poString(s) {
2362
2432
  return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
2363
2433
  }
2364
2434
  function toGettext(body, arg) {
2365
- return body.split(`{${arg}}`).join("%d");
2435
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
2436
+ if (isIcuPluralOrSelect(body)) return gap(body);
2437
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
2366
2438
  }
2367
2439
  var DEFAULT_LOCALE_CASE4 = "lower-hyphen";
2368
2440
  var gettextPo = {
@@ -2422,8 +2494,10 @@ var gettextPo = {
2422
2494
  blocks.push(
2423
2495
  [
2424
2496
  `msgctxt ${poString(key)}`,
2425
- `msgid ${poString(src?.value ?? "")}`,
2426
- `msgstr ${poString(lv?.value ?? "")}`
2497
+ // Scalar keys carry no count arg; toGettext still strips literal
2498
+ // spans and escapes a literal % to %%.
2499
+ `msgid ${poString(toGettext(src?.value ?? "", ""))}`,
2500
+ `msgstr ${poString(toGettext(lv?.value ?? "", ""))}`
2427
2501
  ].join("\n")
2428
2502
  );
2429
2503
  }
@@ -2447,7 +2521,9 @@ function xmlEscape(s) {
2447
2521
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2448
2522
  }
2449
2523
  function toApple(body, arg) {
2450
- return body.split(`{${arg}}`).join("%d");
2524
+ const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
2525
+ if (isIcuPluralOrSelect(body)) return gap(body);
2526
+ return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
2451
2527
  }
2452
2528
  var DEFAULT_LOCALE_CASE5 = "lower-hyphen";
2453
2529
  var appleStringsdict = {
@@ -2511,6 +2587,11 @@ var DEFAULT_LOCALE_CASE6 = "bcp47-hyphen";
2511
2587
  function escape(s) {
2512
2588
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
2513
2589
  }
2590
+ function toApple2(value) {
2591
+ const gap = (text) => text.replace(/%/g, "%%");
2592
+ if (isIcuPluralOrSelect(value)) return gap(value);
2593
+ return withLiterals(value, gap, (lit) => lit.replace(/%/g, "%%"));
2594
+ }
2514
2595
  var appleStrings = {
2515
2596
  name: "apple-strings",
2516
2597
  capabilities: {
@@ -2536,7 +2617,7 @@ var appleStrings = {
2536
2617
  if (entry.plural) continue;
2537
2618
  const value = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
2538
2619
  if (value === null) continue;
2539
- lines.push(`"${escape(key)}" = "${escape(value)}";`);
2620
+ lines.push(`"${escape(key)}" = "${escape(toApple2(value))}";`);
2540
2621
  }
2541
2622
  const contents = lines.length ? lines.join("\n") + "\n" : "";
2542
2623
  files.push({
@@ -2550,6 +2631,10 @@ var appleStrings = {
2550
2631
 
2551
2632
  // src/server/adapters/vue-i18n-json.ts
2552
2633
  var DEFAULT_LOCALE_CASE7 = "lower-hyphen";
2634
+ function toVueI18n(value) {
2635
+ if (isIcuPluralOrSelect(value)) return value;
2636
+ return withLiterals(value, (gap) => gap, (content) => `{'${content}'}`);
2637
+ }
2553
2638
  var vueI18nJson = {
2554
2639
  name: "vue-i18n-json",
2555
2640
  capabilities: {
@@ -2575,7 +2660,7 @@ var vueI18nJson = {
2575
2660
  if (entry.plural) {
2576
2661
  const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
2577
2662
  if (!forms) continue;
2578
- const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
2663
+ const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map(toVueI18n);
2579
2664
  flat[key] = parts.join(" | ");
2580
2665
  } else {
2581
2666
  const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
@@ -2588,7 +2673,7 @@ var vueI18nJson = {
2588
2673
  message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
2589
2674
  });
2590
2675
  }
2591
- flat[key] = raw;
2676
+ flat[key] = toVueI18n(raw);
2592
2677
  }
2593
2678
  }
2594
2679
  let payload = flat;
@@ -2623,27 +2708,30 @@ function angularXMeta(placeholders, name) {
2623
2708
  return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
2624
2709
  }
2625
2710
  function renderInterpolations(text, ids, placeholders) {
2626
- let out = "";
2627
- let last = 0;
2628
- for (const m of text.matchAll(/\{(\w+)\}/g)) {
2629
- const name = m[1];
2630
- out += xmlEscape2(text.slice(last, m.index));
2631
- const meta = angularXMeta(placeholders, name);
2632
- if (meta) {
2633
- const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
2634
- const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
2635
- out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
2636
- } else {
2637
- let id = ids.get(name);
2638
- if (id === void 0) {
2639
- id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
2640
- ids.set(name, id);
2711
+ const convertGap = (gap) => {
2712
+ let out = "";
2713
+ let last = 0;
2714
+ for (const m of gap.matchAll(/\{(\w+)\}/g)) {
2715
+ const name = m[1];
2716
+ out += xmlEscape2(gap.slice(last, m.index));
2717
+ const meta = angularXMeta(placeholders, name);
2718
+ if (meta) {
2719
+ const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
2720
+ const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
2721
+ out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
2722
+ } else {
2723
+ let id = ids.get(name);
2724
+ if (id === void 0) {
2725
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
2726
+ ids.set(name, id);
2727
+ }
2728
+ out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
2641
2729
  }
2642
- out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
2730
+ last = m.index + m[0].length;
2643
2731
  }
2644
- last = m.index + m[0].length;
2645
- }
2646
- return out + xmlEscape2(text.slice(last));
2732
+ return out + xmlEscape2(gap.slice(last));
2733
+ };
2734
+ return withLiterals(text, convertGap, (lit) => xmlEscape2(`'${lit}'`));
2647
2735
  }
2648
2736
  function renderPluralIcu(forms, ids, placeholders) {
2649
2737
  const cats = [
@@ -2885,10 +2973,11 @@ function buildSystemPrompt(hasPluralItems) {
2885
2973
  "You are a professional software localization engine for a UI string catalog.",
2886
2974
  "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.",
2887
2975
  "",
2888
- "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.",
2976
+ "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.",
2889
2977
  "",
2890
2978
  "Hard rules:",
2891
2979
  "- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
2980
+ "- 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.",
2892
2981
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
2893
2982
  "- 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.",
2894
2983
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
@@ -2917,6 +3006,7 @@ function buildBatchPrompt(reqs) {
2917
3006
  // Wrap in braces so the model sees "{site}" not "site" — makes the visual
2918
3007
  // connection to the source string obvious and reduces rename errors.
2919
3008
  placeholders: r.placeholders.map((p) => `{${p}}`),
3009
+ ...r.literals?.length ? { literals: r.literals } : {},
2920
3010
  ...r.glossary?.length ? { glossary: r.glossary } : {},
2921
3011
  hasScreenshot: r.image !== void 0
2922
3012
  };
@@ -3735,6 +3825,7 @@ function selectRequests(state, opts) {
3735
3825
  const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
3736
3826
  if (opts.onlyMissing && complete) continue;
3737
3827
  const glossary = relevantGlossary(other, locale, state.glossary);
3828
+ const literals = quotedLiterals(other);
3738
3829
  reqs.push({
3739
3830
  id: String(id++),
3740
3831
  key,
@@ -3744,6 +3835,7 @@ function selectRequests(state, opts) {
3744
3835
  targetLocale: locale,
3745
3836
  maxLength: entry.maxLength,
3746
3837
  placeholders: extractPlaceholders(other),
3838
+ ...literals.length ? { literals } : {},
3747
3839
  ...glossary.length ? { glossary } : {},
3748
3840
  plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
3749
3841
  });
@@ -3756,6 +3848,7 @@ function selectRequests(state, opts) {
3756
3848
  const existing = entry.values[locale]?.value;
3757
3849
  if (opts.onlyMissing && existing) continue;
3758
3850
  const glossary = relevantGlossary(source, locale, state.glossary);
3851
+ const literals = quotedLiterals(source);
3759
3852
  reqs.push({
3760
3853
  id: String(id++),
3761
3854
  key,
@@ -3765,6 +3858,7 @@ function selectRequests(state, opts) {
3765
3858
  targetLocale: locale,
3766
3859
  maxLength: entry.maxLength,
3767
3860
  placeholders: extractPlaceholders(source),
3861
+ ...literals.length ? { literals } : {},
3768
3862
  ...glossary.length ? { glossary } : {}
3769
3863
  });
3770
3864
  }
@@ -4579,6 +4673,9 @@ function flattenObject(value, prefix, warnings) {
4579
4673
 
4580
4674
  // src/server/import/parsers/vue-i18n-json.ts
4581
4675
  var LOCALE_RE2 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4676
+ function fromVueI18n(value) {
4677
+ return value.replace(/\{'([^']*)'\}/g, "'$1'");
4678
+ }
4582
4679
  var vueI18nJson2 = {
4583
4680
  name: "vue-i18n-json",
4584
4681
  parse(localeRoot, opts) {
@@ -4599,7 +4696,7 @@ var vueI18nJson2 = {
4599
4696
  }
4600
4697
  if (!locales.includes(locale)) locales.push(locale);
4601
4698
  for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
4602
- (keys[key] ??= { values: {} }).values[locale] = value;
4699
+ (keys[key] ??= { values: {} }).values[locale] = fromVueI18n(value);
4603
4700
  }
4604
4701
  }
4605
4702
  return { locales, keys, warnings };
@@ -4612,8 +4709,14 @@ import { join as join8, relative as relative2 } from "path";
4612
4709
  import { execFileSync } from "child_process";
4613
4710
 
4614
4711
  // src/server/import/placeholders.ts
4712
+ function markBareBracesLiteral(value) {
4713
+ return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
4714
+ }
4615
4715
  function laravelToCanonical(value) {
4616
- return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4716
+ return markBareBracesLiteral(value).replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
4717
+ }
4718
+ function railsToCanonical(value) {
4719
+ return markBareBracesLiteral(value).replace(/%\{(\w+)\}/g, "{$1}");
4617
4720
  }
4618
4721
 
4619
4722
  // src/server/import/parsers/laravel-php.ts
@@ -4755,6 +4858,9 @@ function localeFromLproj(dir) {
4755
4858
  if (!m) return null;
4756
4859
  return LOCALE_RE4.test(m[1]) ? m[1] : null;
4757
4860
  }
4861
+ function printfToCanonical(s) {
4862
+ return s.replace(/%%/g, "%");
4863
+ }
4758
4864
  function unescape(body) {
4759
4865
  return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
4760
4866
  const c = esc[0];
@@ -4874,7 +4980,7 @@ var appleStrings2 = {
4874
4980
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4875
4981
  }
4876
4982
  for (const { key, value } of parseStrings(text, file, warnings)) {
4877
- (keys[key] ??= { values: {} }).values[locale] = value;
4983
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical(value);
4878
4984
  }
4879
4985
  }
4880
4986
  return { locales, keys, warnings };
@@ -5006,6 +5112,24 @@ function unescapePo(s) {
5006
5112
  (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
5007
5113
  );
5008
5114
  }
5115
+ function printfToCanonical2(s, arg) {
5116
+ let out = "";
5117
+ for (let i = 0; i < s.length; ) {
5118
+ if (s[i] === "%" && s[i + 1] === "%") {
5119
+ out += "%";
5120
+ i += 2;
5121
+ continue;
5122
+ }
5123
+ if (arg && s[i] === "%" && s[i + 1] === "d") {
5124
+ out += `{${arg}}`;
5125
+ i += 2;
5126
+ continue;
5127
+ }
5128
+ out += s[i];
5129
+ i++;
5130
+ }
5131
+ return out;
5132
+ }
5009
5133
  function parseEntries(text) {
5010
5134
  const entries = [];
5011
5135
  let cur = null;
@@ -5126,13 +5250,13 @@ var gettextPo2 = {
5126
5250
  );
5127
5251
  continue;
5128
5252
  }
5129
- forms[cat] = body.split("%d").join("{count}");
5253
+ forms[cat] = printfToCanonical2(body, "count");
5130
5254
  }
5131
5255
  if (!forms.other) continue;
5132
5256
  (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
5133
5257
  } else {
5134
5258
  if (!entry.msgstr) continue;
5135
- (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
5259
+ (keys[key] ??= { values: {} }).values[locale] = printfToCanonical2(entry.msgstr, "");
5136
5260
  }
5137
5261
  }
5138
5262
  }
@@ -5233,9 +5357,6 @@ import { readdirSync as readdirSync11, readFileSync as readFileSync18 } from "fs
5233
5357
  import { join as join14 } from "path";
5234
5358
  var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5235
5359
  var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5236
- function fromRuby(value) {
5237
- return value.replace(/%\{(\w+)\}/g, "{$1}");
5238
- }
5239
5360
  function makeNode() {
5240
5361
  return /* @__PURE__ */ Object.create(null);
5241
5362
  }
@@ -5421,7 +5542,7 @@ function synthesizeIcu(forms, file, key, warnings) {
5421
5542
  `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5422
5543
  );
5423
5544
  }
5424
- parts.push(`${cat} {${fromRuby(body)}}`);
5545
+ parts.push(`${cat} {${railsToCanonical(body)}}`);
5425
5546
  }
5426
5547
  return `{count, plural, ${parts.join(" ")}}`;
5427
5548
  }
@@ -5439,7 +5560,7 @@ var railsYaml2 = {
5439
5560
  for (const [k, v] of Object.entries(node)) {
5440
5561
  const key = prefix ? `${prefix}.${k}` : k;
5441
5562
  if (typeof v === "string") {
5442
- if (v !== "") addValue(key, locale, fromRuby(v));
5563
+ if (v !== "") addValue(key, locale, railsToCanonical(v));
5443
5564
  continue;
5444
5565
  }
5445
5566
  const forms = asPluralForms(v);
@@ -5566,6 +5687,24 @@ function parsePlistDict(xml) {
5566
5687
  return tag.selfClosing ? {} : readDict();
5567
5688
  }
5568
5689
  var VAR_RE = /%#@([^@]*)@/g;
5690
+ function printfToCanonical3(body, token, arg) {
5691
+ let out = "";
5692
+ for (let i = 0; i < body.length; ) {
5693
+ if (body[i] === "%" && body[i + 1] === "%") {
5694
+ out += "%";
5695
+ i += 2;
5696
+ continue;
5697
+ }
5698
+ if (body.startsWith(token, i)) {
5699
+ out += `{${arg}}`;
5700
+ i += token.length;
5701
+ continue;
5702
+ }
5703
+ out += body[i];
5704
+ i++;
5705
+ }
5706
+ return out;
5707
+ }
5569
5708
  function entryToIcu(key, entry, file, warnings) {
5570
5709
  const warn = (msg) => {
5571
5710
  warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
@@ -5594,7 +5733,7 @@ function entryToIcu(key, entry, file, warnings) {
5594
5733
  for (const cat of PLURAL_CATEGORIES) {
5595
5734
  const body = varDict[cat];
5596
5735
  if (typeof body !== "string") continue;
5597
- forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
5736
+ forms[cat] = prefix + printfToCanonical3(body, token, arg) + suffix;
5598
5737
  }
5599
5738
  if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5600
5739
  return formsToIcu(arg, forms);