glotfile 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,6 +58,14 @@ var init_atomic_write = __esm({
58
58
  });
59
59
 
60
60
  // src/server/lint/registry.ts
61
+ function unknownRuleIds(ids) {
62
+ const valid = new Set(RULE_IDS);
63
+ return ids.filter((id) => !valid.has(id));
64
+ }
65
+ function suggestRuleId(unknown) {
66
+ const lower = unknown.toLowerCase();
67
+ return RULE_IDS.find((id) => id.includes(lower) || lower.includes(id));
68
+ }
61
69
  var RULE_IDS, DEFAULT_SEVERITY;
62
70
  var init_registry = __esm({
63
71
  "src/server/lint/registry.ts"() {
@@ -271,7 +279,8 @@ function validate(raw) {
271
279
  }
272
280
  }
273
281
  if (raw.glossary !== void 0 && !Array.isArray(raw.glossary)) fail("glossary must be an array");
274
- const state = { glossary: [], ...raw };
282
+ if (raw.glossarySuggestions !== void 0 && !Array.isArray(raw.glossarySuggestions)) fail("glossarySuggestions must be an array");
283
+ const state = { glossary: [], glossarySuggestions: [], ...raw };
275
284
  return state;
276
285
  }
277
286
  function defaultState() {
@@ -290,6 +299,7 @@ function defaultState() {
290
299
  autoExport: true
291
300
  },
292
301
  glossary: [],
302
+ glossarySuggestions: [],
293
303
  keys: {}
294
304
  };
295
305
  }
@@ -807,6 +817,39 @@ function upsertGlossaryEntry(state, entry) {
807
817
  function deleteGlossaryEntry(state, term) {
808
818
  state.glossary = state.glossary.filter((e) => e.term !== term);
809
819
  }
820
+ function normGlossaryTerm(term) {
821
+ return term.trim().toLowerCase();
822
+ }
823
+ function mergeGlossarySuggestions(state, found) {
824
+ const known = /* @__PURE__ */ new Set();
825
+ for (const g of state.glossary) known.add(normGlossaryTerm(g.term));
826
+ for (const s of state.glossarySuggestions) known.add(normGlossaryTerm(s.term));
827
+ const added = [];
828
+ for (const f of found) {
829
+ const term = f.term.trim();
830
+ if (!term) continue;
831
+ const key = normGlossaryTerm(term);
832
+ if (known.has(key)) continue;
833
+ known.add(key);
834
+ const sug = { term, status: "pending" };
835
+ if (f.note?.trim()) sug.note = f.note.trim();
836
+ if (f.doNotTranslate) sug.doNotTranslate = true;
837
+ if (f.caseSensitive) sug.caseSensitive = true;
838
+ if (f.wholeWord === false) sug.wholeWord = false;
839
+ state.glossarySuggestions.push(sug);
840
+ added.push(sug);
841
+ }
842
+ return added;
843
+ }
844
+ function dismissGlossarySuggestion(state, term) {
845
+ const key = normGlossaryTerm(term);
846
+ const s = state.glossarySuggestions.find((x) => normGlossaryTerm(x.term) === key);
847
+ if (s) s.status = "dismissed";
848
+ }
849
+ function removeGlossarySuggestion(state, term) {
850
+ const key = normGlossaryTerm(term);
851
+ state.glossarySuggestions = state.glossarySuggestions.filter((x) => normGlossaryTerm(x.term) !== key);
852
+ }
810
853
  function addCustomWord(state, word) {
811
854
  const w = word.trim();
812
855
  if (!w) return;
@@ -1807,6 +1850,71 @@ var init_vue_i18n_json = __esm({
1807
1850
  }
1808
1851
  });
1809
1852
 
1853
+ // src/server/adapters/next-intl-json.ts
1854
+ var DEFAULT_LOCALE_CASE8, nextIntlJson;
1855
+ var init_next_intl_json = __esm({
1856
+ "src/server/adapters/next-intl-json.ts"() {
1857
+ "use strict";
1858
+ init_adapters();
1859
+ init_shared();
1860
+ init_options();
1861
+ init_format();
1862
+ init_plurals();
1863
+ DEFAULT_LOCALE_CASE8 = "lower-hyphen";
1864
+ nextIntlJson = {
1865
+ name: "next-intl-json",
1866
+ capabilities: {
1867
+ plural: "native",
1868
+ select: "native",
1869
+ nesting: "both",
1870
+ metadata: false,
1871
+ placeholderStyle: "icu",
1872
+ fileGrouping: "per-locale"
1873
+ },
1874
+ defaultLocaleCase: DEFAULT_LOCALE_CASE8,
1875
+ export(state, output) {
1876
+ const files = [];
1877
+ const warnings = [];
1878
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
1879
+ const { indent, finalNewline } = resolveFormat(state, output);
1880
+ const fmt = { indent, sortKeys: true, finalNewline };
1881
+ const emptyAs = resolveEmptyAs(output, "omit");
1882
+ const flatOutput = output.style === "flat";
1883
+ for (const locale of state.config.locales) {
1884
+ const flat = {};
1885
+ for (const [key, entry] of Object.entries(state.keys)) {
1886
+ if (entry.plural) {
1887
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1888
+ if (!forms) continue;
1889
+ flat[key] = formsToIcu(entry.plural.arg, forms);
1890
+ } else {
1891
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1892
+ if (raw === null) continue;
1893
+ flat[key] = raw;
1894
+ }
1895
+ }
1896
+ let payload = flat;
1897
+ if (!flatOutput) {
1898
+ const { tree, collisions } = nestKeys(flat);
1899
+ for (const key of collisions) {
1900
+ warnings.push({
1901
+ code: "key-collision",
1902
+ key,
1903
+ locale,
1904
+ message: "key is both a leaf and a parent; dropped from nested output"
1905
+ });
1906
+ }
1907
+ payload = tree;
1908
+ }
1909
+ files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8)), contents: serializeJson(payload, fmt) });
1910
+ }
1911
+ files.sort((a, b) => a.path.localeCompare(b.path));
1912
+ return { files, warnings };
1913
+ }
1914
+ };
1915
+ }
1916
+ });
1917
+
1810
1918
  // src/server/adapters/angular-xliff.ts
