glotfile 0.8.2 → 0.8.3

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.
@@ -639,7 +639,7 @@ function setSourceValue(state, key, value) {
639
639
  function setTargetValue(state, key, locale, value, clock = systemClock) {
640
640
  const entry = requireKey(state, key);
641
641
  if (entry.plural) throw new GlotfileError(`Key is a plural; use the plural setters: ${key}`);
642
- entry.values[locale] = { value: value.trim(), state: "reviewed", updatedAt: clock() };
642
+ entry.values[canonLocale(locale)] = { value: value.trim(), state: "reviewed", updatedAt: clock() };
643
643
  }
644
644
  function formSignature(forms) {
645
645
  return Object.entries(forms).sort(([a], [b]) => a.localeCompare(b)).map(([cat, val]) => `${cat}:${normalizeSource(val ?? "")}`).join("|");
@@ -662,8 +662,9 @@ function setSourcePluralForms(state, key, forms) {
662
662
  }
663
663
  function setPluralForms(state, key, locale, forms, clock = systemClock) {
664
664
  const entry = requirePlural(state, key);
665
- if (locale === state.config.sourceLocale) throw new GlotfileError("Use setSourcePluralForms for the source locale");
666
- entry.values[locale] = { forms: normalizeForms(forms), state: "reviewed", updatedAt: clock() };
665
+ const loc = canonLocale(locale);
666
+ if (loc === state.config.sourceLocale) throw new GlotfileError("Use setSourcePluralForms for the source locale");
667
+ entry.values[loc] = { forms: normalizeForms(forms), state: "reviewed", updatedAt: clock() };
667
668
  }
668
669
  function convertToPlural(state, key, arg) {
669
670
  const entry = requireKey(state, key);
@@ -691,12 +692,15 @@ function convertToScalar(state, key) {
691
692
  }
692
693
  function clearValue(state, key, locale) {
693
694
  const entry = requireKey(state, key);
694
- if (locale === state.config.sourceLocale) throw new GlotfileError("Cannot clear the source value");
695
- delete entry.values[locale];
695
+ const loc = canonLocale(locale);
696
+ if (loc === state.config.sourceLocale) throw new GlotfileError("Cannot clear the source value");
697
+ delete entry.values[loc];
696
698
  }
697
699
  function setKeyState(state, key, locale, next) {
700
+ if (!STATES.includes(next)) throw new GlotfileError(`Unknown translation state: ${next}`);
698
701
  const entry = requireKey(state, key);
699
- const lv = entry.values[locale];
702
+ const loc = canonLocale(locale);
703
+ const lv = entry.values[loc];
700
704
  if (!lv) throw new GlotfileError(`No value for ${key} @ ${locale}`);
701
705
  lv.state = next;
702
706
  }
@@ -769,14 +773,16 @@ function removeCustomWord(state, word) {
769
773
  function applyMachineTranslation(state, key, locale, value, clock = systemClock, force = false) {
770
774
  const entry = requireKey(state, key);
771
775
  if (entry.plural) throw new GlotfileError(`Key is a plural; use applyMachineTranslationForms: ${key}`);
772
- if (!force && entry.values[locale]?.state === "reviewed") return false;
773
- entry.values[locale] = { value: value.trim(), state: "machine", source: "ai", updatedAt: clock() };
776
+ const loc = canonLocale(locale);
777
+ if (!force && entry.values[loc]?.state === "reviewed") return false;
778
+ entry.values[loc] = { value: value.trim(), state: "machine", source: "ai", updatedAt: clock() };
774
779
  return true;
775
780
  }
776
781
  function applyMachineTranslationForms(state, key, locale, forms, clock = systemClock, force = false) {
777
782
  const entry = requirePlural(state, key);
778
- if (!force && entry.values[locale]?.state === "reviewed") return false;
779
- entry.values[locale] = { forms: normalizeForms(forms), state: "machine", source: "ai", updatedAt: clock() };
783
+ const loc = canonLocale(locale);
784
+ if (!force && entry.values[loc]?.state === "reviewed") return false;
785
+ entry.values[loc] = { forms: normalizeForms(forms), state: "machine", source: "ai", updatedAt: clock() };
780
786
  return true;
781
787
  }
782
788
 
@@ -905,7 +911,21 @@ var PATTERNS = {
905
911
  // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
906
912
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
907
913
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
908
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
914
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g,
915
+ // vue-i18n pluralization: $tc('key') and the destructured bare tc('key').
916
+ /\$tc\s*\(\s*'([^']+)'/g,
917
+ /\$tc\s*\(\s*"([^"]+)"/g,
918
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btc\s*\(\s*'([^']+)'/g,
919
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btc\s*\(\s*"([^"]+)"/g,
920
+ // React-i18next <Trans i18nKey="key" /> (attribute order tolerated).
921
+ /<Trans\b[^>]*\bi18nKey\s*=\s*'([^']+)'/g,
922
+ /<Trans\b[^>]*\bi18nKey\s*=\s*"([^"]+)"/g,
923
+ // A renamed translate() wrapper (covers the common `const { t: translate }`
924
+ // alias by name; arbitrary aliases aren't resolved). Method `.translate()`
925
+ // is excluded. Over-matching here only keeps keys "used" — the safe direction
926
+ // for prune, which deletes only keys with no match at all.
927
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btranslate\s*\(\s*'([^']+)'/g,
928
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btranslate\s*\(\s*"([^"]+)"/g
909
929
  ],
