glotfile 0.8.1 → 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.
@@ -2,7 +2,7 @@
2
2
  import { Hono as Hono2 } from "hono";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { fileURLToPath } from "url";
5
- import { dirname as dirname4, join as join17, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join18, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6
6
  import { readFile, stat } from "fs/promises";
7
7
  import { createServer } from "net";
8
8
  import open from "open";
@@ -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
  }
@@ -2594,7 +2619,8 @@ function attrEscape(s) {
2594
2619
  return xmlEscape2(s).replace(/"/g, "&quot;");
2595
2620
  }
2596
2621
  function angularXMeta(placeholders, name) {
2597
- return /^[A-Z][A-Z0-9_]*$/.test(name) ? placeholders?.[name] : void 0;
2622
+ const meta = placeholders?.[name];
2623
+ return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
2598
2624
  }
2599
2625
  function renderInterpolations(text, ids, placeholders) {
2600
2626
  let out = "";
@@ -2841,7 +2867,7 @@ function checkOutputs(state, root) {
2841
2867
  }
2842
2868
 
2843
2869
  // src/server/api.ts
2844
- import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync6 } from "fs";
2870
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync10, rmSync as rmSync6 } from "fs";
2845
2871
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
2846
2872
 
2847
2873
  // src/server/ai/anthropic.ts
@@ -3316,6 +3342,7 @@ var AnthropicProvider = class {
3316
3342
  }, { signal });
3317
3343
  this.recordUsage(res.usage);
3318
3344
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
3345
+ if (res.stop_reason === "max_tokens") throw new MalformedReplyError(text);
3319
3346
  return parseReplyItems(text);
3320
3347
  }
3321
3348
  };
@@ -3401,6 +3428,7 @@ var OpenAIProvider = class {
3401
3428
  ]
3402
3429
  }, { signal });
3403
3430
  const text = res.choices?.[0]?.message?.content ?? "";
3431
+ if (res.choices?.[0]?.finish_reason === "length") throw new MalformedReplyError(text);
3404
3432
  return parseReplyItems(text);
3405
3433
  }
3406
3434
  };
@@ -3510,8 +3538,11 @@ var BedrockProvider = class {
3510
3538
  const res = await this.client.send(this.makeCommand(this.buildInput(batch)), { abortSignal: signal });
3511
3539
  const blocks = res.output?.message?.content ?? [];
3512
3540
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
3513
- if (tool?.input?.items) return tool.input.items;
3514
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;
3515
3546
  return parseReplyItems(text);
3516
3547
  }
3517
3548
  };
@@ -3777,8 +3808,30 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
3777
3808
  return { skipped: keys.size };
3778
3809
  }
3779
3810
  var DEFAULT_LOCALE_CONCURRENCY = 3;
3780
- 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 = {}) {
3781
3832
  if (!reqs.length) return [];
3833
+ const maxRetries = retry.retries ?? 3;
3834
+ const delayMs = retry.delayMs ?? ((attempt) => 250 * 2 ** attempt);
3782
3835
  const byLocale = /* @__PURE__ */ new Map();
3783
3836
  for (const req of reqs) {
3784
3837
  let group = byLocale.get(req.targetLocale);
@@ -3813,10 +3866,20 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
3813
3866
  started.add(locale);
3814
3867
  hooks.onLocaleStart?.(locale);
3815
3868
  }
3816
- const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3817
- done += results.length;
3818
- hooks.onBatchComplete?.(done, total, results, locale);
3819
- }, 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
+ }
3820
3883
  allResults.push(...batchResults);
3821
3884
  const left = remaining.get(locale) - 1;
3822
3885
  remaining.set(locale, left);
@@ -3881,21 +3944,56 @@ function clearPendingBatch(projectRoot) {
3881
3944
  }
3882
3945
 
3883
3946
  // src/server/log.ts
3884
- 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";
3885
3948
  import { resolve as resolve6 } from "path";