1811
1919
  function xmlEscape2(s) {
1812
1920
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -1862,7 +1970,7 @@ function renderEmbeddedIcu(value) {
1862
1970
  function renderScalar(value, ids, placeholders) {
1863
1971
  return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids, placeholders);
1864
1972
  }
1865
- var DEFAULT_LOCALE_CASE8, angularXliff;
1973
+ var DEFAULT_LOCALE_CASE9, angularXliff;
1866
1974
  var init_angular_xliff = __esm({
1867
1975
  "src/server/adapters/angular-xliff.ts"() {
1868
1976
  "use strict";
@@ -1870,7 +1978,7 @@ var init_angular_xliff = __esm({
1870
1978
  init_options();
1871
1979
  init_placeholders();
1872
1980
  init_schema();
1873
- DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
1981
+ DEFAULT_LOCALE_CASE9 = "bcp47-hyphen";
1874
1982
  angularXliff = {
1875
1983
  name: "angular-xliff",
1876
1984
  capabilities: {
@@ -1881,18 +1989,18 @@ var init_angular_xliff = __esm({
1881
1989
  placeholderStyle: "icu",
1882
1990
  fileGrouping: "per-locale"
1883
1991
  },
1884
- defaultLocaleCase: DEFAULT_LOCALE_CASE8,
1992
+ defaultLocaleCase: DEFAULT_LOCALE_CASE9,
1885
1993
  export(state, output) {
1886
1994
  const files = [];
1887
1995
  const warnings = [];
1888
- warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
1996
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE9));
1889
1997
  const sourceLocale = state.config.sourceLocale;
1890
- const sourceToken = resolveLocaleToken(output, sourceLocale, DEFAULT_LOCALE_CASE8);
1998
+ const sourceToken = resolveLocaleToken(output, sourceLocale, DEFAULT_LOCALE_CASE9);
1891
1999
  const emptyAs = resolveEmptyAs(output, "source");
1892
2000
  const keys = Object.keys(state.keys).sort();
1893
2001
  for (const locale of state.config.locales) {
1894
2002
  if (output.skipSourceLocale && locale === sourceLocale) continue;
1895
- const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
2003
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE9);
1896
2004
  const units = [];
1897
2005
  for (const key of keys) {
1898
2006
  const entry = state.keys[key];
@@ -1957,7 +2065,7 @@ function yamlMap(node, indent, level) {
1957
2065
  }
1958
2066
  return lines;
1959
2067
  }
1960
- var RESERVED_KEYS, DEFAULT_LOCALE_CASE9, railsYaml;
2068
+ var RESERVED_KEYS, DEFAULT_LOCALE_CASE10, railsYaml;
1961
2069
  var init_rails_yaml = __esm({
1962
2070
  "src/server/adapters/rails-yaml.ts"() {
1963
2071
  "use strict";
@@ -1967,7 +2075,7 @@ var init_rails_yaml = __esm({
1967
2075
  init_placeholders();
1968
2076
  init_schema();
1969
2077
  RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
1970
- DEFAULT_LOCALE_CASE9 = "bcp47-hyphen";
2078
+ DEFAULT_LOCALE_CASE10 = "bcp47-hyphen";
1971
2079
  railsYaml = {
1972
2080
  name: "rails-yaml",
1973
2081
  capabilities: {
@@ -1978,10 +2086,10 @@ var init_rails_yaml = __esm({
1978
2086
  placeholderStyle: "named",
1979
2087
  fileGrouping: "per-locale"
1980
2088
  },
1981
- defaultLocaleCase: DEFAULT_LOCALE_CASE9,
2089
+ defaultLocaleCase: DEFAULT_LOCALE_CASE10,
1982
2090
  export(state, output) {
1983
2091
  const warnings = [];
1984
- warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE9));
2092
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE10));
1985
2093
  const { indent, finalNewline } = resolveFormat(state, output);
1986
2094
  const emptyAs = resolveEmptyAs(output, "omit");
1987
2095
  const files = [];
@@ -2013,7 +2121,7 @@ var init_rails_yaml = __esm({
2013
2121
  for (const c of collisions) {
2014
2122
  warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
2015
2123
  }
2016
- const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE9);
2124
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE10);
2017
2125
  const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
2018
2126
  files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
2019
2127
  }
@@ -2057,6 +2165,7 @@ function getRegistry() {
2057
2165
  [appleStringsdict.name]: appleStringsdict,
2058
2166
  [appleStrings.name]: appleStrings,
2059
2167
  [vueI18nJson.name]: vueI18nJson,
2168
+ [nextIntlJson.name]: nextIntlJson,
2060
2169
  [angularXliff.name]: angularXliff,
2061
2170
  [railsYaml.name]: railsYaml
2062
2171
  };
@@ -2078,6 +2187,7 @@ var init_adapters = __esm({
2078
2187
  init_apple_stringsdict();
2079
2188
  init_apple_strings();
2080
2189
  init_vue_i18n_json();
2190
+ init_next_intl_json();
2081
2191
  init_angular_xliff();
2082
2192
  init_rails_yaml();
2083
2193
  }
@@ -2461,6 +2571,54 @@ var init_batch = __esm({
2461
2571
  }
2462
2572
  });
2463
2573
 
2574
+ // src/server/ai/price-cache.ts
2575
+ import { readFileSync as readFileSync4 } from "fs";
2576
+ import { homedir } from "os";
2577
+ import { join as join3 } from "path";
2578
+ function isModelPrice(v) {
2579
+ if (!v || typeof v !== "object") return false;
2580
+ const p = v;
2581
+ return typeof p.inputPerMTok === "number" && typeof p.outputPerMTok === "number";
2582
+ }
2583
+ function loadPriceCache(path = defaultPriceCachePath()) {
2584
+ let parsed;
2585
+ try {
2586
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2587
+ } catch {
2588
+ return null;
2589
+ }
2590
+ if (!parsed || typeof parsed !== "object") return null;
2591
+ const raw = parsed;
2592
+ if (!raw.models || typeof raw.models !== "object") return null;
2593
+ const models = {};
2594
+ for (const [id, price] of Object.entries(raw.models)) {
2595
+ if (isModelPrice(price)) models[id] = price;
2596
+ }
2597
+ return {
2598
+ source: typeof raw.source === "string" ? raw.source : "unknown",
2599
+ fetchedAt: typeof raw.fetchedAt === "string" ? raw.fetchedAt : "",
2600
+ models
2601
+ };
2602
+ }
2603
+ function savePriceCache(cache2, path = defaultPriceCachePath()) {
2604
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
2605
+ }
2606
+ function getPriceCache() {
2607
+ if (memo === void 0) memo = loadPriceCache();
2608
+ return memo;
2609
+ }
2610
+ function invalidatePriceCache() {
2611
+ memo = void 0;
2612
+ }
2613
+ var defaultPriceCachePath, memo;
2614
+ var init_price_cache = __esm({
2615
+ "src/server/ai/price-cache.ts"() {
2616
+ "use strict";
2617
+ init_atomic_write();
2618
+ defaultPriceCachePath = () => process.env.GLOTFILE_PRICES_PATH || join3(homedir(), ".glotfile", "model-prices.json");
2619
+ }
2620
+ });
2621
+
2464
2622
  // src/server/ai/pricing.ts
2465
2623
  function addUsage(into, add) {
2466
2624
  into.inputTokens += add.inputTokens;
@@ -2474,7 +2632,9 @@ function usageCostUsd(usage, ai, multiplier = 1) {
2474
2632
  return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
2475
2633
  }
2476
2634
  function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
2477
- const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
2635
+ const writeRate = pricing.cacheWritePerMTok ?? pricing.inputPerMTok * CACHE_WRITE_MULTIPLIER;
2636
+ const readRate = pricing.cacheReadPerMTok ?? pricing.inputPerMTok * CACHE_READ_MULTIPLIER;
2637
+ const inputCost = usage.inputTokens * pricing.inputPerMTok + usage.cacheCreationInputTokens * writeRate + usage.cacheReadInputTokens * readRate;
2478
2638
  return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
2479
2639
  }
2480
2640
  function bareModelId(model) {
@@ -2485,12 +2645,27 @@ function bareModelId(model) {
2485
2645
  if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2486
2646
  return id;
2487
2647
  }
2488
- function resolvePricing(ai) {
2648
+ function lookupCachePrice(cache2, id) {
2649
+ const exact = cache2.models[id];
2650
+ if (exact) return { source: "cache", ...exact };
2651
+ let best;
2652
+ for (const [cid, price] of Object.entries(cache2.models)) {
2653
+ if (id.startsWith(cid) && (!best || cid.length > best.id.length)) {
2654
+ best = { id: cid, price: { source: "cache", ...price } };
2655
+ }
2656
+ }
2657
+ return best ? best.price : null;
2658
+ }
2659
+ function resolvePricing(ai, cache2 = getPriceCache()) {
2489
2660
  if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2490
2661
  return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2491
2662
  }
2492
2663
  if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2493
2664
  const id = bareModelId(ai.model);
2665
+ if (cache2) {
2666
+ const cached = lookupCachePrice(cache2, id);
2667
+ if (cached) return cached;
2668
+ }
2494
2669
  let best;
2495
2670
  for (const row of PRICE_TABLE) {
2496
2671
  if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
@@ -2501,6 +2676,7 @@ var BATCH_PRICE_MULTIPLIER, CACHE_WRITE_MULTIPLIER, CACHE_READ_MULTIPLIER, PRICE
2501
2676
  var init_pricing = __esm({
2502
2677
  "src/server/ai/pricing.ts"() {
2503
2678
  "use strict";
2679
+ init_price_cache();
2504
2680
  BATCH_PRICE_MULTIPLIER = 0.5;
2505
2681
  CACHE_WRITE_MULTIPLIER = 1.25;
2506
2682
  CACHE_READ_MULTIPLIER = 0.1;
@@ -3149,11 +3325,11 @@ var init_glotfile_dir = __esm({
3149
3325
  });
3150
3326
 
3151
3327
  // src/server/local-settings.ts
3152
- import { readFileSync as readFileSync4 } from "fs";
3328
+ import { readFileSync as readFileSync5 } from "fs";
3153
3329
  import { resolve as resolve3 } from "path";
3154
3330
  function readJson(path) {
3155
3331
  try {
3156
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
3332
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3157
3333
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3158
3334
  } catch {
3159
3335
  return {};
@@ -3240,13 +3416,23 @@ var init_local_settings = __esm({
3240
3416
  });
3241
3417
 
3242
3418
  // src/server/glossary.ts
3243
- function contains(haystack, needle, caseSensitive) {
3244
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
3419
+ function escapeRegExp2(s) {
3420
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3421
+ }
3422
+ function contains(haystack, needle, caseSensitive, wholeWord) {
3423
+ if (!wholeWord) {
3424
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
3425
+ }
3426
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp2(needle)}(?![\\p{L}\\p{N}])`, caseSensitive ? "u" : "iu");
3427
+ return re.test(haystack);
3428
+ }
3429
+ function termInSource(source, entry) {
3430
+ return contains(source, entry.term, entry.caseSensitive, entry.wholeWord ?? true);
3245
3431
  }
3246
3432
  function relevantGlossary(source, targetLocale, glossary) {
3247
3433
  const hints = [];
3248
3434
  for (const entry of glossary) {
3249
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
3435
+ if (!termInSource(source, entry)) continue;
3250
3436
  hints.push({
3251
3437
  term: entry.term,
3252
3438
  doNotTranslate: entry.doNotTranslate,
@@ -3256,10 +3442,20 @@ function relevantGlossary(source, targetLocale, glossary) {
3256
3442
  }
3257
3443
  return hints;
3258
3444
  }
3445
+ function sourceKeysForTerm(state, term, opts = {}) {
3446
+ const pseudo = { term, caseSensitive: opts.caseSensitive, wholeWord: opts.wholeWord };
3447
+ const out = [];
3448
+ for (const [key, entry] of Object.entries(state.keys)) {
3449
+ const lv = entry.values[state.config.sourceLocale];
3450
+ const text = lv?.value ?? lv?.forms?.other ?? "";
3451
+ if (text && termInSource(text, pseudo)) out.push(key);
3452
+ }
3453
+ return out;
3454
+ }
3259
3455
  function glossaryViolations(source, value, targetLocale, glossary) {
3260
3456
  const out = [];
3261
3457
  for (const entry of glossary) {
3262
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
3458
+ if (!termInSource(source, entry)) continue;
3263
3459
  if (entry.doNotTranslate) {
3264
3460
  if (!contains(value, entry.term, entry.caseSensitive)) {
3265
3461
  out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
@@ -3280,7 +3476,7 @@ var init_glossary = __esm({
3280
3476
  });
3281
3477
 
3282
3478
  // src/server/ai/run.ts
3283
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
3479
+ import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
3284
3480
  import { resolve as resolve4, extname } from "path";
3285
3481
  function selectRequests(state, opts) {
3286
3482
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -3354,7 +3550,7 @@ function attachScreenshots(reqs, state, projectRoot) {
3354
3550
  if (!existsSync5(abs)) {
3355
3551
  cache2.set(screenshot, null);
3356
3552
  } else {
3357
- const buf = readFileSync5(abs);
3553
+ const buf = readFileSync6(abs);
3358
3554
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
3359
3555
  }
3360
3556
  }
@@ -3500,16 +3696,16 @@ var init_run = __esm({
3500
3696
  });
3501
3697
 
3502
3698
  // src/server/ai/pending-batch.ts
3503
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3504
- import { join as join3 } from "path";
3699
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3700
+ import { join as join4 } from "path";
3505
3701
  function pendingBatchPath(projectRoot) {
3506
- return join3(projectRoot, ".glotfile", "batch.json");
3702
+ return join4(projectRoot, ".glotfile", "batch.json");
3507
3703
  }
3508
3704
  function loadPendingBatch(projectRoot) {
3509
3705
  const path = pendingBatchPath(projectRoot);
3510
3706
  if (!existsSync6(path)) return void 0;
3511
3707
  try {
3512
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
3708
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
3513
3709
  if (parsed?.version !== 1) return void 0;
3514
3710
  return parsed;
3515
3711
  } catch {
@@ -3517,9 +3713,9 @@ function loadPendingBatch(projectRoot) {
3517
3713
  }
3518
3714
  }
3519
3715
  function savePendingBatch(projectRoot, pending) {
3520
- const dir = join3(projectRoot, ".glotfile");
3716
+ const dir = join4(projectRoot, ".glotfile");
3521
3717
  mkdirSync4(dir, { recursive: true });
3522
- const gitignore = join3(dir, ".gitignore");
3718
+ const gitignore = join4(dir, ".gitignore");
3523
3719
  if (!existsSync6(gitignore)) writeFileSync3(gitignore, "*\n");
3524
3720
  writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3525
3721
  }
@@ -3533,7 +3729,7 @@ var init_pending_batch = __esm({
3533
3729
  });
3534
3730
 
3535
3731
  // src/server/log.ts
3536
- import { appendFileSync, existsSync as existsSync7, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync7 } from "fs";
3732
+ import { appendFileSync, existsSync as existsSync7, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
3537
3733
  import { resolve as resolve5 } from "path";
3538
3734
  function logPath(projectRoot) {
3539
3735
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -3546,7 +3742,7 @@ function appendLog(projectRoot, entry) {
3546
3742
  }
3547
3743
  function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
3548
3744
  if (!existsSync7(path) || statSync2(path).size <= maxBytes) return;
3549
- const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3745
+ const lines = readFileSync8(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3550
3746
  const kept = [];
3551
3747
  let bytes = 0;
3552
3748
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -3745,7 +3941,7 @@ var init_batch_run = __esm({
3745
3941
  });
3746
3942
 
3747
3943
  // src/server/ai/context.ts
3748
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3944
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3749
3945
  import { resolve as resolve6 } from "path";
3750
3946
  function globToRegExp2(glob) {
3751
3947
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -3761,7 +3957,7 @@ function extractSnippets(refs, projectRoot, fileCache) {
3761
3957
  const absPath = resolve6(projectRoot, ref.file);
3762
3958
  if (!fileCache.has(ref.file)) {
3763
3959
  if (!existsSync8(absPath)) continue;
3764
- const content = readFileSync8(absPath, "utf8");
3960
+ const content = readFileSync9(absPath, "utf8");
3765
3961
  fileCache.set(ref.file, content.split("\n"));
3766
3962
  }
3767
3963
  const lines = fileCache.get(ref.file);
@@ -3777,6 +3973,21 @@ function extractSnippets(refs, projectRoot, fileCache) {
3777
3973
  }
3778
3974
  return snippets;
3779
3975
  }
3976
+ function attachUsageSnippets(targets, cache2, projectRoot) {
3977
+ const fileCache = /* @__PURE__ */ new Map();
3978
+ for (const target of targets) {
3979
+ const allRefs = Object.entries(cache2.files).flatMap(
3980
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
3981
+ key: r.key,
3982
+ file,
3983
+ line: r.line,
3984
+ col: r.col,
3985
+ scanner: r.scanner
3986
+ }))
3987
+ );
3988
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
3989
+ }
3990
+ }
3780
3991
  function buildUsageIndex(cache2) {
3781
3992
  const index = /* @__PURE__ */ new Map();
3782
3993
  for (const [file, entry] of Object.entries(cache2.files)) {
@@ -3915,16 +4126,16 @@ var init_context = __esm({
3915
4126
  });
3916
4127
 
3917
4128
  // src/server/ai/pending-context-batch.ts
3918
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
3919
- import { join as join4 } from "path";
4129
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4130
+ import { join as join5 } from "path";
3920
4131
  function pendingContextBatchPath(projectRoot) {
3921
- return join4(projectRoot, ".glotfile", "context-batch.json");
4132
+ return join5(projectRoot, ".glotfile", "context-batch.json");
3922
4133
  }
3923
4134
  function loadPendingContextBatch(projectRoot) {
3924
4135
  const path = pendingContextBatchPath(projectRoot);
3925
4136
  if (!existsSync9(path)) return void 0;
3926
4137
  try {
3927
- const parsed = JSON.parse(readFileSync9(path, "utf8"));
4138
+ const parsed = JSON.parse(readFileSync10(path, "utf8"));
3928
4139
  if (parsed?.version !== 1) return void 0;
3929
4140
  return parsed;
3930
4141
  } catch {
@@ -3932,9 +4143,9 @@ function loadPendingContextBatch(projectRoot) {
3932
4143
  }
3933
4144
  }
3934
4145
  function savePendingContextBatch(projectRoot, pending) {
3935
- const dir = join4(projectRoot, ".glotfile");
4146
+ const dir = join5(projectRoot, ".glotfile");
3936
4147
  mkdirSync5(dir, { recursive: true });
3937
- const gitignore = join4(dir, ".gitignore");
4148
+ const gitignore = join5(dir, ".gitignore");
3938
4149
  if (!existsSync9(gitignore)) writeFileSync4(gitignore, "*\n");
3939
4150
  writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3940
4151
  }
@@ -4055,6 +4266,109 @@ var init_context_batch_run = __esm({
4055
4266
  }
4056
4267
  });
4057
4268
 
4269
+ // src/server/ai/glossary-suggest.ts
4270
+ function globToRegExp3(glob) {
4271
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
4272
+ return new RegExp(`^${escaped}$`);
4273
+ }
4274
+ function selectGlossarySources(state, opts) {
4275
+ const keyRe = opts.keyGlob ? globToRegExp3(opts.keyGlob) : null;
4276
+ let rows = [];
4277
+ for (const key of Object.keys(state.keys)) {
4278
+ if (keyRe && !keyRe.test(key)) continue;
4279
+ const entry = state.keys[key];
4280
+ if (opts.since) {
4281
+ if (!entry.createdAt || entry.createdAt < opts.since) continue;
4282
+ }
4283
+ const lv = entry.values[state.config.sourceLocale];
4284
+ const source = (lv?.value ?? lv?.forms?.other ?? "").trim();
4285
+ if (!source) continue;
4286
+ rows.push({ key, source });
4287
+ }
4288
+ rows.sort((a, b) => {
4289
+ const ta = state.keys[a.key].createdAt ?? "";
4290
+ const tb = state.keys[b.key].createdAt ?? "";
4291
+ return tb.localeCompare(ta) || a.key.localeCompare(b.key);
4292
+ });
4293
+ if (opts.limit !== void 0) rows = rows.slice(0, opts.limit);
4294
+ return rows;
4295
+ }
4296
+ function knownTermList(state) {
4297
+ const out = /* @__PURE__ */ new Set();
4298
+ for (const g of state.glossary) out.add(g.term);
4299
+ for (const s of state.glossarySuggestions) out.add(s.term);
4300
+ return [...out];
4301
+ }
4302
+ function buildGlossarySuggestSystemPrompt() {
4303
+ return [
4304
+ "You identify GLOSSARY-CANDIDATE terms in a UI string catalog so they translate consistently.",
4305
+ "A glossary term is a brand or product name, a feature or module name, an acronym, a piece of domain/industry jargon, or any noun phrase that should translate the SAME way everywhere (or stay verbatim).",
4306
+ "You are given source strings (the app's original language). Return the candidate terms you find.",
4307
+ "Rules:",
4308
+ "- Only surface terms a translator would benefit from pinning. IGNORE ordinary words, verbs, and generic UI labels (e.g. 'Save', 'Cancel', 'Welcome').",
4309
+ "- Prefer terms that recur or are clearly proper nouns / product names / acronyms.",
4310
+ "- Set doNotTranslate: true for brand/product names, code identifiers, and acronyms that must stay verbatim in every language.",
4311
+ "- Set caseSensitive: true only when casing is meaningful (e.g. an all-caps acronym that must not match a lowercase common word).",
4312
+ "- Set wholeWord: false ONLY if the term should also match inside larger words; otherwise omit it (whole-word is the default).",
4313
+ "- note: one short phrase on why it's a term (e.g. 'product name', 'industry acronym', 'recurring UI concept'). Keep it under 80 characters.",
4314
+ "- Do NOT return any term in the provided 'Already known' list.",
4315
+ "- Return the term exactly as it appears in the source (preserve casing)."
4316
+ ].join("\n");
4317
+ }
4318
+ function buildGlossarySuggestBatchPrompt(sources, knownTerms) {
4319
+ const known = knownTerms.length ? knownTerms.join(", ") : "(none yet)";
4320
+ const lines = sources.map((s) => `- [${s.key}] ${s.source}`).join("\n");
4321
+ return [
4322
+ `Already known (do NOT return these): ${known}`,
4323
+ "",
4324
+ "Source strings:",
4325
+ lines,
4326
+ "",
4327
+ 'Return JSON {"terms":[{"term","note?","doNotTranslate?","caseSensitive?","wholeWord?"}]}. Return an empty array if you find no good candidates.'
4328
+ ].join("\n");
4329
+ }
4330
+ function dedupeTerms(terms) {
4331
+ const seen = /* @__PURE__ */ new Set();
4332
+ const out = [];
4333
+ for (const t of terms) {
4334
+ const term = t.term?.trim();
4335
+ if (!term) continue;
4336
+ const key = term.toLowerCase();
4337
+ if (seen.has(key)) continue;
4338
+ seen.add(key);
4339
+ out.push({ ...t, term });
4340
+ }
4341
+ return out;
4342
+ }
4343
+ var GLOSSARY_SUGGEST_SCHEMA;
4344
+ var init_glossary_suggest = __esm({
4345
+ "src/server/ai/glossary-suggest.ts"() {
4346
+ "use strict";
4347
+ GLOSSARY_SUGGEST_SCHEMA = {
4348
+ type: "object",
4349
+ properties: {
4350
+ terms: {
4351
+ type: "array",
4352
+ items: {
4353
+ type: "object",
4354
+ properties: {
4355
+ term: { type: "string" },
4356
+ note: { type: "string" },
4357
+ doNotTranslate: { type: "boolean" },
4358
+ caseSensitive: { type: "boolean" },
4359
+ wholeWord: { type: "boolean" }
4360
+ },
4361
+ required: ["term"],
4362
+ additionalProperties: false
4363
+ }
4364
+ }
4365
+ },
4366
+ required: ["terms"],
4367
+ additionalProperties: false
4368
+ };
4369
+ }
4370
+ });
4371
+
4058
4372
  // src/server/ai/estimate.ts
4059
4373
  function estimateTokens(text) {
4060
4374
  const cjk = text.match(CJK_RE)?.length ?? 0;
@@ -4103,29 +4417,131 @@ function estimateTranslation(state, ai, opts) {
4103
4417
  estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4104
4418
  };
4105
4419
  }
4106
- var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
4420
+ function estimateContext(targets, ai) {
4421
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4422
+ const batches = chunk(targets, batchSize);
4423
+ const system = buildContextSystemPrompt();
4424
+ let inputTokens = 0;
4425
+ let outputTokens = 0;
4426
+ for (const batch of batches) {
4427
+ inputTokens += estimateTokens(system) + estimateTokens(buildContextBatchPrompt(batch));
4428
+ outputTokens += batch.length * (CONTEXT_REPLY_OVERHEAD + TYPICAL_CONTEXT_TOKENS);
4429
+ }
4430
+ const pricing = resolvePricing(ai);
4431
+ return {
4432
+ keys: targets.length,
4433
+ batches: batches.length,
4434
+ inputTokens,
4435
+ outputTokens,
4436
+ pricing,
4437
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4438
+ };
4439
+ }
4440
+ function estimateGlossarySuggest(sources, knownTerms, ai) {
4441
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4442
+ const batches = chunk(sources, batchSize);
4443
+ const system = buildGlossarySuggestSystemPrompt();
4444
+ let inputTokens = 0;
4445
+ let outputTokens = 0;
4446
+ for (const batch of batches) {
4447
+ inputTokens += estimateTokens(system) + estimateTokens(buildGlossarySuggestBatchPrompt(batch, knownTerms));
4448
+ outputTokens += Math.ceil(batch.length * TERM_YIELD) * TERM_REPLY_TOKENS;
4449
+ }
4450
+ const pricing = resolvePricing(ai);
4451
+ return {
4452
+ sources: sources.length,
4453
+ batches: batches.length,
4454
+ inputTokens,
4455
+ outputTokens,
4456
+ pricing,
4457
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4458
+ };
4459
+ }
4460
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD, CONTEXT_REPLY_OVERHEAD, TYPICAL_CONTEXT_TOKENS, TERM_REPLY_TOKENS, TERM_YIELD;
4107
4461
  var init_estimate = __esm({
4108
4462
  "src/server/ai/estimate.ts"() {
4109
4463
  "use strict";
4110
4464
  init_run();
4111
4465
  init_provider();
4466
+ init_context();
4467
+ init_glossary_suggest();
4112
4468
  init_batch();
4113
4469
  init_pricing();
4114
4470
  CJK_RE = /[ -鿿가-힯豈-﫿]/g;
4115
4471
  EXPANSION = 1.2;
4116
4472
  ITEM_REPLY_OVERHEAD = 16;
4117
4473
  FORM_REPLY_OVERHEAD = 8;
4474
+ CONTEXT_REPLY_OVERHEAD = 16;
4475
+ TYPICAL_CONTEXT_TOKENS = 35;
4476
+ TERM_REPLY_TOKENS = 24;
4477
+ TERM_YIELD = 0.15;
4478
+ }
4479
+ });
4480
+
4481
+ // src/server/ai/price-fetch.ts
4482
+ function normalizeModelsDevPrices(api) {
4483
+ const out = {};
4484
+ const ranks = {};
4485
+ if (!api || typeof api !== "object") return out;
4486
+ for (const [provId, prov] of Object.entries(api)) {
4487
+ const models = prov?.models;
4488
+ if (!models || typeof models !== "object") continue;
4489
+ const rank = providerRank(provId);
4490
+ for (const [modelKey, model] of Object.entries(models)) {
4491
+ const cost = model?.cost;
4492
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
4493
+ const bareId = bareModelId(modelKey);
4494
+ if (!bareId) continue;
4495
+ const existingRank = ranks[bareId];
4496
+ if (existingRank !== void 0 && existingRank <= rank) continue;
4497
+ const price = { inputPerMTok: cost.input, outputPerMTok: cost.output };
4498
+ if (typeof cost.cache_read === "number") price.cacheReadPerMTok = cost.cache_read;
4499
+ if (typeof cost.cache_write === "number") price.cacheWritePerMTok = cost.cache_write;
4500
+ out[bareId] = price;
4501
+ ranks[bareId] = rank;
4502
+ }
4503
+ }
4504
+ return out;
4505
+ }
4506
+ async function refreshPrices(opts = {}) {
4507
+ const url = opts.url ?? priceUrl();
4508
+ const doFetch = opts.fetchImpl ?? fetch;
4509
+ const res = await doFetch(url);
4510
+ if (!res.ok) throw new Error(`Failed to fetch prices from ${url}: HTTP ${res.status}`);
4511
+ const api = await res.json();
4512
+ const models = normalizeModelsDevPrices(api);
4513
+ const modelCount = Object.keys(models).length;
4514
+ if (modelCount === 0) throw new Error(`No model prices found in response from ${url}`);
4515
+ const cache2 = { source: "models.dev", fetchedAt: (opts.now ?? defaultNow)(), models };
4516
+ const path = opts.path ?? defaultPriceCachePath();
4517
+ savePriceCache(cache2, path);
4518
+ return { source: cache2.source, fetchedAt: cache2.fetchedAt, modelCount, path };
4519
+ }
4520
+ var MODELS_DEV_URL, priceUrl, PROVIDER_PREFERENCE, providerRank, defaultNow;
4521
+ var init_price_fetch = __esm({
4522
+ "src/server/ai/price-fetch.ts"() {
4523
+ "use strict";
4524
+ init_pricing();
4525
+ init_price_cache();
4526
+ MODELS_DEV_URL = "https://models.dev/api.json";
4527
+ priceUrl = () => process.env.GLOTFILE_PRICES_URL || MODELS_DEV_URL;
4528
+ PROVIDER_PREFERENCE = ["anthropic", "openai", "google", "meta-llama", "mistral", "deepseek", "xai", "groq"];
4529
+ providerRank = (provId) => {
4530
+ const i = PROVIDER_PREFERENCE.indexOf(provId);
4531
+ return i === -1 ? PROVIDER_PREFERENCE.length : i;
4532
+ };
4533
+ defaultNow = () => (/* @__PURE__ */ new Date()).toISOString();
4118
4534
  }
4119
4535
  });
4120
4536
 
4121
4537
  // src/server/scan.ts
4122
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
4538
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
4123
4539
  import { resolve as resolve7 } from "path";
4124
4540
  function loadUsageCache(projectRoot) {
4125
4541
  const path = resolve7(projectRoot, ".glotfile", "usage.json");
4126
4542
  if (!existsSync10(path)) return null;
4127
4543
  try {
4128
- return JSON.parse(readFileSync10(path, "utf8"));
4544
+ return JSON.parse(readFileSync11(path, "utf8"));
4129
4545
  } catch {
4130
4546
  return null;
4131
4547
  }
@@ -4190,8 +4606,8 @@ var init_scan = __esm({
4190
4606
  });
4191
4607
 
4192
4608
  // src/server/scanner.ts
4193
- import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync11 } from "fs";
4194
- import { join as join5, extname as extname2, relative } from "path";
4609
+ import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync12 } from "fs";
4610
+ import { join as join6, extname as extname2, relative } from "path";
4195
4611
  function scannerForExt(ext) {
4196
4612
  return EXT_SCANNER[ext] ?? null;
4197
4613
  }
@@ -4230,6 +4646,69 @@ function customPatterns(opts) {
4230
4646
  }
4231
4647
  return out;
4232
4648
  }
4649
+ function isNextIntlFile(content) {
4650
+ return NEXT_INTL_IMPORT.test(content);
4651
+ }
4652
+ function nextIntlBindings(content) {
4653
+ const out = [];
4654
+ for (const m of content.matchAll(NI_BIND)) out.push({ name: m[1], ns: m[2] ?? "", index: m.index });
4655
+ for (const m of content.matchAll(NI_BIND_OBJ)) out.push({ name: m[1], ns: m[2], index: m.index });
4656
+ return out;
4657
+ }
4658
+ function nsForBindingAt(bindings, name, index) {
4659
+ let best = null;
4660
+ for (const b of bindings) {
4661
+ if (b.name === name && b.index < index && (!best || b.index > best.index)) best = b;
4662
+ }
4663
+ return best ? best.ns : null;
4664
+ }
4665
+ function joinKey(ns, rel) {
4666
+ return ns ? `${ns}.${rel}` : rel;
4667
+ }
4668
+ function uniqueBindingNames(bindings) {
4669
+ return [...new Set(bindings.map((b) => b.name))];
4670
+ }
4671
+ function nextIntlRefMatches(content) {
4672
+ const bindings = nextIntlBindings(content);
4673
+ const out = [];
4674
+ for (const name of uniqueBindingNames(bindings)) {
4675
+ const re = new RegExp(
4676
+ `\\b${escapeRe2(name)}${NI_METHOD}\\s*\\(\\s*(?:'([^'\\n]+)'|"([^"\\n]+)"|\`([^\`$\\n]+)\`)`,
4677
+ "g"
4678
+ );
4679
+ let m;
4680
+ while ((m = re.exec(content)) !== null) {
4681
+ const ns = nsForBindingAt(bindings, name, m.index);
4682
+ if (ns === null) continue;
4683
+ out.push({ key: joinKey(ns, m[1] ?? m[2] ?? m[3]), index: m.index });
4684
+ }
4685
+ }
4686
+ return out;
4687
+ }
4688
+ function nextIntlPrefixMatches(content) {
4689
+ const bindings = nextIntlBindings(content);
4690
+ const out = [];
4691
+ for (const name of uniqueBindingNames(bindings)) {
4692
+ const ev = escapeRe2(name);
4693
+ const headConcat = new RegExp(`\\b${ev}${NI_METHOD}\\s*\\(\\s*(?:'([^'\\n]*)'|"([^"\\n]*)")\\s*\\+`, "g");
4694
+ const headTemplate = new RegExp(`\\b${ev}${NI_METHOD}\\s*\\(\\s*\`([^\`$\\n]*)\\$\\{`, "g");
4695
+ const dynamicArg = new RegExp(`\\b${ev}${NI_METHOD}\\s*\\(\\s*[A-Za-z_$][\\w$.]*\\s*[),]`, "g");
4696
+ let m;
4697
+ for (const re of [headConcat, headTemplate]) {
4698
+ while ((m = re.exec(content)) !== null) {
4699
+ const ns = nsForBindingAt(bindings, name, m.index);
4700
+ if (ns === null) continue;
4701
+ out.push({ prefix: joinKey(ns, m[1] ?? m[2] ?? ""), index: m.index });
4702
+ }
4703
+ }
4704
+ while ((m = dynamicArg.exec(content)) !== null) {
4705
+ const ns = nsForBindingAt(bindings, name, m.index);
4706
+ if (!ns) continue;
4707
+ out.push({ prefix: `${ns}.`, index: m.index });
4708
+ }
4709
+ }
4710
+ return out;
4711
+ }
4233
4712
  function lineStartOffsets(content) {
4234
4713
  const starts = [0];
4235
4714
  let idx = content.indexOf("\n");
@@ -4250,49 +4729,56 @@ function offsetToLineCol(starts, offset) {
4250
4729
  return { line: lo + 1, col: offset - starts[lo] + 1 };
4251
4730
  }
4252
4731
  function extractRefs(content, scanner, opts) {
4253
- const base = scanner === "flutter" ? flutterPatterns(content, opts) : PATTERNS[scanner] ?? [];
4254
- const patterns = [...base, ...customPatterns(opts)];
4255
- if (patterns.length === 0) return [];
4732
+ const useNextIntl = scanner === "next-intl" || scanner === "js-i18n" && isNextIntlFile(content);
4733
+ const effScanner = useNextIntl ? "next-intl" : scanner;
4256
4734
  const starts = lineStartOffsets(content);
4257
4735
  const result = [];
4258
4736
  const seen = /* @__PURE__ */ new Set();
4259
- for (const pattern of patterns) {
4260
- const re = new RegExp(pattern.source, "g");
4261
- let m;
4262
- while ((m = re.exec(content)) !== null) {
4263
- if (m.index === re.lastIndex) re.lastIndex++;
4264
- const key = m[1];
4265
- const { line, col } = offsetToLineCol(starts, m.index);
4266
- const dedup = `${line}:${col}:${key}`;
4267
- if (!seen.has(dedup)) {
4268
- seen.add(dedup);
4269
- result.push({ key, line, col, scanner });
4270
- }
4737
+ const push = (key, index) => {
4738
+ const { line, col } = offsetToLineCol(starts, index);
4739
+ const dedup = `${line}:${col}:${key}`;
4740
+ if (!seen.has(dedup)) {
4741
+ seen.add(dedup);
4742
+ result.push({ key, line, col, scanner: effScanner });
4271
4743
  }
4744
+ };
4745
+ if (useNextIntl) {
4746
+ for (const r of nextIntlRefMatches(content)) push(r.key, r.index);
4747
+ } else {
4748
+ const base = scanner === "flutter" ? flutterPatterns(content, opts) : PATTERNS[scanner] ?? [];
4749
+ for (const pattern of base) eachMatch(content, pattern, push);
4272
4750
  }
4751
+ for (const pattern of customPatterns(opts)) eachMatch(content, pattern, push);
4273
4752
  result.sort((a, b) => a.line - b.line || a.col - b.col);
4274
4753
  return result;
4275
4754
  }
4755
+ function eachMatch(content, pattern, fn) {
4756
+ const re = new RegExp(pattern.source, "g");
4757
+ let m;
4758
+ while ((m = re.exec(content)) !== null) {
4759
+ if (m.index === re.lastIndex) re.lastIndex++;
4760
+ fn(m[1], m.index);
4761
+ }
4762
+ }
4276
4763
  function extractPrefixes(content, scanner) {
4277
- const patterns = PREFIX_PATTERNS[scanner];
4278
- if (!patterns) return [];
4764
+ const useNextIntl = scanner === "next-intl" || scanner === "js-i18n" && isNextIntlFile(content);
4765
+ const effScanner = useNextIntl ? "next-intl" : scanner;
4279
4766
  const starts = lineStartOffsets(content);
4280
4767
  const result = [];
4281
4768
  const seen = /* @__PURE__ */ new Set();
4282
- for (const pattern of patterns) {
4283
- const re = new RegExp(pattern.source, "g");
4284
- let m;
4285
- while ((m = re.exec(content)) !== null) {
4286
- if (m.index === re.lastIndex) re.lastIndex++;
4287
- const prefix = m[1];
4288
- if (!prefix) continue;
4289
- const { line, col } = offsetToLineCol(starts, m.index);
4290
- const dedup = `${line}:${col}:${prefix}`;
4291
- if (!seen.has(dedup)) {
4292
- seen.add(dedup);
4293
- result.push({ prefix, line, col, scanner });
4294
- }
4769
+ const push = (prefix, index) => {
4770
+ if (!prefix) return;
4771
+ const { line, col } = offsetToLineCol(starts, index);
4772
+ const dedup = `${line}:${col}:${prefix}`;
4773
+ if (!seen.has(dedup)) {
4774
+ seen.add(dedup);
4775
+ result.push({ prefix, line, col, scanner: effScanner });
4295
4776
  }
4777
+ };
4778
+ if (useNextIntl) {
4779
+ for (const p of nextIntlPrefixMatches(content)) push(p.prefix, p.index);
4780
+ } else {
4781
+ for (const pattern of PREFIX_PATTERNS[scanner] ?? []) eachMatch(content, pattern, push);
4296
4782
  }
4297
4783
  result.sort((a, b) => a.line - b.line || a.col - b.col);
4298
4784
  return result;
@@ -4343,7 +4829,7 @@ function* walkFiles(dir, root, exclude) {
4343
4829
  }
4344
4830
  for (const name of entries) {
4345
4831
  if (ALWAYS_EXCLUDE.has(name)) continue;
4346
- const abs = join5(dir, name);
4832
+ const abs = join6(dir, name);
4347
4833
  const rel = relative(root, abs);
4348
4834
  let st;
4349
4835
  try {
@@ -4373,7 +4859,7 @@ function runScan(projectRoot, opts, existing) {
4373
4859
  const ext = extname2(relPath);
4374
4860
  const scanner = scannerForExt(ext);
4375
4861
  if (!scanner) continue;
4376
- const abs = join5(projectRoot, relPath);
4862
+ const abs = join6(projectRoot, relPath);
4377
4863
  let st;
4378
4864
  try {
4379
4865
  st = statSync3(abs);
@@ -4389,7 +4875,7 @@ function runScan(projectRoot, opts, existing) {
4389
4875
  }
4390
4876
  let content;
4391
4877
  try {
4392
- content = readFileSync11(abs, "utf8");
4878
+ content = readFileSync12(abs, "utf8");
4393
4879
  } catch {
4394
4880
  continue;
4395
4881
  }
@@ -4404,7 +4890,7 @@ function runScan(projectRoot, opts, existing) {
4404
4890
  saveUsageCache(projectRoot, cache2);
4405
4891
  return cache2;
4406
4892
  }
4407
- var PATTERNS, PREFIX_PATTERNS, CACHE_VERSION, EXT_SCANNER, ALWAYS_EXCLUDE, FLUTTER_ACCESSOR_DEFAULTS, KEY_SHAPE, STRING_LITERALS;
4893
+ var PATTERNS, PREFIX_PATTERNS, CACHE_VERSION, EXT_SCANNER, ALWAYS_EXCLUDE, FLUTTER_ACCESSOR_DEFAULTS, NEXT_INTL_IMPORT, NI_BIND, NI_BIND_OBJ, NI_METHOD, KEY_SHAPE, STRING_LITERALS;
4408
4894
  var init_scanner = __esm({
4409
4895
  "src/server/scanner.ts"() {
4410
4896
  "use strict";
@@ -4475,7 +4961,7 @@ var init_scanner = __esm({
4475
4961
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
4476
4962
  ]
4477
4963
  };
4478
- CACHE_VERSION = 7;
4964
+ CACHE_VERSION = 8;
4479
4965
  EXT_SCANNER = {
4480
4966
  ".php": "laravel",
4481
4967
  ".vue": "js-i18n",
@@ -4509,6 +4995,10 @@ var init_scanner = __esm({
4509
4995
  "__pycache__"
4510
4996
  ]);
4511
4997
  FLUTTER_ACCESSOR_DEFAULTS = ["l10n", "loc", "localizations", "translations"];
4998
+ NEXT_INTL_IMPORT = /(?:from\s*|require\(\s*)['"]next-intl(?:\/[\w-]+)?['"]/;
4999
+ NI_BIND = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*(?:['"]([^'"]*)['"])?\s*\)/g;
5000
+ NI_BIND_OBJ = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?getTranslations\s*\(\s*\{[^}]*?\bnamespace\s*:\s*['"]([^'"]+)['"][^}]*?\}\s*\)/g;
5001
+ NI_METHOD = "(?:\\.(?:rich|markup|raw|has))?";
4512
5002
  KEY_SHAPE = /^[A-Za-z0-9_][A-Za-z0-9_/-]*(?:\.(?:[A-Za-z0-9_-]+|%[sd]))+\.?$/;
4513
5003
  STRING_LITERALS = [
4514
5004
  /'([^'\\\n]+)'/g,
@@ -4519,8 +5009,8 @@ var init_scanner = __esm({
4519
5009
  });
4520
5010
 
4521
5011
  // src/server/import/detect.ts
4522
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync4 } from "fs";
4523
- import { join as join6 } from "path";
5012
+ import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
5013
+ import { join as join7 } from "path";
4524
5014
  function safeIsDir(p) {
4525
5015
  try {
4526
5016
  return statSync4(p).isDirectory();
@@ -4529,7 +5019,7 @@ function safeIsDir(p) {
4529
5019
  }
4530
5020
  }
4531
5021
  function listDirs(dir) {
4532
- return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
5022
+ return readdirSync4(dir).filter((e) => safeIsDir(join7(dir, e)));
4533
5023
  }
4534
5024
  function fileCount(dir) {
4535
5025
  try {
@@ -4543,23 +5033,23 @@ function pickSource(locales, sizeOf) {
4543
5033
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4544
5034
  }
4545
5035
  function detectLaravel(root) {
4546
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
5036
+ const localeRoot = [join7(root, "resources", "lang"), join7(root, "lang")].find(safeIsDir);
4547
5037
  if (!localeRoot) return null;
4548
5038
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4549
5039
  if (locales.length === 0) return null;
4550
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
5040
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join7(localeRoot, loc)));
4551
5041
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4552
5042
  }
4553
5043
  function detectVue(root, forced = false) {
4554
5044
  for (const rel of VUE_DIR_CANDIDATES) {
4555
- const localeRoot = join6(root, rel);
5045
+ const localeRoot = join7(root, rel);
4556
5046
  if (!safeIsDir(localeRoot)) continue;
4557
5047
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4558
5048
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4559
5049
  if (enough) {
4560
5050
  const sourceLocale = pickSource(locales, (loc) => {
4561
5051
  try {
4562
- return statSync4(join6(localeRoot, `${loc}.json`)).size;
5052
+ return statSync4(join7(localeRoot, `${loc}.json`)).size;
4563
5053
  } catch {
4564
5054
  return 0;
4565
5055
  }
@@ -4569,22 +5059,60 @@ function detectVue(root, forced = false) {
4569
5059
  }
4570
5060
  return null;
4571
5061
  }
4572
- function detectArb(root) {
4573
- for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4574
- const localeRoot = join6(root, rel);
4575
- if (!safeIsDir(localeRoot)) continue;
4576
- const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4577
- if (locales.length >= 1) {
4578
- return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4579
- }
5062
+ function hasNextIntlSignal(root) {
5063
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join7(root, rel)))) return true;
5064
+ try {
5065
+ const pkg = JSON.parse(readFileSync13(join7(root, "package.json"), "utf8"));
5066
+ if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
5067
+ } catch {
5068
+ }
5069
+ return false;
5070
+ }
5071
+ function nextIntlDefaultLocale(root) {
5072
+ for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
5073
+ try {
5074
+ const m = readFileSync13(join7(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5075
+ if (m) return m[1];
5076
+ } catch {
5077
+ }
5078
+ }
5079
+ return void 0;
5080
+ }
5081
+ function detectNextIntl(root, forced = false) {
5082
+ if (!forced && !hasNextIntlSignal(root)) return null;
5083
+ for (const rel of NEXT_INTL_DIR_CANDIDATES) {
5084
+ const localeRoot = join7(root, rel);
5085
+ if (!safeIsDir(localeRoot)) continue;
5086
+ const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
5087
+ if (locales.length === 0) continue;
5088
+ const def = nextIntlDefaultLocale(root);
5089
+ const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
5090
+ try {
5091
+ return statSync4(join7(localeRoot, `${loc}.json`)).size;
5092
+ } catch {
5093
+ return 0;
5094
+ }
5095
+ });
5096
+ return { format: "next-intl-json", localeRoot, locales, sourceLocale };
5097
+ }
5098
+ return null;
5099
+ }
5100
+ function detectArb(root) {
5101
+ for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
5102
+ const localeRoot = join7(root, rel);
5103
+ if (!safeIsDir(localeRoot)) continue;
5104
+ const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
5105
+ if (locales.length >= 1) {
5106
+ return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
5107
+ }
4580
5108
  }
4581
5109
  return null;
4582
5110
  }
4583
5111
  function lprojLocales(dir) {
4584
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
5112
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.strings")));
4585
5113
  }
4586
5114
  function detectApple(root) {
4587
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5115
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4588
5116
  let best = null;
4589
5117
  for (const dir of candidates) {
4590
5118
  const locales = lprojLocales(dir);
@@ -4596,7 +5124,7 @@ function detectApple(root) {
4596
5124
  locales,
4597
5125
  sourceLocale: pickSource(locales, (loc) => {
4598
5126
  try {
4599
- return statSync4(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
5127
+ return statSync4(join7(dir, `${loc}.lproj`, "Localizable.strings")).size;
4600
5128
  } catch {
4601
5129
  return 0;
4602
5130
  }
@@ -4608,7 +5136,7 @@ function detectApple(root) {
4608
5136
  }
4609
5137
  function detectAngularXliff(root) {
4610
5138
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4611
- const localeRoot = rel === "." ? root : join6(root, rel);
5139
+ const localeRoot = rel === "." ? root : join7(root, rel);
4612
5140
  if (!safeIsDir(localeRoot)) continue;
4613
5141
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4614
5142
  if (files.length === 0) continue;
@@ -4616,7 +5144,7 @@ function detectAngularXliff(root) {
4616
5144
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4617
5145
  let sourceLocale;
4618
5146
  try {
4619
- sourceLocale = readFileSync12(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5147
+ sourceLocale = readFileSync13(join7(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4620
5148
  } catch {
4621
5149
  }
4622
5150
  if (!sourceLocale && locales.length === 0) continue;
@@ -4627,14 +5155,14 @@ function detectAngularXliff(root) {
4627
5155
  return null;
4628
5156
  }
4629
5157
  function detectRails(root) {
4630
- const localeRoot = join6(root, "config", "locales");
5158
+ const localeRoot = join7(root, "config", "locales");
4631
5159
  if (!safeIsDir(localeRoot)) return null;
4632
5160
  const locales = [];
4633
5161
  for (const file of readdirSync4(localeRoot).sort()) {
4634
5162
  if (!/\.ya?ml$/.test(file)) continue;
4635
5163
  let text;
4636
5164
  try {
4637
- text = readFileSync12(join6(localeRoot, file), "utf8");
5165
+ text = readFileSync13(join7(localeRoot, file), "utf8");
4638
5166
  } catch {
4639
5167
  continue;
4640
5168
  }
@@ -4648,15 +5176,15 @@ function detectRails(root) {
4648
5176
  }
4649
5177
  function detectI18next(root) {
4650
5178
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4651
- const localeRoot = join6(root, rel);
5179
+ const localeRoot = join7(root, rel);
4652
5180
  if (!safeIsDir(localeRoot)) continue;
4653
5181
  const locales = listDirs(localeRoot).filter(
4654
- (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
5182
+ (d) => LOCALE_RE.test(d) && readdirSync4(join7(localeRoot, d)).some((f) => f.endsWith(".json"))
4655
5183
  );
4656
5184
  if (locales.length === 0) continue;
4657
5185
  const sourceLocale = pickSource(locales, (loc) => {
4658
5186
  try {
4659
- return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join6(localeRoot, loc, f)).size, 0);
5187
+ return readdirSync4(join7(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join7(localeRoot, loc, f)).size, 0);
4660
5188
  } catch {
4661
5189
  return 0;
4662
5190
  }
@@ -4673,8 +5201,8 @@ function gettextLocales(dir) {
4673
5201
  if (!locales.includes(flat)) locales.push(flat);
4674
5202
  continue;
4675
5203
  }
4676
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4677
- const sub = join6(dir, entry);
5204
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join7(dir, entry))) continue;
5205
+ const sub = join7(dir, entry);
4678
5206
  const hasPo = (d) => {
4679
5207
  try {
4680
5208
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4682,7 +5210,7 @@ function gettextLocales(dir) {
4682
5210
  return false;
4683
5211
  }
4684
5212
  };
4685
- if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
5213
+ if (hasPo(join7(sub, "LC_MESSAGES")) || hasPo(sub)) {
4686
5214
  if (!locales.includes(entry)) locales.push(entry);
4687
5215
  }
4688
5216
  }
@@ -4690,7 +5218,7 @@ function gettextLocales(dir) {
4690
5218
  }
4691
5219
  function detectGettext(root) {
4692
5220
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4693
- const localeRoot = join6(root, rel);
5221
+ const localeRoot = join7(root, rel);
4694
5222
  if (!safeIsDir(localeRoot)) continue;
4695
5223
  const locales = gettextLocales(localeRoot);
4696
5224
  if (locales.length === 0) continue;
@@ -4699,10 +5227,10 @@ function detectGettext(root) {
4699
5227
  return null;
4700
5228
  }
4701
5229
  function detectAppleStringsdict(root) {
4702
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5230
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4703
5231
  let best = null;
4704
5232
  for (const dir of candidates) {
4705
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
5233
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.stringsdict")));
4706
5234
  if (locales.length === 0) continue;
4707
5235
  if (!best || locales.length > best.locales.length) {
4708
5236
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4723,17 +5251,21 @@ function detect(root, formatOverride) {
4723
5251
  }
4724
5252
  return null;
4725
5253
  }
4726
- var LOCALE_RE, VUE_DIR_CANDIDATES, ANGULAR_DIR_CANDIDATES, I18NEXT_DIR_CANDIDATES, GETTEXT_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
5254
+ var LOCALE_RE, VUE_DIR_CANDIDATES, NEXT_INTL_CONFIG_CANDIDATES, NEXT_INTL_ROUTING_CANDIDATES, NEXT_INTL_DIR_CANDIDATES, ANGULAR_DIR_CANDIDATES, I18NEXT_DIR_CANDIDATES, GETTEXT_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
4727
5255
  var init_detect = __esm({
4728
5256
  "src/server/import/detect.ts"() {
4729
5257
  "use strict";
4730
5258
  LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4731
5259
  VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
5260
+ NEXT_INTL_CONFIG_CANDIDATES = ["src/i18n/request.ts", "i18n/request.ts", "src/i18n/request.js", "i18n/request.js"];
5261
+ NEXT_INTL_ROUTING_CANDIDATES = ["src/i18n/routing.ts", "i18n/routing.ts", "src/i18n/routing.js", "i18n/routing.js"];
5262
+ NEXT_INTL_DIR_CANDIDATES = ["messages", "src/messages", "locales", "src/locales", "src/i18n/messages"];
4732
5263
  ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
4733
5264
  I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
4734
5265
  GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
4735
5266
  DETECTORS = [
4736
5267
  detectLaravel,
5268
+ detectNextIntl,
4737
5269
  detectVue,
4738
5270
  detectArb,
4739
5271
  detectApple,
@@ -4745,6 +5277,7 @@ var init_detect = __esm({
4745
5277
  ];
4746
5278
  BY_FORMAT = {
4747
5279
  "laravel-php": detectLaravel,
5280
+ "next-intl-json": (root) => detectNextIntl(root, true),
4748
5281
  "vue-i18n-json": (root) => detectVue(root, true),
4749
5282
  "flutter-arb": detectArb,
4750
5283
  "apple-strings": detectApple,
@@ -4784,8 +5317,8 @@ var init_flatten = __esm({
4784
5317
  });
4785
5318
 
4786
5319
  // src/server/import/parsers/vue-i18n-json.ts
4787
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4788
- import { join as join7 } from "path";
5320
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
5321
+ import { join as join8 } from "path";
4789
5322
  function fromVueI18n(value) {
4790
5323
  return value.replace(/\{'([^']*)'\}/g, "'$1'");
4791
5324
  }
@@ -4808,7 +5341,7 @@ var init_vue_i18n_json2 = __esm({
4808
5341
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4809
5342
  let data;
4810
5343
  try {
4811
- data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
5344
+ data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
4812
5345
  } catch (e) {
4813
5346
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4814
5347
  continue;
@@ -4824,6 +5357,44 @@ var init_vue_i18n_json2 = __esm({
4824
5357
  }
4825
5358
  });
4826
5359
 
5360
+ // src/server/import/parsers/next-intl-json.ts
5361
+ import { readdirSync as readdirSync6, readFileSync as readFileSync15 } from "fs";
5362
+ import { join as join9 } from "path";
5363
+ var LOCALE_RE3, nextIntlJson2;
5364
+ var init_next_intl_json2 = __esm({
5365
+ "src/server/import/parsers/next-intl-json.ts"() {
5366
+ "use strict";
5367
+ init_flatten();
5368
+ LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5369
+ nextIntlJson2 = {
5370
+ name: "next-intl-json",
5371
+ parse(localeRoot, opts) {
5372
+ const warnings = [];
5373
+ const keys = {};
5374
+ const locales = [];
5375
+ for (const file of readdirSync6(localeRoot).sort()) {
5376
+ if (!file.endsWith(".json")) continue;
5377
+ const locale = file.slice(0, -".json".length);
5378
+ if (!LOCALE_RE3.test(locale)) continue;
5379
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
5380
+ let data;
5381
+ try {
5382
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
5383
+ } catch (e) {
5384
+ warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
5385
+ continue;
5386
+ }
5387
+ if (!locales.includes(locale)) locales.push(locale);
5388
+ for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
5389
+ (keys[key] ??= { values: {} }).values[locale] = value;
5390
+ }
5391
+ }
5392
+ return { locales, keys, warnings };
5393
+ }
5394
+ };
5395
+ }
5396
+ });
5397
+
4827
5398
  // src/server/import/placeholders.ts
4828
5399
  function markBareBracesLiteral(value) {
4829
5400
  return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
@@ -4841,17 +5412,17 @@ var init_placeholders2 = __esm({
4841
5412
  });
4842
5413
 
4843
5414
  // src/server/import/parsers/laravel-php.ts
4844
- import { readdirSync as readdirSync6, statSync as statSync5 } from "fs";
4845
- import { join as join8, relative as relative2 } from "path";
5415
+ import { readdirSync as readdirSync7, statSync as statSync5 } from "fs";
5416
+ import { join as join10, relative as relative2 } from "path";
4846
5417
  import { execFileSync } from "child_process";
4847
5418
  function listDirs2(dir) {
4848
- return readdirSync6(dir).filter((e) => statSync5(join8(dir, e)).isDirectory());
5419
+ return readdirSync7(dir).filter((e) => statSync5(join10(dir, e)).isDirectory());
4849
5420
  }
4850
5421
  function listPhpFiles(dir) {
4851
5422
  const out = [];
4852
5423
  const walk = (d) => {
4853
- for (const e of readdirSync6(d)) {
4854
- const full = join8(d, e);
5424
+ for (const e of readdirSync7(d)) {
5425
+ const full = join10(d, e);
4855
5426
  if (statSync5(full).isDirectory()) walk(full);
4856
5427
  else if (e.endsWith(".php")) out.push(full);
4857
5428
  }
@@ -4894,7 +5465,7 @@ var init_laravel_php2 = __esm({
4894
5465
  for (const locale of listDirs2(localeRoot).sort()) {
4895
5466
  if (locale === "vendor") continue;
4896
5467
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4897
- const localeDir = join8(localeRoot, locale);
5468
+ const localeDir = join10(localeRoot, locale);
4898
5469
  locales.push(locale);
4899
5470
  for (const file of listPhpFiles(localeDir)) {
4900
5471
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4919,14 +5490,14 @@ var init_laravel_php2 = __esm({
4919
5490
  });
4920
5491
 
4921
5492
  // src/server/import/parsers/flutter-arb.ts
4922
- import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
4923
- import { join as join9 } from "path";
5493
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
5494
+ import { join as join11 } from "path";
4924
5495
  function localeFromArbName(file) {
4925
5496
  const m = file.match(/^(.+)\.arb$/);
4926
5497
  if (!m) return null;
4927
5498
  let locale = m[1];
4928
5499
  if (locale.startsWith("app_")) locale = locale.slice(4);
4929
- return LOCALE_RE3.test(locale) ? locale : null;
5500
+ return LOCALE_RE4.test(locale) ? locale : null;
4930
5501
  }
4931
5502
  function placeholderMeta(raw) {
4932
5503
  if (!raw || typeof raw !== "object") return void 0;
@@ -4942,25 +5513,25 @@ function placeholderMeta(raw) {
4942
5513
  }
4943
5514
  return Object.keys(out).length ? out : void 0;
4944
5515
  }
4945
- var LOCALE_RE3, flutterArb2;
5516
+ var LOCALE_RE4, flutterArb2;
4946
5517
  var init_flutter_arb2 = __esm({
4947
5518
  "src/server/import/parsers/flutter-arb.ts"() {
4948
5519
  "use strict";
4949
- LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5520
+ LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4950
5521
  flutterArb2 = {
4951
5522
  name: "flutter-arb",
4952
5523
  parse(localeRoot, opts) {
4953
5524
  const warnings = [];
4954
5525
  const keys = {};
4955
5526
  const locales = [];
4956
- for (const file of readdirSync7(localeRoot).sort()) {
5527
+ for (const file of readdirSync8(localeRoot).sort()) {
4957
5528
  if (!file.endsWith(".arb")) continue;
4958
5529
  const locale = localeFromArbName(file);
4959
5530
  if (!locale) continue;
4960
5531
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4961
5532
  let data;
4962
5533
  try {
4963
- data = JSON.parse(readFileSync14(join9(localeRoot, file), "utf8"));
5534
+ data = JSON.parse(readFileSync16(join11(localeRoot, file), "utf8"));
4964
5535
  } catch (e) {
4965
5536
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4966
5537
  continue;
@@ -4987,12 +5558,12 @@ var init_flutter_arb2 = __esm({
4987
5558
  });
4988
5559
 
4989
5560
  // src/server/import/parsers/apple-strings.ts
4990
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync6 } from "fs";
4991
- import { join as join10 } from "path";
5561
+ import { readdirSync as readdirSync9, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
5562
+ import { join as join12 } from "path";
4992
5563
  function localeFromLproj(dir) {
4993
5564
  const m = dir.match(/^(.+)\.lproj$/);
4994
5565
  if (!m) return null;
4995
- return LOCALE_RE4.test(m[1]) ? m[1] : null;
5566
+ return LOCALE_RE5.test(m[1]) ? m[1] : null;
4996
5567
  }
4997
5568
  function printfToCanonical(s) {
4998
5569
  return s.replace(/%%/g, "%");
@@ -5092,11 +5663,11 @@ function parseStrings(text, file, warnings) {
5092
5663
  }
5093
5664
  return pairs;
5094
5665
  }
5095
- var LOCALE_RE4, TABLE, appleStrings2;
5666
+ var LOCALE_RE5, TABLE, appleStrings2;
5096
5667
  var init_apple_strings2 = __esm({
5097
5668
  "src/server/import/parsers/apple-strings.ts"() {
5098
5669
  "use strict";
5099
- LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5670
+ LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5100
5671
  TABLE = "Localizable.strings";
5101
5672
  appleStrings2 = {
5102
5673
  name: "apple-strings",
@@ -5104,20 +5675,20 @@ var init_apple_strings2 = __esm({
5104
5675
  const warnings = [];
5105
5676
  const keys = {};
5106
5677
  const locales = [];
5107
- for (const dir of readdirSync8(localeRoot).sort()) {
5678
+ for (const dir of readdirSync9(localeRoot).sort()) {
5108
5679
  const locale = localeFromLproj(dir);
5109
5680
  if (!locale) continue;
5110
5681
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5111
- const file = join10(localeRoot, dir, TABLE);
5682
+ const file = join12(localeRoot, dir, TABLE);
5112
5683
  let text;
5113
5684
  try {
5114
5685
  if (!statSync6(file).isFile()) continue;
5115
- text = readFileSync15(file, "utf8");
5686
+ text = readFileSync17(file, "utf8");
5116
5687
  } catch {
5117
5688
  continue;
5118
5689
  }
5119
5690
  locales.push(locale);
5120
- const others = readdirSync8(join10(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5691
+ const others = readdirSync9(join12(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5121
5692
  if (others.length) {
5122
5693
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5123
5694
  }
@@ -5132,8 +5703,8 @@ var init_apple_strings2 = __esm({
5132
5703
  });
5133
5704
 
5134
5705
  // src/server/import/parsers/angular-xliff.ts
5135
- import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
5136
- import { join as join11 } from "path";
5706
+ import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5707
+ import { join as join13 } from "path";
5137
5708
  function decodeEntities(s) {
5138
5709
  return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5139
5710
  }
@@ -5182,11 +5753,11 @@ function decodeInline(raw, addMeta) {
5182
5753
  }
5183
5754
  return out + decodeEntities(raw.slice(last));
5184
5755
  }
5185
- var LOCALE_RE5, FILE_RE, ANGULAR_CONVENTION_ID, angularXliff2;
5756
+ var LOCALE_RE6, FILE_RE, ANGULAR_CONVENTION_ID, angularXliff2;
5186
5757
  var init_angular_xliff2 = __esm({
5187
5758
  "src/server/import/parsers/angular-xliff.ts"() {
5188
5759
  "use strict";
5189
- LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5760
+ LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5190
5761
  FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
5191
5762
  ANGULAR_CONVENTION_ID = /^[A-Z][A-Z0-9_]*$/;
5192
5763
  angularXliff2 = {
@@ -5198,13 +5769,13 @@ var init_angular_xliff2 = __esm({
5198
5769
  const seen = (loc) => {
5199
5770
  if (!locales.includes(loc)) locales.push(loc);
5200
5771
  };
5201
- const files = readdirSync9(localeRoot).filter((f) => FILE_RE.test(f)).sort((a, b) => (a === "messages.xlf" ? -1 : 0) - (b === "messages.xlf" ? -1 : 0) || a.localeCompare(b));
5772
+ const files = readdirSync10(localeRoot).filter((f) => FILE_RE.test(f)).sort((a, b) => (a === "messages.xlf" ? -1 : 0) - (b === "messages.xlf" ? -1 : 0) || a.localeCompare(b));
5202
5773
  for (const file of files) {
5203
5774
  const fnameLocale = file.match(FILE_RE)[1];
5204
- if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
5775
+ if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5205
5776
  let xml;
5206
5777
  try {
5207
- xml = readFileSync16(join11(localeRoot, file), "utf8");
5778
+ xml = readFileSync18(join13(localeRoot, file), "utf8");
5208
5779
  } catch (e) {
5209
5780
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5210
5781
  continue;
@@ -5251,8 +5822,8 @@ var init_angular_xliff2 = __esm({
5251
5822
  });
5252
5823
 
5253
5824
  // src/server/import/parsers/gettext-po.ts
5254
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5255
- import { join as join12 } from "path";
5825
+ import { readdirSync as readdirSync11, readFileSync as readFileSync19 } from "fs";
5826
+ import { join as join14 } from "path";
5256
5827
  function unescapePo(s) {
5257
5828
  return s.replace(
5258
5829
  /\\([\\"ntr])/g,
@@ -5337,33 +5908,33 @@ function parseEntries(text) {
5337
5908
  }
5338
5909
  function discoverPoFiles(root) {
5339
5910
  const found = [];
5340
- const entries = readdirSync10(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
5911
+ const entries = readdirSync11(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
5341
5912
  for (const e of entries) {
5342
5913
  if (e.isFile() && e.name.endsWith(".po")) {
5343
5914
  const base = e.name.slice(0, -3);
5344
- found.push({ path: join12(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
5345
- } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
5346
- for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
5915
+ found.push({ path: join14(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5916
+ } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5917
+ for (const sub of [join14(e.name, "LC_MESSAGES"), e.name]) {
5347
5918
  let names;
5348
5919
  try {
5349
- names = readdirSync10(join12(root, sub)).sort();
5920
+ names = readdirSync11(join14(root, sub)).sort();
5350
5921
  } catch {
5351
5922
  continue;
5352
5923
  }
5353
5924
  for (const f of names) {
5354
- if (f.endsWith(".po")) found.push({ path: join12(root, sub, f), rel: join12(sub, f), locale: e.name });
5925
+ if (f.endsWith(".po")) found.push({ path: join14(root, sub, f), rel: join14(sub, f), locale: e.name });
5355
5926
  }
5356
5927
  }
5357
5928
  }
5358
5929
  }
5359
5930
  return found;
5360
5931
  }
5361
- var LOCALE_RE6, DIRECTIVE_RE, CONT_RE, gettextPo2;
5932
+ var LOCALE_RE7, DIRECTIVE_RE, CONT_RE, gettextPo2;
5362
5933
  var init_gettext_po2 = __esm({
5363
5934
  "src/server/import/parsers/gettext-po.ts"() {
5364
5935
  "use strict";
5365
5936
  init_plurals();
5366
- LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5937
+ LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5367
5938
  DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
5368
5939
  CONT_RE = /^[ \t]*"(.*)"\s*$/;
5369
5940
  gettextPo2 = {
@@ -5375,7 +5946,7 @@ var init_gettext_po2 = __esm({
5375
5946
  for (const file of discoverPoFiles(localeRoot)) {
5376
5947
  let entries;
5377
5948
  try {
5378
- entries = parseEntries(readFileSync17(file.path, "utf8"));
5949
+ entries = parseEntries(readFileSync19(file.path, "utf8"));
5379
5950
  } catch (e) {
5380
5951
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5381
5952
  continue;
@@ -5422,8 +5993,8 @@ var init_gettext_po2 = __esm({
5422
5993
  });
5423
5994
 
5424
5995
  // src/server/import/parsers/i18next-json.ts
5425
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync7 } from "fs";
5426
- import { join as join13 } from "path";
5996
+ import { readdirSync as readdirSync12, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5997
+ import { join as join15 } from "path";
5427
5998
  function safeIsDir2(p) {
5428
5999
  try {
5429
6000
  return statSync7(p).isDirectory();
@@ -5438,7 +6009,7 @@ function fromI18next(value) {
5438
6009
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5439
6010
  let data;
5440
6011
  try {
5441
- data = JSON.parse(readFileSync18(path, "utf8"));
6012
+ data = JSON.parse(readFileSync20(path, "utf8"));
5442
6013
  } catch (e) {
5443
6014
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5444
6015
  return false;
@@ -5473,14 +6044,14 @@ function ingestFile(path, label, prefix, locale, keys, warnings) {
5473
6044
  }
5474
6045
  return true;
5475
6046
  }
5476
- var LOCALE_RE7, PLURAL_SUFFIX_RE, PLURAL_ARG, DEFAULT_NAMESPACE, i18nextJson2;
6047
+ var LOCALE_RE8, PLURAL_SUFFIX_RE, PLURAL_ARG, DEFAULT_NAMESPACE, i18nextJson2;
5477
6048
  var init_i18next_json2 = __esm({
5478
6049
  "src/server/import/parsers/i18next-json.ts"() {
5479
6050
  "use strict";
5480
6051
  init_flatten();
5481
6052
  init_plurals();
5482
6053
  init_placeholders();
5483
- LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
6054
+ LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5484
6055
  PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
5485
6056
  PLURAL_ARG = "count";
5486
6057
  DEFAULT_NAMESPACE = "translation";
@@ -5490,22 +6061,22 @@ var init_i18next_json2 = __esm({
5490
6061
  const warnings = [];
5491
6062
  const keys = {};
5492
6063
  const locales = [];
5493
- for (const entry of readdirSync11(localeRoot).sort()) {
5494
- const full = join13(localeRoot, entry);
6064
+ for (const entry of readdirSync12(localeRoot).sort()) {
6065
+ const full = join15(localeRoot, entry);
5495
6066
  if (safeIsDir2(full)) {
5496
- if (!LOCALE_RE7.test(entry)) continue;
6067
+ if (!LOCALE_RE8.test(entry)) continue;
5497
6068
  if (opts?.locales && !opts.locales.includes(entry)) continue;
5498
6069
  let any = false;
5499
- for (const file of readdirSync11(full).sort()) {
6070
+ for (const file of readdirSync12(full).sort()) {
5500
6071
  if (!file.endsWith(".json")) continue;
5501
6072
  const ns = file.slice(0, -".json".length);
5502
6073
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5503
- if (ingestFile(join13(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6074
+ if (ingestFile(join15(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5504
6075
  }
5505
6076
  if (any && !locales.includes(entry)) locales.push(entry);
5506
6077
  } else if (entry.endsWith(".json")) {
5507
6078
  const locale = entry.slice(0, -".json".length);
5508
- if (!LOCALE_RE7.test(locale)) continue;
6079
+ if (!LOCALE_RE8.test(locale)) continue;
5509
6080
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5510
6081
  if (ingestFile(full, entry, "", locale, keys, warnings) && !locales.includes(locale)) {
5511
6082
  locales.push(locale);
@@ -5519,8 +6090,8 @@ var init_i18next_json2 = __esm({
5519
6090
  });
5520
6091
 
5521
6092
  // src/server/import/parsers/rails-yaml.ts
5522
- import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5523
- import { join as join14 } from "path";
6093
+ import { readdirSync as readdirSync13, readFileSync as readFileSync21 } from "fs";
6094
+ import { join as join16 } from "path";
5524
6095
  function makeNode() {
5525
6096
  return /* @__PURE__ */ Object.create(null);
5526
6097
  }
@@ -5710,13 +6281,13 @@ function synthesizeIcu(forms, file, key, warnings) {
5710
6281
  }
5711
6282
  return `{count, plural, ${parts.join(" ")}}`;
5712
6283
  }
5713
- var LOCALE_RE8, CATEGORY_SET, railsYaml2;
6284
+ var LOCALE_RE9, CATEGORY_SET, railsYaml2;
5714
6285
  var init_rails_yaml2 = __esm({
5715
6286
  "src/server/import/parsers/rails-yaml.ts"() {
5716
6287
  "use strict";
5717
6288
  init_schema();
5718
6289
  init_placeholders2();
5719
- LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
6290
+ LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5720
6291
  CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5721
6292
  railsYaml2 = {
5722
6293
  name: "rails-yaml",
@@ -5740,18 +6311,18 @@ var init_rails_yaml2 = __esm({
5740
6311
  else flatten(v, key, locale, file);
5741
6312
  }
5742
6313
  };
5743
- for (const file of readdirSync12(localeRoot).sort()) {
6314
+ for (const file of readdirSync13(localeRoot).sort()) {
5744
6315
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5745
6316
  let text;
5746
6317
  try {
5747
- text = readFileSync19(join14(localeRoot, file), "utf8");
6318
+ text = readFileSync21(join16(localeRoot, file), "utf8");
5748
6319
  } catch (e) {
5749
6320
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5750
6321
  continue;
5751
6322
  }
5752
6323
  const { roots } = parseYamlSubset(text, file, warnings);
5753
6324
  for (const token of Object.keys(roots).sort()) {
5754
- if (!LOCALE_RE8.test(token)) {
6325
+ if (!LOCALE_RE9.test(token)) {
5755
6326
  warnings.push(`rails-yaml: ${file}: top-level key "${token}" is not a locale; subtree skipped`);
5756
6327
  continue;
5757
6328
  }
@@ -5767,12 +6338,12 @@ var init_rails_yaml2 = __esm({
5767
6338
  });
5768
6339
 
5769
6340
  // src/server/import/parsers/apple-stringsdict.ts
5770
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync8 } from "fs";
5771
- import { join as join15 } from "path";
6341
+ import { readdirSync as readdirSync14, readFileSync as readFileSync22, statSync as statSync8 } from "fs";
6342
+ import { join as join17 } from "path";
5772
6343
  function localeFromLproj2(dir) {
5773
6344
  const m = dir.match(/^(.+)\.lproj$/);
5774
6345
  if (!m) return null;
5775
- return LOCALE_RE9.test(m[1]) ? m[1] : null;
6346
+ return LOCALE_RE10.test(m[1]) ? m[1] : null;
5776
6347
  }
5777
6348
  function decodeEntities2(s) {
5778
6349
  return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
@@ -5909,13 +6480,13 @@ function entryToIcu(key, entry, file, warnings) {
5909
6480
  if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5910
6481
  return formsToIcu(arg, forms);
5911
6482
  }
5912
- var LOCALE_RE9, TABLE2, VAR_RE, appleStringsdict2;
6483
+ var LOCALE_RE10, TABLE2, VAR_RE, appleStringsdict2;
5913
6484
  var init_apple_stringsdict2 = __esm({
5914
6485
  "src/server/import/parsers/apple-stringsdict.ts"() {
5915
6486
  "use strict";
5916
6487
  init_schema();
5917
6488
  init_plurals();
5918
- LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
6489
+ LOCALE_RE10 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5919
6490
  TABLE2 = "Localizable.stringsdict";
5920
6491
  VAR_RE = /%#@([^@]*)@/g;
5921
6492
  appleStringsdict2 = {
@@ -5924,20 +6495,20 @@ var init_apple_stringsdict2 = __esm({
5924
6495
  const warnings = [];
5925
6496
  const keys = {};
5926
6497
  const locales = [];
5927
- for (const dir of readdirSync13(localeRoot).sort()) {
6498
+ for (const dir of readdirSync14(localeRoot).sort()) {
5928
6499
  const locale = localeFromLproj2(dir);
5929
6500
  if (!locale) continue;
5930
6501
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5931
- const file = join15(localeRoot, dir, TABLE2);
6502
+ const file = join17(localeRoot, dir, TABLE2);
5932
6503
  let text;
5933
6504
  try {
5934
6505
  if (!statSync8(file).isFile()) continue;
5935
- text = readFileSync20(file, "utf8");
6506
+ text = readFileSync22(file, "utf8");
5936
6507
  } catch {
5937
6508
  continue;
5938
6509
  }
5939
6510
  locales.push(locale);
5940
- const others = readdirSync13(join15(localeRoot, dir)).filter(
6511
+ const others = readdirSync14(join17(localeRoot, dir)).filter(
5941
6512
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5942
6513
  );
5943
6514
  if (others.length) {
@@ -5975,6 +6546,7 @@ var init_parsers = __esm({
5975
6546
  "src/server/import/parsers/index.ts"() {
5976
6547
  "use strict";
5977
6548
  init_vue_i18n_json2();
6549
+ init_next_intl_json2();
5978
6550
  init_laravel_php2();
5979
6551
  init_flutter_arb2();
5980
6552
  init_apple_strings2();
@@ -5985,6 +6557,7 @@ var init_parsers = __esm({
5985
6557
  init_apple_stringsdict2();
5986
6558
  REGISTRY = {
5987
6559
  [vueI18nJson2.name]: vueI18nJson2,
6560
+ [nextIntlJson2.name]: nextIntlJson2,
5988
6561
  [laravelPhp2.name]: laravelPhp2,
5989
6562
  [flutterArb2.name]: flutterArb2,
5990
6563
  [appleStrings2.name]: appleStrings2,
@@ -6409,7 +6982,7 @@ var init_run2 = __esm({
6409
6982
  });
6410
6983
 
6411
6984
  // src/server/lint/outputs.ts
6412
- import { readFileSync as readFileSync21, existsSync as existsSync12 } from "fs";
6985
+ import { readFileSync as readFileSync23, existsSync as existsSync12 } from "fs";
6413
6986
  import { resolve as resolve8 } from "path";
6414
6987
  function checkOutputs(state, root) {
6415
6988
  const out = [];
@@ -6417,7 +6990,7 @@ function checkOutputs(state, root) {
6417
6990
  const result = getAdapter(output.adapter).export(state, output);
6418
6991
  for (const file of result.files) {
6419
6992
  const abs = resolve8(root, file.path);
6420
- const current = existsSync12(abs) ? readFileSync21(abs, "utf8") : null;
6993
+ const current = existsSync12(abs) ? readFileSync23(abs, "utf8") : null;
6421
6994
  if (current === null) {
6422
6995
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
6423
6996
  } else if (current !== file.contents) {
@@ -6525,6 +7098,7 @@ function assemble2(parsed, opts) {
6525
7098
  spelling: { customWords: [] }
6526
7099
  },
6527
7100
  glossary: [],
7101
+ glossarySuggestions: [],
6528
7102
  keys,
6529
7103
  warnings
6530
7104
  };
@@ -6541,6 +7115,9 @@ var init_assemble = __esm({
6541
7115
  OUTPUT_BY_FORMAT = {
6542
7116
  "laravel-php": { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" },
6543
7117
  "vue-i18n-json": { adapter: "vue-i18n-json", path: "src/locale/{locale}.json" },
7118
+ // rootRelative: write back to wherever the messages dir was found (messages/,
7119
+ // src/messages/, …) rather than assuming the conventional root-level location.
7120
+ "next-intl-json": { adapter: "next-intl-json", path: "{locale}.json", rootRelative: true },
6544
7121
  "flutter-arb": { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
6545
7122
  "apple-strings": { adapter: "apple-strings", path: "{locale}.lproj/Localizable.strings", rootRelative: true },
6546
7123
  // skipSourceLocale: ng extract-i18n owns messages.xlf (the source file); glotfile
@@ -6865,13 +7442,63 @@ var init_checks = __esm({
6865
7442
  }
6866
7443
  });
6867
7444
 
7445
+ // src/server/ai/explain-error.ts
7446
+ function rawMessage(err) {
7447
+ if (err instanceof Error && err.message) return err.message;
7448
+ if (typeof err === "string") return err;
7449
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
7450
+ return err.message;
7451
+ }
7452
+ return String(err ?? "Unknown error");
7453
+ }
7454
+ function explainProviderError(provider, err) {
7455
+ const raw = rawMessage(err);
7456
+ const m = raw.toLowerCase();
7457
+ if (provider === "bedrock") {
7458
+ if (/could not load credentials|unable to locate credentials|credentialsprovider|credentials from any providers/.test(m)) {
7459
+ return "No AWS credentials found. Set AWS_PROFILE (or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) in your shell or a .env file in the directory you started glotfile from, or use an SSO / instance role. If you just edited .env, restart glotfile so it reloads. For SSO, run `aws sso login`.";
7460
+ }
7461
+ if (/on-demand throughput isn.?t supported/.test(m) || /inference profile/.test(m)) {
7462
+ return 'This Bedrock model needs an inference profile for on-demand use. Prefix the model id with your region group \u2014 e.g. "eu.anthropic.claude-3-5-sonnet-20241022-v2:0" (or "us." / "apac." for your region).';
7463
+ }
7464
+ if (/not authorized to perform/.test(m) || /no identity-based policy/.test(m) || /bedrock:invoke/.test(m)) {
7465
+ return "Your AWS credentials authenticated, but their IAM policy doesn't allow this action. Add bedrock:InvokeModel (and bedrock:InvokeModelWithResponseStream) for this model to the IAM policy on this user/role.";
7466
+ }
7467
+ if (/access to the model|don.?t have access to the model/.test(m)) {
7468
+ return "Your account doesn't have access to this model in this region. Enable it in the Bedrock console under Model access, for the region you configured.";
7469
+ }
7470
+ if (/access ?denied/.test(m)) {
7471
+ return "Bedrock denied access. Either the model isn't enabled for your account/region (enable it in the Bedrock console under Model access) or your IAM policy is missing bedrock:InvokeModel for this model.";
7472
+ }
7473
+ if (/region/.test(m)) {
7474
+ return "No AWS region set for Bedrock. Set the Region in AI settings, or AWS_REGION in your environment.";
7475
+ }
7476
+ }
7477
+ const keyEnv = KEY_ENV[provider];
7478
+ if (keyEnv && /api key|unauthorized|\b401\b|authentication|incorrect api key|invalid x-api-key/.test(m)) {
7479
+ return `${provider} rejected the request \u2014 check ${keyEnv}. Set it in your environment or a .env file in the directory you started glotfile from.`;
7480
+ }
7481
+ return raw;
7482
+ }
7483
+ var KEY_ENV;
7484
+ var init_explain_error = __esm({
7485
+ "src/server/ai/explain-error.ts"() {
7486
+ "use strict";
7487
+ KEY_ENV = {
7488
+ anthropic: "ANTHROPIC_API_KEY",
7489
+ openai: "OPENAI_API_KEY",
7490
+ openrouter: "OPENROUTER_API_KEY"
7491
+ };
7492
+ }
7493
+ });
7494
+
6868
7495
  // src/server/ui-prefs.ts
6869
- import { readFileSync as readFileSync22 } from "fs";
6870
- import { homedir } from "os";
6871
- import { join as join16 } from "path";
7496
+ import { readFileSync as readFileSync24 } from "fs";
7497
+ import { homedir as homedir2 } from "os";
7498
+ import { join as join18 } from "path";
6872
7499
  function readJson2(path) {
6873
7500
  try {
6874
- const parsed = JSON.parse(readFileSync22(path, "utf8"));
7501
+ const parsed = JSON.parse(readFileSync24(path, "utf8"));
6875
7502
  return parsed && typeof parsed === "object" ? parsed : {};
6876
7503
  } catch {
6877
7504
  return {};
@@ -6896,7 +7523,7 @@ var init_ui_prefs = __esm({
6896
7523
  THEMES = ["system", "light", "dark"];
6897
7524
  isThemeMode = (v) => THEMES.includes(v);
6898
7525
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
6899
- defaultUiPrefsPath = () => join16(homedir(), ".glotfile", "ui.json");
7526
+ defaultUiPrefsPath = () => join18(homedir2(), ".glotfile", "ui.json");
6900
7527
  DEFAULTS = { theme: "system" };
6901
7528
  }
6902
7529
  });
@@ -6929,8 +7556,8 @@ var init_events = __esm({
6929
7556
  });
6930
7557
 
6931
7558
  // src/server/watch.ts
6932
- import { statSync as statSync9, readdirSync as readdirSync14 } from "fs";
6933
- import { join as join17 } from "path";
7559
+ import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
7560
+ import { join as join19 } from "path";
6934
7561
  import { createHash as createHash2 } from "crypto";
6935
7562
  function hashState(state) {
6936
7563
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -6946,15 +7573,15 @@ function signature(statePath) {
6946
7573
  const parts = [];
6947
7574
  for (const rel of ["config.json", "keys.json"]) {
6948
7575
  try {
6949
- const s = statSync9(join17(dir, rel));
7576
+ const s = statSync9(join19(dir, rel));
6950
7577
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6951
7578
  } catch {
6952
7579
  }
6953
7580
  }
6954
7581
  try {
6955
- for (const name of readdirSync14(join17(dir, "locales")).sort()) {
7582
+ for (const name of readdirSync15(join19(dir, "locales")).sort()) {
6956
7583
  if (!name.endsWith(".json")) continue;
6957
- const s = statSync9(join17(dir, "locales", name));
7584
+ const s = statSync9(join19(dir, "locales", name));
6958
7585
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6959
7586
  }
6960
7587
  } catch {
@@ -7033,34 +7660,19 @@ var init_watch = __esm({
7033
7660
  // src/server/api.ts
7034
7661
  import { Hono } from "hono";
7035
7662
  import { streamSSE } from "hono/streaming";
7036
- import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync10, rmSync as rmSync6 } from "fs";
7663
+ import { readFileSync as readFileSync25, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
7037
7664
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
7038
7665
  function projectName(root) {
7039
7666
  const nameFile = resolve9(root, ".idea", ".name");
7040
7667
  if (existsSync13(nameFile)) {
7041
7668
  try {
7042
- const name = readFileSync23(nameFile, "utf8").trim();
7669
+ const name = readFileSync25(nameFile, "utf8").trim();
7043
7670
  if (name) return name;
7044
7671
  } catch {
7045
7672
  }
7046
7673
  }
7047
7674
  return basename(root);
7048
7675
  }
7049
- function attachUsageSnippets(targets, cache2, projectRoot) {
7050
- const fileCache = /* @__PURE__ */ new Map();
7051
- for (const target of targets) {
7052
- const allRefs = Object.entries(cache2.files).flatMap(
7053
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
7054
- key: r.key,
7055
- file,
7056
- line: r.line,
7057
- col: r.col,
7058
- scanner: r.scanner
7059
- }))
7060
- );
7061
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
7062
- }
7063
- }
7064
7676
  function createApi(deps) {
7065
7677
  const app = new Hono();
7066
7678
  const load = () => loadState(deps.statePath);
@@ -7192,6 +7804,61 @@ function createApi(deps) {
7192
7804
  }
7193
7805
  return c.json({ ok: true });
7194
7806
  });
7807
+ app.post("/ai-test", async (c) => {
7808
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7809
+ const meta = { provider: aiCfg.provider, model: aiCfg.model };
7810
+ let provider;
7811
+ try {
7812
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7813
+ } catch (e) {
7814
+ return c.json({ ok: false, ...meta, error: explainProviderError(aiCfg.provider, e) });
7815
+ }
7816
+ const controller = new AbortController();
7817
+ const timer = setTimeout(() => controller.abort(), 3e4);
7818
+ try {
7819
+ const probe = {
7820
+ id: "probe",
7821
+ key: "glotfile.connection-test",
7822
+ source: "Hello",
7823
+ sourceLocale: "en",
7824
+ targetLocale: "es",
7825
+ placeholders: []
7826
+ };
7827
+ await provider.translate([probe], void 0, controller.signal);
7828
+ return c.json({ ok: true, ...meta });
7829
+ } catch (e) {
7830
+ const error = controller.signal.aborted ? "Connection test timed out after 30s \u2014 the provider didn't respond." : explainProviderError(aiCfg.provider, e);
7831
+ return c.json({ ok: false, ...meta, error });
7832
+ } finally {
7833
+ clearTimeout(timer);
7834
+ }
7835
+ });
7836
+ app.get("/prices", (c) => {
7837
+ const cache2 = loadPriceCache();
7838
+ const ai = loadLocalSettings(projectRoot).ai;
7839
+ const pricing = resolvePricing(ai, cache2);
7840
+ return c.json({
7841
+ source: cache2?.source ?? null,
7842
+ fetchedAt: cache2?.fetchedAt ?? null,
7843
+ modelCount: cache2 ? Object.keys(cache2.models).length : 0,
7844
+ path: defaultPriceCachePath(),
7845
+ resolved: pricing ? { provider: ai.provider, model: ai.model, ...pricing } : null
7846
+ });
7847
+ });
7848
+ app.get("/prices/list", (c) => {
7849
+ const cache2 = loadPriceCache();
7850
+ const models = cache2 ? Object.entries(cache2.models).map(([id, p]) => ({ id, ...p })).sort((a, b) => a.id.localeCompare(b.id)) : [];
7851
+ return c.json({ source: cache2?.source ?? null, fetchedAt: cache2?.fetchedAt ?? null, models });
7852
+ });
7853
+ app.post("/prices/refresh", async (c) => {
7854
+ try {
7855
+ const res = await refreshPrices();
7856
+ invalidatePriceCache();
7857
+ return c.json({ ok: true, ...res });
7858
+ } catch (e) {
7859
+ return c.json({ error: e.message }, 502);
7860
+ }
7861
+ });
7195
7862
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
7196
7863
  app.get("/files", (c) => {
7197
7864
  const found = /* @__PURE__ */ new Map();
@@ -7205,7 +7872,7 @@ function createApi(deps) {
7205
7872
  if (depth > 4) return;
7206
7873
  let entries = [];
7207
7874
  try {
7208
- entries = readdirSync15(dir);
7875
+ entries = readdirSync16(dir);
7209
7876
  } catch {
7210
7877
  return;
7211
7878
  }
@@ -7556,6 +8223,90 @@ function createApi(deps) {
7556
8223
  logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
7557
8224
  return c.json({ ok: true });
7558
8225
  });
8226
+ app.get("/glossary/suggestions", (c) => {
8227
+ const s = load();
8228
+ const pending = s.glossarySuggestions.filter((x) => x.status === "pending");
8229
+ return c.json(pending.map((x) => ({
8230
+ ...x,
8231
+ occurrences: sourceKeysForTerm(s, x.term, { caseSensitive: x.caseSensitive, wholeWord: x.wholeWord }).length
8232
+ })));
8233
+ });
8234
+ app.post("/glossary/suggestions/dismiss", async (c) => {
8235
+ const { term } = await c.req.json();
8236
+ if (typeof term !== "string") return c.json({ error: "term must be a string" }, 400);
8237
+ const s = load();
8238
+ dismissGlossarySuggestion(s, term);
8239
+ persist(s);
8240
+ logChange({ kind: "glossary", summary: `Dismissed suggested term "${term}"` });
8241
+ return c.json({ ok: true });
8242
+ });
8243
+ app.delete("/glossary/suggestions/:term", (c) => {
8244
+ const s = load();
8245
+ const term = decodeURIComponent(c.req.param("term"));
8246
+ removeGlossarySuggestion(s, term);
8247
+ persist(s);
8248
+ return c.json({ ok: true });
8249
+ });
8250
+ app.post("/glossary/suggest", async (c) => {
8251
+ const signal = c.req.raw.signal;
8252
+ const body = await c.req.json().catch(() => ({}));
8253
+ return streamSSE(c, async (stream) => {
8254
+ const s0 = load();
8255
+ const sources = selectGlossarySources(s0, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8256
+ if (!sources.length) {
8257
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: 0, terms: [] }) });
8258
+ return;
8259
+ }
8260
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8261
+ let provider;
8262
+ try {
8263
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8264
+ } catch (e) {
8265
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8266
+ return;
8267
+ }
8268
+ const known = knownTermList(s0);
8269
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: sources.length }) });
8270
+ const system = buildGlossarySuggestSystemPrompt();
8271
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8272
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
8273
+ const chunks = [];
8274
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
8275
+ const all = [];
8276
+ let done = 0;
8277
+ let next = 0;
8278
+ async function worker() {
8279
+ while (next < chunks.length) {
8280
+ if (signal?.aborted) break;
8281
+ const chunkRows = chunks[next++];
8282
+ try {
8283
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
8284
+ all.push(...raw.terms ?? []);
8285
+ } catch (e) {
8286
+ void stream.writeSSE({ event: "warn", data: JSON.stringify({ error: e.message }) });
8287
+ }
8288
+ done += chunkRows.length;
8289
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done, total: sources.length }) });
8290
+ }
8291
+ }
8292
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
8293
+ if (signal?.aborted) return;
8294
+ const fresh = load();
8295
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(all));
8296
+ const usage = provider.takeUsage?.();
8297
+ persist(fresh);
8298
+ appendLog(projectRoot, {
8299
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8300
+ kind: "glossary",
8301
+ summary: `Suggested ${added.length} glossary term(s)`,
8302
+ model: aiCfg.model,
8303
+ system,
8304
+ usage,
8305
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8306
+ });
8307
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
8308
+ });
8309
+ });
7559
8310
  app.post("/keys/:key/screenshot", async (c) => {
7560
8311
  const key = c.req.param("key");
7561
8312
  const body = await c.req.parseBody();
@@ -7741,7 +8492,7 @@ function createApi(deps) {
7741
8492
  try {
7742
8493
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7743
8494
  } catch (e) {
7744
- await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8495
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7745
8496
  return;
7746
8497
  }
7747
8498
  const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
@@ -7758,58 +8509,65 @@ function createApi(deps) {
7758
8509
  event: "start",
7759
8510
  data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
7760
8511
  });
7761
- await runLocaleParallel(reqs, provider, {
7762
- // Announce a language the moment a worker picks it up — this is the
7763
- // signal that "something is happening" during the long first LLM call.
7764
- onLocaleStart: (locale) => {
7765
- void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
7766
- },
7767
- onBatchComplete: (done, total, batchResults, locale) => {
7768
- const fresh = load();
7769
- const { written, errors } = applyResults(fresh, reqs, batchResults);
7770
- persist(fresh);
7771
- totalWritten += written;
7772
- allErrors.push(...errors);
7773
- const usage = provider.takeUsage?.();
7774
- appendLog(projectRoot, {
7775
- at: (/* @__PURE__ */ new Date()).toISOString(),
7776
- kind: "translate",
7777
- summary: `Translated ${batchResults.length} item(s)`,
7778
- model: aiCfg.model,
7779
- system,
7780
- items: batchResults.map((r) => {
7781
- const req = reqById.get(r.id);
7782
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
7783
- }),
7784
- results: batchResults,
7785
- usage,
7786
- estimatedCostUsd: usageCostUsd(usage, aiCfg)
7787
- });
7788
- const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
7789
- localeDone.set(locale, ld);
7790
- console.log(`[translate] ${done}/${total}`);
7791
- void stream.writeSSE({
7792
- event: "progress",
7793
- data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
7794
- });
7795
- },
7796
- onLocaleDone: (locale) => {
7797
- void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
7798
- },
7799
- // Record the raw reply so an unparseable model response is diagnosable
7800
- // from the activity log instead of vanishing into per-item errors.
7801
- onMalformedReply: (raw, batchSize, locale) => {
7802
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7803
- appendLog(projectRoot, {
7804
- at: (/* @__PURE__ */ new Date()).toISOString(),
7805
- kind: "translate",
7806
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7807
- model: aiCfg.model,
7808
- locale,
7809
- raw
7810
- });
8512
+ try {
8513
+ await runLocaleParallel(reqs, provider, {
8514
+ // Announce a language the moment a worker picks it up — this is the
8515
+ // signal that "something is happening" during the long first LLM call.
8516
+ onLocaleStart: (locale) => {
8517
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
8518
+ },
8519
+ onBatchComplete: (done, total, batchResults, locale) => {
8520
+ const fresh = load();
8521
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
8522
+ persist(fresh);
8523
+ totalWritten += written;
8524
+ allErrors.push(...errors);
8525
+ const usage = provider.takeUsage?.();
8526
+ appendLog(projectRoot, {
8527
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8528
+ kind: "translate",
8529
+ summary: `Translated ${batchResults.length} item(s)`,
8530
+ model: aiCfg.model,
8531
+ system,
8532
+ items: batchResults.map((r) => {
8533
+ const req = reqById.get(r.id);
8534
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
8535
+ }),
8536
+ results: batchResults,
8537
+ usage,
8538
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8539
+ });
8540
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
8541
+ localeDone.set(locale, ld);
8542
+ console.log(`[translate] ${done}/${total}`);
8543
+ void stream.writeSSE({
8544
+ event: "progress",
8545
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
8546
+ });
8547
+ },
8548
+ onLocaleDone: (locale) => {
8549
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
8550
+ },
8551
+ // Record the raw reply so an unparseable model response is diagnosable
8552
+ // from the activity log instead of vanishing into per-item errors.
8553
+ onMalformedReply: (raw, batchSize, locale) => {
8554
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8555
+ appendLog(projectRoot, {
8556
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8557
+ kind: "translate",
8558
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8559
+ model: aiCfg.model,
8560
+ locale,
8561
+ raw
8562
+ });
8563
+ }
8564
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
8565
+ } catch (e) {
8566
+ if (!signal?.aborted) {
8567
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7811
8568
  }
7812
- }, aiCfg.concurrency, signal, aiCfg.batchSize);
8569
+ return;
8570
+ }
7813
8571
  if (!signal?.aborted) {
7814
8572
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
7815
8573
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -7836,23 +8594,28 @@ function createApi(deps) {
7836
8594
  try {
7837
8595
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7838
8596
  } catch (e) {
7839
- return c.json({ error: e.message }, 400);
8597
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 400);
7840
8598
  }
7841
8599
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
7842
8600
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
7843
- const results = await runLocaleParallel(toTranslate, provider, {
7844
- onMalformedReply: (raw, batchSize, locale) => {
7845
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7846
- appendLog(projectRoot, {
7847
- at: (/* @__PURE__ */ new Date()).toISOString(),
7848
- kind: "translate",
7849
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7850
- model: aiCfg.model,
7851
- locale,
7852
- raw
7853
- });
7854
- }
7855
- }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8601
+ let results;
8602
+ try {
8603
+ results = await runLocaleParallel(toTranslate, provider, {
8604
+ onMalformedReply: (raw, batchSize, locale) => {
8605
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8606
+ appendLog(projectRoot, {
8607
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8608
+ kind: "translate",
8609
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8610
+ model: aiCfg.model,
8611
+ locale,
8612
+ raw
8613
+ });
8614
+ }
8615
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8616
+ } catch (e) {
8617
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 502);
8618
+ }
7856
8619
  const latest = load();
7857
8620
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
7858
8621
  const usage = provider.takeUsage?.();
@@ -8123,6 +8886,22 @@ function createApi(deps) {
8123
8886
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
8124
8887
  });
8125
8888
  });
8889
+ app.post("/context/estimate", async (c) => {
8890
+ const body = await c.req.json().catch(() => ({}));
8891
+ const cache2 = loadUsageCache(projectRoot);
8892
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
8893
+ const targets = selectContextTargets(load(), {
8894
+ all: body.all,
8895
+ keyGlob: body.keyGlob,
8896
+ limit: body.limit,
8897
+ since: body.since,
8898
+ keys: body.keys,
8899
+ force: body.force
8900
+ }, cache2, body.lastRunAt);
8901
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8902
+ attachUsageSnippets(targets, cache2, projectRoot);
8903
+ return c.json(estimateContext(targets, aiCfg));
8904
+ });
8126
8905
  app.get("/context/batch/status", async (c) => {
8127
8906
  const aiCfg = loadLocalSettings(projectRoot).ai;
8128
8907
  let supported = false;
@@ -8227,6 +9006,8 @@ var init_api = __esm({
8227
9006
  "src/server/api.ts"() {
8228
9007
  "use strict";
8229
9008
  init_state();
9009
+ init_glossary_suggest();
9010
+ init_glossary();
8230
9011
  init_accept();
8231
9012
  init_scan();
8232
9013
  init_scanner();
@@ -8240,12 +9021,15 @@ var init_api = __esm({
8240
9021
  init_ai();
8241
9022
  init_run();
8242
9023
  init_provider();
9024
+ init_explain_error();
8243
9025
  init_batch_run();
8244
9026
  init_pending_batch();
8245
9027
  init_context_batch_run();
8246
9028
  init_pending_context_batch();
8247
9029
  init_estimate();
8248
9030
  init_pricing();
9031
+ init_price_fetch();
9032
+ init_price_cache();
8249
9033
  init_log();
8250
9034
  init_schema();
8251
9035
  init_run3();
@@ -8270,7 +9054,7 @@ __export(server_exports, {
8270
9054
  import { Hono as Hono2 } from "hono";
8271
9055
  import { serve } from "@hono/node-server";
8272
9056
  import { fileURLToPath } from "url";
8273
- import { dirname as dirname4, join as join18, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
9057
+ import { dirname as dirname4, join as join20, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
8274
9058
  import { readFile, stat } from "fs/promises";
8275
9059
  import { createServer } from "net";
8276
9060
  import open from "open";
@@ -8326,7 +9110,7 @@ function buildApp(opts) {
8326
9110
  const file = await readFileResponse(target);
8327
9111
  if (file) return file;
8328
9112
  }
8329
- const index = await readFileResponse(join18(root, "index.html"));
9113
+ const index = await readFileResponse(join20(root, "index.html"));
8330
9114
  if (index) return index;
8331
9115
  return c.notFound();
8332
9116
  });
@@ -8396,7 +9180,7 @@ var init_server = __esm({
8396
9180
  init_scanner();
8397
9181
  init_usage();
8398
9182
  here = dirname4(fileURLToPath(import.meta.url));
8399
- DEFAULT_UI_DIR = join18(here, "..", "ui");
9183
+ DEFAULT_UI_DIR = join20(here, "..", "ui");
8400
9184
  MIME = {
8401
9185
  ".html": "text/html; charset=utf-8",
8402
9186
  ".js": "text/javascript; charset=utf-8",
@@ -8428,8 +9212,8 @@ var init_server = __esm({
8428
9212
  // src/server/cli.ts
8429
9213
  init_state();
8430
9214
  init_stats();
8431
- import { resolve as resolve11, dirname as dirname5, join as join19, basename as basename2 } from "path";
8432
- import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
9215
+ import { resolve as resolve11, dirname as dirname5, join as join21, basename as basename2 } from "path";
9216
+ import { readFileSync as readFileSync26, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
8433
9217
  import { fileURLToPath as fileURLToPath2 } from "url";
8434
9218
 
8435
9219
  // src/server/agent-cli.ts
@@ -8561,13 +9345,17 @@ init_pending_batch();
8561
9345
  init_context_batch_run();
8562
9346
  init_pending_context_batch();
8563
9347
  init_estimate();
9348
+ init_glossary_suggest();
8564
9349
  init_pricing();
9350
+ init_price_fetch();
9351
+ init_price_cache();
8565
9352
  init_log();
8566
9353
  init_scan();
8567
9354
  init_scanner();
8568
9355
  init_usage();
8569
9356
  init_context();
8570
9357
  init_run2();
9358
+ init_registry();
8571
9359
  init_outputs();
8572
9360
 
8573
9361
  // src/server/lint/locate.ts
@@ -8644,7 +9432,7 @@ function formatSarif(report, ctx) {
8644
9432
  }
8645
9433
 
8646
9434
  // src/server/cli.ts
8647
- var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "scan", "prune", "split", "skill", "batch", "get", "stats", "set", "set-state", "clear", "apply"];
9435
+ var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "suggest-glossary", "scan", "prune", "split", "skill", "batch", "prices", "get", "stats", "set", "set-state", "clear", "apply"];
8648
9436
  var isCommand = (s) => s != null && COMMANDS.includes(s);
8649
9437
  function parseArgs(argv) {
8650
9438
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -8720,6 +9508,7 @@ function parseArgs(argv) {
8720
9508
  else if (flag === "--batch") args.batch = true;
8721
9509
  else if (flag === "--wait") args.wait = true;
8722
9510
  else if (flag === "--print") args.print = true;
9511
+ else if (flag === "--refresh") args.refresh = true;
8723
9512
  else if (flag === "--state" && next) {
8724
9513
  args.states = next.split(",");
8725
9514
  i++;
@@ -8815,7 +9604,7 @@ function translateSelection(args) {
8815
9604
  }
8816
9605
  function readStdin() {
8817
9606
  try {
8818
- return readFileSync24(0, "utf8");
9607
+ return readFileSync26(0, "utf8");
8819
9608
  } catch {
8820
9609
  return "";
8821
9610
  }
@@ -9083,15 +9872,15 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
9083
9872
  function sarifContextFor(statePath) {
9084
9873
  if (detectFormat(statePath) === "split") {
9085
9874
  const dir = splitDirFor(statePath);
9086
- const keysPath = join19(dir, "keys.json");
9875
+ const keysPath = join21(dir, "keys.json");
9087
9876
  return {
9088
9877
  keysUri: `${basename2(dir)}/keys.json`,
9089
- keysRawText: existsSync14(keysPath) ? readFileSync24(keysPath, "utf8") : ""
9878
+ keysRawText: existsSync14(keysPath) ? readFileSync26(keysPath, "utf8") : ""
9090
9879
  };
9091
9880
  }
9092
9881
  return {
9093
9882
  keysUri: basename2(statePath),
9094
- keysRawText: existsSync14(statePath) ? readFileSync24(statePath, "utf8") : ""
9883
+ keysRawText: existsSync14(statePath) ? readFileSync26(statePath, "utf8") : ""
9095
9884
  };
9096
9885
  }
9097
9886
  function printReport(report, format, statePath) {
@@ -9100,6 +9889,18 @@ function printReport(report, format, statePath) {
9100
9889
  else console.log(formatText(report).trimEnd());
9101
9890
  }
9102
9891
  async function runLintCmd(args) {
9892
+ if (args.ruleIds) {
9893
+ const unknown = unknownRuleIds(args.ruleIds);
9894
+ if (unknown.length > 0) {
9895
+ for (const id of unknown) {
9896
+ const hint = suggestRuleId(id);
9897
+ console.error(`Unknown --rule '${id}'.${hint ? ` Did you mean '${hint}'?` : ""}`);
9898
+ }
9899
+ console.error(`Valid rules: ${RULE_IDS.join(", ")}.`);
9900
+ process.exitCode = 1;
9901
+ return;
9902
+ }
9903
+ }
9103
9904
  const state = loadState(args.statePath);
9104
9905
  if (args.accept) {
9105
9906
  const { acceptFindings: acceptFindings2 } = await Promise.resolve().then(() => (init_accept(), accept_exports));
@@ -9241,29 +10042,30 @@ async function runBuildContext(args) {
9241
10042
  console.log("No keys need context.");
9242
10043
  return;
9243
10044
  }
10045
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10046
+ attachUsageSnippets(targets, cache2, projectRoot);
10047
+ if (args.estimate) {
10048
+ const est = estimateContext(targets, aiCfg);
10049
+ const fmt = (n) => n.toLocaleString("en-US");
10050
+ console.log(`Estimate for ${fmt(est.keys)} key(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10051
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10052
+ if (est.pricing) {
10053
+ const cost = est.estimatedCost;
10054
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
10055
+ } else {
10056
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10057
+ }
10058
+ return;
10059
+ }
9244
10060
  let provider;
9245
10061
  try {
9246
- provider = makeProvider(loadLocalSettings(projectRoot).ai);
10062
+ provider = makeProvider(aiCfg);
9247
10063
  } catch (e) {
9248
10064
  console.error(e.message);
9249
10065
  process.exitCode = 1;
9250
10066
  return;
9251
10067
  }
9252
- const fileCache = /* @__PURE__ */ new Map();
9253
- for (const target of targets) {
9254
- const refs = Object.values(cache2.files).flatMap(
9255
- (f) => f.refs.filter((r) => r.key === target.key).map((r) => ({
9256
- key: r.key,
9257
- file: Object.keys(cache2.files).find((path) => cache2.files[path]?.refs.includes(r)) ?? "",
9258
- line: r.line,
9259
- col: r.col,
9260
- scanner: r.scanner
9261
- }))
9262
- );
9263
- target.usageSnippets = extractSnippets(refs, projectRoot, fileCache);
9264
- }
9265
10068
  const system = buildContextSystemPrompt();
9266
- const aiCfg = loadLocalSettings(projectRoot).ai;
9267
10069
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
9268
10070
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
9269
10071
  if (args.batch) {
@@ -9319,6 +10121,71 @@ async function runBuildContext(args) {
9319
10121
  console.log(`Wrote context for ${written} key(s).`);
9320
10122
  for (const e of errors) console.warn(`skip ${e.key}: ${e.error}`);
9321
10123
  }
10124
+ async function runSuggestGlossary(args) {
10125
+ const state = loadState(args.statePath);
10126
+ const projectRoot = dirname5(resolve11(args.statePath));
10127
+ const sources = selectGlossarySources(state, { keyGlob: args.keyGlob, limit: args.limit, since: args.since });
10128
+ if (!sources.length) {
10129
+ console.log("No source strings to scan.");
10130
+ return;
10131
+ }
10132
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10133
+ const known = knownTermList(state);
10134
+ if (args.estimate) {
10135
+ const est = estimateGlossarySuggest(sources, known, aiCfg);
10136
+ const fmt = (n) => n.toLocaleString("en-US");
10137
+ console.log(`Estimate for ${fmt(est.sources)} source string(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10138
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10139
+ if (est.pricing) {
10140
+ const cost = est.estimatedCost;
10141
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
10142
+ } else {
10143
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10144
+ }
10145
+ return;
10146
+ }
10147
+ let provider;
10148
+ try {
10149
+ provider = makeProvider(aiCfg);
10150
+ } catch (e) {
10151
+ console.error(e.message);
10152
+ process.exitCode = 1;
10153
+ return;
10154
+ }
10155
+ const system = buildGlossarySuggestSystemPrompt();
10156
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
10157
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
10158
+ const chunks = [];
10159
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
10160
+ const all = [];
10161
+ let done = 0;
10162
+ let next = 0;
10163
+ async function worker() {
10164
+ while (next < chunks.length) {
10165
+ const chunkRows = chunks[next++];
10166
+ try {
10167
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
10168
+ const batch = raw;
10169
+ all.push(...batch.terms ?? []);
10170
+ } catch (e) {
10171
+ console.warn(`batch failed: ${e.message}`);
10172
+ }
10173
+ done += chunkRows.length;
10174
+ console.log(`[${done}/${sources.length}] scanned`);
10175
+ }
10176
+ }
10177
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
10178
+ const added = mergeGlossarySuggestions(state, dedupeTerms(all));
10179
+ saveState(args.statePath, state);
10180
+ appendLog(projectRoot, {
10181
+ at: (/* @__PURE__ */ new Date()).toISOString(),
10182
+ kind: "glossary",
10183
+ summary: `Suggested ${added.length} glossary term(s)`,
10184
+ model: aiCfg.model
10185
+ });
10186
+ console.log(`Found ${added.length} new candidate term(s). Review them in the glossary UI.`);
10187
+ for (const s of added) console.log(` \u2022 ${s.term}${s.note ? ` \u2014 ${s.note}` : ""}`);
10188
+ }
9322
10189
  async function runScanCmd(args) {
9323
10190
  const state = loadState(args.statePath);
9324
10191
  const projectRoot = dirname5(resolve11(args.statePath));
@@ -9392,10 +10259,10 @@ function runSplit(args) {
9392
10259
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
9393
10260
  );
9394
10261
  }
9395
- var SKILL_SRC = join19(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
10262
+ var SKILL_SRC = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
9396
10263
  function runSkill(args) {
9397
10264
  if (args.print) {
9398
- console.log(readFileSync24(join19(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
10265
+ console.log(readFileSync26(join21(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
9399
10266
  return;
9400
10267
  }
9401
10268
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
@@ -9574,6 +10441,39 @@ function runApply(args) {
9574
10441
  console.log(JSON.stringify({ applied: r.applied, keysTouched: r.keysTouched, saved, dryRun: !!args.dryRun, errors: r.errors }, null, 2));
9575
10442
  if (r.errors.length) process.exitCode = 1;
9576
10443
  }
10444
+ async function runPrices(args) {
10445
+ const projectRoot = dirname5(resolve11(args.statePath));
10446
+ if (args.refresh) {
10447
+ try {
10448
+ const res = await refreshPrices();
10449
+ invalidatePriceCache();
10450
+ console.log(`Updated ${res.modelCount} model price(s) from ${res.source}.`);
10451
+ console.log(`Fetched ${new Date(res.fetchedAt).toLocaleString()} \u2192 ${res.path}`);
10452
+ } catch (e) {
10453
+ console.error(`Could not refresh prices: ${e.message}`);
10454
+ console.error("Existing cached prices (if any) are unchanged.");
10455
+ process.exitCode = 1;
10456
+ }
10457
+ return;
10458
+ }
10459
+ const cache2 = loadPriceCache();
10460
+ if (cache2) {
10461
+ const when = cache2.fetchedAt ? new Date(cache2.fetchedAt).toLocaleString() : "unknown time";
10462
+ console.log(`Price cache: ${Object.keys(cache2.models).length} model(s) from ${cache2.source}, fetched ${when}.`);
10463
+ console.log(`Location: ${defaultPriceCachePath()}`);
10464
+ } else {
10465
+ console.log("No price cache yet. Run `glotfile prices --refresh` to fetch the latest from models.dev.");
10466
+ }
10467
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10468
+ const pricing = resolvePricing(aiCfg, cache2);
10469
+ if (pricing) {
10470
+ console.log(`
10471
+ ${aiCfg.provider} \xB7 ${aiCfg.model}: $${pricing.inputPerMTok}/$${pricing.outputPerMTok} per MTok (${pricing.source}).`);
10472
+ } else {
10473
+ console.log(`
10474
+ No price known for ${aiCfg.provider} \xB7 ${aiCfg.model}. Set inputPricePerMTok/outputPricePerMTok in AI settings, or refresh.`);
10475
+ }
10476
+ }
9577
10477
  var GLOBAL_OPTS = [
9578
10478
  ["-f, --file <path>", "State file to use (default: ./glotfile.json)"],
9579
10479
  ["-h, --help", "Show this help"]
@@ -9652,12 +10552,24 @@ var COMMAND_HELP = {
9652
10552
  },
9653
10553
  "build-context": {
9654
10554
  summary: "AI-generate per-key context to improve translation (requires a prior scan).",
9655
- usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--batch]",
10555
+ usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--estimate] [--batch]",
9656
10556
  options: [
9657
10557
  ["--all", "(Re)build context for every key, not just those missing it"],
9658
10558
  ["--key <glob>", "Only keys matching this glob"],
9659
10559
  ["--limit <n>", "Process at most n keys"],
9660
- ["--since <date>", "Only keys added or changed since this date"]
10560
+ ["--since <date>", "Only keys added or changed since this date"],
10561
+ ["--estimate", "Print batches, tokens and estimated cost without building"],
10562
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"]
10563
+ ]
10564
+ },
10565
+ "suggest-glossary": {
10566
+ summary: "AI-scan source strings for candidate glossary terms (adds a review queue; existing terms are skipped).",
10567
+ usage: "glotfile suggest-glossary [--key <glob>] [--limit <n>] [--since <date>] [--estimate]",
10568
+ options: [
10569
+ ["--key <glob>", "Only scan keys matching this glob"],
10570
+ ["--limit <n>", "Scan at most n source strings"],
10571
+ ["--since <date>", "Only keys added since this date"],
10572
+ ["--estimate", "Print batches, tokens and estimated cost without scanning"]
9661
10573
  ]
9662
10574
  },
9663
10575
  scan: {
@@ -9696,6 +10608,13 @@ var COMMAND_HELP = {
9696
10608
  ["cancel", "Cancel the pending batch and discard the handle"]
9697
10609
  ]
9698
10610
  },
10611
+ prices: {
10612
+ summary: "Show or refresh the model price cache used for cost estimates (models.dev).",
10613
+ usage: "glotfile prices [--refresh]",
10614
+ options: [
10615
+ ["--refresh", "Fetch the latest prices from models.dev into the cache (the only command that hits the network)"]
10616
+ ]
10617
+ },
9699
10618
  get: {
9700
10619
  summary: "Extract values from the catalog (filtered) without loading the whole file. Prints JSON.",
9701
10620
  usage: "glotfile get [<key-glob>\u2026] [--key <glob>] [--locale <list>] [--state <list>] [--fields <list>] [--keys-only] [--format json|ndjson]",
@@ -9785,8 +10704,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
9785
10704
  );
9786
10705
  }
9787
10706
  function printVersion() {
9788
- const pkgPath = join19(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
9789
- console.log(JSON.parse(readFileSync24(pkgPath, "utf8")).version);
10707
+ const pkgPath = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10708
+ console.log(JSON.parse(readFileSync26(pkgPath, "utf8")).version);
9790
10709
  }
9791
10710
  async function main(argv) {
9792
10711
  const args = parseArgs(argv);
@@ -9806,11 +10725,13 @@ async function main(argv) {
9806
10725
  if (args.command === "import") return runImportCmd(args);
9807
10726
  if (args.command === "sync") return runSyncCmd(args);
9808
10727
  if (args.command === "build-context") return runBuildContext(args);
10728
+ if (args.command === "suggest-glossary") return runSuggestGlossary(args);
9809
10729
  if (args.command === "scan") return runScanCmd(args);
9810
10730
  if (args.command === "prune") return runPrune(args);
9811
10731
  if (args.command === "split") return runSplit(args);
9812
10732
  if (args.command === "skill") return runSkill(args);
9813
10733
  if (args.command === "batch") return runBatch(args);
10734
+ if (args.command === "prices") return runPrices(args);
9814
10735
  if (args.command === "get") return runGetCmd(args);
9815
10736
  if (args.command === "stats") return runStatsCmd(args);
9816
10737
  if (args.command === "set") return runSet(args);