910
930
  gettext: [
911
931
  /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
@@ -939,7 +959,7 @@ var PREFIX_PATTERNS = {
939
959
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
940
960
  ]
941
961
  };
942
- var CACHE_VERSION = 6;
962
+ var CACHE_VERSION = 7;
943
963
  var EXT_SCANNER = {
944
964
  ".php": "laravel",
945
965
  ".vue": "js-i18n",
@@ -2300,28 +2320,33 @@ var i18nextJson = {
2300
2320
  export(state, output) {
2301
2321
  const files = [];
2302
2322
  const warnings = [];
2303
- const fmt = state.config.format;
2323
+ const { indent, finalNewline } = resolveFormat(state, output);
2324
+ const fmt = { indent, sortKeys: true, finalNewline };
2325
+ const emptyAs = resolveEmptyAs(output, "omit");
2304
2326
  const collided = /* @__PURE__ */ new Set();
2305
2327
  warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE3));
2306
2328
  for (const locale of state.config.locales) {
2307
2329
  const obj = {};
2308
2330
  for (const [key, entry] of Object.entries(state.keys)) {
2309
- const lv = entry.values[locale];
2310
- if (!lv) continue;
2311
2331
  const segments = key.split(".");
2312
2332
  const leaf = segments[segments.length - 1];
2313
2333
  const parent = segments.slice(0, -1);
2314
2334
  if (entry.plural) {
2315
- if (!lv.forms) continue;
2335
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
2336
+ if (!forms) continue;
2316
2337
  for (const cat of PLURAL_CATEGORIES) {
2317
- const body = lv.forms[cat];
2338
+ const body = forms[cat];
2318
2339
  if (body === void 0) continue;
2319
2340
  if (setNested(obj, [...parent, `${leaf}_${cat}`], toI18next(body))) collided.add(key);
2320
2341
  }
2321
2342
  continue;
2322
2343
  }
2323
- if (lv.value === void 0) continue;
2324
- if (setNested(obj, segments, toI18next(lv.value))) collided.add(key);
2344
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
2345
+ if (raw === null) continue;
2346
+ if (raw && isIcuPluralOrSelect(raw)) {
2347
+ warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
2348
+ }
2349
+ if (setNested(obj, segments, toI18next(raw))) collided.add(key);
2325
2350
  }
2326
2351
  files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
2327
2352
  }
@@ -2842,7 +2867,7 @@ function checkOutputs(state, root) {
2842
2867
  }
2843
2868
 
2844
2869
  // src/server/api.ts
2845
- import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync9, rmSync as rmSync6 } from "fs";
2870
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync10, rmSync as rmSync6 } from "fs";
2846
2871
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
2847
2872
 
