glotfile 0.5.3 → 0.6.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.
@@ -1,35 +1,8 @@
1
- var __defProp = Object.defineProperty;
2
- var __getOwnPropNames = Object.getOwnPropertyNames;
3
- var __esm = (fn, res) => function __init() {
4
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
- };
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
-
11
- // node_modules/dictionary-en/index.js
12
- var dictionary_en_exports = {};
13
- __export(dictionary_en_exports, {
14
- default: () => dictionary_en_default
15
- });
16
- import fs from "fs/promises";
17
- var aff, dic, dictionary, dictionary_en_default;
18
- var init_dictionary_en = __esm({
19
- async "node_modules/dictionary-en/index.js"() {
20
- "use strict";
21
- aff = await fs.readFile(new URL("index.aff", import.meta.url));
22
- dic = await fs.readFile(new URL("index.dic", import.meta.url));
23
- dictionary = { aff, dic };
24
- dictionary_en_default = dictionary;
25
- }
26
- });
27
-
28
1
  // src/server/server.ts
29
2
  import { Hono as Hono2 } from "hono";
30
3
  import { serve } from "@hono/node-server";
31
4
  import { fileURLToPath } from "url";
32
- import { dirname as dirname4, join as join10, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join15, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
33
6
  import { readFile, stat } from "fs/promises";
34
7
  import { createServer } from "net";
35
8
  import open from "open";
@@ -148,6 +121,7 @@ function validate(raw) {
148
121
  if (o.indent !== void 0 && typeof o.indent !== "number") fail("config.outputs[].indent must be a number");
149
122
  if (o.finalNewline !== void 0 && typeof o.finalNewline !== "boolean") fail("config.outputs[].finalNewline must be a boolean");
150
123
  if (o.includeLocale !== void 0 && typeof o.includeLocale !== "boolean") fail("config.outputs[].includeLocale must be a boolean");
124
+ if (o.skipSourceLocale !== void 0 && typeof o.skipSourceLocale !== "boolean") fail("config.outputs[].skipSourceLocale must be a boolean");
151
125
  if (o.localeAliases !== void 0) {
152
126
  if (!isObject(o.localeAliases)) fail("config.outputs[].localeAliases must be an object");
153
127
  for (const [k, v] of Object.entries(o.localeAliases)) {
@@ -865,7 +839,7 @@ function findMissing(state) {
865
839
  const entry = state.keys[key];
866
840
  if (entry.skipTranslate) continue;
867
841
  for (const locale of targets) {
868
- const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
842
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value?.trim();
869
843
  if (!v) out.push({ key, locale });
870
844
  }
871
845
  }
@@ -1487,162 +1461,14 @@ function pluralFormPlaceholdersMatch(category, source, form) {
1487
1461
  return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
1488
1462
  }
1489
1463
 
1490
- // src/server/ai/run.ts
1491
- import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
1492
- import { resolve as resolve4, extname as extname2 } from "path";
1493
-
1494
- // src/server/glob.ts
1495
- function globToRegExp2(glob) {
1496
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1497
- return new RegExp(`^${escaped}$`);
1498
- }
1499
-
1500
- // src/server/ai/batch.ts
1501
- var MalformedReplyError = class extends Error {
1502
- constructor(raw) {
1503
- super("Model reply was not valid translation JSON.");
1504
- this.raw = raw;
1505
- this.name = "MalformedReplyError";
1506
- }
1507
- raw;
1508
- };
1509
- function parseReplyItems(text) {
1510
- let parsed;
1511
- try {
1512
- parsed = JSON.parse(text);
1513
- } catch {
1514
- throw new MalformedReplyError(text);
1515
- }
1516
- if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
1517
- return parsed.items;
1518
- }
1519
- function chunk(items, size) {
1520
- const out = [];
1521
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
1522
- return out;
1523
- }
1524
- function validateTranslation(req, translation) {
1525
- if (translation === void 0) return { id: req.id, error: "No translation returned." };
1526
- if (!placeholdersMatch(req.source, translation)) {
1527
- return { id: req.id, error: "Placeholder mismatch between source and translation." };
1528
- }
1529
- if (req.maxLength !== void 0 && translation.length > req.maxLength) {
1530
- return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1531
- }
1532
- return { id: req.id, translation };
1533
- }
1534
- function validatePlural(req, forms) {
1535
- if (!forms) return { id: req.id, error: "No translation returned." };
1536
- const plural = req.plural;
1537
- if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
1538
- const cats = plural.categories;
1539
- const missing = cats.filter((c) => typeof forms[c] !== "string");
1540
- if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
1541
- const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
1542
- if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
1543
- if (req.maxLength !== void 0) {
1544
- const over = cats.find((c) => forms[c].length > req.maxLength);
1545
- if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
1546
- }
1547
- const out = {};
1548
- for (const c of cats) out[c] = forms[c];
1549
- return { id: req.id, forms: out };
1550
- }
1551
- function validateReply(req, item) {
1552
- return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
1553
- }
1554
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
1555
- const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
1556
- async function resolveBatch(batch, isRetry = false) {
1557
- let reply;
1558
- try {
1559
- reply = await callBatch(batch, signal);
1560
- } catch (err) {
1561
- if (!(err instanceof MalformedReplyError)) throw err;
1562
- onMalformedReply?.(err.raw, batch.length);
1563
- if (signal?.aborted) return failBatch(batch);
1564
- if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
1565
- const mid = Math.ceil(batch.length / 2);
1566
- return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
1567
- }
1568
- const byId = new Map(reply.map((r) => [r.id, r]));
1569
- return batch.map((req) => validateReply(req, byId.get(req.id)));
1570
- }
1571
- const results = [];
1572
- const total = reqs.length;
1573
- for (const batch of chunk(reqs, Math.max(1, batchSize))) {
1574
- if (signal?.aborted) break;
1575
- const batchResults = await resolveBatch(batch);
1576
- results.push(...batchResults);
1577
- onBatchComplete?.(results.length, total, batchResults);
1578
- }
1579
- return results;
1580
- }
1581
-
1582
- // src/server/ai/run.ts
1583
- function selectRequests(state, opts) {
1584
- const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
1585
- const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
1586
- const keySet = opts.keys ? new Set(opts.keys) : null;
1587
- const reqs = [];
1588
- let id = 0;
1589
- for (const key of Object.keys(state.keys).sort()) {
1590
- const entry = state.keys[key];
1591
- if (entry.skipTranslate) continue;
1592
- if (keyRe && !keyRe.test(key)) continue;
1593
- if (keySet && !keySet.has(key)) continue;
1594
- const sourceLv = entry.values[state.config.sourceLocale];
1595
- if (entry.plural) {
1596
- const sourceForms = sourceLv?.forms;
1597
- const other = sourceForms?.other;
1598
- if (!sourceForms || !other) continue;
1599
- for (const locale of targets) {
1600
- const have = entry.values[locale]?.forms ?? {};
1601
- const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
1602
- if (opts.onlyMissing && complete) continue;
1603
- const glossary = relevantGlossary(other, locale, state.glossary);
1604
- reqs.push({
1605
- id: String(id++),
1606
- key,
1607
- source: other,
1608
- sourceLocale: state.config.sourceLocale,
1609
- context: entry.context,
1610
- targetLocale: locale,
1611
- maxLength: entry.maxLength,
1612
- placeholders: extractPlaceholders(other),
1613
- ...glossary.length ? { glossary } : {},
1614
- plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
1615
- });
1616
- }
1617
- continue;
1618
- }
1619
- const source = sourceLv?.value;
1620
- if (!source) continue;
1621
- for (const locale of targets) {
1622
- const existing = entry.values[locale]?.value;
1623
- if (opts.onlyMissing && existing) continue;
1624
- const glossary = relevantGlossary(source, locale, state.glossary);
1625
- reqs.push({
1626
- id: String(id++),
1627
- key,
1628
- source,
1629
- sourceLocale: state.config.sourceLocale,
1630
- context: entry.context,
1631
- targetLocale: locale,
1632
- maxLength: entry.maxLength,
1633
- placeholders: extractPlaceholders(source),
1634
- ...glossary.length ? { glossary } : {}
1635
- });
1636
- }
1637
- }
1638
- return reqs;
1464
+ // src/server/glossary.ts
1465
+ function contains(haystack, needle, caseSensitive) {
1466
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1639
1467
  }
1640
1468
  function relevantGlossary(source, targetLocale, glossary) {
1641
1469
  const hints = [];
1642
1470
  for (const entry of glossary) {
1643
- const haystack = entry.caseSensitive ? source : source.toLowerCase();
1644
- const needle = entry.caseSensitive ? entry.term : entry.term.toLowerCase();
1645
- if (!haystack.includes(needle)) continue;
1471
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
1646
1472
  hints.push({
1647
1473
  term: entry.term,
1648
1474
  doNotTranslate: entry.doNotTranslate,
@@ -1652,170 +1478,84 @@ function relevantGlossary(source, targetLocale, glossary) {
1652
1478
  }
1653
1479
  return hints;
1654
1480
  }
1655
- var MEDIA_TYPES = {
1656
- ".png": "image/png",
1657
- ".jpg": "image/jpeg",
1658
- ".jpeg": "image/jpeg",
1659
- ".webp": "image/webp",
1660
- ".gif": "image/gif"
1661
- };
1662
- var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1663
- function attachScreenshots(reqs, state, projectRoot) {
1664
- const cache2 = /* @__PURE__ */ new Map();
1665
- for (const req of reqs) {
1666
- const screenshot = state.keys[req.key]?.screenshot;
1667
- if (!screenshot) continue;
1668
- const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
1669
- if (!mediaType) continue;
1670
- if (!cache2.has(screenshot)) {
1671
- const abs = resolve4(projectRoot, screenshot);
1672
- if (!existsSync6(abs)) {
1673
- cache2.set(screenshot, null);
1674
- } else {
1675
- const buf = readFileSync6(abs);
1676
- cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
1481
+ function glossaryViolations(source, value, targetLocale, glossary) {
1482
+ const out = [];
1483
+ for (const entry of glossary) {
1484
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
1485
+ if (entry.doNotTranslate) {
1486
+ if (!contains(value, entry.term, entry.caseSensitive)) {
1487
+ out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
1677
1488
  }
1489
+ continue;
1490
+ }
1491
+ const forced = entry.translations?.[targetLocale];
1492
+ if (forced && !contains(value, forced, entry.caseSensitive)) {
1493
+ out.push({ term: entry.term, expected: forced, kind: "forced" });
1678
1494
  }
1679
- const image = cache2.get(screenshot);
1680
- if (image) req.image = image;
1681
1495
  }
1496
+ return out;
1682
1497
  }
1683
- function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision) {
1684
- if (supportsVision) {
1685
- attachScreenshots(reqs, state, projectRoot);
1686
- return { skipped: 0 };
1498
+
1499
+ // src/server/spell.ts
1500
+ var instances = /* @__PURE__ */ new Map();
1501
+ var loading = /* @__PURE__ */ new Set();
1502
+ var unavailable = /* @__PURE__ */ new Set();
1503
+ var cache = /* @__PURE__ */ new Map();
1504
+ var norm = (dictId) => dictId.toLowerCase();
1505
+ var ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
1506
+ var MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
1507
+ var WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
1508
+ function spellTokens(value) {
1509
+ return value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
1510
+ }
1511
+ function ignoreWordsFor(glossary, customWords = []) {
1512
+ const set = /* @__PURE__ */ new Set();
1513
+ const add = (text) => {
1514
+ for (const w of text.match(WORD) ?? []) set.add(w.toLowerCase());
1515
+ };
1516
+ for (const e of glossary) {
1517
+ add(e.term);
1518
+ for (const t of Object.values(e.translations ?? {})) add(t);
1687
1519
  }
1688
- const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
1689
- return { skipped: keys.size };
1520
+ for (const w of customWords) add(w);
1521
+ return set;
1690
1522
  }
1691
- var DEFAULT_LOCALE_CONCURRENCY = 3;
1692
- async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
1693
- if (!reqs.length) return [];
1694
- const byLocale = /* @__PURE__ */ new Map();
1695
- for (const req of reqs) {
1696
- let group = byLocale.get(req.targetLocale);
1697
- if (!group) {
1698
- group = [];
1699
- byLocale.set(req.targetLocale, group);
1700
- }
1701
- group.push(req);
1523
+ async function getSpeller(dictId) {
1524
+ const key = norm(dictId);
1525
+ const existing = instances.get(key);
1526
+ if (existing) return existing;
1527
+ if (unavailable.has(key)) return null;
1528
+ try {
1529
+ const nspellMod = await import("nspell");
1530
+ const nspell = nspellMod.default ?? nspellMod;
1531
+ const dictMod = await import(`dictionary-${key}`);
1532
+ const dictExport = dictMod.default ?? dictMod;
1533
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
1534
+ const speller = nspell(dict);
1535
+ instances.set(key, speller);
1536
+ return speller;
1537
+ } catch {
1538
+ unavailable.add(key);
1539
+ return null;
1540
+ } finally {
1541
+ loading.delete(key);
1702
1542
  }
1703
- const localeBatches = [...byLocale.entries()].map(([locale, group]) => ({
1704
- locale,
1705
- batches: chunk(group, Math.max(1, batchSize))
1706
- }));
1707
- const jobs = [];
1708
- const maxBatches = Math.max(...localeBatches.map((g) => g.batches.length));
1709
- for (let i = 0; i < maxBatches; i++) {
1710
- for (const g of localeBatches) {
1711
- if (i < g.batches.length) jobs.push({ locale: g.locale, batch: g.batches[i] });
1543
+ }
1544
+ function spellValue(dictId, value, ignore) {
1545
+ const key = norm(dictId);
1546
+ if (unavailable.has(key)) return [];
1547
+ const spell = instances.get(key);
1548
+ if (!spell) {
1549
+ if (!loading.has(key)) {
1550
+ loading.add(key);
1551
+ void getSpeller(key);
1712
1552
  }
1713
- }
1714
- const remaining = new Map(localeBatches.map((g) => [g.locale, g.batches.length]));
1715
- const started = /* @__PURE__ */ new Set();
1716
- const total = reqs.length;
1717
- let done = 0;
1718
- const allResults = [];
1719
- let next = 0;
1720
- async function worker() {
1721
- while (next < jobs.length) {
1722
- if (signal?.aborted) break;
1723
- const { locale, batch } = jobs[next++];
1724
- if (!started.has(locale)) {
1725
- started.add(locale);
1726
- hooks.onLocaleStart?.(locale);
1727
- }
1728
- const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
1729
- done += results.length;
1730
- hooks.onBatchComplete?.(done, total, results, locale);
1731
- }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
1732
- allResults.push(...batchResults);
1733
- const left = remaining.get(locale) - 1;
1734
- remaining.set(locale, left);
1735
- if (left === 0 && !signal?.aborted) hooks.onLocaleDone?.(locale);
1736
- }
1737
- }
1738
- const workers = Array.from({ length: Math.min(concurrency, jobs.length) }, worker);
1739
- await Promise.all(workers);
1740
- return allResults;
1741
- }
1742
- function applyResults(state, reqs, results, clock = systemClock, force = false) {
1743
- const byId = new Map(reqs.map((r) => [r.id, r]));
1744
- let written = 0;
1745
- const errors = [];
1746
- for (const res of results) {
1747
- const req = byId.get(res.id);
1748
- if (!req) continue;
1749
- if (req.plural) {
1750
- if (res.error || res.forms === void 0) {
1751
- errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
1752
- continue;
1753
- }
1754
- if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
1755
- continue;
1756
- }
1757
- if (res.translation === void 0) {
1758
- errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
1759
- continue;
1760
- }
1761
- if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
1762
- if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
1763
- }
1764
- return { written, errors };
1765
- }
1766
-
1767
- // src/server/spell.ts
1768
- import nspell from "nspell";
1769
- var LOADERS = {
1770
- en: () => init_dictionary_en().then(() => dictionary_en_exports),
1771
- es: () => import("dictionary-es"),
1772
- fr: () => import("dictionary-fr")
1773
- };
1774
- var instances = /* @__PURE__ */ new Map();
1775
- var loading = /* @__PURE__ */ new Set();
1776
- var unavailable = /* @__PURE__ */ new Set();
1777
- var cache = /* @__PURE__ */ new Map();
1778
- var norm = (locale) => locale.toLowerCase();
1779
- var ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
1780
- var MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
1781
- var WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
1782
- async function loadDictionary(locale) {
1783
- const key = norm(locale);
1784
- if (instances.has(key) || unavailable.has(key)) return;
1785
- const loader = LOADERS[key];
1786
- if (!loader) {
1787
- unavailable.add(key);
1788
- return;
1789
- }
1790
- try {
1791
- const { default: dict } = await loader();
1792
- instances.set(key, nspell(dict));
1793
- } catch {
1794
- unavailable.add(key);
1795
- } finally {
1796
- loading.delete(key);
1797
- }
1798
- }
1799
- function spellValue(locale, value, ignore) {
1800
- const key = norm(locale);
1801
- if (unavailable.has(key)) return [];
1802
- const spell = instances.get(key);
1803
- if (!spell) {
1804
- if (!LOADERS[key]) {
1805
- unavailable.add(key);
1806
- return [];
1807
- }
1808
- if (!loading.has(key)) {
1809
- loading.add(key);
1810
- void loadDictionary(key);
1811
- }
1812
- return null;
1553
+ return null;
1813
1554
  }
1814
1555
  const cacheKey = key + " " + value;
1815
1556
  let allBad = cache.get(cacheKey);
1816
1557
  if (!allBad) {
1817
- const words = value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
1818
- allBad = words.filter((w) => !spell.correct(w));
1558
+ allBad = spellTokens(value).filter((w) => !spell.correct(w));
1819
1559
  cache.set(cacheKey, allBad);
1820
1560
  }
1821
1561
  return allBad.filter((w) => !ignore.has(w.toLowerCase()));
@@ -1830,39 +1570,21 @@ var CHECK_RULE = {
1830
1570
  length: "max-length",
1831
1571
  glossary: "glossary-violation"
1832
1572
  };
1833
- function contains(haystack, needle, caseSensitive) {
1834
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1835
- }
1836
1573
  function runChecks(state, opts = {}) {
1837
1574
  const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
1838
1575
  const on = (id) => (!opts.only || opts.only.includes(id)) && !ruleOff(id);
1839
1576
  const issues = [];
1840
1577
  let spellPending = false;
1841
1578
  const { sourceLocale } = state.config;
1842
- const byTerm = new Map(state.glossary.map((e) => [e.term, e]));
1843
- const ignore = /* @__PURE__ */ new Set();
1844
- for (const e of state.glossary) {
1845
- for (const w of e.term.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
1846
- for (const t of Object.values(e.translations ?? {})) {
1847
- for (const w of t.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
1579
+ const ignore = ignoreWordsFor(state.glossary, state.config.spelling?.customWords);
1580
+ if (on("untranslated")) {
1581
+ for (const m of findMissing(state)) {
1582
+ issues.push({ key: m.key, locale: m.locale, check: "untranslated", message: "Not translated yet" });
1848
1583
  }
1849
1584
  }
1850
- for (const word of state.config.spelling?.customWords ?? []) {
1851
- const w = word.trim().toLowerCase();
1852
- if (w) ignore.add(w);
1853
- }
1854
- const targetLocales = state.config.locales.filter((l) => l !== sourceLocale);
1855
1585
  for (const key of Object.keys(state.keys).sort()) {
1856
1586
  const entry = state.keys[key];
1857
1587
  const source = entry.values[sourceLocale]?.value ?? "";
1858
- if (on("untranslated") && !entry.skipTranslate) {
1859
- for (const locale of targetLocales) {
1860
- const translated = entry.plural ? (entry.values[locale]?.forms?.other ?? "").trim() !== "" : (entry.values[locale]?.value ?? "").trim() !== "";
1861
- if (!translated) {
1862
- issues.push({ key, locale, check: "untranslated", message: "Not translated yet" });
1863
- }
1864
- }
1865
- }
1866
1588
  if (entry.plural) {
1867
1589
  if (on("placeholder")) {
1868
1590
  const sourceForm = entry.values[sourceLocale]?.forms?.other ?? "";
@@ -1906,7 +1628,8 @@ function runChecks(state, opts = {}) {
1906
1628
  });
1907
1629
  }
1908
1630
  if (on("spelling") && !blank) {
1909
- const bad = spellValue(locale, value, ignore);
1631
+ const dictId = state.config.lint?.spelling?.locales?.[locale] ?? locale;
1632
+ const bad = spellValue(dictId, value, ignore);
1910
1633
  if (bad === null) spellPending = true;
1911
1634
  else if (bad.length) {
1912
1635
  issues.push({
@@ -1936,29 +1659,14 @@ function runChecks(state, opts = {}) {
1936
1659
  });
1937
1660
  }
1938
1661
  if (on("glossary") && source) {
1939
- for (const hint of relevantGlossary(source, locale, state.glossary)) {
1940
- const cs = byTerm.get(hint.term)?.caseSensitive;
1941
- if (hint.doNotTranslate) {
1942
- if (!contains(value, hint.term, cs)) {
1943
- issues.push({
1944
- key,
1945
- locale,
1946
- check: "glossary",
1947
- message: `Do-not-translate term "${hint.term}" is missing from the translation`,
1948
- detail: [hint.term]
1949
- });
1950
- }
1951
- } else if (hint.forced) {
1952
- if (!contains(value, hint.forced, cs)) {
1953
- issues.push({
1954
- key,
1955
- locale,
1956
- check: "glossary",
1957
- message: `Should use "${hint.forced}" for "${hint.term}"`,
1958
- detail: [hint.forced]
1959
- });
1960
- }
1961
- }
1662
+ for (const viol of glossaryViolations(source, value, locale, state.glossary)) {
1663
+ issues.push({
1664
+ key,
1665
+ locale,
1666
+ check: "glossary",
1667
+ message: viol.kind === "do-not-translate" ? `Do-not-translate term "${viol.term}" is missing from the translation` : `Should use "${viol.expected}" for "${viol.term}"`,
1668
+ detail: [viol.expected]
1669
+ });
1962
1670
  }
1963
1671
  }
1964
1672
  }
@@ -1970,19 +1678,13 @@ function runChecks(state, opts = {}) {
1970
1678
  return { issues: visible, spellPending };
1971
1679
  }
1972
1680
 
1973
- // src/server/lint/spelling.ts
1974
- function tokenize(text) {
1975
- return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
1976
- }
1977
- function buildAllowWords(glossary, dictionary2 = []) {
1978
- const set = /* @__PURE__ */ new Set();
1979
- const add = (s) => {
1980
- for (const w of tokenize(s)) set.add(w.toLowerCase());
1981
- };
1982
- for (const g of glossary) add(g.term);
1983
- for (const w of dictionary2) add(w);
1984
- return set;
1681
+ // src/server/glob.ts
1682
+ function globToRegExp2(glob) {
1683
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1684
+ return new RegExp(`^${escaped}$`);
1985
1685
  }
1686
+
1687
+ // src/server/lint/spelling.ts
1986
1688
  var spellingRule = {
1987
1689
  id: "spelling",
1988
1690
  run(state, ctx) {
@@ -1994,10 +1696,8 @@ var spellingRule = {
1994
1696
  if (!speller) continue;
1995
1697
  const value = entry.values[locale]?.value;
1996
1698
  if (!value) continue;
1997
- const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
1998
- for (const word of tokenize(value)) {
1999
- const lower = word.toLowerCase();
2000
- if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
1699
+ for (const word of spellTokens(value)) {
1700
+ if (ctx.allowWords.has(word.toLowerCase())) continue;
2001
1701
  if (!speller.correct(word)) {
2002
1702
  out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
2003
1703
  }
@@ -2007,18 +1707,7 @@ var spellingRule = {
2007
1707
  return out;
2008
1708
  }
2009
1709
  };
2010
- var defaultLoader = async (dictId) => {
2011
- try {
2012
- const nspellMod = await import("nspell");
2013
- const nspell2 = nspellMod.default ?? nspellMod;
2014
- const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
2015
- const dictExport = dictMod.default ?? dictMod;
2016
- const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
2017
- return nspell2(dict);
2018
- } catch {
2019
- return null;
2020
- }
2021
- };
1710
+ var defaultLoader = (dictId) => getSpeller(dictId);
2022
1711
 
2023
1712
  // src/server/lint/rules.ts
2024
1713
  var emptySourceRule = {
@@ -2035,17 +1724,14 @@ var emptySourceRule = {
2035
1724
  };
2036
1725
  var emptyTranslationRule = {
2037
1726
  id: "empty-translation",
2038
- run(state, ctx) {
1727
+ // findMissing is the shared "untranslated" walk (also behind the editor's
1728
+ // untranslated check and /scan/missing); a whitespace-only value counts as
1729
+ // missing there, so no separate whitespace pass is needed.
1730
+ run(state) {
2039
1731
  const out = [];
2040
1732
  for (const m of findMissing(state)) {
2041
1733
  out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
2042
1734
  }
2043
- for (const key of Object.keys(state.keys)) {
2044
- for (const locale of ctx.targetLocales) {
2045
- const v = state.keys[key].values[locale]?.value;
2046
- if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
2047
- }
2048
- }
2049
1735
  return out;
2050
1736
  }
2051
1737
  };
@@ -2173,13 +1859,13 @@ var glossaryViolationRule = {
2173
1859
  for (const locale of ctx.targetLocales) {
2174
1860
  const v = entry.values[locale]?.value;
2175
1861
  if (!v) continue;
2176
- for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
2177
- if (hint.doNotTranslate && !v.includes(hint.term)) {
2178
- out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
2179
- }
2180
- if (hint.forced && !v.includes(hint.forced)) {
2181
- out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
2182
- }
1862
+ for (const viol of glossaryViolations(src, v, locale, ctx.glossary)) {
1863
+ out.push({
1864
+ ruleId: "glossary-violation",
1865
+ key,
1866
+ locale,
1867
+ message: viol.kind === "do-not-translate" ? `do-not-translate term "${viol.term}" is missing or altered` : `expected glossary translation "${viol.expected}" for "${viol.term}"`
1868
+ });
2183
1869
  }
2184
1870
  }
2185
1871
  }
@@ -2238,7 +1924,7 @@ async function runLint(state, options = {}) {
2238
1924
  const active = rules.filter(isActive);
2239
1925
  const spellingOn = active.some((r) => r.id === "spelling");
2240
1926
  const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
2241
- const allowWords = spellingOn ? buildAllowWords(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
1927
+ const allowWords = spellingOn ? ignoreWordsFor(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
2242
1928
  const ctx = {
2243
1929
  config,
2244
1930
  sourceLocale: state.config.sourceLocale,
@@ -2271,8 +1957,8 @@ async function runLint(state, options = {}) {
2271
1957
  }
2272
1958
 
2273
1959
  // src/server/lint/outputs.ts
2274
- import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
2275
- import { resolve as resolve5 } from "path";
1960
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
1961
+ import { resolve as resolve4 } from "path";
2276
1962
 
2277
1963
  // src/server/adapters/options.ts
2278
1964
  function applyCase(canonical, style) {
@@ -2850,27 +2536,41 @@ var vueI18nJson = {
2850
2536
  function xmlEscape2(s) {
2851
2537
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2852
2538
  }
2853
- function renderInterpolations(text, ids) {
2539
+ function attrEscape(s) {
2540
+ return xmlEscape2(s).replace(/"/g, "&quot;");
2541
+ }
2542
+ function angularXMeta(placeholders, name) {
2543
+ return /^[A-Z][A-Z0-9_]*$/.test(name) ? placeholders?.[name] : void 0;
2544
+ }
2545
+ function renderInterpolations(text, ids, placeholders) {
2854
2546
  let out = "";
2855
2547
  let last = 0;
2856
2548
  for (const m of text.matchAll(/\{(\w+)\}/g)) {
2857
2549
  const name = m[1];
2858
- let id = ids.get(name);
2859
- if (id === void 0) {
2860
- id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
2861
- ids.set(name, id);
2550
+ out += xmlEscape2(text.slice(last, m.index));
2551
+ const meta = angularXMeta(placeholders, name);
2552
+ if (meta) {
2553
+ const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
2554
+ const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
2555
+ out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
2556
+ } else {
2557
+ let id = ids.get(name);
2558
+ if (id === void 0) {
2559
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
2560
+ ids.set(name, id);
2561
+ }
2562
+ out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
2862
2563
  }
2863
- out += xmlEscape2(text.slice(last, m.index)) + `<x id="${id}" equiv-text="{{${name}}}"/>`;
2864
2564
  last = m.index + m[0].length;
2865
2565
  }
2866
2566
  return out + xmlEscape2(text.slice(last));
2867
2567
  }
2868
- function renderPluralIcu(forms, ids) {
2568
+ function renderPluralIcu(forms, ids, placeholders) {
2869
2569
  const cats = [
2870
2570
  ...Object.keys(forms).filter((c) => c.startsWith("=")),
2871
2571
  ...PLURAL_CATEGORIES.filter((c) => forms[c] !== void 0)
2872
2572
  ];
2873
- const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids)}}`);
2573
+ const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids, placeholders)}}`);
2874
2574
  return `{VAR_PLURAL, plural, ${branches.join(" ")}}`;
2875
2575
  }
2876
2576
  function renderEmbeddedIcu(value) {
@@ -2880,8 +2580,8 @@ function renderEmbeddedIcu(value) {
2880
2580
  );
2881
2581
  return xmlEscape2(renamed);
2882
2582
  }
2883
- function renderScalar(value, ids) {
2884
- return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids);
2583
+ function renderScalar(value, ids, placeholders) {
2584
+ return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids, placeholders);
2885
2585
  }
2886
2586
  var DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
2887
2587
  var angularXliff = {
@@ -2904,6 +2604,7 @@ var angularXliff = {
2904
2604
  const emptyAs = resolveEmptyAs(output, "source");
2905
2605
  const keys = Object.keys(state.keys).sort();
2906
2606
  for (const locale of state.config.locales) {
2607
+ if (output.skipSourceLocale && locale === sourceLocale) continue;
2907
2608
  const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
2908
2609
  const units = [];
2909
2610
  for (const key of keys) {
@@ -2914,17 +2615,18 @@ var angularXliff = {
2914
2615
  if (entry.plural) {
2915
2616
  const targetForms = resolveForms(entry, locale, sourceLocale, emptyAs);
2916
2617
  if (targetForms === null) continue;
2917
- source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids);
2918
- target = renderPluralIcu(targetForms, ids);
2618
+ source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids, entry.placeholders);
2619
+ target = renderPluralIcu(targetForms, ids, entry.placeholders);
2919
2620
  } else {
2920
2621
  const targetValue = resolveScalar(entry, locale, sourceLocale, emptyAs);
2921
2622
  if (targetValue === null) continue;
2922
- source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids);
2923
- target = renderScalar(targetValue, ids);
2623
+ source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids, entry.placeholders);
2624
+ target = renderScalar(targetValue, ids, entry.placeholders);
2924
2625
  }
2626
+ const translated = locale === sourceLocale || (entry.plural ? entry.values[locale]?.forms !== void 0 : !!entry.values[locale]?.value);
2925
2627
  units.push(` <trans-unit id="${xmlEscape2(key)}" datatype="html">`);
2926
2628
  units.push(` <source>${source}</source>`);
2927
- units.push(` <target>${target}</target>`);
2629
+ units.push(` <target${translated ? "" : ' state="new"'}>${target}</target>`);
2928
2630
  if (entry.description) {
2929
2631
  units.push(` <note priority="1" from="description">${xmlEscape2(entry.description)}</note>`);
2930
2632
  }
@@ -3072,8 +2774,8 @@ function checkOutputs(state, root) {
3072
2774
  for (const output of state.config.outputs) {
3073
2775
  const result = getAdapter(output.adapter).export(state, output);
3074
2776
  for (const file of result.files) {
3075
- const abs = resolve5(root, file.path);
3076
- const current = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
2777
+ const abs = resolve4(root, file.path);
2778
+ const current = existsSync6(abs) ? readFileSync6(abs, "utf8") : null;
3077
2779
  if (current === null) {
3078
2780
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
3079
2781
  } else if (current !== file.contents) {
@@ -3085,7 +2787,7 @@ function checkOutputs(state, root) {
3085
2787
  }
3086
2788
 
3087
2789
  // src/server/api.ts
3088
- import { readFileSync as readFileSync15, existsSync as existsSync11, readdirSync as readdirSync9, statSync as statSync6, rmSync as rmSync4 } from "fs";
2790
+ import { readFileSync as readFileSync21, existsSync as existsSync11, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync4 } from "fs";
3089
2791
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
3090
2792
 
3091
2793
  // src/server/ai/anthropic.ts
@@ -3187,6 +2889,88 @@ var BATCH_SCHEMA = {
3187
2889
  additionalProperties: false
3188
2890
  };
3189
2891
 
2892
+ // src/server/ai/batch.ts
2893
+ var MalformedReplyError = class extends Error {
2894
+ constructor(raw) {
2895
+ super("Model reply was not valid translation JSON.");
2896
+ this.raw = raw;
2897
+ this.name = "MalformedReplyError";
2898
+ }
2899
+ raw;
2900
+ };
2901
+ function parseReplyItems(text) {
2902
+ let parsed;
2903
+ try {
2904
+ parsed = JSON.parse(text);
2905
+ } catch {
2906
+ throw new MalformedReplyError(text);
2907
+ }
2908
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
2909
+ return parsed.items;
2910
+ }
2911
+ function chunk(items, size) {
2912
+ const out = [];
2913
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2914
+ return out;
2915
+ }
2916
+ function validateTranslation(req, translation) {
2917
+ if (translation === void 0) return { id: req.id, error: "No translation returned." };
2918
+ if (!placeholdersMatch(req.source, translation)) {
2919
+ return { id: req.id, error: "Placeholder mismatch between source and translation." };
2920
+ }
2921
+ if (req.maxLength !== void 0 && translation.length > req.maxLength) {
2922
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2923
+ }
2924
+ return { id: req.id, translation };
2925
+ }
2926
+ function validatePlural(req, forms) {
2927
+ if (!forms) return { id: req.id, error: "No translation returned." };
2928
+ const plural = req.plural;
2929
+ if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
2930
+ const cats = plural.categories;
2931
+ const missing = cats.filter((c) => typeof forms[c] !== "string");
2932
+ if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
2933
+ const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
2934
+ if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
2935
+ if (req.maxLength !== void 0) {
2936
+ const over = cats.find((c) => forms[c].length > req.maxLength);
2937
+ if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
2938
+ }
2939
+ const out = {};
2940
+ for (const c of cats) out[c] = forms[c];
2941
+ return { id: req.id, forms: out };
2942
+ }
2943
+ function validateReply(req, item) {
2944
+ return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
2945
+ }
2946
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
2947
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
2948
+ async function resolveBatch(batch, isRetry = false) {
2949
+ let reply;
2950
+ try {
2951
+ reply = await callBatch(batch, signal);
2952
+ } catch (err) {
2953
+ if (!(err instanceof MalformedReplyError)) throw err;
2954
+ onMalformedReply?.(err.raw, batch.length);
2955
+ if (signal?.aborted) return failBatch(batch);
2956
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
2957
+ const mid = Math.ceil(batch.length / 2);
2958
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
2959
+ }
2960
+ const byId = new Map(reply.map((r) => [r.id, r]));
2961
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
2962
+ }
2963
+ const results = [];
2964
+ const total = reqs.length;
2965
+ for (const batch of chunk(reqs, Math.max(1, batchSize))) {
2966
+ if (signal?.aborted) break;
2967
+ const batchResults = await resolveBatch(batch);
2968
+ results.push(...batchResults);
2969
+ onBatchComplete?.(results.length, total, batchResults);
2970
+ }
2971
+ return results;
2972
+ }
2973
+
3190
2974
  // src/server/ai/anthropic.ts
3191
2975
  var AnthropicProvider = class {
3192
2976
  constructor(config, client) {
@@ -3618,61 +3402,233 @@ function makeProvider(ai) {
3618
3402
  }
3619
3403
  }
3620
3404
 
3621
- // src/server/ai/pricing.ts
3622
- var PRICE_TABLE = [
3623
- ["claude-fable-5", 10, 50],
3624
- ["claude-mythos-5", 10, 50],
3625
- // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3626
- // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3627
- ["claude-opus-4-1", 15, 75],
3628
- ["claude-opus-4-0", 15, 75],
3629
- ["claude-opus-4-2025", 15, 75],
3630
- ["claude-opus-4", 5, 25],
3631
- ["claude-sonnet-4", 3, 15],
3632
- ["claude-haiku-4", 1, 5],
3633
- ["claude-3-5-haiku", 0.8, 4],
3634
- ["gpt-5.5-pro", 30, 180],
3635
- ["gpt-5.5", 5, 30],
3636
- ["gpt-5.4-pro", 30, 180],
3637
- ["gpt-5.4-mini", 0.75, 4.5],
3638
- ["gpt-5.4-nano", 0.2, 1.25],
3639
- ["gpt-5.4", 2.5, 15],
3640
- ["gpt-5.3-codex", 1.75, 14]
3641
- ];
3642
- var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3643
- function bareModelId(model) {
3644
- let id = model.trim().toLowerCase();
3645
- const slash = id.lastIndexOf("/");
3646
- if (slash !== -1) id = id.slice(slash + 1);
3647
- const anth = id.lastIndexOf("anthropic.");
3648
- if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3649
- return id;
3650
- }
3651
- function resolvePricing(ai) {
3652
- if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3653
- return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3654
- }
3655
- if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3656
- const id = bareModelId(ai.model);
3657
- let best;
3658
- for (const row of PRICE_TABLE) {
3659
- if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3660
- }
3661
- return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3662
- }
3663
-
3664
- // src/server/ai/estimate.ts
3665
- var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3666
- function estimateTokens(text) {
3667
- const cjk = text.match(CJK_RE)?.length ?? 0;
3668
- return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3669
- }
3670
- var EXPANSION = 1.2;
3671
- var ITEM_REPLY_OVERHEAD = 16;
3672
- var FORM_REPLY_OVERHEAD = 8;
3673
- function estimateOutputTokens(req) {
3674
- const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3675
- if (req.plural) {
3405
+ // src/server/ai/run.ts
3406
+ import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3407
+ import { resolve as resolve5, extname as extname2 } from "path";
3408
+ function selectRequests(state, opts) {
3409
+ const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
3410
+ const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3411
+ const keySet = opts.keys ? new Set(opts.keys) : null;
3412
+ const reqs = [];
3413
+ let id = 0;
3414
+ for (const key of Object.keys(state.keys).sort()) {
3415
+ const entry = state.keys[key];
3416
+ if (entry.skipTranslate) continue;
3417
+ if (keyRe && !keyRe.test(key)) continue;
3418
+ if (keySet && !keySet.has(key)) continue;
3419
+ const sourceLv = entry.values[state.config.sourceLocale];
3420
+ if (entry.plural) {
3421
+ const sourceForms = sourceLv?.forms;
3422
+ const other = sourceForms?.other;
3423
+ if (!sourceForms || !other) continue;
3424
+ for (const locale of targets) {
3425
+ const have = entry.values[locale]?.forms ?? {};
3426
+ const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
3427
+ if (opts.onlyMissing && complete) continue;
3428
+ const glossary = relevantGlossary(other, locale, state.glossary);
3429
+ reqs.push({
3430
+ id: String(id++),
3431
+ key,
3432
+ source: other,
3433
+ sourceLocale: state.config.sourceLocale,
3434
+ context: entry.context,
3435
+ targetLocale: locale,
3436
+ maxLength: entry.maxLength,
3437
+ placeholders: extractPlaceholders(other),
3438
+ ...glossary.length ? { glossary } : {},
3439
+ plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
3440
+ });
3441
+ }
3442
+ continue;
3443
+ }
3444
+ const source = sourceLv?.value;
3445
+ if (!source) continue;
3446
+ for (const locale of targets) {
3447
+ const existing = entry.values[locale]?.value;
3448
+ if (opts.onlyMissing && existing) continue;
3449
+ const glossary = relevantGlossary(source, locale, state.glossary);
3450
+ reqs.push({
3451
+ id: String(id++),
3452
+ key,
3453
+ source,
3454
+ sourceLocale: state.config.sourceLocale,
3455
+ context: entry.context,
3456
+ targetLocale: locale,
3457
+ maxLength: entry.maxLength,
3458
+ placeholders: extractPlaceholders(source),
3459
+ ...glossary.length ? { glossary } : {}
3460
+ });
3461
+ }
3462
+ }
3463
+ return reqs;
3464
+ }
3465
+ var MEDIA_TYPES = {
3466
+ ".png": "image/png",
3467
+ ".jpg": "image/jpeg",
3468
+ ".jpeg": "image/jpeg",
3469
+ ".webp": "image/webp",
3470
+ ".gif": "image/gif"
3471
+ };
3472
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3473
+ function attachScreenshots(reqs, state, projectRoot) {
3474
+ const cache2 = /* @__PURE__ */ new Map();
3475
+ for (const req of reqs) {
3476
+ const screenshot = state.keys[req.key]?.screenshot;
3477
+ if (!screenshot) continue;
3478
+ const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
3479
+ if (!mediaType) continue;
3480
+ if (!cache2.has(screenshot)) {
3481
+ const abs = resolve5(projectRoot, screenshot);
3482
+ if (!existsSync7(abs)) {
3483
+ cache2.set(screenshot, null);
3484
+ } else {
3485
+ const buf = readFileSync7(abs);
3486
+ cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
3487
+ }
3488
+ }
3489
+ const image = cache2.get(screenshot);
3490
+ if (image) req.image = image;
3491
+ }
3492
+ }
3493
+ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision) {
3494
+ if (supportsVision) {
3495
+ attachScreenshots(reqs, state, projectRoot);
3496
+ return { skipped: 0 };
3497
+ }
3498
+ const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
3499
+ return { skipped: keys.size };
3500
+ }
3501
+ var DEFAULT_LOCALE_CONCURRENCY = 3;
3502
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
3503
+ if (!reqs.length) return [];
3504
+ const byLocale = /* @__PURE__ */ new Map();
3505
+ for (const req of reqs) {
3506
+ let group = byLocale.get(req.targetLocale);
3507
+ if (!group) {
3508
+ group = [];
3509
+ byLocale.set(req.targetLocale, group);
3510
+ }
3511
+ group.push(req);
3512
+ }
3513
+ const localeBatches = [...byLocale.entries()].map(([locale, group]) => ({
3514
+ locale,
3515
+ batches: chunk(group, Math.max(1, batchSize))
3516
+ }));
3517
+ const jobs = [];
3518
+ const maxBatches = Math.max(...localeBatches.map((g) => g.batches.length));
3519
+ for (let i = 0; i < maxBatches; i++) {
3520
+ for (const g of localeBatches) {
3521
+ if (i < g.batches.length) jobs.push({ locale: g.locale, batch: g.batches[i] });
3522
+ }
3523
+ }
3524
+ const remaining = new Map(localeBatches.map((g) => [g.locale, g.batches.length]));
3525
+ const started = /* @__PURE__ */ new Set();
3526
+ const total = reqs.length;
3527
+ let done = 0;
3528
+ const allResults = [];
3529
+ let next = 0;
3530
+ async function worker() {
3531
+ while (next < jobs.length) {
3532
+ if (signal?.aborted) break;
3533
+ const { locale, batch } = jobs[next++];
3534
+ if (!started.has(locale)) {
3535
+ started.add(locale);
3536
+ hooks.onLocaleStart?.(locale);
3537
+ }
3538
+ const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3539
+ done += results.length;
3540
+ hooks.onBatchComplete?.(done, total, results, locale);
3541
+ }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3542
+ allResults.push(...batchResults);
3543
+ const left = remaining.get(locale) - 1;
3544
+ remaining.set(locale, left);
3545
+ if (left === 0 && !signal?.aborted) hooks.onLocaleDone?.(locale);
3546
+ }
3547
+ }
3548
+ const workers = Array.from({ length: Math.min(concurrency, jobs.length) }, worker);
3549
+ await Promise.all(workers);
3550
+ return allResults;
3551
+ }
3552
+ function applyResults(state, reqs, results, clock = systemClock, force = false) {
3553
+ const byId = new Map(reqs.map((r) => [r.id, r]));
3554
+ let written = 0;
3555
+ const errors = [];
3556
+ for (const res of results) {
3557
+ const req = byId.get(res.id);
3558
+ if (!req) continue;
3559
+ if (req.plural) {
3560
+ if (res.error || res.forms === void 0) {
3561
+ errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
3562
+ continue;
3563
+ }
3564
+ if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
3565
+ continue;
3566
+ }
3567
+ if (res.translation === void 0) {
3568
+ errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
3569
+ continue;
3570
+ }
3571
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
3572
+ if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
3573
+ }
3574
+ return { written, errors };
3575
+ }
3576
+
3577
+ // src/server/ai/pricing.ts
3578
+ var PRICE_TABLE = [
3579
+ ["claude-fable-5", 10, 50],
3580
+ ["claude-mythos-5", 10, 50],
3581
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3582
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3583
+ ["claude-opus-4-1", 15, 75],
3584
+ ["claude-opus-4-0", 15, 75],
3585
+ ["claude-opus-4-2025", 15, 75],
3586
+ ["claude-opus-4", 5, 25],
3587
+ ["claude-sonnet-4", 3, 15],
3588
+ ["claude-haiku-4", 1, 5],
3589
+ ["claude-3-5-haiku", 0.8, 4],
3590
+ ["gpt-5.5-pro", 30, 180],
3591
+ ["gpt-5.5", 5, 30],
3592
+ ["gpt-5.4-pro", 30, 180],
3593
+ ["gpt-5.4-mini", 0.75, 4.5],
3594
+ ["gpt-5.4-nano", 0.2, 1.25],
3595
+ ["gpt-5.4", 2.5, 15],
3596
+ ["gpt-5.3-codex", 1.75, 14]
3597
+ ];
3598
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3599
+ function bareModelId(model) {
3600
+ let id = model.trim().toLowerCase();
3601
+ const slash = id.lastIndexOf("/");
3602
+ if (slash !== -1) id = id.slice(slash + 1);
3603
+ const anth = id.lastIndexOf("anthropic.");
3604
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3605
+ return id;
3606
+ }
3607
+ function resolvePricing(ai) {
3608
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3609
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3610
+ }
3611
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3612
+ const id = bareModelId(ai.model);
3613
+ let best;
3614
+ for (const row of PRICE_TABLE) {
3615
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3616
+ }
3617
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3618
+ }
3619
+
3620
+ // src/server/ai/estimate.ts
3621
+ var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3622
+ function estimateTokens(text) {
3623
+ const cjk = text.match(CJK_RE)?.length ?? 0;
3624
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3625
+ }
3626
+ var EXPANSION = 1.2;
3627
+ var ITEM_REPLY_OVERHEAD = 16;
3628
+ var FORM_REPLY_OVERHEAD = 8;
3629
+ function estimateOutputTokens(req) {
3630
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3631
+ if (req.plural) {
3676
3632
  return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
3677
3633
  }
3678
3634
  return ITEM_REPLY_OVERHEAD + translated;
@@ -3736,7 +3692,7 @@ function readLog(projectRoot, limit = 100) {
3736
3692
  import { relative as relative3 } from "path";
3737
3693
 
3738
3694
  // src/server/import/detect.ts
3739
- import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
3695
+ import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
3740
3696
  import { join as join4 } from "path";
3741
3697
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3742
3698
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
@@ -3825,41 +3781,163 @@ function detectApple(root) {
3825
3781
  }
3826
3782
  return best;
3827
3783
  }
3828
- var DETECTORS = [detectLaravel, detectVue, detectArb, detectApple];
3829
- var BY_FORMAT = {
3830
- "laravel-php": detectLaravel,
3831
- "vue-i18n-json": (root) => detectVue(root, true),
3832
- "flutter-arb": detectArb,
3833
- "apple-strings": detectApple
3834
- };
3835
- function detect(root, formatOverride) {
3836
- if (!existsSync9(root)) return null;
3837
- if (formatOverride) {
3838
- const fn = BY_FORMAT[formatOverride];
3839
- if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
3840
- return fn(root);
3841
- }
3842
- for (const fn of DETECTORS) {
3843
- const d = fn(root);
3844
- if (d) return d;
3784
+ var ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
3785
+ function detectAngularXliff(root) {
3786
+ for (const rel of ANGULAR_DIR_CANDIDATES) {
3787
+ const localeRoot = rel === "." ? root : join4(root, rel);
3788
+ if (!safeIsDir(localeRoot)) continue;
3789
+ const files = readdirSync3(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
3790
+ if (files.length === 0) continue;
3791
+ const locales = files.map((f) => f.match(/^messages\.(.+)\.xlf$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3792
+ const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
3793
+ let sourceLocale;
3794
+ try {
3795
+ sourceLocale = readFileSync9(join4(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
3796
+ } catch {
3797
+ }
3798
+ if (!sourceLocale && locales.length === 0) continue;
3799
+ sourceLocale ??= pickSource(locales, () => 0);
3800
+ if (!locales.includes(sourceLocale)) locales.unshift(sourceLocale);
3801
+ return { format: "angular-xliff", localeRoot, locales, sourceLocale };
3845
3802
  }
3846
3803
  return null;
3847
3804
  }
3848
-
3849
- // src/server/import/parsers/vue-i18n-json.ts
3850
- import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
3851
- import { join as join5 } from "path";
3852
-
3853
- // src/server/import/flatten.ts
3854
- function flattenObject(value, prefix, warnings) {
3855
- const out = {};
3856
- const walk = (node, path) => {
3857
- if (typeof node === "string") {
3858
- out[path] = node;
3859
- } else if (typeof node === "number" || typeof node === "boolean") {
3860
- out[path] = String(node);
3861
- } else if (Array.isArray(node)) {
3862
- node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
3805
+ function detectRails(root) {
3806
+ const localeRoot = join4(root, "config", "locales");
3807
+ if (!safeIsDir(localeRoot)) return null;
3808
+ const locales = [];
3809
+ for (const file of readdirSync3(localeRoot).sort()) {
3810
+ if (!/\.ya?ml$/.test(file)) continue;
3811
+ let text;
3812
+ try {
3813
+ text = readFileSync9(join4(localeRoot, file), "utf8");
3814
+ } catch {
3815
+ continue;
3816
+ }
3817
+ for (const m of text.matchAll(/^(["']?)([A-Za-z][\w-]*)\1:\s*(?:#.*)?$/gm)) {
3818
+ const token = m[2];
3819
+ if (LOCALE_RE.test(token) && !locales.includes(token)) locales.push(token);
3820
+ }
3821
+ }
3822
+ if (locales.length === 0) return null;
3823
+ return { format: "rails-yaml", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3824
+ }
3825
+ var I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
3826
+ function detectI18next(root) {
3827
+ for (const rel of I18NEXT_DIR_CANDIDATES) {
3828
+ const localeRoot = join4(root, rel);
3829
+ if (!safeIsDir(localeRoot)) continue;
3830
+ const locales = listDirs(localeRoot).filter(
3831
+ (d) => LOCALE_RE.test(d) && readdirSync3(join4(localeRoot, d)).some((f) => f.endsWith(".json"))
3832
+ );
3833
+ if (locales.length === 0) continue;
3834
+ const sourceLocale = pickSource(locales, (loc) => {
3835
+ try {
3836
+ return readdirSync3(join4(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join4(localeRoot, loc, f)).size, 0);
3837
+ } catch {
3838
+ return 0;
3839
+ }
3840
+ });
3841
+ return { format: "i18next-json", localeRoot, locales, sourceLocale };
3842
+ }
3843
+ return null;
3844
+ }
3845
+ function gettextLocales(dir) {
3846
+ const locales = [];
3847
+ for (const entry of readdirSync3(dir).sort()) {
3848
+ const flat = entry.match(/^(.+)\.po$/)?.[1];
3849
+ if (flat && LOCALE_RE.test(flat)) {
3850
+ if (!locales.includes(flat)) locales.push(flat);
3851
+ continue;
3852
+ }
3853
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join4(dir, entry))) continue;
3854
+ const sub = join4(dir, entry);
3855
+ const hasPo = (d) => {
3856
+ try {
3857
+ return readdirSync3(d).some((f) => f.endsWith(".po"));
3858
+ } catch {
3859
+ return false;
3860
+ }
3861
+ };
3862
+ if (hasPo(join4(sub, "LC_MESSAGES")) || hasPo(sub)) {
3863
+ if (!locales.includes(entry)) locales.push(entry);
3864
+ }
3865
+ }
3866
+ return locales;
3867
+ }
3868
+ var GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
3869
+ function detectGettext(root) {
3870
+ for (const rel of GETTEXT_DIR_CANDIDATES) {
3871
+ const localeRoot = join4(root, rel);
3872
+ if (!safeIsDir(localeRoot)) continue;
3873
+ const locales = gettextLocales(localeRoot);
3874
+ if (locales.length === 0) continue;
3875
+ return { format: "gettext-po", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3876
+ }
3877
+ return null;
3878
+ }
3879
+ function detectAppleStringsdict(root) {
3880
+ const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
3881
+ let best = null;
3882
+ for (const dir of candidates) {
3883
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync9(join4(dir, `${l}.lproj`, "Localizable.stringsdict")));
3884
+ if (locales.length === 0) continue;
3885
+ if (!best || locales.length > best.locales.length) {
3886
+ best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
3887
+ }
3888
+ }
3889
+ return best;
3890
+ }
3891
+ var DETECTORS = [
3892
+ detectLaravel,
3893
+ detectVue,
3894
+ detectArb,
3895
+ detectApple,
3896
+ detectAngularXliff,
3897
+ detectRails,
3898
+ detectI18next,
3899
+ detectGettext,
3900
+ detectAppleStringsdict
3901
+ ];
3902
+ var BY_FORMAT = {
3903
+ "laravel-php": detectLaravel,
3904
+ "vue-i18n-json": (root) => detectVue(root, true),
3905
+ "flutter-arb": detectArb,
3906
+ "apple-strings": detectApple,
3907
+ "angular-xliff": detectAngularXliff,
3908
+ "rails-yaml": detectRails,
3909
+ "i18next-json": detectI18next,
3910
+ "gettext-po": detectGettext,
3911
+ "apple-stringsdict": detectAppleStringsdict
3912
+ };
3913
+ function detect(root, formatOverride) {
3914
+ if (!existsSync9(root)) return null;
3915
+ if (formatOverride) {
3916
+ const fn = BY_FORMAT[formatOverride];
3917
+ if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
3918
+ return fn(root);
3919
+ }
3920
+ for (const fn of DETECTORS) {
3921
+ const d = fn(root);
3922
+ if (d) return d;
3923
+ }
3924
+ return null;
3925
+ }
3926
+
3927
+ // src/server/import/parsers/vue-i18n-json.ts
3928
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
3929
+ import { join as join5 } from "path";
3930
+
3931
+ // src/server/import/flatten.ts
3932
+ function flattenObject(value, prefix, warnings) {
3933
+ const out = {};
3934
+ const walk = (node, path) => {
3935
+ if (typeof node === "string") {
3936
+ out[path] = node;
3937
+ } else if (typeof node === "number" || typeof node === "boolean") {
3938
+ out[path] = String(node);
3939
+ } else if (Array.isArray(node)) {
3940
+ node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
3863
3941
  } else if (node && typeof node === "object") {
3864
3942
  for (const [k, v] of Object.entries(node)) {
3865
3943
  walk(v, path ? `${path}.${k}` : k);
@@ -3887,7 +3965,7 @@ var vueI18nJson2 = {
3887
3965
  if (opts?.locales && !opts.locales.includes(locale)) continue;
3888
3966
  let data;
3889
3967
  try {
3890
- data = JSON.parse(readFileSync9(join5(localeRoot, file), "utf8"));
3968
+ data = JSON.parse(readFileSync10(join5(localeRoot, file), "utf8"));
3891
3969
  } catch (e) {
3892
3970
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
3893
3971
  continue;
@@ -3927,240 +4005,970 @@ function listPhpFiles(dir) {
3927
4005
  walk(dir);
3928
4006
  return out.sort();
3929
4007
  }
3930
- var PHP_READ_ALL = '$fs=array_filter(array_map("trim",explode("\\n",stream_get_contents(STDIN))),"strlen");$o=[];foreach($fs as $f){try{$o[$f]=require $f;}catch(\\Throwable $e){}}echo json_encode($o);';
3931
- function readPhpArrays(files) {
3932
- if (files.length === 0) return {};
3933
- let stdout;
4008
+ var PHP_READ_ALL = '$fs=array_filter(array_map("trim",explode("\\n",stream_get_contents(STDIN))),"strlen");$o=[];foreach($fs as $f){try{$o[$f]=require $f;}catch(\\Throwable $e){}}echo json_encode($o);';
4009
+ function readPhpArrays(files) {
4010
+ if (files.length === 0) return {};
4011
+ let stdout;
4012
+ try {
4013
+ stdout = execFileSync("php", ["-r", PHP_READ_ALL], {
4014
+ input: files.join("\n"),
4015
+ encoding: "utf8",
4016
+ maxBuffer: 256 * 1024 * 1024
4017
+ });
4018
+ } catch (e) {
4019
+ const err = e;
4020
+ if (err.code === "ENOENT") {
4021
+ throw new Error("php is required to import Laravel PHP files but was not found on PATH");
4022
+ }
4023
+ throw new Error(`php failed to evaluate Laravel lang files: ${err.message}`);
4024
+ }
4025
+ return JSON.parse(stdout);
4026
+ }
4027
+ var laravelPhp2 = {
4028
+ name: "laravel-php",
4029
+ parse(localeRoot, opts) {
4030
+ const warnings = [];
4031
+ const keys = {};
4032
+ const locales = [];
4033
+ const entries = [];
4034
+ for (const locale of listDirs2(localeRoot).sort()) {
4035
+ if (locale === "vendor") continue;
4036
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4037
+ const localeDir = join6(localeRoot, locale);
4038
+ locales.push(locale);
4039
+ for (const file of listPhpFiles(localeDir)) {
4040
+ const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
4041
+ entries.push({ locale, group, file });
4042
+ }
4043
+ }
4044
+ const data = readPhpArrays(entries.map((e) => e.file));
4045
+ for (const { locale, group, file } of entries) {
4046
+ if (!(file in data)) {
4047
+ warnings.push(`laravel-php: failed to read ${file}`);
4048
+ continue;
4049
+ }
4050
+ for (const [inner, value] of Object.entries(flattenObject(data[file], "", warnings))) {
4051
+ const key = `${group}.${inner}`;
4052
+ (keys[key] ??= { values: {} }).values[locale] = laravelToCanonical(value);
4053
+ }
4054
+ }
4055
+ return { locales, keys, warnings };
4056
+ }
4057
+ };
4058
+
4059
+ // src/server/import/parsers/flutter-arb.ts
4060
+ import { readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
4061
+ import { join as join7 } from "path";
4062
+ var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4063
+ function localeFromArbName(file) {
4064
+ const m = file.match(/^(.+)\.arb$/);
4065
+ if (!m) return null;
4066
+ let locale = m[1];
4067
+ if (locale.startsWith("app_")) locale = locale.slice(4);
4068
+ return LOCALE_RE3.test(locale) ? locale : null;
4069
+ }
4070
+ function placeholderMeta(raw) {
4071
+ if (!raw || typeof raw !== "object") return void 0;
4072
+ const out = {};
4073
+ for (const [name, def] of Object.entries(raw)) {
4074
+ if (!def || typeof def !== "object") continue;
4075
+ const o = def;
4076
+ const d = {};
4077
+ if (typeof o.type === "string") d.type = o.type;
4078
+ if (typeof o.format === "string") d.format = o.format;
4079
+ if (typeof o.example === "string") d.example = o.example;
4080
+ if (Object.keys(d).length) out[name] = d;
4081
+ }
4082
+ return Object.keys(out).length ? out : void 0;
4083
+ }
4084
+ var flutterArb2 = {
4085
+ name: "flutter-arb",
4086
+ parse(localeRoot, opts) {
4087
+ const warnings = [];
4088
+ const keys = {};
4089
+ const locales = [];
4090
+ for (const file of readdirSync6(localeRoot).sort()) {
4091
+ if (!file.endsWith(".arb")) continue;
4092
+ const locale = localeFromArbName(file);
4093
+ if (!locale) continue;
4094
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4095
+ let data;
4096
+ try {
4097
+ data = JSON.parse(readFileSync11(join7(localeRoot, file), "utf8"));
4098
+ } catch (e) {
4099
+ warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4100
+ continue;
4101
+ }
4102
+ if (!locales.includes(locale)) locales.push(locale);
4103
+ for (const [key, value] of Object.entries(data)) {
4104
+ if (key.startsWith("@@")) continue;
4105
+ if (key.startsWith("@")) {
4106
+ const meta = placeholderMeta(value?.placeholders);
4107
+ if (meta) (keys[key.slice(1)] ??= { values: {} }).placeholders = meta;
4108
+ continue;
4109
+ }
4110
+ if (typeof value !== "string") {
4111
+ warnings.push(`flutter-arb: skipped non-string ${file}:${key}`);
4112
+ continue;
4113
+ }
4114
+ (keys[key] ??= { values: {} }).values[locale] = value;
4115
+ }
4116
+ }
4117
+ return { locales, keys, warnings };
4118
+ }
4119
+ };
4120
+
4121
+ // src/server/import/parsers/apple-strings.ts
4122
+ import { readdirSync as readdirSync7, readFileSync as readFileSync12, statSync as statSync4 } from "fs";
4123
+ import { join as join8 } from "path";
4124
+ var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4125
+ var TABLE = "Localizable.strings";
4126
+ function localeFromLproj(dir) {
4127
+ const m = dir.match(/^(.+)\.lproj$/);
4128
+ if (!m) return null;
4129
+ return LOCALE_RE4.test(m[1]) ? m[1] : null;
4130
+ }
4131
+ function unescape(body) {
4132
+ return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
4133
+ const c = esc[0];
4134
+ if (c === "U" || c === "u") return String.fromCharCode(parseInt(esc.slice(1), 16));
4135
+ if (c === "n") return "\n";
4136
+ if (c === "t") return " ";
4137
+ if (c === "r") return "\r";
4138
+ return esc;
4139
+ });
4140
+ }
4141
+ function parseStrings(text, file, warnings) {
4142
+ const pairs = [];
4143
+ let i = 0;
4144
+ const n = text.length;
4145
+ const skipTrivia = () => {
4146
+ while (i < n) {
4147
+ const c = text[i];
4148
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
4149
+ i++;
4150
+ continue;
4151
+ }
4152
+ if (c === "/" && text[i + 1] === "/") {
4153
+ i += 2;
4154
+ while (i < n && text[i] !== "\n") i++;
4155
+ continue;
4156
+ }
4157
+ if (c === "/" && text[i + 1] === "*") {
4158
+ i += 2;
4159
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
4160
+ i += 2;
4161
+ continue;
4162
+ }
4163
+ break;
4164
+ }
4165
+ };
4166
+ const readToken = () => {
4167
+ if (i >= n) return null;
4168
+ if (text[i] === '"') {
4169
+ i++;
4170
+ let raw2 = "";
4171
+ while (i < n) {
4172
+ const c = text[i];
4173
+ if (c === "\\") {
4174
+ raw2 += c + (text[i + 1] ?? "");
4175
+ i += 2;
4176
+ continue;
4177
+ }
4178
+ if (c === '"') {
4179
+ i++;
4180
+ return unescape(raw2);
4181
+ }
4182
+ raw2 += c;
4183
+ i++;
4184
+ }
4185
+ return null;
4186
+ }
4187
+ let raw = "";
4188
+ while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4189
+ return raw.length ? raw : null;
4190
+ };
4191
+ while (true) {
4192
+ skipTrivia();
4193
+ if (i >= n) break;
4194
+ const key = readToken();
4195
+ if (key === null) {
4196
+ warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4197
+ break;
4198
+ }
4199
+ skipTrivia();
4200
+ if (text[i] !== "=") {
4201
+ warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4202
+ break;
4203
+ }
4204
+ i++;
4205
+ skipTrivia();
4206
+ const value = readToken();
4207
+ if (value === null) {
4208
+ warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4209
+ break;
4210
+ }
4211
+ skipTrivia();
4212
+ if (text[i] === ";") i++;
4213
+ pairs.push({ key, value });
4214
+ }
4215
+ return pairs;
4216
+ }
4217
+ var appleStrings2 = {
4218
+ name: "apple-strings",
4219
+ parse(localeRoot, opts) {
4220
+ const warnings = [];
4221
+ const keys = {};
4222
+ const locales = [];
4223
+ for (const dir of readdirSync7(localeRoot).sort()) {
4224
+ const locale = localeFromLproj(dir);
4225
+ if (!locale) continue;
4226
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4227
+ const file = join8(localeRoot, dir, TABLE);
4228
+ let text;
4229
+ try {
4230
+ if (!statSync4(file).isFile()) continue;
4231
+ text = readFileSync12(file, "utf8");
4232
+ } catch {
4233
+ continue;
4234
+ }
4235
+ locales.push(locale);
4236
+ const others = readdirSync7(join8(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4237
+ if (others.length) {
4238
+ warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4239
+ }
4240
+ for (const { key, value } of parseStrings(text, file, warnings)) {
4241
+ (keys[key] ??= { values: {} }).values[locale] = value;
4242
+ }
4243
+ }
4244
+ return { locales, keys, warnings };
4245
+ }
4246
+ };
4247
+
4248
+ // src/server/import/parsers/angular-xliff.ts
4249
+ import { readdirSync as readdirSync8, readFileSync as readFileSync13 } from "fs";
4250
+ import { join as join9 } from "path";
4251
+ var LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4252
+ var FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
4253
+ function decodeEntities(s) {
4254
+ return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
4255
+ }
4256
+ function parseAttrs(s) {
4257
+ const out = {};
4258
+ for (const m of s.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]] = decodeEntities(m[2]);
4259
+ return out;
4260
+ }
4261
+ function decodeInline(raw, addMeta) {
4262
+ let out = "";
4263
+ let last = 0;
4264
+ for (const m of raw.matchAll(/<x\b([^>]*?)\/>/g)) {
4265
+ out += decodeEntities(raw.slice(last, m.index));
4266
+ const attrs = parseAttrs(m[1]);
4267
+ const id = attrs["id"] ?? "X";
4268
+ const equiv = attrs["equiv-text"];
4269
+ const simple = equiv?.match(/^\{\{\s*(\w+)\s*\}\}$/);
4270
+ if (simple) {
4271
+ out += `{${simple[1]}}`;
4272
+ } else {
4273
+ out += `{${id}}`;
4274
+ const meta = {};
4275
+ if (attrs["ctype"]) meta.type = attrs["ctype"];
4276
+ if (equiv !== void 0) meta.example = equiv;
4277
+ addMeta(id, meta);
4278
+ }
4279
+ last = m.index + m[0].length;
4280
+ }
4281
+ return out + decodeEntities(raw.slice(last));
4282
+ }
4283
+ var angularXliff2 = {
4284
+ name: "angular-xliff",
4285
+ parse(localeRoot, opts) {
4286
+ const warnings = [];
4287
+ const keys = {};
4288
+ const locales = [];
4289
+ const seen = (loc) => {
4290
+ if (!locales.includes(loc)) locales.push(loc);
4291
+ };
4292
+ const files = readdirSync8(localeRoot).filter((f) => FILE_RE.test(f)).sort((a, b) => (a === "messages.xlf" ? -1 : 0) - (b === "messages.xlf" ? -1 : 0) || a.localeCompare(b));
4293
+ for (const file of files) {
4294
+ const fnameLocale = file.match(FILE_RE)[1];
4295
+ if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4296
+ let xml;
4297
+ try {
4298
+ xml = readFileSync13(join9(localeRoot, file), "utf8");
4299
+ } catch (e) {
4300
+ warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4301
+ continue;
4302
+ }
4303
+ const sourceLocale = xml.match(/source-language="([^"]+)"/)?.[1];
4304
+ if (!sourceLocale) {
4305
+ warnings.push(`angular-xliff: ${file} has no source-language attribute; skipped`);
4306
+ continue;
4307
+ }
4308
+ const targetLocale = xml.match(/target-language="([^"]+)"/)?.[1] ?? fnameLocale;
4309
+ if (opts?.locales && !opts.locales.includes(targetLocale ?? sourceLocale)) continue;
4310
+ for (const unit of xml.matchAll(/<trans-unit\b([^>]*)>([\s\S]*?)<\/trans-unit>/g)) {
4311
+ const id = parseAttrs(unit[1])["id"];
4312
+ if (!id) {
4313
+ warnings.push(`angular-xliff: ${file} has a trans-unit without an id; skipped`);
4314
+ continue;
4315
+ }
4316
+ const body = unit[2];
4317
+ const src = body.match(/<source\b[^>]*>([\s\S]*?)<\/source>/);
4318
+ let tgt = body.match(/<target\b([^>]*)>([\s\S]*?)<\/target>/);
4319
+ if (tgt && /\bstate="new"/.test(tgt[1])) tgt = null;
4320
+ const entry = keys[id] ??= { values: {} };
4321
+ const addMeta = (name, meta) => {
4322
+ (entry.placeholders ??= {})[name] ??= meta;
4323
+ };
4324
+ if (src && entry.values[sourceLocale] === void 0) {
4325
+ entry.values[sourceLocale] = decodeInline(src[1], addMeta);
4326
+ seen(sourceLocale);
4327
+ }
4328
+ if (tgt && tgt[2] !== "" && targetLocale !== void 0) {
4329
+ entry.values[targetLocale] = decodeInline(tgt[2], addMeta);
4330
+ seen(targetLocale);
4331
+ }
4332
+ }
4333
+ }
4334
+ return { locales, keys, warnings };
4335
+ }
4336
+ };
4337
+
4338
+ // src/server/import/parsers/gettext-po.ts
4339
+ import { readdirSync as readdirSync9, readFileSync as readFileSync14 } from "fs";
4340
+ import { join as join10 } from "path";
4341
+ var LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4342
+ var DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
4343
+ var CONT_RE = /^[ \t]*"(.*)"\s*$/;
4344
+ function unescapePo(s) {
4345
+ return s.replace(
4346
+ /\\([\\"ntr])/g,
4347
+ (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
4348
+ );
4349
+ }
4350
+ function parseEntries(text) {
4351
+ const entries = [];
4352
+ let cur = null;
4353
+ let append = null;
4354
+ const flush = () => {
4355
+ if (cur && cur.msgid !== void 0) entries.push(cur);
4356
+ cur = null;
4357
+ append = null;
4358
+ };
4359
+ for (const line of text.split("\n")) {
4360
+ if (line.trim() === "") {
4361
+ flush();
4362
+ continue;
4363
+ }
4364
+ if (line.startsWith("#")) continue;
4365
+ const m = line.match(DIRECTIVE_RE);
4366
+ if (m) {
4367
+ const kw = m[1];
4368
+ const idx = m[2];
4369
+ const body = unescapePo(m[3]);
4370
+ if (cur && (kw === "msgctxt" || kw === "msgid" && cur.msgid !== void 0)) flush();
4371
+ cur ??= { plurals: /* @__PURE__ */ new Map() };
4372
+ const entry = cur;
4373
+ if (kw === "msgctxt") {
4374
+ entry.msgctxt = body;
4375
+ append = (c) => {
4376
+ entry.msgctxt = (entry.msgctxt ?? "") + c;
4377
+ };
4378
+ } else if (kw === "msgid") {
4379
+ entry.msgid = body;
4380
+ append = (c) => {
4381
+ entry.msgid = (entry.msgid ?? "") + c;
4382
+ };
4383
+ } else if (kw === "msgid_plural") {
4384
+ entry.msgidPlural = body;
4385
+ append = (c) => {
4386
+ entry.msgidPlural = (entry.msgidPlural ?? "") + c;
4387
+ };
4388
+ } else if (idx !== void 0) {
4389
+ const i = Number(idx);
4390
+ entry.plurals.set(i, body);
4391
+ append = (c) => {
4392
+ entry.plurals.set(i, (entry.plurals.get(i) ?? "") + c);
4393
+ };
4394
+ } else {
4395
+ entry.msgstr = body;
4396
+ append = (c) => {
4397
+ entry.msgstr = (entry.msgstr ?? "") + c;
4398
+ };
4399
+ }
4400
+ continue;
4401
+ }
4402
+ const cont = line.match(CONT_RE);
4403
+ if (cont && append) append(unescapePo(cont[1]));
4404
+ }
4405
+ flush();
4406
+ return entries;
4407
+ }
4408
+ function discoverPoFiles(root) {
4409
+ const found = [];
4410
+ const entries = readdirSync9(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
4411
+ for (const e of entries) {
4412
+ if (e.isFile() && e.name.endsWith(".po")) {
4413
+ const base = e.name.slice(0, -3);
4414
+ found.push({ path: join10(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4415
+ } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4416
+ for (const sub of [join10(e.name, "LC_MESSAGES"), e.name]) {
4417
+ let names;
4418
+ try {
4419
+ names = readdirSync9(join10(root, sub)).sort();
4420
+ } catch {
4421
+ continue;
4422
+ }
4423
+ for (const f of names) {
4424
+ if (f.endsWith(".po")) found.push({ path: join10(root, sub, f), rel: join10(sub, f), locale: e.name });
4425
+ }
4426
+ }
4427
+ }
4428
+ }
4429
+ return found;
4430
+ }
4431
+ var gettextPo2 = {
4432
+ name: "gettext-po",
4433
+ parse(localeRoot, opts) {
4434
+ const warnings = [];
4435
+ const keys = {};
4436
+ const locales = [];
4437
+ for (const file of discoverPoFiles(localeRoot)) {
4438
+ let entries;
4439
+ try {
4440
+ entries = parseEntries(readFileSync14(file.path, "utf8"));
4441
+ } catch (e) {
4442
+ warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
4443
+ continue;
4444
+ }
4445
+ const header = entries.find((e) => e.msgid === "" && e.msgctxt === void 0);
4446
+ const headerLang = header?.msgstr?.match(/^Language:[ \t]*([A-Za-z0-9_-]+)/m)?.[1];
4447
+ const locale = file.locale ?? headerLang;
4448
+ if (!locale) {
4449
+ warnings.push(`gettext-po: cannot determine locale for ${file.rel}; skipped`);
4450
+ continue;
4451
+ }
4452
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4453
+ if (!locales.includes(locale)) locales.push(locale);
4454
+ const cats = categoriesFor(locale);
4455
+ for (const entry of entries) {
4456
+ if (entry === header) continue;
4457
+ const key = entry.msgctxt ?? entry.msgid;
4458
+ if (!key) continue;
4459
+ if (entry.msgidPlural !== void 0) {
4460
+ const forms = {};
4461
+ for (const [i, body] of [...entry.plurals].sort((a, b) => a[0] - b[0])) {
4462
+ if (body === "") continue;
4463
+ const cat = cats[i];
4464
+ if (!cat) {
4465
+ warnings.push(
4466
+ `gettext-po: ${file.rel} "${key}": msgstr[${i}] exceeds the ${cats.length} plural forms of "${locale}"; ignored`
4467
+ );
4468
+ continue;
4469
+ }
4470
+ forms[cat] = body.split("%d").join("{count}");
4471
+ }
4472
+ if (!forms.other) continue;
4473
+ (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
4474
+ } else {
4475
+ if (!entry.msgstr) continue;
4476
+ (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
4477
+ }
4478
+ }
4479
+ }
4480
+ return { locales, keys, warnings };
4481
+ }
4482
+ };
4483
+
4484
+ // src/server/import/parsers/i18next-json.ts
4485
+ import { readdirSync as readdirSync10, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4486
+ import { join as join11 } from "path";
4487
+ var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4488
+ var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
4489
+ var PLURAL_ARG = "count";
4490
+ var DEFAULT_NAMESPACE = "translation";
4491
+ function safeIsDir2(p) {
4492
+ try {
4493
+ return statSync5(p).isDirectory();
4494
+ } catch {
4495
+ return false;
4496
+ }
4497
+ }
4498
+ function fromI18next(value) {
4499
+ if (isIcuPluralOrSelect(value)) return value;
4500
+ return value.replace(/\{\{(\w+)\}\}/g, "{$1}");
4501
+ }
4502
+ function ingestFile(path, label, prefix, locale, keys, warnings) {
4503
+ let data;
3934
4504
  try {
3935
- stdout = execFileSync("php", ["-r", PHP_READ_ALL], {
3936
- input: files.join("\n"),
3937
- encoding: "utf8",
3938
- maxBuffer: 256 * 1024 * 1024
3939
- });
4505
+ data = JSON.parse(readFileSync15(path, "utf8"));
3940
4506
  } catch (e) {
3941
- const err = e;
3942
- if (err.code === "ENOENT") {
3943
- throw new Error("php is required to import Laravel PHP files but was not found on PATH");
4507
+ warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
4508
+ return false;
4509
+ }
4510
+ const fileWarnings = [];
4511
+ const flat = flattenObject(data, "", fileWarnings);
4512
+ for (const w of fileWarnings) warnings.push(`i18next-json: ${label}: ${w}`);
4513
+ const families = /* @__PURE__ */ new Set();
4514
+ for (const [k, v] of Object.entries(flat)) {
4515
+ const m = PLURAL_SUFFIX_RE.exec(k);
4516
+ if (m && m[2] === "other" && v !== "") families.add(m[1]);
4517
+ }
4518
+ const pluralForms = {};
4519
+ for (const [k, raw] of Object.entries(flat)) {
4520
+ if (raw === "") continue;
4521
+ const value = fromI18next(raw);
4522
+ const m = PLURAL_SUFFIX_RE.exec(k);
4523
+ if (m && families.has(m[1])) {
4524
+ (pluralForms[m[1]] ??= {})[m[2]] = value;
4525
+ continue;
3944
4526
  }
3945
- throw new Error(`php failed to evaluate Laravel lang files: ${err.message}`);
4527
+ if (families.has(k)) {
4528
+ warnings.push(
4529
+ `i18next-json: ${label}: key "${k}" collides with its own plural suffix family; the plural wins`
4530
+ );
4531
+ continue;
4532
+ }
4533
+ (keys[prefix + k] ??= { values: {} }).values[locale] = value;
3946
4534
  }
3947
- return JSON.parse(stdout);
4535
+ for (const [base, forms] of Object.entries(pluralForms)) {
4536
+ (keys[prefix + base] ??= { values: {} }).values[locale] = formsToIcu(PLURAL_ARG, forms);
4537
+ }
4538
+ return true;
3948
4539
  }
3949
- var laravelPhp2 = {
3950
- name: "laravel-php",
4540
+ var i18nextJson2 = {
4541
+ name: "i18next-json",
3951
4542
  parse(localeRoot, opts) {
3952
4543
  const warnings = [];
3953
4544
  const keys = {};
3954
4545
  const locales = [];
3955
- const entries = [];
3956
- for (const locale of listDirs2(localeRoot).sort()) {
3957
- if (locale === "vendor") continue;
3958
- if (opts?.locales && !opts.locales.includes(locale)) continue;
3959
- const localeDir = join6(localeRoot, locale);
3960
- locales.push(locale);
3961
- for (const file of listPhpFiles(localeDir)) {
3962
- const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
3963
- entries.push({ locale, group, file });
4546
+ for (const entry of readdirSync10(localeRoot).sort()) {
4547
+ const full = join11(localeRoot, entry);
4548
+ if (safeIsDir2(full)) {
4549
+ if (!LOCALE_RE7.test(entry)) continue;
4550
+ if (opts?.locales && !opts.locales.includes(entry)) continue;
4551
+ let any = false;
4552
+ for (const file of readdirSync10(full).sort()) {
4553
+ if (!file.endsWith(".json")) continue;
4554
+ const ns = file.slice(0, -".json".length);
4555
+ const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
4556
+ if (ingestFile(join11(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
4557
+ }
4558
+ if (any && !locales.includes(entry)) locales.push(entry);
4559
+ } else if (entry.endsWith(".json")) {
4560
+ const locale = entry.slice(0, -".json".length);
4561
+ if (!LOCALE_RE7.test(locale)) continue;
4562
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4563
+ if (ingestFile(full, entry, "", locale, keys, warnings) && !locales.includes(locale)) {
4564
+ locales.push(locale);
4565
+ }
3964
4566
  }
3965
4567
  }
3966
- const data = readPhpArrays(entries.map((e) => e.file));
3967
- for (const { locale, group, file } of entries) {
3968
- if (!(file in data)) {
3969
- warnings.push(`laravel-php: failed to read ${file}`);
4568
+ return { locales, keys, warnings };
4569
+ }
4570
+ };
4571
+
4572
+ // src/server/import/parsers/rails-yaml.ts
4573
+ import { readdirSync as readdirSync11, readFileSync as readFileSync16 } from "fs";
4574
+ import { join as join12 } from "path";
4575
+ var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
4576
+ var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
4577
+ function fromRuby(value) {
4578
+ return value.replace(/%\{(\w+)\}/g, "{$1}");
4579
+ }
4580
+ function makeNode() {
4581
+ return /* @__PURE__ */ Object.create(null);
4582
+ }
4583
+ function decodeDouble(body) {
4584
+ let out = "";
4585
+ for (let i = 0; i < body.length; i++) {
4586
+ const c = body[i];
4587
+ if (c !== "\\") {
4588
+ out += c;
4589
+ continue;
4590
+ }
4591
+ const n = body[++i];
4592
+ if (n === void 0) break;
4593
+ out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
4594
+ }
4595
+ return out;
4596
+ }
4597
+ function scanQuoted(s, start) {
4598
+ const q = s[start];
4599
+ if (q === '"') {
4600
+ for (let i = start + 1; i < s.length; i++) {
4601
+ if (s[i] === "\\") i++;
4602
+ else if (s[i] === '"') return { text: decodeDouble(s.slice(start + 1, i)), end: i + 1 };
4603
+ }
4604
+ return null;
4605
+ }
4606
+ let out = "";
4607
+ for (let i = start + 1; i < s.length; i++) {
4608
+ if (s[i] === "'") {
4609
+ if (s[i + 1] === "'") {
4610
+ out += "'";
4611
+ i++;
4612
+ } else {
4613
+ return { text: out, end: i + 1 };
4614
+ }
4615
+ } else {
4616
+ out += s[i];
4617
+ }
4618
+ }
4619
+ return null;
4620
+ }
4621
+ function stripPlainComment(s) {
4622
+ const m = /(^|\s)#/.exec(s);
4623
+ return (m && m.index >= 0 ? s.slice(0, m.index) : s).trim();
4624
+ }
4625
+ function onlyTrailing(s) {
4626
+ return /^\s*(#.*)?$/.test(s);
4627
+ }
4628
+ function parseYamlSubset(text, file, warnings) {
4629
+ const roots = {};
4630
+ const lines = text.split(/\r?\n/);
4631
+ let stack = [];
4632
+ let skipDeeperThan = null;
4633
+ let lastLeafIndent = null;
4634
+ for (let n = 0; n < lines.length; n++) {
4635
+ const raw = lines[n];
4636
+ const lineNo = n + 1;
4637
+ if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
4638
+ if (raw.trim() === "---") continue;
4639
+ const indentMatch = /^[ \t]*/.exec(raw)[0];
4640
+ if (indentMatch.includes(" ")) {
4641
+ warnings.push(`rails-yaml: ${file}:${lineNo}: tab in indentation; line skipped`);
4642
+ continue;
4643
+ }
4644
+ const indent = indentMatch.length;
4645
+ if (skipDeeperThan !== null) {
4646
+ if (indent > skipDeeperThan) continue;
4647
+ skipDeeperThan = null;
4648
+ }
4649
+ const content = raw.slice(indent);
4650
+ if (content.startsWith("- ") || content === "-") {
4651
+ warnings.push(`rails-yaml: ${file}:${lineNo}: sequences are not supported; node skipped`);
4652
+ skipDeeperThan = indent;
4653
+ continue;
4654
+ }
4655
+ let key;
4656
+ let rest;
4657
+ if (content[0] === '"' || content[0] === "'") {
4658
+ const k = scanQuoted(content, 0);
4659
+ if (!k || content[k.end] !== ":") {
4660
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unparseable quoted key; line skipped`);
4661
+ skipDeeperThan = indent;
3970
4662
  continue;
3971
4663
  }
3972
- for (const [inner, value] of Object.entries(flattenObject(data[file], "", warnings))) {
3973
- const key = `${group}.${inner}`;
3974
- (keys[key] ??= { values: {} }).values[locale] = laravelToCanonical(value);
4664
+ key = k.text;
4665
+ rest = content.slice(k.end + 1);
4666
+ } else {
4667
+ const m = /^(.*?):(?=\s|$)/.exec(content);
4668
+ if (!m) {
4669
+ warnings.push(`rails-yaml: ${file}:${lineNo}: not a "key: value" mapping line; line skipped`);
4670
+ skipDeeperThan = indent;
4671
+ continue;
3975
4672
  }
4673
+ key = m[1].trim();
4674
+ rest = content.slice(m[0].length);
4675
+ }
4676
+ if (lastLeafIndent !== null && indent > lastLeafIndent) {
4677
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unexpected indentation under a scalar; line skipped`);
4678
+ skipDeeperThan = indent - 1;
4679
+ continue;
4680
+ }
4681
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
4682
+ const trimmed = rest.trim();
4683
+ let value;
4684
+ if (trimmed === "" || trimmed.startsWith("#")) {
4685
+ value = null;
4686
+ } else if (trimmed[0] === "&" || trimmed[0] === "*") {
4687
+ warnings.push(`rails-yaml: ${file}:${lineNo}: YAML anchors/aliases are not supported; node skipped`);
4688
+ skipDeeperThan = indent;
4689
+ continue;
4690
+ } else if (trimmed[0] === "|" || trimmed[0] === ">") {
4691
+ warnings.push(`rails-yaml: ${file}:${lineNo}: block scalars are not supported; node skipped`);
4692
+ skipDeeperThan = indent;
4693
+ continue;
4694
+ } else if (trimmed[0] === "[" || trimmed[0] === "{") {
4695
+ warnings.push(`rails-yaml: ${file}:${lineNo}: flow collections are not supported; node skipped`);
4696
+ skipDeeperThan = indent;
4697
+ continue;
4698
+ } else if (trimmed[0] === '"' || trimmed[0] === "'") {
4699
+ const v = scanQuoted(trimmed, 0);
4700
+ if (!v || !onlyTrailing(trimmed.slice(v.end))) {
4701
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unterminated or trailing-garbage quoted value; line skipped`);
4702
+ continue;
4703
+ }
4704
+ value = v.text;
4705
+ } else {
4706
+ value = stripPlainComment(trimmed);
4707
+ }
4708
+ if (stack.length === 0) {
4709
+ if (value !== null) {
4710
+ warnings.push(`rails-yaml: ${file}:${lineNo}: top-level key "${key}" has a scalar value; skipped`);
4711
+ lastLeafIndent = indent;
4712
+ continue;
4713
+ }
4714
+ const root = roots[key] ??= makeNode();
4715
+ stack = [{ indent, node: root }];
4716
+ lastLeafIndent = null;
4717
+ continue;
4718
+ }
4719
+ const parent = stack[stack.length - 1].node;
4720
+ if (key in parent) {
4721
+ warnings.push(`rails-yaml: ${file}:${lineNo}: duplicate key "${key}"; later value wins`);
4722
+ }
4723
+ if (value === null) {
4724
+ const child = makeNode();
4725
+ parent[key] = child;
4726
+ stack.push({ indent, node: child });
4727
+ lastLeafIndent = null;
4728
+ } else {
4729
+ parent[key] = value;
4730
+ lastLeafIndent = indent;
3976
4731
  }
3977
- return { locales, keys, warnings };
3978
4732
  }
3979
- };
3980
-
3981
- // src/server/import/parsers/flutter-arb.ts
3982
- import { readdirSync as readdirSync6, readFileSync as readFileSync10 } from "fs";
3983
- import { join as join7 } from "path";
3984
- var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3985
- function localeFromArbName(file) {
3986
- const m = file.match(/^(.+)\.arb$/);
3987
- if (!m) return null;
3988
- let locale = m[1];
3989
- if (locale.startsWith("app_")) locale = locale.slice(4);
3990
- return LOCALE_RE3.test(locale) ? locale : null;
4733
+ return { roots };
3991
4734
  }
3992
- function placeholderMeta(raw) {
3993
- if (!raw || typeof raw !== "object") return void 0;
3994
- const out = {};
3995
- for (const [name, def] of Object.entries(raw)) {
3996
- if (!def || typeof def !== "object") continue;
3997
- const o = def;
3998
- const d = {};
3999
- if (typeof o.type === "string") d.type = o.type;
4000
- if (typeof o.format === "string") d.format = o.format;
4001
- if (typeof o.example === "string") d.example = o.example;
4002
- if (Object.keys(d).length) out[name] = d;
4735
+ function asPluralForms(node) {
4736
+ const entries = Object.entries(node);
4737
+ if (entries.length === 0) return null;
4738
+ const forms = {};
4739
+ for (const [k, v] of entries) {
4740
+ if (!CATEGORY_SET.has(k) || typeof v !== "string") return null;
4741
+ if (v !== "") forms[k] = v;
4003
4742
  }
4004
- return Object.keys(out).length ? out : void 0;
4743
+ if (!("other" in forms)) return null;
4744
+ return forms;
4005
4745
  }
4006
- var flutterArb2 = {
4007
- name: "flutter-arb",
4746
+ function synthesizeIcu(forms, file, key, warnings) {
4747
+ const parts = [];
4748
+ for (const cat of PLURAL_CATEGORIES) {
4749
+ const body = forms[cat];
4750
+ if (body === void 0) continue;
4751
+ if (body.includes("#")) {
4752
+ warnings.push(
4753
+ `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
4754
+ );
4755
+ }
4756
+ parts.push(`${cat} {${fromRuby(body)}}`);
4757
+ }
4758
+ return `{count, plural, ${parts.join(" ")}}`;
4759
+ }
4760
+ var railsYaml2 = {
4761
+ name: "rails-yaml",
4008
4762
  parse(localeRoot, opts) {
4009
4763
  const warnings = [];
4010
4764
  const keys = {};
4011
4765
  const locales = [];
4012
- for (const file of readdirSync6(localeRoot).sort()) {
4013
- if (!file.endsWith(".arb")) continue;
4014
- const locale = localeFromArbName(file);
4015
- if (!locale) continue;
4016
- if (opts?.locales && !opts.locales.includes(locale)) continue;
4017
- let data;
4766
+ const wanted = opts?.locales?.map((l) => l.toLowerCase());
4767
+ const addValue = (key, locale, value) => {
4768
+ (keys[key] ??= { values: {} }).values[locale] = value;
4769
+ };
4770
+ const flatten = (node, prefix, locale, file) => {
4771
+ for (const [k, v] of Object.entries(node)) {
4772
+ const key = prefix ? `${prefix}.${k}` : k;
4773
+ if (typeof v === "string") {
4774
+ if (v !== "") addValue(key, locale, fromRuby(v));
4775
+ continue;
4776
+ }
4777
+ const forms = asPluralForms(v);
4778
+ if (forms) addValue(key, locale, synthesizeIcu(forms, file, key, warnings));
4779
+ else flatten(v, key, locale, file);
4780
+ }
4781
+ };
4782
+ for (const file of readdirSync11(localeRoot).sort()) {
4783
+ if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
4784
+ let text;
4018
4785
  try {
4019
- data = JSON.parse(readFileSync10(join7(localeRoot, file), "utf8"));
4786
+ text = readFileSync16(join12(localeRoot, file), "utf8");
4020
4787
  } catch (e) {
4021
- warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4788
+ warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
4022
4789
  continue;
4023
4790
  }
4024
- if (!locales.includes(locale)) locales.push(locale);
4025
- for (const [key, value] of Object.entries(data)) {
4026
- if (key.startsWith("@@")) continue;
4027
- if (key.startsWith("@")) {
4028
- const meta = placeholderMeta(value?.placeholders);
4029
- if (meta) (keys[key.slice(1)] ??= { values: {} }).placeholders = meta;
4030
- continue;
4031
- }
4032
- if (typeof value !== "string") {
4033
- warnings.push(`flutter-arb: skipped non-string ${file}:${key}`);
4791
+ const { roots } = parseYamlSubset(text, file, warnings);
4792
+ for (const token of Object.keys(roots).sort()) {
4793
+ if (!LOCALE_RE8.test(token)) {
4794
+ warnings.push(`rails-yaml: ${file}: top-level key "${token}" is not a locale; subtree skipped`);
4034
4795
  continue;
4035
4796
  }
4036
- (keys[key] ??= { values: {} }).values[locale] = value;
4797
+ if (wanted && !wanted.includes(token.toLowerCase())) continue;
4798
+ if (!locales.includes(token)) locales.push(token);
4799
+ flatten(roots[token], "", token, file);
4037
4800
  }
4038
4801
  }
4039
4802
  return { locales, keys, warnings };
4040
4803
  }
4041
4804
  };
4042
4805
 
4043
- // src/server/import/parsers/apple-strings.ts
4044
- import { readdirSync as readdirSync7, readFileSync as readFileSync11, statSync as statSync4 } from "fs";
4045
- import { join as join8 } from "path";
4046
- var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4047
- var TABLE = "Localizable.strings";
4048
- function localeFromLproj(dir) {
4806
+ // src/server/import/parsers/apple-stringsdict.ts
4807
+ import { readdirSync as readdirSync12, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
4808
+ import { join as join13 } from "path";
4809
+ var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4810
+ var TABLE2 = "Localizable.stringsdict";
4811
+ function localeFromLproj2(dir) {
4049
4812
  const m = dir.match(/^(.+)\.lproj$/);
4050
4813
  if (!m) return null;
4051
- return LOCALE_RE4.test(m[1]) ? m[1] : null;
4814
+ return LOCALE_RE9.test(m[1]) ? m[1] : null;
4052
4815
  }
4053
- function unescape(body) {
4054
- return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
4055
- const c = esc[0];
4056
- if (c === "U" || c === "u") return String.fromCharCode(parseInt(esc.slice(1), 16));
4057
- if (c === "n") return "\n";
4058
- if (c === "t") return " ";
4059
- if (c === "r") return "\r";
4060
- return esc;
4061
- });
4816
+ function decodeEntities2(s) {
4817
+ return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
4062
4818
  }
4063
- function parseStrings(text, file, warnings) {
4064
- const pairs = [];
4819
+ function parsePlistDict(xml) {
4065
4820
  let i = 0;
4066
- const n = text.length;
4821
+ const n = xml.length;
4067
4822
  const skipTrivia = () => {
4068
- while (i < n) {
4069
- const c = text[i];
4070
- if (c === " " || c === " " || c === "\n" || c === "\r") {
4071
- i++;
4072
- continue;
4073
- }
4074
- if (c === "/" && text[i + 1] === "/") {
4075
- i += 2;
4076
- while (i < n && text[i] !== "\n") i++;
4823
+ for (; ; ) {
4824
+ while (i < n && /\s/.test(xml[i])) i++;
4825
+ if (xml.startsWith("<!--", i)) {
4826
+ const end = xml.indexOf("-->", i + 4);
4827
+ if (end === -1) throw new Error("unterminated comment");
4828
+ i = end + 3;
4077
4829
  continue;
4078
4830
  }
4079
- if (c === "/" && text[i + 1] === "*") {
4080
- i += 2;
4081
- while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
4082
- i += 2;
4831
+ if (xml.startsWith("<?", i) || xml.startsWith("<!", i) && !xml.startsWith("<!--", i)) {
4832
+ const end = xml.indexOf(">", i);
4833
+ if (end === -1) throw new Error("unterminated declaration");
4834
+ i = end + 1;
4083
4835
  continue;
4084
4836
  }
4085
4837
  break;
4086
4838
  }
4087
4839
  };
4088
- const readToken = () => {
4089
- if (i >= n) return null;
4090
- if (text[i] === '"') {
4091
- i++;
4092
- let raw2 = "";
4093
- while (i < n) {
4094
- const c = text[i];
4095
- if (c === "\\") {
4096
- raw2 += c + (text[i + 1] ?? "");
4097
- i += 2;
4098
- continue;
4099
- }
4100
- if (c === '"') {
4101
- i++;
4102
- return unescape(raw2);
4103
- }
4104
- raw2 += c;
4105
- i++;
4106
- }
4107
- return null;
4108
- }
4109
- let raw = "";
4110
- while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4111
- return raw.length ? raw : null;
4840
+ const readTag = () => {
4841
+ if (xml[i] !== "<") throw new Error(`expected a tag at offset ${i}`);
4842
+ const end = xml.indexOf(">", i);
4843
+ if (end === -1) throw new Error("unterminated tag");
4844
+ let body = xml.slice(i + 1, end).trim();
4845
+ i = end + 1;
4846
+ const closing = body.startsWith("/");
4847
+ if (closing) body = body.slice(1).trim();
4848
+ const selfClosing = body.endsWith("/");
4849
+ if (selfClosing) body = body.slice(0, -1).trim();
4850
+ const name = body.split(/\s/)[0];
4851
+ if (!name) throw new Error(`empty tag at offset ${end}`);
4852
+ return { name, closing, selfClosing };
4112
4853
  };
4113
- while (true) {
4114
- skipTrivia();
4115
- if (i >= n) break;
4116
- const key = readToken();
4117
- if (key === null) {
4118
- warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4119
- break;
4854
+ const readElementText = (name) => {
4855
+ const re = new RegExp(`</${name}\\s*>`, "g");
4856
+ re.lastIndex = i;
4857
+ const m = re.exec(xml);
4858
+ if (!m) throw new Error(`unterminated <${name}>`);
4859
+ const text = xml.slice(i, m.index);
4860
+ i = m.index + m[0].length;
4861
+ return decodeEntities2(text);
4862
+ };
4863
+ const readValue = (tag2) => {
4864
+ if (tag2.name === "dict") return tag2.selfClosing ? {} : readDict();
4865
+ if (tag2.name === "true" || tag2.name === "false") {
4866
+ if (!tag2.selfClosing) readElementText(tag2.name);
4867
+ return tag2.name;
4120
4868
  }
4121
- skipTrivia();
4122
- if (text[i] !== "=") {
4123
- warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4124
- break;
4869
+ if (["string", "integer", "real", "date", "data"].includes(tag2.name)) {
4870
+ return tag2.selfClosing ? "" : readElementText(tag2.name);
4125
4871
  }
4126
- i++;
4127
- skipTrivia();
4128
- const value = readToken();
4129
- if (value === null) {
4130
- warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4131
- break;
4872
+ throw new Error(`unsupported plist element <${tag2.name}>`);
4873
+ };
4874
+ const readDict = () => {
4875
+ const out = {};
4876
+ for (; ; ) {
4877
+ skipTrivia();
4878
+ const tag2 = readTag();
4879
+ if (tag2.closing) {
4880
+ if (tag2.name !== "dict") throw new Error(`unexpected </${tag2.name}> inside <dict>`);
4881
+ return out;
4882
+ }
4883
+ if (tag2.name !== "key") throw new Error(`expected <key> inside <dict>, got <${tag2.name}>`);
4884
+ const key = readElementText("key");
4885
+ skipTrivia();
4886
+ const vt = readTag();
4887
+ if (vt.closing) throw new Error(`<key>${key}</key> has no value`);
4888
+ out[key] = readValue(vt);
4132
4889
  }
4890
+ };
4891
+ skipTrivia();
4892
+ let tag = readTag();
4893
+ if (tag.name === "plist" && !tag.closing && !tag.selfClosing) {
4133
4894
  skipTrivia();
4134
- if (text[i] === ";") i++;
4135
- pairs.push({ key, value });
4895
+ tag = readTag();
4136
4896
  }
4137
- return pairs;
4897
+ if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
4898
+ return tag.selfClosing ? {} : readDict();
4138
4899
  }
4139
- var appleStrings2 = {
4140
- name: "apple-strings",
4900
+ var VAR_RE = /%#@([^@]*)@/g;
4901
+ function entryToIcu(key, entry, file, warnings) {
4902
+ const warn = (msg) => {
4903
+ warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
4904
+ return null;
4905
+ };
4906
+ if (typeof entry !== "object") return warn("value is not a dict; skipped");
4907
+ const fmt = entry["NSStringLocalizedFormatKey"];
4908
+ if (typeof fmt !== "string") return warn("missing NSStringLocalizedFormatKey; skipped");
4909
+ const vars = [...fmt.matchAll(VAR_RE)];
4910
+ if (vars.length !== 1) {
4911
+ return warn(`format key has ${vars.length} %#@\u2026@ variables; only exactly one is supported; skipped`);
4912
+ }
4913
+ const arg = vars[0][1];
4914
+ if (!/^\w+$/.test(arg)) return warn(`variable name "${arg}" is not a valid ICU argument; skipped`);
4915
+ const prefix = fmt.slice(0, vars[0].index);
4916
+ const suffix = fmt.slice(vars[0].index + vars[0][0].length);
4917
+ const varDict = entry[arg];
4918
+ if (typeof varDict !== "object") return warn(`variable "${arg}" has no dict; skipped`);
4919
+ const specType = varDict["NSStringFormatSpecTypeKey"];
4920
+ if (specType !== void 0 && specType !== "NSStringPluralRuleType") {
4921
+ return warn(`variable "${arg}" is not a plural rule (${String(specType)}); skipped`);
4922
+ }
4923
+ const valueType = varDict["NSStringFormatValueTypeKey"];
4924
+ const token = `%${typeof valueType === "string" && valueType ? valueType : "d"}`;
4925
+ const forms = {};
4926
+ for (const cat of PLURAL_CATEGORIES) {
4927
+ const body = varDict[cat];
4928
+ if (typeof body !== "string") continue;
4929
+ forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
4930
+ }
4931
+ if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
4932
+ return formsToIcu(arg, forms);
4933
+ }
4934
+ var appleStringsdict2 = {
4935
+ name: "apple-stringsdict",
4141
4936
  parse(localeRoot, opts) {
4142
4937
  const warnings = [];
4143
4938
  const keys = {};
4144
4939
  const locales = [];
4145
- for (const dir of readdirSync7(localeRoot).sort()) {
4146
- const locale = localeFromLproj(dir);
4940
+ for (const dir of readdirSync12(localeRoot).sort()) {
4941
+ const locale = localeFromLproj2(dir);
4147
4942
  if (!locale) continue;
4148
4943
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4149
- const file = join8(localeRoot, dir, TABLE);
4944
+ const file = join13(localeRoot, dir, TABLE2);
4150
4945
  let text;
4151
4946
  try {
4152
- if (!statSync4(file).isFile()) continue;
4153
- text = readFileSync11(file, "utf8");
4947
+ if (!statSync6(file).isFile()) continue;
4948
+ text = readFileSync17(file, "utf8");
4154
4949
  } catch {
4155
4950
  continue;
4156
4951
  }
4157
4952
  locales.push(locale);
4158
- const others = readdirSync7(join8(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4953
+ const others = readdirSync12(join13(localeRoot, dir)).filter(
4954
+ (f) => f.endsWith(".stringsdict") && f !== TABLE2
4955
+ );
4159
4956
  if (others.length) {
4160
- warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4957
+ warnings.push(
4958
+ `apple-stringsdict: ${dir} has other .stringsdict tables (${others.join(", ")}); only ${TABLE2} is imported`
4959
+ );
4161
4960
  }
4162
- for (const { key, value } of parseStrings(text, file, warnings)) {
4163
- (keys[key] ??= { values: {} }).values[locale] = value;
4961
+ let root;
4962
+ try {
4963
+ root = parsePlistDict(text);
4964
+ } catch (e) {
4965
+ warnings.push(`apple-stringsdict: failed to parse ${file}: ${e.message}`);
4966
+ continue;
4967
+ }
4968
+ for (const key of Object.keys(root).sort()) {
4969
+ const icu = entryToIcu(key, root[key], file, warnings);
4970
+ if (icu === null) continue;
4971
+ (keys[key] ??= { values: {} }).values[locale] = icu;
4164
4972
  }
4165
4973
  }
4166
4974
  return { locales, keys, warnings };
@@ -4172,7 +4980,12 @@ var REGISTRY = {
4172
4980
  [vueI18nJson2.name]: vueI18nJson2,
4173
4981
  [laravelPhp2.name]: laravelPhp2,
4174
4982
  [flutterArb2.name]: flutterArb2,
4175
- [appleStrings2.name]: appleStrings2
4983
+ [appleStrings2.name]: appleStrings2,
4984
+ [angularXliff2.name]: angularXliff2,
4985
+ [gettextPo2.name]: gettextPo2,
4986
+ [i18nextJson2.name]: i18nextJson2,
4987
+ [railsYaml2.name]: railsYaml2,
4988
+ [appleStringsdict2.name]: appleStringsdict2
4176
4989
  };
4177
4990
  function getParser(name) {
4178
4991
  const p = REGISTRY[name];
@@ -4185,7 +4998,14 @@ var OUTPUT_BY_FORMAT = {
4185
4998
  "laravel-php": { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" },
4186
4999
  "vue-i18n-json": { adapter: "vue-i18n-json", path: "src/locale/{locale}.json" },
4187
5000
  "flutter-arb": { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
4188
- "apple-strings": { adapter: "apple-strings", path: "{locale}.lproj/Localizable.strings", rootRelative: true }
5001
+ "apple-strings": { adapter: "apple-strings", path: "{locale}.lproj/Localizable.strings", rootRelative: true },
5002
+ // skipSourceLocale: ng extract-i18n owns messages.xlf (the source file); glotfile
5003
+ // only writes the translation files back next to it.
5004
+ "angular-xliff": { adapter: "angular-xliff", path: "messages.{locale}.xlf", rootRelative: true, skipSourceLocale: true },
5005
+ "gettext-po": { adapter: "gettext-po", path: "{locale}.po", rootRelative: true },
5006
+ "i18next-json": { adapter: "i18next-json", path: "{locale}/translation.json", rootRelative: true },
5007
+ "rails-yaml": { adapter: "rails-yaml", path: "config/locales/{locale}.yml" },
5008
+ "apple-stringsdict": { adapter: "apple-stringsdict", path: "{locale}.lproj/Localizable.stringsdict", rootRelative: true }
4189
5009
  };
4190
5010
  function assemble2(parsed, opts) {
4191
5011
  const warnings = [...parsed.warnings];
@@ -4303,7 +5123,7 @@ function runImport(opts) {
4303
5123
  }
4304
5124
 
4305
5125
  // src/server/export-run.ts
4306
- import { existsSync as existsSync10, readFileSync as readFileSync12, readdirSync as readdirSync8, rmdirSync, statSync as statSync5, unlinkSync } from "fs";
5126
+ import { existsSync as existsSync10, readFileSync as readFileSync18, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
4307
5127
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
4308
5128
  function effectiveLocales(config) {
4309
5129
  const limit = config.exportLocales;
@@ -4346,7 +5166,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
4346
5166
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
4347
5167
  const next = resolve7(dir, segment);
4348
5168
  if (isLast) {
4349
- if (stale(locale) && existsSync10(next) && statSync5(next).isFile()) {
5169
+ if (stale(locale) && existsSync10(next) && statSync7(next).isFile()) {
4350
5170
  unlinkSync(next);
4351
5171
  deleted++;
4352
5172
  removeEmptyDirs(dir, root);
@@ -4359,7 +5179,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
4359
5179
  const re = segmentRegExp(segment);
4360
5180
  let entries;
4361
5181
  try {
4362
- entries = readdirSync8(dir, { withFileTypes: true });
5182
+ entries = readdirSync13(dir, { withFileTypes: true });
4363
5183
  } catch {
4364
5184
  return;
4365
5185
  }
@@ -4402,7 +5222,7 @@ function exportToDisk(state, projectRoot, opts) {
4402
5222
  writtenPaths.add(abs);
4403
5223
  let current = null;
4404
5224
  try {
4405
- current = readFileSync12(abs, "utf8");
5225
+ current = readFileSync18(abs, "utf8");
4406
5226
  } catch {
4407
5227
  }
4408
5228
  if (current === f.contents) {
@@ -4419,17 +5239,17 @@ function exportToDisk(state, projectRoot, opts) {
4419
5239
  }
4420
5240
 
4421
5241
  // src/server/ui-prefs.ts
4422
- import { readFileSync as readFileSync13 } from "fs";
5242
+ import { readFileSync as readFileSync19 } from "fs";
4423
5243
  import { homedir } from "os";
4424
- import { join as join9 } from "path";
5244
+ import { join as join14 } from "path";
4425
5245
  var THEMES = ["system", "light", "dark"];
4426
5246
  var isThemeMode = (v) => THEMES.includes(v);
4427
5247
  var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
4428
- var defaultUiPrefsPath = () => join9(homedir(), ".glotfile", "ui.json");
5248
+ var defaultUiPrefsPath = () => join14(homedir(), ".glotfile", "ui.json");
4429
5249
  var DEFAULTS = { theme: "system" };
4430
5250
  function readJson(path) {
4431
5251
  try {
4432
- const parsed = JSON.parse(readFileSync13(path, "utf8"));
5252
+ const parsed = JSON.parse(readFileSync19(path, "utf8"));
4433
5253
  return parsed && typeof parsed === "object" ? parsed : {};
4434
5254
  } catch {
4435
5255
  return {};
@@ -4448,7 +5268,7 @@ function saveUiPrefs(path, prefs) {
4448
5268
  }
4449
5269
 
4450
5270
  // src/server/local-settings.ts
4451
- import { readFileSync as readFileSync14 } from "fs";
5271
+ import { readFileSync as readFileSync20 } from "fs";
4452
5272
  import { resolve as resolve8 } from "path";
4453
5273
  var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
4454
5274
  var isEditorId = (v) => EDITOR_IDS.includes(v);
@@ -4463,7 +5283,7 @@ var DEFAULT_EDITOR = "vscode";
4463
5283
  var settingsPath = (projectRoot) => resolve8(projectRoot, ".glotfile", "settings.json");
4464
5284
  function readJson2(path) {
4465
5285
  try {
4466
- const parsed = JSON.parse(readFileSync14(path, "utf8"));
5286
+ const parsed = JSON.parse(readFileSync20(path, "utf8"));
4467
5287
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4468
5288
  } catch {
4469
5289
  return {};
@@ -4536,7 +5356,7 @@ function projectName(root) {
4536
5356
  const nameFile = resolve9(root, ".idea", ".name");
4537
5357
  if (existsSync11(nameFile)) {
4538
5358
  try {
4539
- const name = readFileSync15(nameFile, "utf8").trim();
5359
+ const name = readFileSync21(nameFile, "utf8").trim();
4540
5360
  if (name) return name;
4541
5361
  } catch {
4542
5362
  }
@@ -4661,7 +5481,7 @@ function createApi(deps) {
4661
5481
  if (depth > 4) return;
4662
5482
  let entries = [];
4663
5483
  try {
4664
- entries = readdirSync9(dir);
5484
+ entries = readdirSync14(dir);
4665
5485
  } catch {
4666
5486
  return;
4667
5487
  }
@@ -4675,7 +5495,7 @@ function createApi(deps) {
4675
5495
  filePath = abs;
4676
5496
  } else {
4677
5497
  try {
4678
- if (statSync6(abs).isDirectory()) walk(abs, depth + 1);
5498
+ if (statSync8(abs).isDirectory()) walk(abs, depth + 1);
4679
5499
  } catch {
4680
5500
  }
4681
5501
  continue;
@@ -5456,7 +6276,7 @@ function createApi(deps) {
5456
6276
 
5457
6277
  // src/server/server.ts
5458
6278
  var here = dirname4(fileURLToPath(import.meta.url));
5459
- var DEFAULT_UI_DIR = join10(here, "..", "ui");
6279
+ var DEFAULT_UI_DIR = join15(here, "..", "ui");
5460
6280
  var MIME = {
5461
6281
  ".html": "text/html; charset=utf-8",
5462
6282
  ".js": "text/javascript; charset=utf-8",
@@ -5510,7 +6330,7 @@ function buildApp(opts) {
5510
6330
  const file = await readFileResponse(target);
5511
6331
  if (file) return file;
5512
6332
  }
5513
- const index = await readFileResponse(join10(root, "index.html"));
6333
+ const index = await readFileResponse(join15(root, "index.html"));
5514
6334
  if (index) return index;
5515
6335
  return c.notFound();
5516
6336
  });