@verbatra/sdk 0.5.0-next.2 → 0.5.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -472,6 +472,76 @@ function validateRequest(request) {
472
472
  }
473
473
  return parsed.data;
474
474
  }
475
+ var LENGTH_RATIO_MIN = 0.35;
476
+ var LENGTH_RATIO_MAX = 3;
477
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
478
+ var UNICODE_LETTER = /\p{L}/u;
479
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
480
+ "FORMALITY_DOWNGRADED",
481
+ "GLOSSARY_IGNORED"
482
+ ]);
483
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
484
+ const trimmedSource = sourceValue.trim();
485
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
486
+ return false;
487
+ }
488
+ const ratio = translatedValue.trim().length / trimmedSource.length;
489
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
490
+ }
491
+ function isEqualsSource(input) {
492
+ const trimmedSource = input.sourceValue.trim();
493
+ const trimmedTranslated = input.translatedValue.trim();
494
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
495
+ }
496
+ function isGlossaryTermMissed(input) {
497
+ const glossary = input.glossary;
498
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
499
+ return false;
500
+ }
501
+ const sourceLower = input.sourceValue.toLowerCase();
502
+ const translatedLower = input.translatedValue.toLowerCase();
503
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
504
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
505
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
506
+ if (sourceHit && !targetHit) {
507
+ return true;
508
+ }
509
+ }
510
+ return false;
511
+ }
512
+ function isIntegrityReordered(integrity) {
513
+ return integrity.matches && integrity.reordered;
514
+ }
515
+ function computeReviewFlags(input) {
516
+ const reasons = [];
517
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
518
+ reasons.push("LENGTH_RATIO_OUTLIER");
519
+ }
520
+ if (isEqualsSource(input)) {
521
+ reasons.push("EQUALS_SOURCE");
522
+ }
523
+ if (isGlossaryTermMissed(input)) {
524
+ reasons.push("GLOSSARY_TERM_MISSED");
525
+ }
526
+ if (isIntegrityReordered(input.integrity)) {
527
+ reasons.push("INTEGRITY_REORDERED");
528
+ }
529
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
530
+ }
531
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
532
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
533
+ return reviewFlags;
534
+ }
535
+ const next = new Map(reviewFlags);
536
+ for (const key of acceptedKeys) {
537
+ const existing = next.get(key);
538
+ next.set(key, {
539
+ status: "review",
540
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
541
+ });
542
+ }
543
+ return next;
544
+ }
475
545
  function toIntegrityInputs(entries, values) {
476
546
  const inputs = [];
477
547
  for (const entry of entries) {
@@ -567,7 +637,33 @@ async function runLlmTranslation(request, mechanism) {
567
637
  request.extractPlaceholders,
568
638
  request.comparePlaceholders
569
639
  );
570
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
640
+ const notices = [];
641
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
642
+ ...values.keys()
643
+ ]);
644
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
645
+ }
646
+ function buildReviewFlags(data, values, integrity) {
647
+ const reviewFlags = /* @__PURE__ */ new Map();
648
+ for (const entry of data.entries) {
649
+ const translatedValue = values.get(entry.key);
650
+ const entryIntegrity = integrity.get(entry.key);
651
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
652
+ continue;
653
+ }
654
+ const flag = computeReviewFlags({
655
+ sourceValue: entry.value,
656
+ translatedValue,
657
+ sourceLocale: data.sourceLocale,
658
+ targetLocale: data.targetLocale,
659
+ integrity: entryIntegrity,
660
+ glossary: data.glossary
661
+ });
662
+ if (flag !== void 0) {
663
+ reviewFlags.set(entry.key, flag);
664
+ }
665
+ }
666
+ return reviewFlags;
571
667
  }
572
668
  async function requestTranslations(mechanism, data, signal) {
573
669
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -777,6 +873,50 @@ function createDefaultClient2() {
777
873
  };
778
874
  return { client, freeAccount };
779
875
  }
876
+ var DEEPL_MAX_TEXTS_PER_REQUEST = 50;
877
+ var DEEPL_MAX_PAYLOAD_BYTES = 128 * 1024;
878
+ var DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES = 4 * 1024;
879
+ var DEEPL_MAX_TEXT_PAYLOAD_BYTES = DEEPL_MAX_PAYLOAD_BYTES - DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES;
880
+ function estimateWireBytes(text) {
881
+ return encodeURIComponent(text).length;
882
+ }
883
+ function chunkTextsForDeepL(texts) {
884
+ const chunks = [];
885
+ let current = [];
886
+ let currentBytes = 0;
887
+ for (const text of texts) {
888
+ const textBytes = estimateWireBytes(text);
889
+ const startsNewChunk = current.length > 0 && (current.length >= DEEPL_MAX_TEXTS_PER_REQUEST || currentBytes + textBytes > DEEPL_MAX_TEXT_PAYLOAD_BYTES);
890
+ if (startsNewChunk) {
891
+ chunks.push(current);
892
+ current = [];
893
+ currentBytes = 0;
894
+ }
895
+ current.push(text);
896
+ currentBytes += textBytes;
897
+ }
898
+ if (current.length > 0) {
899
+ chunks.push(current);
900
+ }
901
+ return chunks;
902
+ }
903
+ var DEPRECATED_BARE_TARGET_CODES = /* @__PURE__ */ new Set(["en", "pt"]);
904
+ function assertValidDeepLSourceLocale(locale) {
905
+ if (locale.includes("-")) {
906
+ throw new ProviderError(
907
+ "INVALID_REQUEST",
908
+ `DeepL does not accept a regional or script source locale code: "${locale}". Only the base language code is valid as a DeepL source (for example, use "en" instead of "en-US").`
909
+ );
910
+ }
911
+ }
912
+ function assertValidDeepLTargetLocale(locale) {
913
+ if (DEPRECATED_BARE_TARGET_CODES.has(locale.toLowerCase())) {
914
+ throw new ProviderError(
915
+ "INVALID_REQUEST",
916
+ `DeepL requires a disambiguated target locale code instead of "${locale}" (for example, "en-GB" or "en-US" for English, "pt-PT" or "pt-BR" for Portuguese).`
917
+ );
918
+ }
919
+ }
780
920
  var PLACEHOLDER_UNSUPPORTED_MESSAGE = "Some entries contain placeholders or ICU syntax that DeepL cannot preserve; they were left untranslated. Use an LLM provider to translate placeholder-bearing strings.";