2848
2873
  // src/server/ai/anthropic.ts
@@ -3317,6 +3342,7 @@ var AnthropicProvider = class {
3317
3342
  }, { signal });
3318
3343
  this.recordUsage(res.usage);
3319
3344
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
3345
+ if (res.stop_reason === "max_tokens") throw new MalformedReplyError(text);
3320
3346
  return parseReplyItems(text);
3321
3347
  }
3322
3348
  };
@@ -3402,6 +3428,7 @@ var OpenAIProvider = class {
3402
3428
  ]
3403
3429
  }, { signal });
3404
3430
  const text = res.choices?.[0]?.message?.content ?? "";
3431
+ if (res.choices?.[0]?.finish_reason === "length") throw new MalformedReplyError(text);
3405
3432
  return parseReplyItems(text);
3406
3433
  }
3407
3434
  };
@@ -3511,8 +3538,11 @@ var BedrockProvider = class {
3511
3538
  const res = await this.client.send(this.makeCommand(this.buildInput(batch)), { abortSignal: signal });
3512
3539
  const blocks = res.output?.message?.content ?? [];
3513
3540
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
3514
- if (tool?.input?.items) return tool.input.items;
3515
3541
  const text = blocks.find((b) => b.text)?.text ?? "";
3542
+ if (res.stopReason === "max_tokens") {
3543
+ throw new MalformedReplyError(text || JSON.stringify(tool?.input ?? {}));
3544
+ }
3545
+ if (tool?.input?.items) return tool.input.items;
3516
3546
  return parseReplyItems(text);
3517
3547
  }
3518
3548
  };
@@ -3778,8 +3808,30 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
3778
3808
  return { skipped: keys.size };
3779
3809
  }
3780
3810
  var DEFAULT_LOCALE_CONCURRENCY = 3;
3781
- async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
3811
+ function isTransientError(err) {
3812
+ const e = err;
3813
+ if (e && typeof e.status === "number") return e.status === 429 || e.status >= 500;
3814
+ const code = e?.code;
3815
+ return code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "EPIPE" || code === "EAI_AGAIN";
3816
+ }
3817
+ function sleep(ms, signal) {
3818
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
3819
+ return new Promise((resolve11) => {
3820
+ const t = setTimeout(() => {
3821
+ signal?.removeEventListener("abort", onAbort);
3822
+ resolve11();
3823
+ }, ms);
3824
+ const onAbort = () => {
3825
+ clearTimeout(t);
3826
+ resolve11();
3827
+ };
3828
+ signal?.addEventListener("abort", onAbort, { once: true });
3829
+ });
3830
+ }
3831
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity, retry = {}) {
3782
3832
  if (!reqs.length) return [];
3833
+ const maxRetries = retry.retries ?? 3;
3834
+ const delayMs = retry.delayMs ?? ((attempt) => 250 * 2 ** attempt);
3783
3835
  const byLocale = /* @__PURE__ */ new Map();
3784
3836
  for (const req of reqs) {
3785
3837
  let group = byLocale.get(req.targetLocale);
@@ -3814,10 +3866,20 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
3814
3866
  started.add(locale);
3815
3867
  hooks.onLocaleStart?.(locale);
3816
3868
  }
3817
- const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3818
- done += results.length;
3819
- hooks.onBatchComplete?.(done, total, results, locale);
3820
- }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3869
+ let batchResults;
3870
+ for (let attempt = 0; ; attempt++) {
3871
+ try {
3872
+ batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3873
+ done += results.length;
3874
+ hooks.onBatchComplete?.(done, total, results, locale);
3875
+ }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3876
+ break;
3877
+ } catch (err) {
3878
+ if (attempt >= maxRetries || signal?.aborted || !isTransientError(err)) throw err;
3879
+ hooks.onRetry?.(locale, attempt + 1, err);
3880
+ await sleep(delayMs(attempt), signal);
3881
+ }
3882
+ }
3821
3883
  allResults.push(...batchResults);