3886
3949
  function logPath(projectRoot) {
3887
3950
  return resolve6(projectRoot, ".glotfile", "log.jsonl");
3888
3951
  }
3952
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
3953
+ var TRIM_LOG_TO_BYTES = 4 * 1024 * 1024;
3889
3954
  function appendLog(projectRoot, entry) {
3890
3955
  ensureGlotfileDir(projectRoot);
3891
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3892
- }
3893
- function readLog(projectRoot, limit = 100) {
3894
3956
  const path = logPath(projectRoot);
3895
- 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;
3896
3962
  const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3897
- const entries = lines.map((l) => JSON.parse(l));
3898
- 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();
3899
3997
  }
3900
3998
 
3901
3999
  // src/server/ai/batch-run.ts
@@ -4222,13 +4320,13 @@ function estimateTranslation(state, ai, opts) {
4222
4320
  import { relative as relative3 } from "path";
4223
4321
 
4224
4322
  // src/server/import/detect.ts
4225
- 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";
4226
4324
  import { join as join6 } from "path";
4227
4325
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4228
4326
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
4229
4327
  function safeIsDir(p) {
4230
4328
  try {
4231
- return statSync2(p).isDirectory();
4329
+ return statSync3(p).isDirectory();
4232
4330
  } catch {
4233
4331
  return false;
4234
4332
  }
@@ -4264,7 +4362,7 @@ function detectVue(root, forced = false) {
4264
4362
  if (enough) {
4265
4363
  const sourceLocale = pickSource(locales, (loc) => {
4266
4364
  try {
4267
- return statSync2(join6(localeRoot, `${loc}.json`)).size;
4365
+ return statSync3(join6(localeRoot, `${loc}.json`)).size;
4268
4366
  } catch {
4269
4367
  return 0;
4270
4368
  }
@@ -4301,7 +4399,7 @@ function detectApple(root) {
4301
4399
  locales,
4302
4400
  sourceLocale: pickSource(locales, (loc) => {
4303
4401
  try {
4304
- return statSync2(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4402
+ return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4305
4403
  } catch {
4306
4404
  return 0;
4307
4405
  }
@@ -4363,7 +4461,7 @@ function detectI18next(root) {
4363
4461
  if (locales.length === 0) continue;
4364
4462
  const sourceLocale = pickSource(locales, (loc) => {
4365
4463
  try {
4366
- 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);
4367
4465
  } catch {
4368
4466
  return 0;
4369
4467
  }
@@ -4462,10 +4560,9 @@ import { join as join7 } from "path";
4462
4560
  function flattenObject(value, prefix, warnings) {
4463
4561
  const out = {};
4464
4562
  const walk = (node, path) => {
4465
- if (typeof node === "string") {
4466
- out[path] = node;
4467
- } else if (typeof node === "number" || typeof node === "boolean") {
4468
- 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);
4469
4566
  } else if (Array.isArray(node)) {
4470
4567
  node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
4471
4568
  } else if (node && typeof node === "object") {
@@ -4510,7 +4607,7 @@ var vueI18nJson2 = {
4510
4607
  };
4511
4608
 
4512
4609
  // src/server/import/parsers/laravel-php.ts
4513
- import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
4610
+ import { readdirSync as readdirSync5, statSync as statSync4 } from "fs";
4514
4611
  import { join as join8, relative as relative2 } from "path";
4515
4612
  import { execFileSync } from "child_process";
4516
4613
 
@@ -4521,14 +4618,14 @@ function laravelToCanonical(value) {
4521
4618
 
4522
4619
  // src/server/import/parsers/laravel-php.ts
4523
4620
  function listDirs2(dir) {
4524
- return readdirSync5(dir).filter((e) => statSync3(join8(dir, e)).isDirectory());
4621
+ return readdirSync5(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4525
4622
  }
4526
4623
  function listPhpFiles(dir) {
4527
4624
  const out = [];
4528
4625
  const walk = (d) => {
4529
4626
  for (const e of readdirSync5(d)) {
4530
4627
  const full = join8(d, e);
4531
- if (statSync3(full).isDirectory()) walk(full);
4628
+ if (statSync4(full).isDirectory()) walk(full);
4532
4629
  else if (e.endsWith(".php")) out.push(full);
4533
4630
  }
4534
4631
  };
@@ -4649,7 +4746,7 @@ var flutterArb2 = {
4649
4746
  };
4650
4747
 
4651
4748
  // src/server/import/parsers/apple-strings.ts
4652
- 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";
4653
4750
  import { join as join10 } from "path";
4654
4751
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4655
4752
  var TABLE = "Localizable.strings";
@@ -4718,25 +4815,34 @@ function parseStrings(text, file, warnings) {
4718
4815
  while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4719
4816
  return raw.length ? raw : null;
4720
4817
  };
4818
+ const recover = () => {
4819
+ while (i < n && text[i] !== ";") i++;
4820
+ if (i >= n) return false;
4821
+ i++;
4822
+ return true;
4823
+ };
4721
4824
  while (true) {
4722
4825
  skipTrivia();
4723
4826
  if (i >= n) break;
4724
4827
  const key = readToken();
4725
4828
  if (key === null) {
4726
4829
  warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4727
- break;
4830
+ if (!recover()) break;
4831
+ continue;
4728
4832
  }
4729
4833
  skipTrivia();
4730
4834
  if (text[i] !== "=") {
4731
4835
  warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4732
- break;
4836
+ if (!recover()) break;
4837
+ continue;
4733
4838
  }
4734
4839
  i++;
4735
4840
  skipTrivia();
4736
4841
  const value = readToken();
4737
4842
  if (value === null) {
4738
4843
  warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4739
- break;
4844
+ if (!recover()) break;
4845
+ continue;
4740
4846
  }
4741
4847
  skipTrivia();
4742
4848
  if (text[i] === ";") i++;
@@ -4757,7 +4863,7 @@ var appleStrings2 = {
4757
4863
  const file = join10(localeRoot, dir, TABLE);
4758
4864
  let text;
4759
4865
  try {
4760
- if (!statSync4(file).isFile()) continue;
4866
+ if (!statSync5(file).isFile()) continue;
4761
4867
  text = readFileSync14(file, "utf8");
4762
4868
  } catch {
4763
4869
  continue;
@@ -4788,6 +4894,7 @@ function parseAttrs(s) {
4788
4894
  for (const m of s.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]] = decodeEntities(m[2]);
4789
4895
  return out;
4790
4896
  }
4897
+ var ANGULAR_CONVENTION_ID = /^[A-Z][A-Z0-9_]*$/;
4791
4898
  function decodeLocations(body) {
4792
4899
  const out = [];
4793
4900
  const seen = /* @__PURE__ */ new Set();
@@ -4821,6 +4928,7 @@ function decodeInline(raw, addMeta) {
4821
4928
  const meta = {};
4822
4929
  if (attrs["ctype"]) meta.type = attrs["ctype"];
4823
4930
  if (equiv !== void 0) meta.example = equiv;
4931
+ if (!ANGULAR_CONVENTION_ID.test(id)) meta.origin = "x";
4824
4932
  addMeta(id, meta);
4825
4933
  }
4826
4934
  last = m.index + m[0].length;
@@ -5033,7 +5141,7 @@ var gettextPo2 = {
5033
5141
  };
5034
5142
 
5035
5143
  // src/server/import/parsers/i18next-json.ts
5036
- 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";
5037
5145
  import { join as join13 } from "path";
5038
5146
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5039
5147
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
@@ -5041,7 +5149,7 @@ var PLURAL_ARG = "count";
5041
5149
  var DEFAULT_NAMESPACE = "translation";
5042
5150
  function safeIsDir2(p) {
5043
5151
  try {
5044
- return statSync5(p).isDirectory();
5152
+ return statSync6(p).isDirectory();
5045
5153
  } catch {
5046
5154
  return false;
5047
5155
  }
@@ -5141,6 +5249,15 @@ function decodeDouble(body) {
5141
5249
  }
5142
5250
  const n = body[++i];
5143
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
+ }
5144
5261
  out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5145
5262
  }
5146
5263
  return out;
@@ -5355,7 +5472,7 @@ var railsYaml2 = {
5355
5472
  };
5356
5473
 
5357
5474
  // src/server/import/parsers/apple-stringsdict.ts
5358
- 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";
5359
5476
  import { join as join15 } from "path";
5360
5477
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5361
5478
  var TABLE2 = "Localizable.stringsdict";
@@ -5495,7 +5612,7 @@ var appleStringsdict2 = {
5495
5612
  const file = join15(localeRoot, dir, TABLE2);
5496
5613
  let text;
5497
5614
  try {
5498
- if (!statSync6(file).isFile()) continue;
5615
+ if (!statSync7(file).isFile()) continue;
5499
5616
  text = readFileSync19(file, "utf8");
5500
5617
  } catch {
5501
5618
  continue;
@@ -5817,7 +5934,7 @@ function refreshLocationUsage(projectRoot, format) {
5817
5934
  }
5818
5935
 
5819
5936
  // src/server/export-run.ts
5820
- 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";
5821
5938
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
5822
5939
  function effectiveLocales(config) {
5823
5940
  const limit = config.exportLocales;
@@ -5860,7 +5977,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
5860
5977
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
5861
5978
  const next = resolve7(dir, segment);
5862
5979
  if (isLast) {
5863
- if (stale(locale) && existsSync12(next) && statSync7(next).isFile()) {
5980
+ if (stale(locale) && existsSync12(next) && statSync8(next).isFile()) {
5864
5981
  unlinkSync(next);
5865
5982
  deleted++;
5866
5983
  removeEmptyDirs(dir, root);
@@ -6043,6 +6160,122 @@ function aiConfigError(ai) {
6043
6160
  return null;
6044
6161
  }
6045
6162
 
6163
+ // src/server/events.ts
6164
+ function createEventHub() {
6165
+ const senders = /* @__PURE__ */ new Set();
6166
+ return {
6167
+ subscribe(send) {
6168
+ senders.add(send);
6169
+ return () => senders.delete(send);
6170
+ },
6171
+ broadcast(event, data) {
6172
+ for (const send of [...senders]) {
6173
+ try {
6174
+ send(event, data);
6175
+ } catch {
6176
+ }
6177
+ }
6178
+ },
6179
+ size() {
6180
+ return senders.size;
6181
+ }
6182
+ };
6183
+ }
6184
+
6185
+ // src/server/watch.ts
6186
+ import { statSync as statSync9, readdirSync as readdirSync14 } from "fs";
6187
+ import { join as join17 } from "path";
6188
+ import { createHash as createHash2 } from "crypto";
6189
+ function hashState(state) {
6190
+ return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
6191
+ }
6192
+ function signature(statePath) {
6193
+ const fmt = detectFormat(statePath);
6194
+ if (fmt === "none") return "none";
6195
+ if (fmt === "single") {
6196
+ const s = statSync9(statePath);
6197
+ return `single:${s.size}:${s.mtimeMs}`;
6198
+ }
6199
+ const dir = splitDirFor(statePath);
6200
+ const parts = [];
6201
+ for (const rel of ["config.json", "keys.json"]) {
6202
+ try {
6203
+ const s = statSync9(join17(dir, rel));
6204
+ parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6205
+ } catch {
6206
+ }
6207
+ }
6208
+ try {
6209
+ for (const name of readdirSync14(join17(dir, "locales")).sort()) {
6210
+ if (!name.endsWith(".json")) continue;
6211
+ const s = statSync9(join17(dir, "locales", name));
6212
+ parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6213
+ }
6214
+ } catch {
6215
+ }
6216
+ return `split:${parts.join("|")}`;
6217
+ }
6218
+ function createStateWatcher(opts) {
6219
+ const intervalMs = opts.intervalMs ?? 750;
6220
+ let statePath = opts.statePath;
6221
+ let lastSig = "";
6222
+ let lastHash = "";
6223
+ let timer;
6224
+ function baseline() {
6225
+ try {
6226
+ lastSig = signature(statePath);
6227
+ lastHash = hashState(loadState(statePath));
6228
+ } catch {
6229
+ lastSig = "";
6230
+ lastHash = "";
6231
+ }
6232
+ }
6233
+ function check() {
6234
+ let sig;
6235
+ try {
6236
+ sig = signature(statePath);
6237
+ } catch {
6238
+ return;
6239
+ }
6240
+ if (sig === lastSig) return;
6241
+ let hash;
6242
+ try {
6243
+ hash = hashState(loadState(statePath));
6244
+ } catch {
6245
+ lastSig = sig;
6246
+ return;
6247
+ }
6248
+ lastSig = sig;
6249
+ if (hash !== lastHash) {
6250
+ lastHash = hash;
6251
+ opts.onChange();
6252
+ }
6253
+ }
6254
+ function noteWrite(state) {
6255
+ try {
6256
+ lastSig = signature(statePath);
6257
+ } catch {
6258
+ lastSig = "";
6259
+ }
6260
+ lastHash = hashState(state);
6261
+ }
6262
+ function retarget(next) {
6263
+ statePath = next;
6264
+ baseline();
6265
+ }
6266
+ function start() {
6267
+ if (timer) return;
6268
+ timer = setInterval(check, intervalMs);
6269
+ timer.unref?.();
6270
+ }
6271
+ function stop() {
6272
+ if (timer) clearInterval(timer);
6273
+ timer = void 0;
6274
+ }
6275
+ baseline();
6276
+ return { check, noteWrite, retarget, start, stop };
6277
+ }
6278
+
6046
6279
  // src/server/api.ts
6047
6280
  var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
6048
6281
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
@@ -6076,6 +6309,14 @@ function createApi(deps) {
6076
6309
  const app = new Hono();
6077
6310
  const load = () => loadState(deps.statePath);
6078
6311
  const projectRoot = dirname3(resolve9(deps.statePath));
6312
+ const hub = deps.eventHub ?? createEventHub();
6313
+ const watcher = createStateWatcher({
6314
+ statePath: deps.statePath,
6315
+ intervalMs: deps.watchIntervalMs,
6316
+ onChange: () => hub.broadcast("state-changed", JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() }))
6317
+ });
6318
+ deps.onWatcher?.(watcher);
6319
+ if (deps.watch) watcher.start();
6079
6320
  let translateQueue = Promise.resolve();
6080
6321
  const withTranslateLock = (fn) => {
6081
6322
  const next = translateQueue.then(fn, fn);
@@ -6097,12 +6338,30 @@ function createApi(deps) {
6097
6338
  };
6098
6339
  const persist = (s) => {
6099
6340
  saveState(deps.statePath, s);
6341
+ watcher.noteWrite(s);
6100
6342
  scheduleAutoExport(s);
6101
6343
  };
6102
6344
  const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
6103
6345
  const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
6104
6346
  const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
6105
6347
  app.get("/state", (c) => c.json(load()));
6348
+ app.get("/events", (c) => streamSSE(c, async (stream) => {
6349
+ const send = (event, data) => {
6350
+ void stream.writeSSE({ event, data });
6351
+ };
6352
+ const unsubscribe = hub.subscribe(send);
6353
+ stream.onAbort(unsubscribe);
6354
+ await stream.writeSSE({ event: "ready", data: "" });
6355
+ try {
6356
+ while (!stream.aborted) {
6357
+ await stream.sleep(3e4);
6358
+ if (stream.aborted) break;
6359
+ await stream.writeSSE({ event: "ping", data: "" });
6360
+ }
6361
+ } finally {
6362
+ unsubscribe();
6363
+ }
6364
+ }));
6106
6365
  app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
6107
6366
  app.put("/ui-prefs", async (c) => {
6108
6367
  const body = await c.req.json();
@@ -6190,7 +6449,7 @@ function createApi(deps) {
6190
6449
  if (depth > 4) return;
6191
6450
  let entries = [];
6192
6451
  try {
6193
- entries = readdirSync14(dir);
6452
+ entries = readdirSync15(dir);
6194
6453
  } catch {
6195
6454
  return;
6196
6455
  }
@@ -6204,7 +6463,7 @@ function createApi(deps) {
6204
6463
  filePath = abs;
6205
6464
  } else {
6206
6465
  try {
6207
- if (statSync8(abs).isDirectory()) walk(abs, depth + 1);
6466
+ if (statSync10(abs).isDirectory()) walk(abs, depth + 1);
6208
6467
  } catch {
6209
6468
  }
6210
6469
  continue;
@@ -6235,6 +6494,7 @@ function createApi(deps) {
6235
6494
  if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
6236
6495
  loadState(resolved);
6237
6496
  deps.statePath = resolved;
6497
+ watcher.retarget(resolved);
6238
6498
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
6239
6499
  });
6240
6500
  app.post("/keys", async (c) => {
@@ -7209,7 +7469,7 @@ function createApi(deps) {
7209
7469
 
7210
7470
  // src/server/server.ts
7211
7471
  var here = dirname4(fileURLToPath(import.meta.url));
7212
- var DEFAULT_UI_DIR = join17(here, "..", "ui");
7472
+ var DEFAULT_UI_DIR = join18(here, "..", "ui");
7213
7473
  var MIME = {
7214
7474
  ".html": "text/html; charset=utf-8",
7215
7475
  ".js": "text/javascript; charset=utf-8",
@@ -7235,9 +7495,22 @@ async function readFileResponse(absPath) {
7235
7495
  return null;
7236
7496
  }
7237
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
+ }
7238
7502
  function buildApp(opts) {
7239
7503
  const app = new Hono2();
7240
- const apiDeps = { statePath: opts.statePath, autoExport: true };
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
+ });
7508
+ const apiDeps = {
7509
+ statePath: opts.statePath,
7510
+ autoExport: true,
7511
+ watch: opts.watch,
7512
+ onWatcher: opts.onWatcher
7513
+ };
7241
7514
  app.route("/api", createApi(apiDeps));
7242
7515
  app.get("/:dir/*", async (c, next) => {
7243
7516
  const dirSeg = c.req.param("dir");
@@ -7263,7 +7536,7 @@ function buildApp(opts) {
7263
7536
  const file = await readFileResponse(target);
7264
7537
  if (file) return file;
7265
7538
  }
7266
- const index = await readFileResponse(join17(root, "index.html"));
7539
+ const index = await readFileResponse(join18(root, "index.html"));
7267
7540
  if (index) return index;
7268
7541
  return c.notFound();
7269
7542
  });
@@ -7299,13 +7572,19 @@ function findAvailablePort(start) {
7299
7572
  });
7300
7573
  }
7301
7574
  async function startServer(opts) {
7302
- const app = buildApp(opts);
7575
+ let watcher;
7576
+ const app = buildApp({ ...opts, watch: opts.watch ?? true, onWatcher: (w) => {
7577
+ watcher = w;
7578
+ } });
7303
7579
  const port = await findAvailablePort(opts.dev ? DEV_PORT : DEFAULT_PORT);
7304
7580
  return new Promise((resolveP) => {
7305
7581
  const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, (info) => {
7306
7582
  const url = `http://127.0.0.1:${info.port}`;
7307
7583
  if (opts.open !== false && !opts.dev) void open(url);
7308
- resolveP({ url, close: () => server.close() });
7584
+ resolveP({ url, close: () => {
7585
+ watcher?.stop();
7586
+ server.close();
7587
+ } });
7309
7588
  backgroundScan(opts.statePath);
7310
7589
  });
7311
7590
  });