781
921
  function partitionByPlaceholders(entries) {
782
922
  const protectable = [];
@@ -856,6 +996,8 @@ function resolveClient(deps) {
856
996
  }
857
997
  async function translate(bundle, config, request) {
858
998
  const data = validateRequest(request);
999
+ assertValidDeepLSourceLocale(data.sourceLocale);
1000
+ assertValidDeepLTargetLocale(data.targetLocale);
859
1001
  const { protectable, unprotectable } = partitionByPlaceholders(data.entries);
860
1002
  const genericGlossarySupplied = request.glossary !== void 0 && Object.keys(request.glossary).length > 0;
861
1003
  const { options, notices } = buildTranslateOptions({
@@ -876,14 +1018,41 @@ async function translate(bundle, config, request) {
876
1018
  if (unprotectable.length > 0) {
877
1019
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
878
1020
  }
879
- return { values, integrity, notices };
1021
+ const reviewFlags = applyProviderDegraded(
1022
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1023
+ notices,
1024
+ [...values.keys()]
1025
+ );
1026
+ return { values, integrity, notices, reviewFlags };
1027
+ }
1028
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1029
+ const reviewFlags = /* @__PURE__ */ new Map();
1030
+ for (const entry of protectable) {
1031
+ const translatedValue = values.get(entry.key);
1032
+ const entryIntegrity = integrity.get(entry.key);
1033
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1034
+ continue;
1035
+ }
1036
+ const flag = computeReviewFlags({
1037
+ sourceValue: entry.value,
1038
+ translatedValue,
1039
+ sourceLocale: data.sourceLocale,
1040
+ targetLocale: data.targetLocale,
1041
+ integrity: entryIntegrity,
1042
+ glossary
1043
+ });
1044
+ if (flag !== void 0) {
1045
+ reviewFlags.set(entry.key, flag);
1046
+ }
1047
+ }
1048
+ return reviewFlags;
880
1049
  }
881
1050
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
882
1051
  if (protectable.length === 0) {
883
1052
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
884
1053
  }
885
1054
  const texts = protectable.map((entry) => entry.value);
886
- const results = await callClient2(
1055
+ const results = await callClientChunked(
887
1056
  client,
888
1057
  texts,
889
1058
  data.sourceLocale,
@@ -901,6 +1070,14 @@ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
901
1070
  signal
902
1071
  );
903
1072
  }
1073
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
1074
+ const results = [];
1075
+ for (const chunk2 of chunkTextsForDeepL(texts)) {
1076
+ const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
1077
+ results.push(...chunkResults);
1078
+ }
1079
+ return results;
1080
+ }
904
1081
  var geminiConfigSchema = z.object({
905
1082
  model: z.string().min(1),
906
1083
  maxOutputTokens: z.number().int().positive()
@@ -1372,6 +1549,18 @@ function buildProvider(config) {
1372
1549
 
1373
1550
  // src/config/schema.ts
1374
1551
  var DEFAULT_MAX_BATCH_SIZE = 50;
1552
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1553
+ function findCaseInsensitiveDuplicate(locales) {
1554
+ const seen = /* @__PURE__ */ new Set();
1555
+ for (const locale of locales) {
1556
+ const key = locale.toLowerCase();
1557
+ if (seen.has(key)) {
1558
+ return locale;
1559
+ }
1560
+ seen.add(key);
1561
+ }
1562
+ return void 0;
1563
+ }
1375
1564
  var verbatraConfigSchema = z.strictObject({
1376
1565
  sourceLocale: z.string().min(1),
1377
1566
  targetLocales: z.array(z.string().min(1)).min(1),
@@ -1409,10 +1598,34 @@ var verbatraConfigSchema = z.strictObject({
1409
1598
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1410
1599
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1411
1600
  */
1412
- maxBatchSize: z.number().int().positive().optional()
1601
+ maxBatchSize: z.number().int().positive().optional(),
1602
+ /**
1603
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1604
+ * translation and plural generation alike, across all target locales). Checked after each completed
1605
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1606
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1607
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1608
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1609
+ */
1610
+ maxTokens: z.number().int().positive().optional(),
1611
+ /**
1612
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1613
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1614
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1615
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1616
+ */
1617
+ budgetBehavior: z.enum(["warn", "stop"]).optional()
1413
1618
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1414
1619
  message: "targetLocales must not include the source locale",
1415
1620
  path: ["targetLocales"]
1621
+ }).refine((config) => findCaseInsensitiveDuplicate(config.targetLocales) === void 0, {
1622
+ error: (issue) => {
1623
+ const duplicate = findCaseInsensitiveDuplicate(
1624
+ issue.input.targetLocales
1625
+ );
1626
+ return `targetLocales must not contain case-insensitively duplicate locales: "${duplicate}"`;
1627
+ },
1628
+ path: ["targetLocales"]
1416
1629
  }).refine((config) => config.files.pattern.includes(LOCALE_TOKEN), {
1417
1630
  message: `files.pattern must contain the ${LOCALE_TOKEN} token`,
1418
1631
  path: ["files", "pattern"]
@@ -1835,7 +2048,7 @@ var AdapterError = class extends Error {
1835
2048
  var MAX_DEPTH = 100;
1836
2049
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1837
2050
  var jsonTreeSchema = z.lazy(
1838
- () => z.union([z.string(), z.record(z.string(), jsonTreeSchema)])
2051
+ () => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), jsonTreeSchema)])
1839
2052
  );
1840
2053
  var rootSchema = z.record(z.string(), jsonTreeSchema);
1841
2054
  function assertWithinDepth(value, max) {
@@ -1863,7 +2076,7 @@ function assertJsonRecord(value) {
1863
2076
  if (!result.success) {
1864
2077
  throw new AdapterError(
1865
2078
  "INVALID_STRUCTURE",
1866
- "The file is not a valid object (expected nested objects of string values)."
2079
+ "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves)."
1867
2080
  );
1868
2081
  }
1869
2082
  return result.data;
@@ -1881,6 +2094,9 @@ function serializeJsonTree(tree) {
1881
2094
  return `${JSON.stringify(tree, null, 2)}
1882
2095
  `;
1883
2096
  }
2097
+ function isJsonNode(value) {
2098
+ return typeof value === "object" && value !== null;
2099
+ }
1884
2100
  function namespaceOf(filePath) {
1885
2101
  return basename(filePath, extname(filePath));
1886
2102
  }
@@ -1908,9 +2124,25 @@ function buildCanHandle(extensions, sniff) {
1908
2124
  return sample === void 0 || sniff === void 0 || sniff(sample);
1909
2125
  };
1910
2126
  }
2127
+ async function fsyncPath(path) {
2128
+ const handle = await open(path, "r");
2129
+ try {
2130
+ await handle.sync();
2131
+ } finally {
2132
+ await handle.close();
2133
+ }
2134
+ }
2135
+ async function fsyncDirBestEffort(path) {
2136
+ try {
2137
+ await fsyncPath(path);
2138
+ } catch {
2139
+ }
2140
+ }
1911
2141
  var nodeOps = {
1912
2142
  writeFile: (path, data) => writeFile(path, data, "utf8"),
2143
+ fsyncFile: (path) => fsyncPath(path),
1913
2144
  rename: (from, to) => rename(from, to),
2145
+ fsyncDir: (path) => fsyncDirBestEffort(path),
1914
2146
  rm: (path) => rm(path, { force: true })
1915
2147
  };
1916
2148
  async function cleanup(ops, tmp) {
@@ -1926,11 +2158,16 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1926
2158
  const tmp = tempFileName2(path);
1927
2159
  try {
1928
2160
  await ops.writeFile(tmp, data);
2161
+ await ops.fsyncFile(tmp);
1929
2162
  await ops.rename(tmp, path);
1930
2163
  } catch (error) {
1931
2164
  await cleanup(ops, tmp);
1932
2165
  throw error;
1933
2166
  }
2167
+ try {
2168
+ await ops.fsyncDir(dirname(path));
2169
+ } catch {
2170
+ }
1934
2171
  }
1935
2172
  function stripLeadingBom(content) {
1936
2173
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2052,20 +2289,24 @@ function addLeaf(ctx, segments, key, value) {
2052
2289
  );
2053
2290
  }
2054
2291
  ctx.claimed.set(effectivePath, mapKey);
2292
+ if (typeof value !== "string") {
2293
+ ctx.excluded.push(mapKey);
2294
+ return;
2295
+ }
2055
2296
  const { placeholders, isPlural } = ctx.derive(key, value);
2056
2297
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2057
2298
  }
2058
2299
  function addEntries(ctx, prefix, node) {
2059
2300
  for (const [key, value] of Object.entries(node)) {
2060
2301
  const segments = [...prefix, key];
2061
- if (typeof value === "string") {
2062
- addLeaf(ctx, segments, key, value);
2063
- } else {
2302
+ if (isJsonNode(value)) {
2064
2303
  addEntries(ctx, segments, value);
2304
+ } else {
2305
+ addLeaf(ctx, segments, key, value);
2065
2306
  }
2066
2307
  }
2067
2308
  }
2068
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2309
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2069
2310
  for (const [key, value] of Object.entries(node)) {
2070
2311
  const path = prefix === "" ? key : `${prefix}.${key}`;
2071
2312
  if (claimedPaths.has(path)) {
@@ -2075,22 +2316,26 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2075
2316
  );
2076
2317
  }
2077
2318
  claimedPaths.add(path);
2078
- if (typeof value === "string") {
2319
+ if (isJsonNode(value)) {
2320
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2321
+ } else if (typeof value === "string") {
2079
2322
  const { placeholders, isPlural } = derive(key, value);
2080
2323
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2081
2324
  } else {
2082
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2325
+ excluded.push(path);
2083
2326
  }
2084
2327
  }
2085
2328
  }
2086
2329
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2087
2330
  const out = /* @__PURE__ */ new Map();
2088
2331
  if (keyMode === "path-notation") {
2089
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2090
- return out;
2332
+ const excluded = [];
2333
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2334
+ return { entries: out, excludedLeafPaths: excluded };
2091
2335
  }
2092
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2093
- return out;
2336
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2337
+ addEntries(ctx, [], tree);
2338
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2094
2339
  }
2095
2340
  function emptyNode() {
2096
2341
  return /* @__PURE__ */ Object.create(null);
@@ -2128,11 +2373,24 @@ function unflattenEntries(entries) {
2128
2373
  }
2129
2374
  return root;
2130
2375
  }
2131
- function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
2376
+ function mergeDescriptions(entries, content, deriveDescriptions) {
2377
+ if (!deriveDescriptions) {
2378
+ return;
2379
+ }
2380
+ for (const [key, description] of deriveDescriptions(content)) {
2381
+ const entry = entries.get(key);
2382
+ if (entry !== void 0) {
2383
+ entries.set(key, { ...entry, description });
2384
+ }
2385
+ }
2386
+ }
2387
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree, deriveDescriptions) {
2132
2388
  try {
2133
2389
  const tree = parse3(content);
2134
2390
  validateTree?.(tree);
2135
- return flattenTree(tree, namespace, deriveEntry, keyMode);
2391
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2392
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2393
+ return result;
2136
2394
  } catch (error) {
2137
2395
  rethrowStructured(error, "The file could not be parsed.");
2138
2396
  }
@@ -2151,6 +2409,7 @@ function createTreeFileAdapter(options) {
2151
2409
  validateTree,
2152
2410
  buildWriteTree,
2153
2411
  comparePlaceholders,
2412
+ deriveDescriptions,
2154
2413
  keyMode = "literal-leaf"
2155
2414
  } = options;
2156
2415
  return {
@@ -2162,10 +2421,18 @@ function createTreeFileAdapter(options) {
2162
2421
  async read(filePath, locale) {
2163
2422
  const content = await readFileContent(filePath);
2164
2423
  const namespace = namespaceOf(filePath);
2165
- const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
2424
+ const { entries, excludedLeafPaths } = toEntries(
2425
+ content,
2426
+ namespace,
2427
+ parse3,
2428
+ deriveEntry,
2429
+ keyMode,
2430
+ validateTree,
2431
+ deriveDescriptions
2432
+ );
2166
2433
  const resource = { locale, namespace, format, entries };
2167
2434
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2168
- return { resource, invalidIcuKeys };
2435
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2169
2436
  },
2170
2437
  async write(resource, filePath) {
2171
2438
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2203,6 +2470,31 @@ function stripArbMetadata(tree) {
2203
2470
  function originalKey(encoded) {
2204
2471
  return decodeKeyToSegments(encoded).join(".");
2205
2472
  }
2473
+ function messageKeyForMetadata(key) {
2474
+ return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2475
+ }
2476
+ function descriptionOf(value) {
2477
+ if (typeof value !== "object" || value === null) {
2478
+ return void 0;
2479
+ }
2480
+ const description = value.description;
2481
+ return typeof description === "string" ? description : void 0;
2482
+ }
2483
+ function extractArbDescriptions(content) {
2484
+ const tree = parseArbObject(content);
2485
+ const out = /* @__PURE__ */ new Map();
2486
+ for (const [key, value] of Object.entries(tree)) {
2487
+ const messageKey = messageKeyForMetadata(key);
2488
+ if (messageKey === null) {
2489
+ continue;
2490
+ }
2491
+ const description = descriptionOf(value);
2492
+ if (description !== void 0) {
2493
+ out.set(encodeSegment(messageKey), description);
2494
+ }
2495
+ }
2496
+ return out;
2497
+ }
2206
2498
  function messagesFromEntries(entries) {
2207
2499
  const out = /* @__PURE__ */ new Map();
2208
2500
  for (const [key, entry] of entries) {
@@ -2234,8 +2526,10 @@ async function buildArbWriteTree(entries, filePath) {
2234
2526
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2235
2527
  if (translated !== void 0) {
2236
2528
  consumed.add(key);
2529
+ out[key] = translated;
2530
+ } else if (isMetadataKey(key) || typeof value === "string") {
2531
+ out[key] = value;
2237
2532
  }
2238
- out[key] = translated ?? value;
2239
2533
  }
2240
2534
  for (const [key, value] of messages) {
2241
2535
  if (!consumed.has(key)) {
@@ -2259,7 +2553,8 @@ function createArbAdapter() {
2259
2553
  computeInvalidIcuKeys: icuInvalidKeys,
2260
2554
  validateMessage: icuIsValid,
2261
2555
  comparePlaceholders: compareIcuPlaceholders,
2262
- buildWriteTree: buildArbWriteTree
2556
+ buildWriteTree: buildArbWriteTree,
2557
+ deriveDescriptions: extractArbDescriptions
2263
2558
  });
2264
2559
  }
2265
2560
  function createJsonFileAdapter(options) {
@@ -2328,7 +2623,7 @@ function createNextIntlJsonAdapter() {
2328
2623
  }
2329
2624
  function assertNoDottedNestedKey(tree) {
2330
2625
  for (const [key, value] of Object.entries(tree)) {
2331
- if (typeof value !== "object") {
2626
+ if (!isJsonNode(value)) {
2332
2627
  continue;
2333
2628
  }
2334
2629
  if (key.includes(".")) {
@@ -2344,7 +2639,7 @@ function assertNotMixed(tree) {
2344
2639
  let hasNested = false;
2345
2640
  let hasFlatDottedKey = false;
2346
2641
  for (const [key, value] of Object.entries(tree)) {
2347
- if (typeof value === "object") {
2642
+ if (isJsonNode(value)) {
2348
2643
  hasNested = true;
2349
2644
  } else if (key.includes(".")) {
2350
2645
  hasFlatDottedKey = true;
@@ -2505,7 +2800,7 @@ function createFlatFileAdapter(options) {
2505
2800
  const entries = toEntries2(content, namespace, parseEntries);
2506
2801
  const resource = { locale, namespace, format, entries };
2507
2802
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2508
- return { resource, invalidIcuKeys };
2803
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2509
2804
  },
2510
2805
  async write(resource, filePath) {
2511
2806
  const data = await serializeEntries(resource.entries, filePath);
@@ -2565,16 +2860,33 @@ function parseXml(content) {
2565
2860
  }
2566
2861
  return { doc, root };
2567
2862
  }
2863
+ function firstNoteText(parent) {
2864
+ const note = childByName(parent, "note");
2865
+ if (note === null) {
2866
+ return void 0;
2867
+ }
2868
+ const text = (note.textContent ?? "").trim();
2869
+ return text === "" ? void 0 : text;
2870
+ }
2871
+ function transUnitDescription(tu) {
2872
+ return firstNoteText(tu);
2873
+ }
2874
+ function unitDescription(unit) {
2875
+ const notes = childByName(unit, "notes");
2876
+ return notes === null ? void 0 : firstNoteText(notes);
2877
+ }
2568
2878
  function walkXliff12(root) {
2569
2879
  const units = [];
2570
2880
  collectByTag(root, "trans-unit").forEach((tu, index) => {
2571
2881
  const source = childByName(tu, "source");
2572
2882
  if (source !== null) {
2883
+ const description = transUnitDescription(tu);
2573
2884
  units.push({
2574
2885
  key: unitKey(tu, index),
2575
2886
  source,
2576
2887
  target: childByName(tu, "target"),
2577
- container: tu
2888
+ container: tu,
2889
+ ...description !== void 0 ? { description } : {}
2578
2890
  });
2579
2891
  }
2580
2892
  });
@@ -2584,12 +2896,19 @@ function walkXliff20(root) {
2584
2896
  const units = [];
2585
2897
  collectByTag(root, "unit").forEach((unit, index) => {
2586
2898
  const baseKey = unitKey(unit, index);
2899
+ const description = unitDescription(unit);
2587
2900
  const segments = elementChildren(unit).filter((el) => el.localName === "segment");
2588
2901
  segments.forEach((segment, segIndex) => {
2589
2902
  const source = childByName(segment, "source");
2590
2903
  if (source !== null) {
2591
2904
  const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
2592
- units.push({ key, source, target: childByName(segment, "target"), container: segment });
2905
+ units.push({
2906
+ key,
2907
+ source,
2908
+ target: childByName(segment, "target"),
2909
+ container: segment,
2910
+ ...description !== void 0 ? { description } : {}
2911
+ });
2593
2912
  }
2594
2913
  });
2595
2914
  });
@@ -2628,7 +2947,8 @@ function parseXliffEntries(content, namespace) {
2628
2947
  namespace,
2629
2948
  value,
2630
2949
  placeholders: extractXliffPlaceholders(value),
2631
- isPlural: false
2950
+ isPlural: false,
2951
+ ...unit.description !== void 0 ? { description: unit.description } : {}
2632
2952
  });
2633
2953
  }
2634
2954
  return out;
@@ -2934,6 +3254,46 @@ function selectProvider(config, createProvider = buildProvider) {
2934
3254
  }
2935
3255
  }
2936
3256
 
3257
+ // src/flow/budget.ts
3258
+ function createBudgetTracker(maxTokens, behavior) {
3259
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3260
+ }
3261
+ function foldTrackerUsage(tracker, usage) {
3262
+ if (usage === void 0) {
3263
+ return;
3264
+ }
3265
+ tracker.usageSeen = true;
3266
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3267
+ }
3268
+ function checkBudgetTrip(tracker) {
3269
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3270
+ return false;
3271
+ }
3272
+ tracker.exceeded = true;
3273
+ if (tracker.behavior === "stop") {
3274
+ tracker.stopped = true;
3275
+ }
3276
+ return true;
3277
+ }
3278
+ function toBudgetSummary(tracker) {
3279
+ if (tracker.maxTokens === void 0) {
3280
+ return void 0;
3281
+ }
3282
+ return {
3283
+ maxTokens: tracker.maxTokens,
3284
+ behavior: tracker.behavior,
3285
+ supported: tracker.usageSeen,
3286
+ tokensUsed: tracker.tokensUsed,
3287
+ exceeded: tracker.exceeded
3288
+ };
3289
+ }
3290
+ function budgetExceededNotice(tracker) {
3291
+ return {
3292
+ code: "BUDGET_TOKENS_EXCEEDED",
3293
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3294
+ };
3295
+ }
3296
+
2937
3297
  // src/flow/locale-failure.ts
2938
3298
  function describeError(error) {
2939
3299
  if (error instanceof Error) {
@@ -2953,8 +3313,10 @@ function failureSummary(locale, error) {
2953
3313
  invalidIcuSource: [],
2954
3314
  integrityMismatches: [],
2955
3315
  providerFailures: [],
3316
+ budgetWithheld: [],
2956
3317
  generated: [],
2957
3318
  notices: [],
3319
+ needsReview: [],
2958
3320
  error: describeError(error)
2959
3321
  };
2960
3322
  }
@@ -3124,7 +3486,43 @@ function planPluralGeneration(source, targetLocale, format) {
3124
3486
  return { items };
3125
3487
  }
3126
3488
 
3489
+ // src/flow/usage.ts
3490
+ function createUsageAccumulator() {
3491
+ return { total: void 0 };
3492
+ }
3493
+ function foldUsage(accumulator, usage) {
3494
+ if (usage === void 0) {
3495
+ return;
3496
+ }
3497
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
3498
+ accumulator.total = {
3499
+ inputTokens: prior.inputTokens + usage.inputTokens,
3500
+ outputTokens: prior.outputTokens + usage.outputTokens
3501
+ };
3502
+ }
3503
+ function combineUsage(a, b) {
3504
+ if (a === void 0) {
3505
+ return b;
3506
+ }
3507
+ if (b === void 0) {
3508
+ return a;
3509
+ }
3510
+ return {
3511
+ inputTokens: a.inputTokens + b.inputTokens,
3512
+ outputTokens: a.outputTokens + b.outputTokens
3513
+ };
3514
+ }
3515
+
3127
3516
  // src/flow/plural-generation.ts
3517
+ var EMPTY_RESULT = {
3518
+ accepted: [],
3519
+ withheld: [],
3520
+ providerFailures: [],
3521
+ budgetWithheld: [],
3522
+ notices: [],
3523
+ usage: void 0,
3524
+ tripped: false
3525
+ };
3128
3526
  function generatedLockHash(governingEntries, category) {
3129
3527
  const governingHashes = governingEntries.map(contentHash).sort();
3130
3528
  return contentHash({
@@ -3163,23 +3561,45 @@ async function generatePluralForms(context) {
3163
3561
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3164
3562
  const stale = staleItems(plan.items, context.baseline);
3165
3563
  if (stale.length === 0) {
3166
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3564
+ return EMPTY_RESULT;
3167
3565
  }
3168
3566
  const accepted = [];
3169
3567
  const withheld = [];
3170
3568
  const providerFailures = [];
3569
+ const budgetWithheld = [];
3171
3570
  const notices = [];
3571
+ const usage = createUsageAccumulator();
3572
+ let tripped = false;
3172
3573
  for (const batch of chunk(stale, context.maxBatchSize)) {
3173
- const batchNotices = await runGenerationSubBatch(
3574
+ if (context.budget.stopped) {
3575
+ for (const item of batch) {
3576
+ budgetWithheld.push(item.targetKey);
3577
+ }
3578
+ continue;
3579
+ }
3580
+ const subResult = await runGenerationSubBatch(
3174
3581
  context,
3175
3582
  batch,
3176
3583
  accepted,
3177
3584
  withheld,
3178
3585
  providerFailures
3179
3586
  );
3180
- notices.push(...batchNotices);
3587
+ notices.push(...subResult.notices);
3588
+ foldUsage(usage, subResult.usage);
3589
+ foldTrackerUsage(context.budget, subResult.usage);
3590
+ if (checkBudgetTrip(context.budget)) {
3591
+ tripped = true;
3592
+ }
3181
3593
  }
3182
- return { accepted, withheld, providerFailures, notices };
3594
+ return {
3595
+ accepted,
3596
+ withheld,
3597
+ providerFailures,
3598
+ budgetWithheld,
3599
+ notices,
3600
+ usage: usage.total,
3601
+ tripped
3602
+ };
3183
3603
  }
3184
3604
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3185
3605
  let result;
@@ -3190,12 +3610,12 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3190
3610
  for (const item of batch) {
3191
3611
  providerFailures.push(item.targetKey);
3192
3612
  }
3193
- return [subBatchFailedNotice(batch.length, error)];
3613
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3194
3614
  }
3195
3615
  for (const item of batch) {
3196
3616
  foldGenerationItem(item, result, accepted, withheld, providerFailures);
3197
3617
  }
3198
- return readNotices(result);
3618
+ return { notices: readNotices(result), usage: result.usage };
3199
3619
  }
3200
3620
  function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3201
3621
  const value = result.values.get(item.targetKey);
@@ -3263,6 +3683,7 @@ async function runLocale(params) {
3263
3683
  generated: [],
3264
3684
  integrityMismatches: [],
3265
3685
  providerFailures: [],
3686
+ budgetWithheld: [],
3266
3687
  pruned,
3267
3688
  notices: sdkNotices
3268
3689
  }),
@@ -3270,16 +3691,21 @@ async function runLocale(params) {
3270
3691
  };
3271
3692
  }
3272
3693
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
3694
+ const startedStopped = params.budget.stopped;
3273
3695
  const accepted = /* @__PURE__ */ new Map();
3274
3696
  const integrityMismatches = [];
3275
3697
  const providerFailures = [];
3276
- const subBatchNotices = await translateAndCheck(
3698
+ const budgetWithheld = [];
3699
+ const reviewFlags = /* @__PURE__ */ new Map();
3700
+ const translation = await translateAndCheck(
3277
3701
  provider,
3278
3702
  params,
3279
3703
  entries,
3280
3704
  accepted,
3281
3705
  integrityMismatches,
3282
- providerFailures
3706
+ providerFailures,
3707
+ budgetWithheld,
3708
+ reviewFlags
3283
3709
  );
3284
3710
  const merged = new Map(target.entries);
3285
3711
  for (const key of pruned) {
@@ -3305,16 +3731,20 @@ async function runLocale(params) {
3305
3731
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3306
3732
  const notices = [
3307
3733
  ...pluralNotices,
3308
- ...subBatchNotices,
3309
- ...generation.notices
3734
+ ...translation.notices,
3735
+ ...generation.notices,
3736
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3310
3737
  ];
3311
3738
  const withheld = /* @__PURE__ */ new Set([
3312
3739
  ...integrityMismatches,
3313
3740
  ...providerFailures,
3314
3741
  ...invalidIcuSource,
3315
3742
  ...generation.withheld,
3316
- ...generation.providerFailures
3743
+ ...generation.providerFailures,
3744
+ // Source-present keys withheld by the budget guardrail must keep their prior lock hash too.
3745
+ ...budgetWithheld
3317
3746
  ]);
3747
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3318
3748
  return {
3319
3749
  summary: baseSummary({
3320
3750
  locale: params.targetLocale,
@@ -3327,15 +3757,27 @@ async function runLocale(params) {
3327
3757
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3328
3758
  // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3329
3759
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
3760
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3330
3761
  pruned,
3331
- notices
3762
+ notices,
3763
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
3764
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3332
3765
  }),
3333
3766
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3334
3767
  };
3335
3768
  }
3769
+ var NO_GENERATION_RESULT = {
3770
+ accepted: [],
3771
+ withheld: [],
3772
+ providerFailures: [],
3773
+ budgetWithheld: [],
3774
+ notices: [],
3775
+ usage: void 0,
3776
+ tripped: false
3777
+ };
3336
3778
  async function runGeneration(params, provider) {
3337
3779
  if (!params.generatePlurals || provider.kind !== "llm") {
3338
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3780
+ return NO_GENERATION_RESULT;
3339
3781
  }
3340
3782
  return generatePluralForms({
3341
3783
  source: params.source,
@@ -3347,9 +3789,13 @@ async function runGeneration(params, provider) {
3347
3789
  glossary: params.glossary,
3348
3790
  tone: params.tone,
3349
3791
  baseline: params.baseline,
3350
- maxBatchSize: params.maxBatchSize
3792
+ maxBatchSize: params.maxBatchSize,
3793
+ budget: params.budget
3351
3794
  });
3352
3795
  }
3796
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
3797
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
3798
+ }
3353
3799
  function pluralNoticeFor(params, merged) {
3354
3800
  if (params.format !== "i18next-json") {
3355
3801
  return [];
@@ -3370,26 +3816,53 @@ function baseSummary(parts) {
3370
3816
  invalidIcuSource: parts.invalidIcuSource,
3371
3817
  integrityMismatches: parts.integrityMismatches,
3372
3818
  providerFailures: parts.providerFailures,
3819
+ budgetWithheld: parts.budgetWithheld,
3373
3820
  generated: parts.generated,
3374
- notices: parts.notices
3821
+ notices: parts.notices,
3822
+ needsReview: parts.needsReview ?? [],
3823
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3375
3824
  };
3376
3825
  }
3377
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
3826
+ function needsReviewFor(acceptedKeys, reviewFlags) {
3827
+ const entries = [];
3828
+ for (const key of acceptedKeys) {
3829
+ const flag = reviewFlags.get(key);
3830
+ if (flag !== void 0) {
3831
+ entries.push({ key, reasons: flag.reasons });
3832
+ }
3833
+ }
3834
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
3835
+ }
3836
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3378
3837
  const notices = [];
3838
+ const usage = createUsageAccumulator();
3839
+ let tripped = false;
3379
3840
  for (const batch of chunk(entries, params.maxBatchSize)) {
3380
- const subNotices = await runSubBatch(
3841
+ if (params.budget.stopped) {
3842
+ for (const entry of batch) {
3843
+ budgetWithheld.push(entry.key);
3844
+ }
3845
+ continue;
3846
+ }
3847
+ const subResult = await runSubBatch(
3381
3848
  provider,
3382
3849
  params,
3383
3850
  batch,
3384
3851
  accepted,
3385
3852
  integrityMismatches,
3386
- providerFailures
3853
+ providerFailures,
3854
+ reviewFlags
3387
3855
  );
3388
- notices.push(...subNotices);
3856
+ notices.push(...subResult.notices);
3857
+ foldUsage(usage, subResult.usage);
3858
+ foldTrackerUsage(params.budget, subResult.usage);
3859
+ if (checkBudgetTrip(params.budget)) {
3860
+ tripped = true;
3861
+ }
3389
3862
  }
3390
- return notices;
3863
+ return { notices, tripped, usage: usage.total };
3391
3864
  }
3392
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
3865
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3393
3866
  let result;
3394
3867
  try {
3395
3868
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3397,12 +3870,17 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3397
3870
  for (const entry of batch) {
3398
3871
  providerFailures.push(entry.key);
3399
3872
  }
3400
- return [subBatchFailedNotice(batch.length, error)];
3873
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3401
3874
  }
3402
3875
  for (const entry of batch) {
3403
3876
  foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3404
3877
  }
3405
- return readNotices(result);
3878
+ if (result.reviewFlags !== void 0) {
3879
+ for (const [key, flag] of result.reviewFlags) {
3880
+ reviewFlags.set(key, flag);
3881
+ }
3882
+ }
3883
+ return { notices: readNotices(result), usage: result.usage };
3406
3884
  }
3407
3885
  function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3408
3886
  const value = result.values.get(entry.key);
@@ -3460,6 +3938,10 @@ async function translate2(input, deps = {}) {
3460
3938
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3461
3939
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3462
3940
  const fs = deps.fs ?? defaultFs;
3941
+ const budget = createBudgetTracker(
3942
+ config.maxTokens,
3943
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
3944
+ );
3463
3945
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3464
3946
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3465
3947
  const source = await readSource(config, cwd, fs, adapter);
@@ -3484,7 +3966,8 @@ async function translate2(input, deps = {}) {
3484
3966
  prune,
3485
3967
  generatePlurals,
3486
3968
  maxBatchSize,
3487
- fs
3969
+ fs,
3970
+ budget
3488
3971
  };
3489
3972
  const { summary, lockEntries } = await runLocale(params);
3490
3973
  if (!dryRun) {
@@ -3497,7 +3980,19 @@ async function translate2(input, deps = {}) {
3497
3980
  }
3498
3981
  }
3499
3982
  const { succeeded, failed } = partition(summaries);
3500
- return { dryRun, locales: summaries, succeeded, failed };
3983
+ const usage = summaries.reduce(
3984
+ (total, summary) => combineUsage(total, summary.usage),
3985
+ void 0
3986
+ );
3987
+ const budgetSummary = toBudgetSummary(budget);
3988
+ return {
3989
+ dryRun,
3990
+ locales: summaries,
3991
+ succeeded,
3992
+ failed,
3993
+ ...usage !== void 0 ? { usage } : {},
3994
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
3995
+ };
3501
3996
  }
3502
3997
  var ExchangeError = class extends Error {
3503
3998
  code;
@@ -3519,10 +4014,13 @@ var INSTRUCTIONS_LINES = [
3519
4014
  " structure and the argument names exactly. Translate only the human-readable text.",
3520
4015
  "6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
3521
4016
  " back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
4017
+ "7. The 'Context' column, when present, is reference text from the source file explaining what the",
4018
+ " string means or where it is used. It is read-only; leave it as-is.",
3522
4019
  "",
3523
4020
  "Status values:",
3524
4021
  " new - this string has no translation yet.",
3525
4022
  " changed - the source string changed and the translation needs updating.",
4023
+ " unchanged - already up to date, included for reference. No action needed.",
3526
4024
  "",
3527
4025
  "When you are done, save the file and send it back. verbatra checks every value on import:",
3528
4026
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
@@ -3534,7 +4032,10 @@ var COLUMN = {
3534
4032
  current: 3,
3535
4033
  status: 4,
3536
4034
  translation: 5,
3537
- sourceHash: 6
4035
+ sourceHash: 6,
4036
+ context: 7,
4037
+ reviewStatus: 8,
4038
+ reviewReasons: 9
3538
4039
  };
3539
4040
  var HEADERS = [
3540
4041
  "Key",
@@ -3542,7 +4043,10 @@ var HEADERS = [
3542
4043
  "Current translation",
3543
4044
  "Status",
3544
4045
  "Translation",
3545
- "Source hash"
4046
+ "Source hash",
4047
+ "Context",
4048
+ "Review status",
4049
+ "Review reasons"
3546
4050
  ];
3547
4051
  var HEADER_ROW = 1;
3548
4052
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3561,7 +4065,10 @@ var COLUMN_WIDTHS = {
3561
4065
  [COLUMN.source]: 50,
3562
4066
  [COLUMN.current]: 50,
3563
4067
  [COLUMN.status]: 12,
3564
- [COLUMN.translation]: 50
4068
+ [COLUMN.translation]: 50,
4069
+ [COLUMN.context]: 50,
4070
+ [COLUMN.reviewStatus]: 12,
4071
+ [COLUMN.reviewReasons]: 40
3565
4072
  };
3566
4073
  var TEXT_NUMBER_FORMAT = "@";
3567
4074
  function styleHeader(sheet) {
@@ -3592,7 +4099,10 @@ function writeRow(sheet, sheetRow) {
3592
4099
  translationCell.numFmt = TEXT_NUMBER_FORMAT;
3593
4100
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3594
4101
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3595
- for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
4102
+ row.getCell(COLUMN.context).value = sheetRow.context;
4103
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4104
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4105
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3596
4106
  const cell = row.getCell(column);
3597
4107
  cell.protection = { locked: column !== COLUMN.translation };
3598
4108
  if (column !== COLUMN.translation) {
@@ -3617,6 +4127,26 @@ function assertValidWorksheetName(locale) {
3617
4127
  );
3618
4128
  }
3619
4129
  }
4130
+ function assertNoWorksheetNameCollisions(sheets) {
4131
+ const reservedKey = INSTRUCTIONS_SHEET_NAME.toLowerCase();
4132
+ const seen = /* @__PURE__ */ new Set();
4133
+ for (const sheet of sheets) {
4134
+ const key = sheet.locale.toLowerCase();
4135
+ if (key === reservedKey) {
4136
+ throw new ExchangeError(
4137
+ "WORKBOOK_INVALID",
4138
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with the reserved "${INSTRUCTIONS_SHEET_NAME}" sheet.`
4139
+ );
4140
+ }
4141
+ if (seen.has(key)) {
4142
+ throw new ExchangeError(
4143
+ "WORKBOOK_INVALID",
4144
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with another target locale's sheet name.`
4145
+ );
4146
+ }
4147
+ seen.add(key);
4148
+ }
4149
+ }
3620
4150
  async function buildDataSheet(workbook, sheet) {
3621
4151
  assertValidWorksheetName(sheet.locale);
3622
4152
  const worksheet = workbook.addWorksheet(sheet.locale);
@@ -3643,6 +4173,7 @@ function buildInstructionsSheet(workbook) {
3643
4173
  sheet.getRow(1).font = { bold: true };
3644
4174
  }
3645
4175
  async function buildWorkbook(model) {
4176
+ assertNoWorksheetNameCollisions(model.sheets);
3646
4177
  const workbook = new ExcelJS.Workbook();
3647
4178
  buildInstructionsSheet(workbook);
3648
4179
  for (const sheet of model.sheets) {
@@ -3743,9 +4274,14 @@ var rowSchema = z.object({
3743
4274
  key: z.string().min(1),
3744
4275
  source: z.string(),
3745
4276
  currentTarget: z.string(),
3746
- status: z.enum(["new", "changed"]),
4277
+ status: z.enum(["new", "changed", "unchanged"]),
3747
4278
  sourceHash: z.string(),
3748
- translation: z.string()
4279
+ translation: z.string(),
4280
+ context: z.string(),
4281
+ // A legacy workbook exported before these columns existed has no such cell; cellString then yields
4282
+ // "", and .catch maps it to "ok" / "" rather than rejecting the row.
4283
+ reviewStatus: z.enum(["ok", "review"]).catch("ok"),
4284
+ reviewReasons: z.string().catch("")
3749
4285
  });
3750
4286
  function cellString(cell) {
3751
4287
  const value = cell.value;
@@ -3778,7 +4314,13 @@ function parseRow(sheet, row) {
3778
4314
  currentTarget: cellString(row.getCell(COLUMN.current)),
3779
4315
  status: cellString(row.getCell(COLUMN.status)),
3780
4316
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3781
- translation: cellString(row.getCell(COLUMN.translation))
4317
+ translation: cellString(row.getCell(COLUMN.translation)),
4318
+ // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
4319
+ // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
4320
+ context: cellString(row.getCell(COLUMN.context)),
4321
+ // Same backward-compatible pattern for a workbook built before the Review columns existed.
4322
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4323
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3782
4324
  };
3783
4325
  const result = rowSchema.safeParse(candidate);
3784
4326
  if (!result.success) {
@@ -3852,7 +4394,34 @@ async function readTarget3(cwd, config, adapter, fs, locale) {
3852
4394
  }
3853
4395
  return (await adapter.read(path, locale)).resource;
3854
4396
  }
3855
- function buildRows(source, target, baseline, includeUnchanged) {
4397
+ function reasonLabel(reason) {
4398
+ return reason.toLowerCase().replace(/_/g, "-");
4399
+ }
4400
+ function reviewColumns(flag) {
4401
+ if (flag === void 0) {
4402
+ return { reviewStatus: "ok", reviewReasons: "" };
4403
+ }
4404
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4405
+ }
4406
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
4407
+ if (currentTarget === "") {
4408
+ return { reviewStatus: "ok", reviewReasons: "" };
4409
+ }
4410
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
4411
+ adapter.extractPlaceholders(sourceValue),
4412
+ adapter.extractPlaceholders(currentTarget)
4413
+ );
4414
+ const flag = computeReviewFlags({
4415
+ sourceValue,
4416
+ translatedValue: currentTarget,
4417
+ sourceLocale,
4418
+ targetLocale,
4419
+ integrity,
4420
+ glossary
4421
+ });
4422
+ return reviewColumns(flag);
4423
+ }
4424
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
3856
4425
  const diff2 = diffResources(source, target, { baseline });
3857
4426
  const rows = [];
3858
4427
  const add = (keys, status) => {
@@ -3861,20 +4430,30 @@ function buildRows(source, target, baseline, includeUnchanged) {
3861
4430
  if (sourceEntry === void 0) {
3862
4431
  continue;
3863
4432
  }
4433
+ const currentTarget = target.entries.get(key)?.value ?? "";
3864
4434
  rows.push({
3865
4435
  key,
3866
4436
  source: sourceEntry.value,
3867
- currentTarget: target.entries.get(key)?.value ?? "",
4437
+ currentTarget,
3868
4438
  status,
3869
4439
  sourceHash: contentHash(sourceEntry),
3870
- translation: ""
4440
+ translation: "",
4441
+ context: sourceEntry.description ?? "",
4442
+ ...computeRowReview(
4443
+ adapter,
4444
+ sourceEntry.value,
4445
+ currentTarget,
4446
+ source.locale,
4447
+ target.locale,
4448
+ glossary
4449
+ )
3871
4450
  });
3872
4451
  }
3873
4452
  };
3874
4453
  add(diff2.missing, "new");
3875
4454
  add(diff2.changed, "changed");
3876
4455
  if (includeUnchanged) {
3877
- add(diff2.unchanged, "changed");
4456
+ add(diff2.unchanged, "unchanged");
3878
4457
  }
3879
4458
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
3880
4459
  }
@@ -3893,7 +4472,9 @@ async function exportWorkbook(input, deps = {}) {
3893
4472
  source.resource,
3894
4473
  target,
3895
4474
  baselineFor(lock, locale),
3896
- input.includeUnchanged ?? false
4475
+ input.includeUnchanged ?? false,
4476
+ adapter,
4477
+ config.glossary
3897
4478
  );
3898
4479
  return { locale, rows };
3899
4480
  })
@@ -3994,9 +4575,14 @@ function importLocale(params) {
3994
4575
  integrityMismatches: [...buckets.mismatches].sort(),
3995
4576
  // A workbook import never calls a provider, so a provider-call failure cannot occur here.
3996
4577
  providerFailures: [],
4578
+ // A workbook import never calls a provider, so the budget guardrail never withholds anything here.
4579
+ budgetWithheld: [],
3997
4580
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
3998
4581
  generated: [],
3999
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
4582
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
4583
+ // A workbook import never calls a provider and never recomputes review flags on its own path; the
4584
+ // Excel workbook's Review status/reasons columns are informational only (see export-workbook.ts).
4585
+ needsReview: []
4000
4586
  };
4001
4587
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4002
4588
  }