3822
3884
  const left = remaining.get(locale) - 1;
3823
3885
  remaining.set(locale, left);
@@ -3882,21 +3944,56 @@ function clearPendingBatch(projectRoot) {
3882
3944
  }
3883
3945
 
3884
3946
  // src/server/log.ts
3885
- import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3947
+ import { appendFileSync, existsSync as existsSync9, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
3886
3948
  import { resolve as resolve6 } from "path";
3887
3949
  function logPath(projectRoot) {
3888
3950
  return resolve6(projectRoot, ".glotfile", "log.jsonl");
3889
3951
  }
3952
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
3953
+ var TRIM_LOG_TO_BYTES = 4 * 1024 * 1024;
3890
3954
  function appendLog(projectRoot, entry) {
3891
3955
  ensureGlotfileDir(projectRoot);
3892
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3893
- }
3894
- function readLog(projectRoot, limit = 100) {
3895
3956
  const path = logPath(projectRoot);
3896
- if (!existsSync9(path)) return [];
3957
+ appendFileSync(path, JSON.stringify(entry) + "\n", "utf8");
3958
+ trimLog(path);
3959
+ }
3960
+ function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
3961
+ if (!existsSync9(path) || statSync2(path).size <= maxBytes) return;
3897
3962
  const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3898
- const entries = lines.map((l) => JSON.parse(l));
3899
- return entries.reverse().slice(0, limit);
3963
+ const kept = [];
3964
+ let bytes = 0;
3965
+ for (let i = lines.length - 1; i >= 0; i--) {
3966
+ const lineBytes = Buffer.byteLength(lines[i], "utf8") + 1;
3967
+ if (kept.length > 0 && bytes + lineBytes > targetBytes) break;
3968
+ kept.unshift(lines[i]);
3969
+ bytes += lineBytes;
3970
+ }
3971
+ writeFileAtomic(path, kept.length ? kept.join("\n") + "\n" : "");
3972
+ }
3973
+ function readLastLines(path, n, chunkSize = 64 * 1024) {
3974
+ if (n <= 0 || !existsSync9(path)) return [];
3975
+ const fd = openSync(path, "r");
3976
+ try {
3977
+ let pos = fstatSync(fd).size;
3978
+ if (pos === 0) return [];
3979
+ const chunks = [];
3980
+ while (pos > 0) {
3981
+ const size = Math.min(chunkSize, pos);
3982
+ pos -= size;
3983
+ const buf = Buffer.alloc(size);
3984
+ readSync(fd, buf, 0, size, pos);
3985
+ chunks.unshift(buf);
3986
+ const segments = Buffer.concat(chunks).toString("utf8").split("\n");
3987
+ const complete = (pos > 0 ? segments.slice(1) : segments).filter((l) => l.trim() !== "");
3988
+ if (complete.length >= n || pos === 0) return complete.slice(-n);
3989
+ }
3990
+ return [];
3991
+ } finally {
3992
+ closeSync(fd);
3993
+ }
3994
+ }
3995
+ function readLog(projectRoot, limit = 100) {
3996
+ return readLastLines(logPath(projectRoot), limit).map((l) => JSON.parse(l)).reverse();
3900
3997
  }
3901
3998
 
3902
3999
  // src/server/ai/batch-run.ts
@@ -4223,13 +4320,13 @@ function estimateTranslation(state, ai, opts) {
4223
4320
  import { relative as relative3 } from "path";
4224
4321
 
4225
4322
  // src/server/import/detect.ts
4226
- import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync11, statSync as statSync2 } from "fs";
4323
+ import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync11, statSync as statSync3 } from "fs";
4227
4324
  import { join as join6 } from "path";
