glotfile 0.5.3 → 0.5.4

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,30 +1,3 @@
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";
@@ -865,7 +838,7 @@ function findMissing(state) {
865
838
  const entry = state.keys[key];
866
839
  if (entry.skipTranslate) continue;
867
840
  for (const locale of targets) {
868
- const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
841
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value?.trim();
869
842
  if (!v) out.push({ key, locale });
870
843
  }
871
844
  }
@@ -1487,162 +1460,14 @@ function pluralFormPlaceholdersMatch(category, source, form) {
1487
1460
  return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
1488
1461
  }
1489
1462
 
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;
1463
+ // src/server/glossary.ts
1464
+ function contains(haystack, needle, caseSensitive) {
1465
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1639
1466
  }
1640
1467
  function relevantGlossary(source, targetLocale, glossary) {
1641
1468
  const hints = [];
1642
1469
  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;
1470
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
1646
1471
  hints.push({
1647
1472
  term: entry.term,
1648
1473
  doNotTranslate: entry.doNotTranslate,
@@ -1652,170 +1477,84 @@ function relevantGlossary(source, targetLocale, glossary) {
1652
1477
  }
1653
1478
  return hints;
1654
1479
  }
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") });
1480
+ function glossaryViolations(source, value, targetLocale, glossary) {
1481
+ const out = [];
1482
+ for (const entry of glossary) {
1483
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
1484
+ if (entry.doNotTranslate) {
1485
+ if (!contains(value, entry.term, entry.caseSensitive)) {
1486
+ out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
1677
1487
  }
1488
+ continue;
1489
+ }
1490
+ const forced = entry.translations?.[targetLocale];
1491
+ if (forced && !contains(value, forced, entry.caseSensitive)) {
1492
+ out.push({ term: entry.term, expected: forced, kind: "forced" });
1678
1493
  }
1679
- const image = cache2.get(screenshot);
1680
- if (image) req.image = image;
1681
1494
  }
1495
+ return out;
1682
1496
  }
1683
- function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision) {
1684
- if (supportsVision) {
1685
- attachScreenshots(reqs, state, projectRoot);
1686
- return { skipped: 0 };
1497
+
1498
+ // src/server/spell.ts
1499
+ var instances = /* @__PURE__ */ new Map();
1500
+ var loading = /* @__PURE__ */ new Set();
1501
+ var unavailable = /* @__PURE__ */ new Set();
1502
+ var cache = /* @__PURE__ */ new Map();
1503
+ var norm = (dictId) => dictId.toLowerCase();
1504
+ var ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
1505
+ var MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
1506
+ var WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
1507
+ function spellTokens(value) {
1508
+ return value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
1509
+ }
1510
+ function ignoreWordsFor(glossary, customWords = []) {
1511
+ const set = /* @__PURE__ */ new Set();
1512
+ const add = (text) => {
1513
+ for (const w of text.match(WORD) ?? []) set.add(w.toLowerCase());
1514
+ };
1515
+ for (const e of glossary) {
1516
+ add(e.term);
1517
+ for (const t of Object.values(e.translations ?? {})) add(t);
1687
1518
  }
1688
- const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
1689
- return { skipped: keys.size };
1519
+ for (const w of customWords) add(w);
1520
+ return set;
1690
1521
  }
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);
1522
+ async function getSpeller(dictId) {
1523
+ const key = norm(dictId);
1524
+ const existing = instances.get(key);
1525
+ if (existing) return existing;
1526
+ if (unavailable.has(key)) return null;
1527
+ try {
1528
+ const nspellMod = await import("nspell");
1529
+ const nspell = nspellMod.default ?? nspellMod;
1530
+ const dictMod = await import(`dictionary-${key}`);
1531
+ const dictExport = dictMod.default ?? dictMod;
1532
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
1533
+ const speller = nspell(dict);
1534
+ instances.set(key, speller);
1535
+ return speller;
1536
+ } catch {
1537
+ unavailable.add(key);
1538
+ return null;
1539
+ } finally {
1540
+ loading.delete(key);
1702
1541
  }
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] });
1542
+ }
1543
+ function spellValue(dictId, value, ignore) {
1544
+ const key = norm(dictId);
1545
+ if (unavailable.has(key)) return [];
1546
+ const spell = instances.get(key);
1547
+ if (!spell) {
1548
+ if (!loading.has(key)) {
1549
+ loading.add(key);
1550
+ void getSpeller(key);
1712
1551
  }
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;
1552
+ return null;
1813
1553
  }
1814
1554
  const cacheKey = key + " " + value;
1815
1555
  let allBad = cache.get(cacheKey);
1816
1556
  if (!allBad) {
1817
- const words = value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
1818
- allBad = words.filter((w) => !spell.correct(w));
1557
+ allBad = spellTokens(value).filter((w) => !spell.correct(w));
1819
1558
  cache.set(cacheKey, allBad);
1820
1559
  }
1821
1560
  return allBad.filter((w) => !ignore.has(w.toLowerCase()));
@@ -1830,39 +1569,21 @@ var CHECK_RULE = {
1830
1569
  length: "max-length",
1831
1570
  glossary: "glossary-violation"
1832
1571
  };
1833
- function contains(haystack, needle, caseSensitive) {
1834
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1835
- }
1836
1572
  function runChecks(state, opts = {}) {
1837
1573
  const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
1838
1574
  const on = (id) => (!opts.only || opts.only.includes(id)) && !ruleOff(id);
1839
1575
  const issues = [];
1840
1576
  let spellPending = false;
1841
1577
  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);
1578
+ const ignore = ignoreWordsFor(state.glossary, state.config.spelling?.customWords);
1579
+ if (on("untranslated")) {
1580
+ for (const m of findMissing(state)) {
1581
+ issues.push({ key: m.key, locale: m.locale, check: "untranslated", message: "Not translated yet" });
1848
1582
  }
1849
1583
  }
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
1584
  for (const key of Object.keys(state.keys).sort()) {
1856
1585
  const entry = state.keys[key];
1857
1586
  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
1587
  if (entry.plural) {
1867
1588
  if (on("placeholder")) {
1868
1589
  const sourceForm = entry.values[sourceLocale]?.forms?.other ?? "";
@@ -1906,7 +1627,8 @@ function runChecks(state, opts = {}) {
1906
1627
  });
1907
1628
  }
1908
1629
  if (on("spelling") && !blank) {
1909
- const bad = spellValue(locale, value, ignore);
1630
+ const dictId = state.config.lint?.spelling?.locales?.[locale] ?? locale;
1631
+ const bad = spellValue(dictId, value, ignore);
1910
1632
  if (bad === null) spellPending = true;
1911
1633
  else if (bad.length) {
1912
1634
  issues.push({
@@ -1936,29 +1658,14 @@ function runChecks(state, opts = {}) {
1936
1658
  });
1937
1659
  }
1938
1660
  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
- }
1661
+ for (const viol of glossaryViolations(source, value, locale, state.glossary)) {
1662
+ issues.push({
1663
+ key,
1664
+ locale,
1665
+ check: "glossary",
1666
+ message: viol.kind === "do-not-translate" ? `Do-not-translate term "${viol.term}" is missing from the translation` : `Should use "${viol.expected}" for "${viol.term}"`,
1667
+ detail: [viol.expected]
1668
+ });
1962
1669
  }
1963
1670
  }
1964
1671
  }
@@ -1970,19 +1677,13 @@ function runChecks(state, opts = {}) {
1970
1677
  return { issues: visible, spellPending };
1971
1678
  }
1972
1679
 
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;
1680
+ // src/server/glob.ts
1681
+ function globToRegExp2(glob) {
1682
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1683
+ return new RegExp(`^${escaped}$`);
1985
1684
  }
1685
+
1686
+ // src/server/lint/spelling.ts
1986
1687
  var spellingRule = {
1987
1688
  id: "spelling",
1988
1689
  run(state, ctx) {
@@ -1994,10 +1695,8 @@ var spellingRule = {
1994
1695
  if (!speller) continue;
1995
1696
  const value = entry.values[locale]?.value;
1996
1697
  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;
1698
+ for (const word of spellTokens(value)) {
1699
+ if (ctx.allowWords.has(word.toLowerCase())) continue;
2001
1700
  if (!speller.correct(word)) {
2002
1701
  out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
2003
1702
  }
@@ -2007,18 +1706,7 @@ var spellingRule = {
2007
1706
  return out;
2008
1707
  }
2009
1708
  };
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
- };
1709
+ var defaultLoader = (dictId) => getSpeller(dictId);
2022
1710
 
2023
1711
  // src/server/lint/rules.ts
2024
1712
  var emptySourceRule = {
@@ -2035,17 +1723,14 @@ var emptySourceRule = {
2035
1723
  };
2036
1724
  var emptyTranslationRule = {
2037
1725
  id: "empty-translation",
2038
- run(state, ctx) {
1726
+ // findMissing is the shared "untranslated" walk (also behind the editor's
1727
+ // untranslated check and /scan/missing); a whitespace-only value counts as
1728
+ // missing there, so no separate whitespace pass is needed.
1729
+ run(state) {
2039
1730
  const out = [];
2040
1731
  for (const m of findMissing(state)) {
2041
1732
  out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
2042
1733
  }
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
1734
  return out;
2050
1735
  }
2051
1736
  };
@@ -2173,13 +1858,13 @@ var glossaryViolationRule = {
2173
1858
  for (const locale of ctx.targetLocales) {
2174
1859
  const v = entry.values[locale]?.value;
2175
1860
  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
- }
1861
+ for (const viol of glossaryViolations(src, v, locale, ctx.glossary)) {
1862
+ out.push({
1863
+ ruleId: "glossary-violation",
1864
+ key,
1865
+ locale,
1866
+ message: viol.kind === "do-not-translate" ? `do-not-translate term "${viol.term}" is missing or altered` : `expected glossary translation "${viol.expected}" for "${viol.term}"`
1867
+ });
2183
1868
  }
2184
1869
  }
2185
1870
  }
@@ -2238,7 +1923,7 @@ async function runLint(state, options = {}) {
2238
1923
  const active = rules.filter(isActive);
2239
1924
  const spellingOn = active.some((r) => r.id === "spelling");
2240
1925
  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();
1926
+ const allowWords = spellingOn ? ignoreWordsFor(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
2242
1927
  const ctx = {
2243
1928
  config,
2244
1929
  sourceLocale: state.config.sourceLocale,
@@ -2271,8 +1956,8 @@ async function runLint(state, options = {}) {
2271
1956
  }
2272
1957
 
2273
1958
  // src/server/lint/outputs.ts
2274
- import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
2275
- import { resolve as resolve5 } from "path";
1959
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
1960
+ import { resolve as resolve4 } from "path";
2276
1961
 
2277
1962
  // src/server/adapters/options.ts
2278
1963
  function applyCase(canonical, style) {
@@ -3072,8 +2757,8 @@ function checkOutputs(state, root) {
3072
2757
  for (const output of state.config.outputs) {
3073
2758
  const result = getAdapter(output.adapter).export(state, output);
3074
2759
  for (const file of result.files) {
3075
- const abs = resolve5(root, file.path);
3076
- const current = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
2760
+ const abs = resolve4(root, file.path);
2761
+ const current = existsSync6(abs) ? readFileSync6(abs, "utf8") : null;
3077
2762
  if (current === null) {
3078
2763
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
3079
2764
  } else if (current !== file.contents) {
@@ -3187,6 +2872,88 @@ var BATCH_SCHEMA = {
3187
2872
  additionalProperties: false
3188
2873
  };
3189
2874
 
2875
+ // src/server/ai/batch.ts
2876
+ var MalformedReplyError = class extends Error {
2877
+ constructor(raw) {
2878
+ super("Model reply was not valid translation JSON.");
2879
+ this.raw = raw;
2880
+ this.name = "MalformedReplyError";
2881
+ }
2882
+ raw;
2883
+ };
2884
+ function parseReplyItems(text) {
2885
+ let parsed;
2886
+ try {
2887
+ parsed = JSON.parse(text);
2888
+ } catch {
2889
+ throw new MalformedReplyError(text);
2890
+ }
2891
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
2892
+ return parsed.items;
2893
+ }
2894
+ function chunk(items, size) {
2895
+ const out = [];
2896
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2897
+ return out;
2898
+ }
2899
+ function validateTranslation(req, translation) {
2900
+ if (translation === void 0) return { id: req.id, error: "No translation returned." };
2901
+ if (!placeholdersMatch(req.source, translation)) {
2902
+ return { id: req.id, error: "Placeholder mismatch between source and translation." };
2903
+ }
2904
+ if (req.maxLength !== void 0 && translation.length > req.maxLength) {
2905
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2906
+ }
2907
+ return { id: req.id, translation };
2908
+ }
2909
+ function validatePlural(req, forms) {
2910
+ if (!forms) return { id: req.id, error: "No translation returned." };
2911
+ const plural = req.plural;
2912
+ if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
2913
+ const cats = plural.categories;
2914
+ const missing = cats.filter((c) => typeof forms[c] !== "string");
2915
+ if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
2916
+ const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
2917
+ if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
2918
+ if (req.maxLength !== void 0) {
2919
+ const over = cats.find((c) => forms[c].length > req.maxLength);
2920
+ if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
2921
+ }
2922
+ const out = {};
2923
+ for (const c of cats) out[c] = forms[c];
2924
+ return { id: req.id, forms: out };
2925
+ }
2926
+ function validateReply(req, item) {
2927
+ return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
2928
+ }
2929
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
2930
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
2931
+ async function resolveBatch(batch, isRetry = false) {
2932
+ let reply;
2933
+ try {
2934
+ reply = await callBatch(batch, signal);
2935
+ } catch (err) {
2936
+ if (!(err instanceof MalformedReplyError)) throw err;
2937
+ onMalformedReply?.(err.raw, batch.length);
2938
+ if (signal?.aborted) return failBatch(batch);
2939
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
2940
+ const mid = Math.ceil(batch.length / 2);
2941
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
2942
+ }
2943
+ const byId = new Map(reply.map((r) => [r.id, r]));
2944
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
2945
+ }
2946
+ const results = [];
2947
+ const total = reqs.length;
2948
+ for (const batch of chunk(reqs, Math.max(1, batchSize))) {
2949
+ if (signal?.aborted) break;
2950
+ const batchResults = await resolveBatch(batch);
2951
+ results.push(...batchResults);
2952
+ onBatchComplete?.(results.length, total, batchResults);
2953
+ }
2954
+ return results;
2955
+ }
2956
+
3190
2957
  // src/server/ai/anthropic.ts
3191
2958
  var AnthropicProvider = class {
3192
2959
  constructor(config, client) {
@@ -3618,6 +3385,178 @@ function makeProvider(ai) {
3618
3385
  }
3619
3386
  }
3620
3387
 
3388
+ // src/server/ai/run.ts
3389
+ import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3390
+ import { resolve as resolve5, extname as extname2 } from "path";
3391
+ function selectRequests(state, opts) {
3392
+ const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
3393
+ const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3394
+ const keySet = opts.keys ? new Set(opts.keys) : null;
3395
+ const reqs = [];
3396
+ let id = 0;
3397
+ for (const key of Object.keys(state.keys).sort()) {
3398
+ const entry = state.keys[key];
3399
+ if (entry.skipTranslate) continue;
3400
+ if (keyRe && !keyRe.test(key)) continue;
3401
+ if (keySet && !keySet.has(key)) continue;
3402
+ const sourceLv = entry.values[state.config.sourceLocale];
3403
+ if (entry.plural) {
3404
+ const sourceForms = sourceLv?.forms;
3405
+ const other = sourceForms?.other;
3406
+ if (!sourceForms || !other) continue;
3407
+ for (const locale of targets) {
3408
+ const have = entry.values[locale]?.forms ?? {};
3409
+ const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
3410
+ if (opts.onlyMissing && complete) continue;
3411
+ const glossary = relevantGlossary(other, locale, state.glossary);
3412
+ reqs.push({
3413
+ id: String(id++),
3414
+ key,
3415
+ source: other,
3416
+ sourceLocale: state.config.sourceLocale,
3417
+ context: entry.context,
3418
+ targetLocale: locale,
3419
+ maxLength: entry.maxLength,
3420
+ placeholders: extractPlaceholders(other),
3421
+ ...glossary.length ? { glossary } : {},
3422
+ plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
3423
+ });
3424
+ }
3425
+ continue;
3426
+ }
3427
+ const source = sourceLv?.value;
3428
+ if (!source) continue;
3429
+ for (const locale of targets) {
3430
+ const existing = entry.values[locale]?.value;
3431
+ if (opts.onlyMissing && existing) continue;
3432
+ const glossary = relevantGlossary(source, locale, state.glossary);
3433
+ reqs.push({
3434
+ id: String(id++),
3435
+ key,
3436
+ source,
3437
+ sourceLocale: state.config.sourceLocale,
3438
+ context: entry.context,
3439
+ targetLocale: locale,
3440
+ maxLength: entry.maxLength,
3441
+ placeholders: extractPlaceholders(source),
3442
+ ...glossary.length ? { glossary } : {}
3443
+ });
3444
+ }
3445
+ }
3446
+ return reqs;
3447
+ }
3448
+ var MEDIA_TYPES = {
3449
+ ".png": "image/png",
3450
+ ".jpg": "image/jpeg",
3451
+ ".jpeg": "image/jpeg",
3452
+ ".webp": "image/webp",
3453
+ ".gif": "image/gif"
3454
+ };
3455
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3456
+ function attachScreenshots(reqs, state, projectRoot) {
3457
+ const cache2 = /* @__PURE__ */ new Map();
3458
+ for (const req of reqs) {
3459
+ const screenshot = state.keys[req.key]?.screenshot;
3460
+ if (!screenshot) continue;
3461
+ const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
3462
+ if (!mediaType) continue;
3463
+ if (!cache2.has(screenshot)) {
3464
+ const abs = resolve5(projectRoot, screenshot);
3465
+ if (!existsSync7(abs)) {
3466
+ cache2.set(screenshot, null);
3467
+ } else {
3468
+ const buf = readFileSync7(abs);
3469
+ cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
3470
+ }
3471
+ }
3472
+ const image = cache2.get(screenshot);
3473
+ if (image) req.image = image;
3474
+ }
3475
+ }
3476
+ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision) {
3477
+ if (supportsVision) {
3478
+ attachScreenshots(reqs, state, projectRoot);
3479
+ return { skipped: 0 };
3480
+ }
3481
+ const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
3482
+ return { skipped: keys.size };
3483
+ }
3484
+ var DEFAULT_LOCALE_CONCURRENCY = 3;
3485
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
3486
+ if (!reqs.length) return [];
3487
+ const byLocale = /* @__PURE__ */ new Map();
3488
+ for (const req of reqs) {
3489
+ let group = byLocale.get(req.targetLocale);
3490
+ if (!group) {
3491
+ group = [];
3492
+ byLocale.set(req.targetLocale, group);
3493
+ }
3494
+ group.push(req);
3495
+ }
3496
+ const localeBatches = [...byLocale.entries()].map(([locale, group]) => ({
3497
+ locale,
3498
+ batches: chunk(group, Math.max(1, batchSize))
3499
+ }));
3500
+ const jobs = [];
3501
+ const maxBatches = Math.max(...localeBatches.map((g) => g.batches.length));
3502
+ for (let i = 0; i < maxBatches; i++) {
3503
+ for (const g of localeBatches) {
3504
+ if (i < g.batches.length) jobs.push({ locale: g.locale, batch: g.batches[i] });
3505
+ }
3506
+ }
3507
+ const remaining = new Map(localeBatches.map((g) => [g.locale, g.batches.length]));
3508
+ const started = /* @__PURE__ */ new Set();
3509
+ const total = reqs.length;
3510
+ let done = 0;
3511
+ const allResults = [];
3512
+ let next = 0;
3513
+ async function worker() {
3514
+ while (next < jobs.length) {
3515
+ if (signal?.aborted) break;
3516
+ const { locale, batch } = jobs[next++];
3517
+ if (!started.has(locale)) {
3518
+ started.add(locale);
3519
+ hooks.onLocaleStart?.(locale);
3520
+ }
3521
+ const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3522
+ done += results.length;
3523
+ hooks.onBatchComplete?.(done, total, results, locale);
3524
+ }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3525
+ allResults.push(...batchResults);
3526
+ const left = remaining.get(locale) - 1;
3527
+ remaining.set(locale, left);
3528
+ if (left === 0 && !signal?.aborted) hooks.onLocaleDone?.(locale);
3529
+ }
3530
+ }
3531
+ const workers = Array.from({ length: Math.min(concurrency, jobs.length) }, worker);
3532
+ await Promise.all(workers);
3533
+ return allResults;
3534
+ }
3535
+ function applyResults(state, reqs, results, clock = systemClock, force = false) {
3536
+ const byId = new Map(reqs.map((r) => [r.id, r]));
3537
+ let written = 0;
3538
+ const errors = [];
3539
+ for (const res of results) {
3540
+ const req = byId.get(res.id);
3541
+ if (!req) continue;
3542
+ if (req.plural) {
3543
+ if (res.error || res.forms === void 0) {
3544
+ errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
3545
+ continue;
3546
+ }
3547
+ if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
3548
+ continue;
3549
+ }
3550
+ if (res.translation === void 0) {
3551
+ errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
3552
+ continue;
3553
+ }
3554
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
3555
+ if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
3556
+ }
3557
+ return { written, errors };
3558
+ }
3559
+
3621
3560
  // src/server/ai/pricing.ts
3622
3561
  var PRICE_TABLE = [
3623
3562
  ["claude-fable-5", 10, 50],