4228
4325
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4229
4326
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
4230
4327
  function safeIsDir(p) {
4231
4328
  try {
4232
- return statSync2(p).isDirectory();
4329
+ return statSync3(p).isDirectory();
4233
4330
  } catch {
4234
4331
  return false;
4235
4332
  }
@@ -4265,7 +4362,7 @@ function detectVue(root, forced = false) {
4265
4362
  if (enough) {
4266
4363
  const sourceLocale = pickSource(locales, (loc) => {
4267
4364
  try {
4268
- return statSync2(join6(localeRoot, `${loc}.json`)).size;
4365
+ return statSync3(join6(localeRoot, `${loc}.json`)).size;
4269
4366
  } catch {
4270
4367
  return 0;
4271
4368
  }
@@ -4302,7 +4399,7 @@ function detectApple(root) {
4302
4399
  locales,
4303
4400
  sourceLocale: pickSource(locales, (loc) => {
4304
4401
  try {
4305
- return statSync2(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4402
+ return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4306
4403
  } catch {
4307
4404
  return 0;
4308
4405
  }
@@ -4364,7 +4461,7 @@ function detectI18next(root) {
4364
4461
  if (locales.length === 0) continue;
4365
4462
  const sourceLocale = pickSource(locales, (loc) => {
4366
4463
  try {
4367
- return readdirSync3(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join6(localeRoot, loc, f)).size, 0);
4464
+ return readdirSync3(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
4368
4465
  } catch {
4369
4466
  return 0;
4370
4467
  }
@@ -4463,10 +4560,9 @@ import { join as join7 } from "path";
4463
4560
  function flattenObject(value, prefix, warnings) {
4464
4561
  const out = {};
4465
4562
  const walk = (node, path) => {
4466
- if (typeof node === "string") {
4467
- out[path] = node;
4468
- } else if (typeof node === "number" || typeof node === "boolean") {
4469
- out[path] = String(node);
4563
+ if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") {
4564
+ if (path in out) warnings.push(`duplicate flattened key "${path}" \u2014 keeping the first value`);
4565
+ else out[path] = typeof node === "string" ? node : String(node);
4470
4566
  } else if (Array.isArray(node)) {
4471
4567
  node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
4472
4568
  } else if (node && typeof node === "object") {
@@ -4511,7 +4607,7 @@ var vueI18nJson2 = {
4511
4607
  };
4512
4608
 
4513
4609
  // src/server/import/parsers/laravel-php.ts
4514
- import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
4610
+ import { readdirSync as readdirSync5, statSync as statSync4 } from "fs";
4515
4611
  import { join as join8, relative as relative2 } from "path";
4516
4612
  import { execFileSync } from "child_process";
4517
4613
 
@@ -4522,14 +4618,14 @@ function laravelToCanonical(value) {
4522
4618
 
4523
4619
  // src/server/import/parsers/laravel-php.ts
4524
4620
  function listDirs2(dir) {
4525
- return readdirSync5(dir).filter((e) => statSync3(join8(dir, e)).isDirectory());
4621
+ return readdirSync5(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4526
4622
  }
4527
4623
  function listPhpFiles(dir) {
4528
4624
  const out = [];
4529
4625
  const walk = (d) => {
4530
4626
  for (const e of readdirSync5(d)) {
4531
4627
  const full = join8(d, e);
4532
- if (statSync3(full).isDirectory()) walk(full);
4628
+ if (statSync4(full).isDirectory()) walk(full);
4533
4629
  else if (e.endsWith(".php")) out.push(full);
4534
4630
  }
4535
4631
  };
@@ -4650,7 +4746,7 @@ var flutterArb2 = {
4650
4746
  };
4651
4747
 
4652
4748
  // src/server/import/parsers/apple-strings.ts
4653
- import { readdirSync as readdirSync7, readFileSync as readFileSync14, statSync as statSync4 } from "fs";
4749
+ import { readdirSync as readdirSync7, readFileSync as readFileSync14, statSync as statSync5 } from "fs";
4654
4750
  import { join as join10 } from "path";
4655
4751
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4656
4752
  var TABLE = "Localizable.strings";
@@ -4719,25 +4815,34 @@ function parseStrings(text, file, warnings) {
4719
4815
  while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4720
4816
  return raw.length ? raw : null;
4721
4817
  };
4818
+ const recover = () => {
4819
+ while (i < n && text[i] !== ";") i++;
4820
+ if (i >= n) return false;
4821
+ i++;
4822
+ return true;
4823
+ };
4722
4824
  while (true) {
4723
4825
  skipTrivia();
4724
4826
  if (i >= n) break;
4725
4827
  const key = readToken();
4726
4828
  if (key === null) {
4727
4829
  warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4728
- break;
4830
+ if (!recover()) break;
4831
+ continue;
4729
4832
  }
4730
4833
  skipTrivia();
4731
4834
  if (text[i] !== "=") {
4732
4835
  warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4733
- break;
4836
+ if (!recover()) break;
4837
+ continue;
4734
4838
  }
4735
4839
  i++;
4736
4840
  skipTrivia();
4737
4841
  const value = readToken();
4738
4842
  if (value === null) {
4739
4843
  warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4740
- break;
4844
+ if (!recover()) break;
4845
+ continue;
4741
4846
  }
4742
4847
  skipTrivia();
4743
4848
  if (text[i] === ";") i++;
@@ -4758,7 +4863,7 @@ var appleStrings2 = {
4758
4863
  const file = join10(localeRoot, dir, TABLE);
4759
4864
  let text;
4760
4865
  try {
4761
- if (!statSync4(file).isFile()) continue;
4866
+ if (!statSync5(file).isFile()) continue;
4762
4867
  text = readFileSync14(file, "utf8");
4763
4868
  } catch {
4764
4869
  continue;
@@ -5036,7 +5141,7 @@ var gettextPo2 = {
5036
5141
  };
5037
5142
 
5038
5143
  // src/server/import/parsers/i18next-json.ts
5039
- import { readdirSync as readdirSync10, readFileSync as readFileSync17, statSync as statSync5 } from "fs";
5144
+ import { readdirSync as readdirSync10, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
5040
5145
  import { join as join13 } from "path";
5041
5146
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5042
5147
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
@@ -5044,7 +5149,7 @@ var PLURAL_ARG = "count";
5044
5149
  var DEFAULT_NAMESPACE = "translation";
5045
5150
  function safeIsDir2(p) {
5046
5151
  try {
5047
- return statSync5(p).isDirectory();
5152
+ return statSync6(p).isDirectory();
5048
5153
  } catch {
5049
5154
  return false;
5050
5155
  }
@@ -5144,6 +5249,15 @@ function decodeDouble(body) {
5144
5249
  }
5145
5250
  const n = body[++i];
5146
5251
  if (n === void 0) break;
5252
+ const hexLen = n === "x" ? 2 : n === "u" ? 4 : n === "U" ? 8 : 0;
5253
+ if (hexLen) {
5254
+ const hex = body.slice(i + 1, i + 1 + hexLen);
5255
+ if (hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex)) {
5256
+ out += String.fromCodePoint(parseInt(hex, 16));
5257
+ i += hexLen;
5258
+ continue;
5259
+ }
5260
+ }
5147
5261
  out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5148
5262
  }
5149
5263
  return out;
@@ -5358,7 +5472,7 @@ var railsYaml2 = {
5358
5472
  };
5359
5473
 
5360
5474
  // src/server/import/parsers/apple-stringsdict.ts
5361
- import { readdirSync as readdirSync12, readFileSync as readFileSync19, statSync as statSync6 } from "fs";
5475
+ import { readdirSync as readdirSync12, readFileSync as readFileSync19, statSync as statSync7 } from "fs";
5362
5476
  import { join as join15 } from "path";
5363
5477
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5364
5478
  var TABLE2 = "Localizable.stringsdict";
@@ -5498,7 +5612,7 @@ var appleStringsdict2 = {
5498
5612
  const file = join15(localeRoot, dir, TABLE2);
5499
5613
  let text;
5500
5614
  try {
5501
- if (!statSync6(file).isFile()) continue;
5615
+ if (!statSync7(file).isFile()) continue;
5502
5616
  text = readFileSync19(file, "utf8");
5503
5617
  } catch {
5504
5618
  continue;
@@ -5820,7 +5934,7 @@ function refreshLocationUsage(projectRoot, format) {
5820
5934
  }
5821
5935
 
5822
5936
  // src/server/export-run.ts
5823
- import { existsSync as existsSync12, readFileSync as readFileSync20, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
5937
+ import { existsSync as existsSync12, readFileSync as readFileSync20, readdirSync as readdirSync13, rmdirSync, statSync as statSync8, unlinkSync } from "fs";
5824
5938
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
5825
5939
  function effectiveLocales(config) {
5826
5940
  const limit = config.exportLocales;
@@ -5863,7 +5977,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
5863
5977
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
5864
5978
  const next = resolve7(dir, segment);
5865
5979
  if (isLast) {
5866
- if (stale(locale) && existsSync12(next) && statSync7(next).isFile()) {
5980
+ if (stale(locale) && existsSync12(next) && statSync8(next).isFile()) {
5867
5981
  unlinkSync(next);
5868
5982
  deleted++;
5869
5983
  removeEmptyDirs(dir, root);
@@ -6069,7 +6183,7 @@ function createEventHub() {
6069
6183
  }
6070
6184
 
6071
6185
  // src/server/watch.ts
6072
- import { statSync as statSync8, readdirSync as readdirSync14 } from "fs";
6186
+ import { statSync as statSync9, readdirSync as readdirSync14 } from "fs";
6073
6187
  import { join as join17 } from "path";
6074
6188
  import { createHash as createHash2 } from "crypto";
6075
6189
  function hashState(state) {
@@ -6079,14 +6193,14 @@ function signature(statePath) {
6079
6193
  const fmt = detectFormat(statePath);
6080
6194
  if (fmt === "none") return "none";
6081
6195
  if (fmt === "single") {
6082
- const s = statSync8(statePath);
6196
+ const s = statSync9(statePath);
6083
6197
  return `single:${s.size}:${s.mtimeMs}`;
6084
6198
  }
6085
6199
  const dir = splitDirFor(statePath);
6086
6200
  const parts = [];
6087
6201
  for (const rel of ["config.json", "keys.json"]) {
6088
6202
  try {
6089
- const s = statSync8(join17(dir, rel));
6203
+ const s = statSync9(join17(dir, rel));
6090
6204
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6091
6205
  } catch {
6092
6206
  }
@@ -6094,7 +6208,7 @@ function signature(statePath) {
6094
6208
  try {
6095
6209
  for (const name of readdirSync14(join17(dir, "locales")).sort()) {
6096
6210
  if (!name.endsWith(".json")) continue;
6097
- const s = statSync8(join17(dir, "locales", name));
6211
+ const s = statSync9(join17(dir, "locales", name));
6098
6212
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6099
6213
  }
6100
6214
  } catch {
@@ -6349,7 +6463,7 @@ function createApi(deps) {
6349
6463
  filePath = abs;
6350
6464
  } else {
6351
6465
  try {
6352
- if (statSync9(abs).isDirectory()) walk(abs, depth + 1);
6466
+ if (statSync10(abs).isDirectory()) walk(abs, depth + 1);
6353
6467
  } catch {
6354
6468
  }
6355
6469
  continue;
@@ -7381,8 +7495,16 @@ async function readFileResponse(absPath) {
7381
7495
  return null;
7382
7496
  }
7383
7497
  }
7498
+ function isLocalHost(hostname) {
7499
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
7500
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
7501
+ }
7384
7502
  function buildApp(opts) {
7385
7503
  const app = new Hono2();
7504
+ app.use("*", async (c, next) => {
7505
+ if (!isLocalHost(new URL(c.req.url).hostname)) return c.text("Forbidden: non-local Host header", 403);
7506
+ return next();
7507
+ });
7386
7508
  const apiDeps = {
7387
7509
  statePath: opts.statePath,
7388
7510
  autoExport: true,