@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.cjs CHANGED
@@ -502,6 +502,76 @@ function validateRequest(request) {
502
502
  }
503
503
  return parsed.data;
504
504
  }
505
+ var LENGTH_RATIO_MIN = 0.35;
506
+ var LENGTH_RATIO_MAX = 3;
507
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
508
+ var UNICODE_LETTER = /\p{L}/u;
509
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
510
+ "FORMALITY_DOWNGRADED",
511
+ "GLOSSARY_IGNORED"
512
+ ]);
513
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
514
+ const trimmedSource = sourceValue.trim();
515
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
516
+ return false;
517
+ }
518
+ const ratio = translatedValue.trim().length / trimmedSource.length;
519
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
520
+ }
521
+ function isEqualsSource(input) {
522
+ const trimmedSource = input.sourceValue.trim();
523
+ const trimmedTranslated = input.translatedValue.trim();
524
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
525
+ }
526
+ function isGlossaryTermMissed(input) {
527
+ const glossary = input.glossary;
528
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
529
+ return false;
530
+ }
531
+ const sourceLower = input.sourceValue.toLowerCase();
532
+ const translatedLower = input.translatedValue.toLowerCase();
533
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
534
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
535
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
536
+ if (sourceHit && !targetHit) {
537
+ return true;
538
+ }
539
+ }
540
+ return false;
541
+ }
542
+ function isIntegrityReordered(integrity) {
543
+ return integrity.matches && integrity.reordered;
544
+ }
545
+ function computeReviewFlags(input) {
546
+ const reasons = [];
547
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
548
+ reasons.push("LENGTH_RATIO_OUTLIER");
549
+ }
550
+ if (isEqualsSource(input)) {
551
+ reasons.push("EQUALS_SOURCE");
552
+ }
553
+ if (isGlossaryTermMissed(input)) {
554
+ reasons.push("GLOSSARY_TERM_MISSED");
555
+ }
556
+ if (isIntegrityReordered(input.integrity)) {
557
+ reasons.push("INTEGRITY_REORDERED");
558
+ }
559
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
560
+ }
561
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
562
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
563
+ return reviewFlags;
564
+ }
565
+ const next = new Map(reviewFlags);
566
+ for (const key of acceptedKeys) {
567
+ const existing = next.get(key);
568
+ next.set(key, {
569
+ status: "review",
570
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
571
+ });
572
+ }
573
+ return next;
574
+ }
505
575
  function toIntegrityInputs(entries, values) {
506
576
  const inputs = [];
507
577
  for (const entry of entries) {
@@ -597,7 +667,33 @@ async function runLlmTranslation(request, mechanism) {
597
667
  request.extractPlaceholders,
598
668
  request.comparePlaceholders
599
669
  );
600
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
670
+ const notices = [];
671
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
672
+ ...values.keys()
673
+ ]);
674
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
675
+ }
676
+ function buildReviewFlags(data, values, integrity) {
677
+ const reviewFlags = /* @__PURE__ */ new Map();
678
+ for (const entry of data.entries) {
679
+ const translatedValue = values.get(entry.key);
680
+ const entryIntegrity = integrity.get(entry.key);
681
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
682
+ continue;
683
+ }
684
+ const flag = computeReviewFlags({
685
+ sourceValue: entry.value,
686
+ translatedValue,
687
+ sourceLocale: data.sourceLocale,
688
+ targetLocale: data.targetLocale,
689
+ integrity: entryIntegrity,
690
+ glossary: data.glossary
691
+ });
692
+ if (flag !== void 0) {
693
+ reviewFlags.set(entry.key, flag);
694
+ }
695
+ }
696
+ return reviewFlags;
601
697
  }
602
698
  async function requestTranslations(mechanism, data, signal) {
603
699
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -807,6 +903,50 @@ function createDefaultClient2() {
807
903
  };
808
904
  return { client, freeAccount };
809
905
  }
906
+ var DEEPL_MAX_TEXTS_PER_REQUEST = 50;
907
+ var DEEPL_MAX_PAYLOAD_BYTES = 128 * 1024;
908
+ var DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES = 4 * 1024;
909
+ var DEEPL_MAX_TEXT_PAYLOAD_BYTES = DEEPL_MAX_PAYLOAD_BYTES - DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES;
910
+ function estimateWireBytes(text) {
911
+ return encodeURIComponent(text).length;
912
+ }
913
+ function chunkTextsForDeepL(texts) {
914
+ const chunks = [];
915
+ let current = [];
916
+ let currentBytes = 0;
917
+ for (const text of texts) {
918
+ const textBytes = estimateWireBytes(text);
919
+ const startsNewChunk = current.length > 0 && (current.length >= DEEPL_MAX_TEXTS_PER_REQUEST || currentBytes + textBytes > DEEPL_MAX_TEXT_PAYLOAD_BYTES);
920
+ if (startsNewChunk) {
921
+ chunks.push(current);
922
+ current = [];
923
+ currentBytes = 0;
924
+ }
925
+ current.push(text);
926
+ currentBytes += textBytes;
927
+ }
928
+ if (current.length > 0) {
929
+ chunks.push(current);
930
+ }
931
+ return chunks;
932
+ }
933
+ var DEPRECATED_BARE_TARGET_CODES = /* @__PURE__ */ new Set(["en", "pt"]);
934
+ function assertValidDeepLSourceLocale(locale) {
935
+ if (locale.includes("-")) {
936
+ throw new ProviderError(
937
+ "INVALID_REQUEST",
938
+ `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").`
939
+ );
940
+ }
941
+ }
942
+ function assertValidDeepLTargetLocale(locale) {
943
+ if (DEPRECATED_BARE_TARGET_CODES.has(locale.toLowerCase())) {
944
+ throw new ProviderError(
945
+ "INVALID_REQUEST",
946
+ `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).`
947
+ );
948
+ }
949
+ }
810
950
  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.";
811
951
  function partitionByPlaceholders(entries) {
812
952
  const protectable = [];
@@ -886,6 +1026,8 @@ function resolveClient(deps) {
886
1026
  }
887
1027
  async function translate(bundle, config, request) {
888
1028
  const data = validateRequest(request);
1029
+ assertValidDeepLSourceLocale(data.sourceLocale);
1030
+ assertValidDeepLTargetLocale(data.targetLocale);
889
1031
  const { protectable, unprotectable } = partitionByPlaceholders(data.entries);
890
1032
  const genericGlossarySupplied = request.glossary !== void 0 && Object.keys(request.glossary).length > 0;
891
1033
  const { options, notices } = buildTranslateOptions({
@@ -906,14 +1048,41 @@ async function translate(bundle, config, request) {
906
1048
  if (unprotectable.length > 0) {
907
1049
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
908
1050
  }
909
- return { values, integrity, notices };
1051
+ const reviewFlags = applyProviderDegraded(
1052
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1053
+ notices,
1054
+ [...values.keys()]
1055
+ );
1056
+ return { values, integrity, notices, reviewFlags };
1057
+ }
1058
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1059
+ const reviewFlags = /* @__PURE__ */ new Map();
1060
+ for (const entry of protectable) {
1061
+ const translatedValue = values.get(entry.key);
1062
+ const entryIntegrity = integrity.get(entry.key);
1063
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1064
+ continue;
1065
+ }
1066
+ const flag = computeReviewFlags({
1067
+ sourceValue: entry.value,
1068
+ translatedValue,
1069
+ sourceLocale: data.sourceLocale,
1070
+ targetLocale: data.targetLocale,
1071
+ integrity: entryIntegrity,
1072
+ glossary
1073
+ });
1074
+ if (flag !== void 0) {
1075
+ reviewFlags.set(entry.key, flag);
1076
+ }
1077
+ }
1078
+ return reviewFlags;
910
1079
  }
911
1080
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
912
1081
  if (protectable.length === 0) {
913
1082
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
914
1083
  }
915
1084
  const texts = protectable.map((entry) => entry.value);
916
- const results = await callClient2(
1085
+ const results = await callClientChunked(
917
1086
  client,
918
1087
  texts,
919
1088
  data.sourceLocale,
@@ -931,6 +1100,14 @@ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
931
1100
  signal
932
1101
  );
933
1102
  }
1103
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
1104
+ const results = [];
1105
+ for (const chunk2 of chunkTextsForDeepL(texts)) {
1106
+ const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
1107
+ results.push(...chunkResults);
1108
+ }
1109
+ return results;
1110
+ }
934
1111
  var geminiConfigSchema = zod.z.object({
935
1112
  model: zod.z.string().min(1),
936
1113
  maxOutputTokens: zod.z.number().int().positive()
@@ -1402,6 +1579,18 @@ function buildProvider(config) {
1402
1579
 
1403
1580
  // src/config/schema.ts
1404
1581
  var DEFAULT_MAX_BATCH_SIZE = 50;
1582
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1583
+ function findCaseInsensitiveDuplicate(locales) {
1584
+ const seen = /* @__PURE__ */ new Set();
1585
+ for (const locale of locales) {
1586
+ const key = locale.toLowerCase();
1587
+ if (seen.has(key)) {
1588
+ return locale;
1589
+ }
1590
+ seen.add(key);
1591
+ }
1592
+ return void 0;
1593
+ }
1405
1594
  var verbatraConfigSchema = zod.z.strictObject({
1406
1595
  sourceLocale: zod.z.string().min(1),
1407
1596
  targetLocales: zod.z.array(zod.z.string().min(1)).min(1),
@@ -1439,10 +1628,34 @@ var verbatraConfigSchema = zod.z.strictObject({
1439
1628
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1440
1629
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1441
1630
  */
1442
- maxBatchSize: zod.z.number().int().positive().optional()
1631
+ maxBatchSize: zod.z.number().int().positive().optional(),
1632
+ /**
1633
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1634
+ * translation and plural generation alike, across all target locales). Checked after each completed
1635
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1636
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1637
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1638
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1639
+ */
1640
+ maxTokens: zod.z.number().int().positive().optional(),
1641
+ /**
1642
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1643
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1644
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1645
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1646
+ */
1647
+ budgetBehavior: zod.z.enum(["warn", "stop"]).optional()
1443
1648
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1444
1649
  message: "targetLocales must not include the source locale",
1445
1650
  path: ["targetLocales"]
1651
+ }).refine((config) => findCaseInsensitiveDuplicate(config.targetLocales) === void 0, {
1652
+ error: (issue) => {
1653
+ const duplicate = findCaseInsensitiveDuplicate(
1654
+ issue.input.targetLocales
1655
+ );
1656
+ return `targetLocales must not contain case-insensitively duplicate locales: "${duplicate}"`;
1657
+ },
1658
+ path: ["targetLocales"]
1446
1659
  }).refine((config) => config.files.pattern.includes(LOCALE_TOKEN), {
1447
1660
  message: `files.pattern must contain the ${LOCALE_TOKEN} token`,
1448
1661
  path: ["files", "pattern"]
@@ -1865,7 +2078,7 @@ var AdapterError = class extends Error {
1865
2078
  var MAX_DEPTH = 100;
1866
2079
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1867
2080
  var jsonTreeSchema = zod.z.lazy(
1868
- () => zod.z.union([zod.z.string(), zod.z.record(zod.z.string(), jsonTreeSchema)])
2081
+ () => zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null(), zod.z.record(zod.z.string(), jsonTreeSchema)])
1869
2082
  );
1870
2083
  var rootSchema = zod.z.record(zod.z.string(), jsonTreeSchema);
1871
2084
  function assertWithinDepth(value, max) {
@@ -1893,7 +2106,7 @@ function assertJsonRecord(value) {
1893
2106
  if (!result.success) {
1894
2107
  throw new AdapterError(
1895
2108
  "INVALID_STRUCTURE",
1896
- "The file is not a valid object (expected nested objects of string values)."
2109
+ "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves)."
1897
2110
  );
1898
2111
  }
1899
2112
  return result.data;
@@ -1911,6 +2124,9 @@ function serializeJsonTree(tree) {
1911
2124
  return `${JSON.stringify(tree, null, 2)}
1912
2125
  `;
1913
2126
  }
2127
+ function isJsonNode(value) {
2128
+ return typeof value === "object" && value !== null;
2129
+ }
1914
2130
  function namespaceOf(filePath) {
1915
2131
  return path.basename(filePath, path.extname(filePath));
1916
2132
  }
@@ -1938,9 +2154,25 @@ function buildCanHandle(extensions, sniff) {
1938
2154
  return sample === void 0 || sniff === void 0 || sniff(sample);
1939
2155
  };
1940
2156
  }
2157
+ async function fsyncPath(path) {
2158
+ const handle = await promises.open(path, "r");
2159
+ try {
2160
+ await handle.sync();
2161
+ } finally {
2162
+ await handle.close();
2163
+ }
2164
+ }
2165
+ async function fsyncDirBestEffort(path) {
2166
+ try {
2167
+ await fsyncPath(path);
2168
+ } catch {
2169
+ }
2170
+ }
1941
2171
  var nodeOps = {
1942
2172
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
2173
+ fsyncFile: (path) => fsyncPath(path),
1943
2174
  rename: (from, to) => promises.rename(from, to),
2175
+ fsyncDir: (path) => fsyncDirBestEffort(path),
1944
2176
  rm: (path) => promises.rm(path, { force: true })
1945
2177
  };
1946
2178
  async function cleanup(ops, tmp) {
@@ -1952,15 +2184,20 @@ async function cleanup(ops, tmp) {
1952
2184
  function tempFileName2(path$1) {
1953
2185
  return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
1954
2186
  }
1955
- async function atomicWriteFile(path, data, ops = nodeOps) {
1956
- const tmp = tempFileName2(path);
2187
+ async function atomicWriteFile(path$1, data, ops = nodeOps) {
2188
+ const tmp = tempFileName2(path$1);
1957
2189
  try {
1958
2190
  await ops.writeFile(tmp, data);
1959
- await ops.rename(tmp, path);
2191
+ await ops.fsyncFile(tmp);
2192
+ await ops.rename(tmp, path$1);
1960
2193
  } catch (error) {
1961
2194
  await cleanup(ops, tmp);
1962
2195
  throw error;
1963
2196
  }
2197
+ try {
2198
+ await ops.fsyncDir(path.dirname(path$1));
2199
+ } catch {
2200
+ }
1964
2201
  }
1965
2202
  function stripLeadingBom(content) {
1966
2203
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2082,20 +2319,24 @@ function addLeaf(ctx, segments, key, value) {
2082
2319
  );
2083
2320
  }
2084
2321
  ctx.claimed.set(effectivePath, mapKey);
2322
+ if (typeof value !== "string") {
2323
+ ctx.excluded.push(mapKey);
2324
+ return;
2325
+ }
2085
2326
  const { placeholders, isPlural } = ctx.derive(key, value);
2086
2327
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2087
2328
  }
2088
2329
  function addEntries(ctx, prefix, node) {
2089
2330
  for (const [key, value] of Object.entries(node)) {
2090
2331
  const segments = [...prefix, key];
2091
- if (typeof value === "string") {
2092
- addLeaf(ctx, segments, key, value);
2093
- } else {
2332
+ if (isJsonNode(value)) {
2094
2333
  addEntries(ctx, segments, value);
2334
+ } else {
2335
+ addLeaf(ctx, segments, key, value);
2095
2336
  }
2096
2337
  }
2097
2338
  }
2098
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2339
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2099
2340
  for (const [key, value] of Object.entries(node)) {
2100
2341
  const path = prefix === "" ? key : `${prefix}.${key}`;
2101
2342
  if (claimedPaths.has(path)) {
@@ -2105,22 +2346,26 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2105
2346
  );
2106
2347
  }
2107
2348
  claimedPaths.add(path);
2108
- if (typeof value === "string") {
2349
+ if (isJsonNode(value)) {
2350
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2351
+ } else if (typeof value === "string") {
2109
2352
  const { placeholders, isPlural } = derive(key, value);
2110
2353
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2111
2354
  } else {
2112
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2355
+ excluded.push(path);
2113
2356
  }
2114
2357
  }
2115
2358
  }
2116
2359
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2117
2360
  const out = /* @__PURE__ */ new Map();
2118
2361
  if (keyMode === "path-notation") {
2119
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2120
- return out;
2362
+ const excluded = [];
2363
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2364
+ return { entries: out, excludedLeafPaths: excluded };
2121
2365
  }
2122
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2123
- return out;
2366
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2367
+ addEntries(ctx, [], tree);
2368
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2124
2369
  }
2125
2370
  function emptyNode() {
2126
2371
  return /* @__PURE__ */ Object.create(null);
@@ -2158,11 +2403,24 @@ function unflattenEntries(entries) {
2158
2403
  }
2159
2404
  return root;
2160
2405
  }
2161
- function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
2406
+ function mergeDescriptions(entries, content, deriveDescriptions) {
2407
+ if (!deriveDescriptions) {
2408
+ return;
2409
+ }
2410
+ for (const [key, description] of deriveDescriptions(content)) {
2411
+ const entry = entries.get(key);
2412
+ if (entry !== void 0) {
2413
+ entries.set(key, { ...entry, description });
2414
+ }
2415
+ }
2416
+ }
2417
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree, deriveDescriptions) {
2162
2418
  try {
2163
2419
  const tree = parse3(content);
2164
2420
  validateTree?.(tree);
2165
- return flattenTree(tree, namespace, deriveEntry, keyMode);
2421
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2422
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2423
+ return result;
2166
2424
  } catch (error) {
2167
2425
  rethrowStructured(error, "The file could not be parsed.");
2168
2426
  }
@@ -2181,6 +2439,7 @@ function createTreeFileAdapter(options) {
2181
2439
  validateTree,
2182
2440
  buildWriteTree,
2183
2441
  comparePlaceholders,
2442
+ deriveDescriptions,
2184
2443
  keyMode = "literal-leaf"
2185
2444
  } = options;
2186
2445
  return {
@@ -2192,10 +2451,18 @@ function createTreeFileAdapter(options) {
2192
2451
  async read(filePath, locale) {
2193
2452
  const content = await readFileContent(filePath);
2194
2453
  const namespace = namespaceOf(filePath);
2195
- const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
2454
+ const { entries, excludedLeafPaths } = toEntries(
2455
+ content,
2456
+ namespace,
2457
+ parse3,
2458
+ deriveEntry,
2459
+ keyMode,
2460
+ validateTree,
2461
+ deriveDescriptions
2462
+ );
2196
2463
  const resource = { locale, namespace, format, entries };
2197
2464
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2198
- return { resource, invalidIcuKeys };
2465
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2199
2466
  },
2200
2467
  async write(resource, filePath) {
2201
2468
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2233,6 +2500,31 @@ function stripArbMetadata(tree) {
2233
2500
  function originalKey(encoded) {
2234
2501
  return decodeKeyToSegments(encoded).join(".");
2235
2502
  }
2503
+ function messageKeyForMetadata(key) {
2504
+ return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2505
+ }
2506
+ function descriptionOf(value) {
2507
+ if (typeof value !== "object" || value === null) {
2508
+ return void 0;
2509
+ }
2510
+ const description = value.description;
2511
+ return typeof description === "string" ? description : void 0;
2512
+ }
2513
+ function extractArbDescriptions(content) {
2514
+ const tree = parseArbObject(content);
2515
+ const out = /* @__PURE__ */ new Map();
2516
+ for (const [key, value] of Object.entries(tree)) {
2517
+ const messageKey = messageKeyForMetadata(key);
2518
+ if (messageKey === null) {
2519
+ continue;
2520
+ }
2521
+ const description = descriptionOf(value);
2522
+ if (description !== void 0) {
2523
+ out.set(encodeSegment(messageKey), description);
2524
+ }
2525
+ }
2526
+ return out;
2527
+ }
2236
2528
  function messagesFromEntries(entries) {
2237
2529
  const out = /* @__PURE__ */ new Map();
2238
2530
  for (const [key, entry] of entries) {
@@ -2264,8 +2556,10 @@ async function buildArbWriteTree(entries, filePath) {
2264
2556
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2265
2557
  if (translated !== void 0) {
2266
2558
  consumed.add(key);
2559
+ out[key] = translated;
2560
+ } else if (isMetadataKey(key) || typeof value === "string") {
2561
+ out[key] = value;
2267
2562
  }
2268
- out[key] = translated ?? value;
2269
2563
  }
2270
2564
  for (const [key, value] of messages) {
2271
2565
  if (!consumed.has(key)) {
@@ -2289,7 +2583,8 @@ function createArbAdapter() {
2289
2583
  computeInvalidIcuKeys: icuInvalidKeys,
2290
2584
  validateMessage: icuIsValid,
2291
2585
  comparePlaceholders: compareIcuPlaceholders,
2292
- buildWriteTree: buildArbWriteTree
2586
+ buildWriteTree: buildArbWriteTree,
2587
+ deriveDescriptions: extractArbDescriptions
2293
2588
  });
2294
2589
  }
2295
2590
  function createJsonFileAdapter(options) {
@@ -2358,7 +2653,7 @@ function createNextIntlJsonAdapter() {
2358
2653
  }
2359
2654
  function assertNoDottedNestedKey(tree) {
2360
2655
  for (const [key, value] of Object.entries(tree)) {
2361
- if (typeof value !== "object") {
2656
+ if (!isJsonNode(value)) {
2362
2657
  continue;
2363
2658
  }
2364
2659
  if (key.includes(".")) {
@@ -2374,7 +2669,7 @@ function assertNotMixed(tree) {
2374
2669
  let hasNested = false;
2375
2670
  let hasFlatDottedKey = false;
2376
2671
  for (const [key, value] of Object.entries(tree)) {
2377
- if (typeof value === "object") {
2672
+ if (isJsonNode(value)) {
2378
2673
  hasNested = true;
2379
2674
  } else if (key.includes(".")) {
2380
2675
  hasFlatDottedKey = true;
@@ -2535,7 +2830,7 @@ function createFlatFileAdapter(options) {
2535
2830
  const entries = toEntries2(content, namespace, parseEntries);
2536
2831
  const resource = { locale, namespace, format, entries };
2537
2832
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2538
- return { resource, invalidIcuKeys };
2833
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2539
2834
  },
2540
2835
  async write(resource, filePath) {
2541
2836
  const data = await serializeEntries(resource.entries, filePath);
@@ -2595,16 +2890,33 @@ function parseXml(content) {
2595
2890
  }
2596
2891
  return { doc, root };
2597
2892
  }
2893
+ function firstNoteText(parent) {
2894
+ const note = childByName(parent, "note");
2895
+ if (note === null) {
2896
+ return void 0;
2897
+ }
2898
+ const text = (note.textContent ?? "").trim();
2899
+ return text === "" ? void 0 : text;
2900
+ }
2901
+ function transUnitDescription(tu) {
2902
+ return firstNoteText(tu);
2903
+ }
2904
+ function unitDescription(unit) {
2905
+ const notes = childByName(unit, "notes");
2906
+ return notes === null ? void 0 : firstNoteText(notes);
2907
+ }
2598
2908
  function walkXliff12(root) {
2599
2909
  const units = [];
2600
2910
  collectByTag(root, "trans-unit").forEach((tu, index) => {
2601
2911
  const source = childByName(tu, "source");
2602
2912
  if (source !== null) {
2913
+ const description = transUnitDescription(tu);
2603
2914
  units.push({
2604
2915
  key: unitKey(tu, index),
2605
2916
  source,
2606
2917
  target: childByName(tu, "target"),
2607
- container: tu
2918
+ container: tu,
2919
+ ...description !== void 0 ? { description } : {}
2608
2920
  });
2609
2921
  }
2610
2922
  });
@@ -2614,12 +2926,19 @@ function walkXliff20(root) {
2614
2926
  const units = [];
2615
2927
  collectByTag(root, "unit").forEach((unit, index) => {
2616
2928
  const baseKey = unitKey(unit, index);
2929
+ const description = unitDescription(unit);
2617
2930
  const segments = elementChildren(unit).filter((el) => el.localName === "segment");
2618
2931
  segments.forEach((segment, segIndex) => {
2619
2932
  const source = childByName(segment, "source");
2620
2933
  if (source !== null) {
2621
2934
  const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
2622
- units.push({ key, source, target: childByName(segment, "target"), container: segment });
2935
+ units.push({
2936
+ key,
2937
+ source,
2938
+ target: childByName(segment, "target"),
2939
+ container: segment,
2940
+ ...description !== void 0 ? { description } : {}
2941
+ });
2623
2942
  }
2624
2943
  });
2625
2944
  });
@@ -2658,7 +2977,8 @@ function parseXliffEntries(content, namespace) {
2658
2977
  namespace,
2659
2978
  value,
2660
2979
  placeholders: extractXliffPlaceholders(value),
2661
- isPlural: false
2980
+ isPlural: false,
2981
+ ...unit.description !== void 0 ? { description: unit.description } : {}
2662
2982
  });
2663
2983
  }
2664
2984
  return out;
@@ -2964,6 +3284,46 @@ function selectProvider(config, createProvider = buildProvider) {
2964
3284
  }
2965
3285
  }
2966
3286
 
3287
+ // src/flow/budget.ts
3288
+ function createBudgetTracker(maxTokens, behavior) {
3289
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3290
+ }
3291
+ function foldTrackerUsage(tracker, usage) {
3292
+ if (usage === void 0) {
3293
+ return;
3294
+ }
3295
+ tracker.usageSeen = true;
3296
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3297
+ }
3298
+ function checkBudgetTrip(tracker) {
3299
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3300
+ return false;
3301
+ }
3302
+ tracker.exceeded = true;
3303
+ if (tracker.behavior === "stop") {
3304
+ tracker.stopped = true;
3305
+ }
3306
+ return true;
3307
+ }
3308
+ function toBudgetSummary(tracker) {
3309
+ if (tracker.maxTokens === void 0) {
3310
+ return void 0;
3311
+ }
3312
+ return {
3313
+ maxTokens: tracker.maxTokens,
3314
+ behavior: tracker.behavior,
3315
+ supported: tracker.usageSeen,
3316
+ tokensUsed: tracker.tokensUsed,
3317
+ exceeded: tracker.exceeded
3318
+ };
3319
+ }
3320
+ function budgetExceededNotice(tracker) {
3321
+ return {
3322
+ code: "BUDGET_TOKENS_EXCEEDED",
3323
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3324
+ };
3325
+ }
3326
+
2967
3327
  // src/flow/locale-failure.ts
2968
3328
  function describeError(error) {
2969
3329
  if (error instanceof Error) {
@@ -2983,8 +3343,10 @@ function failureSummary(locale, error) {
2983
3343
  invalidIcuSource: [],
2984
3344
  integrityMismatches: [],
2985
3345
  providerFailures: [],
3346
+ budgetWithheld: [],
2986
3347
  generated: [],
2987
3348
  notices: [],
3349
+ needsReview: [],
2988
3350
  error: describeError(error)
2989
3351
  };
2990
3352
  }
@@ -3154,7 +3516,43 @@ function planPluralGeneration(source, targetLocale, format) {
3154
3516
  return { items };
3155
3517
  }
3156
3518
 
3519
+ // src/flow/usage.ts
3520
+ function createUsageAccumulator() {
3521
+ return { total: void 0 };
3522
+ }
3523
+ function foldUsage(accumulator, usage) {
3524
+ if (usage === void 0) {
3525
+ return;
3526
+ }
3527
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
3528
+ accumulator.total = {
3529
+ inputTokens: prior.inputTokens + usage.inputTokens,
3530
+ outputTokens: prior.outputTokens + usage.outputTokens
3531
+ };
3532
+ }
3533
+ function combineUsage(a, b) {
3534
+ if (a === void 0) {
3535
+ return b;
3536
+ }
3537
+ if (b === void 0) {
3538
+ return a;
3539
+ }
3540
+ return {
3541
+ inputTokens: a.inputTokens + b.inputTokens,
3542
+ outputTokens: a.outputTokens + b.outputTokens
3543
+ };
3544
+ }
3545
+
3157
3546
  // src/flow/plural-generation.ts
3547
+ var EMPTY_RESULT = {
3548
+ accepted: [],
3549
+ withheld: [],
3550
+ providerFailures: [],
3551
+ budgetWithheld: [],
3552
+ notices: [],
3553
+ usage: void 0,
3554
+ tripped: false
3555
+ };
3158
3556
  function generatedLockHash(governingEntries, category) {
3159
3557
  const governingHashes = governingEntries.map(contentHash).sort();
3160
3558
  return contentHash({
@@ -3193,23 +3591,45 @@ async function generatePluralForms(context) {
3193
3591
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3194
3592
  const stale = staleItems(plan.items, context.baseline);
3195
3593
  if (stale.length === 0) {
3196
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3594
+ return EMPTY_RESULT;
3197
3595
  }
3198
3596
  const accepted = [];
3199
3597
  const withheld = [];
3200
3598
  const providerFailures = [];
3599
+ const budgetWithheld = [];
3201
3600
  const notices = [];
3601
+ const usage = createUsageAccumulator();
3602
+ let tripped = false;
3202
3603
  for (const batch of chunk(stale, context.maxBatchSize)) {
3203
- const batchNotices = await runGenerationSubBatch(
3604
+ if (context.budget.stopped) {
3605
+ for (const item of batch) {
3606
+ budgetWithheld.push(item.targetKey);
3607
+ }
3608
+ continue;
3609
+ }
3610
+ const subResult = await runGenerationSubBatch(
3204
3611
  context,
3205
3612
  batch,
3206
3613
  accepted,
3207
3614
  withheld,
3208
3615
  providerFailures
3209
3616
  );
3210
- notices.push(...batchNotices);
3617
+ notices.push(...subResult.notices);
3618
+ foldUsage(usage, subResult.usage);
3619
+ foldTrackerUsage(context.budget, subResult.usage);
3620
+ if (checkBudgetTrip(context.budget)) {
3621
+ tripped = true;
3622
+ }
3211
3623
  }
3212
- return { accepted, withheld, providerFailures, notices };
3624
+ return {
3625
+ accepted,
3626
+ withheld,
3627
+ providerFailures,
3628
+ budgetWithheld,
3629
+ notices,
3630
+ usage: usage.total,
3631
+ tripped
3632
+ };
3213
3633
  }
3214
3634
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3215
3635
  let result;
@@ -3220,12 +3640,12 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3220
3640
  for (const item of batch) {
3221
3641
  providerFailures.push(item.targetKey);
3222
3642
  }
3223
- return [subBatchFailedNotice(batch.length, error)];
3643
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3224
3644
  }
3225
3645
  for (const item of batch) {
3226
3646
  foldGenerationItem(item, result, accepted, withheld, providerFailures);
3227
3647
  }
3228
- return readNotices(result);
3648
+ return { notices: readNotices(result), usage: result.usage };
3229
3649
  }
3230
3650
  function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3231
3651
  const value = result.values.get(item.targetKey);
@@ -3293,6 +3713,7 @@ async function runLocale(params) {
3293
3713
  generated: [],
3294
3714
  integrityMismatches: [],
3295
3715
  providerFailures: [],
3716
+ budgetWithheld: [],
3296
3717
  pruned,
3297
3718
  notices: sdkNotices
3298
3719
  }),
@@ -3300,16 +3721,21 @@ async function runLocale(params) {
3300
3721
  };
3301
3722
  }
3302
3723
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
3724
+ const startedStopped = params.budget.stopped;
3303
3725
  const accepted = /* @__PURE__ */ new Map();
3304
3726
  const integrityMismatches = [];
3305
3727
  const providerFailures = [];
3306
- const subBatchNotices = await translateAndCheck(
3728
+ const budgetWithheld = [];
3729
+ const reviewFlags = /* @__PURE__ */ new Map();
3730
+ const translation = await translateAndCheck(
3307
3731
  provider,
3308
3732
  params,
3309
3733
  entries,
3310
3734
  accepted,
3311
3735
  integrityMismatches,
3312
- providerFailures
3736
+ providerFailures,
3737
+ budgetWithheld,
3738
+ reviewFlags
3313
3739
  );
3314
3740
  const merged = new Map(target.entries);
3315
3741
  for (const key of pruned) {
@@ -3335,16 +3761,20 @@ async function runLocale(params) {
3335
3761
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3336
3762
  const notices = [
3337
3763
  ...pluralNotices,
3338
- ...subBatchNotices,
3339
- ...generation.notices
3764
+ ...translation.notices,
3765
+ ...generation.notices,
3766
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3340
3767
  ];
3341
3768
  const withheld = /* @__PURE__ */ new Set([
3342
3769
  ...integrityMismatches,
3343
3770
  ...providerFailures,
3344
3771
  ...invalidIcuSource,
3345
3772
  ...generation.withheld,
3346
- ...generation.providerFailures
3773
+ ...generation.providerFailures,
3774
+ // Source-present keys withheld by the budget guardrail must keep their prior lock hash too.
3775
+ ...budgetWithheld
3347
3776
  ]);
3777
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3348
3778
  return {
3349
3779
  summary: baseSummary({
3350
3780
  locale: params.targetLocale,
@@ -3357,15 +3787,27 @@ async function runLocale(params) {
3357
3787
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3358
3788
  // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3359
3789
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
3790
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3360
3791
  pruned,
3361
- notices
3792
+ notices,
3793
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
3794
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3362
3795
  }),
3363
3796
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3364
3797
  };
3365
3798
  }
3799
+ var NO_GENERATION_RESULT = {
3800
+ accepted: [],
3801
+ withheld: [],
3802
+ providerFailures: [],
3803
+ budgetWithheld: [],
3804
+ notices: [],
3805
+ usage: void 0,
3806
+ tripped: false
3807
+ };
3366
3808
  async function runGeneration(params, provider) {
3367
3809
  if (!params.generatePlurals || provider.kind !== "llm") {
3368
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3810
+ return NO_GENERATION_RESULT;
3369
3811
  }
3370
3812
  return generatePluralForms({
3371
3813
  source: params.source,
@@ -3377,9 +3819,13 @@ async function runGeneration(params, provider) {
3377
3819
  glossary: params.glossary,
3378
3820
  tone: params.tone,
3379
3821
  baseline: params.baseline,
3380
- maxBatchSize: params.maxBatchSize
3822
+ maxBatchSize: params.maxBatchSize,
3823
+ budget: params.budget
3381
3824
  });
3382
3825
  }
3826
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
3827
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
3828
+ }
3383
3829
  function pluralNoticeFor(params, merged) {
3384
3830
  if (params.format !== "i18next-json") {
3385
3831
  return [];
@@ -3400,26 +3846,53 @@ function baseSummary(parts) {
3400
3846
  invalidIcuSource: parts.invalidIcuSource,
3401
3847
  integrityMismatches: parts.integrityMismatches,
3402
3848
  providerFailures: parts.providerFailures,
3849
+ budgetWithheld: parts.budgetWithheld,
3403
3850
  generated: parts.generated,
3404
- notices: parts.notices
3851
+ notices: parts.notices,
3852
+ needsReview: parts.needsReview ?? [],
3853
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3405
3854
  };
3406
3855
  }
3407
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
3856
+ function needsReviewFor(acceptedKeys, reviewFlags) {
3857
+ const entries = [];
3858
+ for (const key of acceptedKeys) {
3859
+ const flag = reviewFlags.get(key);
3860
+ if (flag !== void 0) {
3861
+ entries.push({ key, reasons: flag.reasons });
3862
+ }
3863
+ }
3864
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
3865
+ }
3866
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3408
3867
  const notices = [];
3868
+ const usage = createUsageAccumulator();
3869
+ let tripped = false;
3409
3870
  for (const batch of chunk(entries, params.maxBatchSize)) {
3410
- const subNotices = await runSubBatch(
3871
+ if (params.budget.stopped) {
3872
+ for (const entry of batch) {
3873
+ budgetWithheld.push(entry.key);
3874
+ }
3875
+ continue;
3876
+ }
3877
+ const subResult = await runSubBatch(
3411
3878
  provider,
3412
3879
  params,
3413
3880
  batch,
3414
3881
  accepted,
3415
3882
  integrityMismatches,
3416
- providerFailures
3883
+ providerFailures,
3884
+ reviewFlags
3417
3885
  );
3418
- notices.push(...subNotices);
3886
+ notices.push(...subResult.notices);
3887
+ foldUsage(usage, subResult.usage);
3888
+ foldTrackerUsage(params.budget, subResult.usage);
3889
+ if (checkBudgetTrip(params.budget)) {
3890
+ tripped = true;
3891
+ }
3419
3892
  }
3420
- return notices;
3893
+ return { notices, tripped, usage: usage.total };
3421
3894
  }
3422
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
3895
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3423
3896
  let result;
3424
3897
  try {
3425
3898
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3427,12 +3900,17 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3427
3900
  for (const entry of batch) {
3428
3901
  providerFailures.push(entry.key);
3429
3902
  }
3430
- return [subBatchFailedNotice(batch.length, error)];
3903
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3431
3904
  }
3432
3905
  for (const entry of batch) {
3433
3906
  foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3434
3907
  }
3435
- return readNotices(result);
3908
+ if (result.reviewFlags !== void 0) {
3909
+ for (const [key, flag] of result.reviewFlags) {
3910
+ reviewFlags.set(key, flag);
3911
+ }
3912
+ }
3913
+ return { notices: readNotices(result), usage: result.usage };
3436
3914
  }
3437
3915
  function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3438
3916
  const value = result.values.get(entry.key);
@@ -3490,6 +3968,10 @@ async function translate2(input, deps = {}) {
3490
3968
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3491
3969
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3492
3970
  const fs = deps.fs ?? defaultFs;
3971
+ const budget = createBudgetTracker(
3972
+ config.maxTokens,
3973
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
3974
+ );
3493
3975
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3494
3976
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3495
3977
  const source = await readSource(config, cwd, fs, adapter);
@@ -3514,7 +3996,8 @@ async function translate2(input, deps = {}) {
3514
3996
  prune,
3515
3997
  generatePlurals,
3516
3998
  maxBatchSize,
3517
- fs
3999
+ fs,
4000
+ budget
3518
4001
  };
3519
4002
  const { summary, lockEntries } = await runLocale(params);
3520
4003
  if (!dryRun) {
@@ -3527,7 +4010,19 @@ async function translate2(input, deps = {}) {
3527
4010
  }
3528
4011
  }
3529
4012
  const { succeeded, failed } = partition(summaries);
3530
- return { dryRun, locales: summaries, succeeded, failed };
4013
+ const usage = summaries.reduce(
4014
+ (total, summary) => combineUsage(total, summary.usage),
4015
+ void 0
4016
+ );
4017
+ const budgetSummary = toBudgetSummary(budget);
4018
+ return {
4019
+ dryRun,
4020
+ locales: summaries,
4021
+ succeeded,
4022
+ failed,
4023
+ ...usage !== void 0 ? { usage } : {},
4024
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4025
+ };
3531
4026
  }
3532
4027
  var ExchangeError = class extends Error {
3533
4028
  code;
@@ -3549,10 +4044,13 @@ var INSTRUCTIONS_LINES = [
3549
4044
  " structure and the argument names exactly. Translate only the human-readable text.",
3550
4045
  "6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
3551
4046
  " back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
4047
+ "7. The 'Context' column, when present, is reference text from the source file explaining what the",
4048
+ " string means or where it is used. It is read-only; leave it as-is.",
3552
4049
  "",
3553
4050
  "Status values:",
3554
4051
  " new - this string has no translation yet.",
3555
4052
  " changed - the source string changed and the translation needs updating.",
4053
+ " unchanged - already up to date, included for reference. No action needed.",
3556
4054
  "",
3557
4055
  "When you are done, save the file and send it back. verbatra checks every value on import:",
3558
4056
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
@@ -3564,7 +4062,10 @@ var COLUMN = {
3564
4062
  current: 3,
3565
4063
  status: 4,
3566
4064
  translation: 5,
3567
- sourceHash: 6
4065
+ sourceHash: 6,
4066
+ context: 7,
4067
+ reviewStatus: 8,
4068
+ reviewReasons: 9
3568
4069
  };
3569
4070
  var HEADERS = [
3570
4071
  "Key",
@@ -3572,7 +4073,10 @@ var HEADERS = [
3572
4073
  "Current translation",
3573
4074
  "Status",
3574
4075
  "Translation",
3575
- "Source hash"
4076
+ "Source hash",
4077
+ "Context",
4078
+ "Review status",
4079
+ "Review reasons"
3576
4080
  ];
3577
4081
  var HEADER_ROW = 1;
3578
4082
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3591,7 +4095,10 @@ var COLUMN_WIDTHS = {
3591
4095
  [COLUMN.source]: 50,
3592
4096
  [COLUMN.current]: 50,
3593
4097
  [COLUMN.status]: 12,
3594
- [COLUMN.translation]: 50
4098
+ [COLUMN.translation]: 50,
4099
+ [COLUMN.context]: 50,
4100
+ [COLUMN.reviewStatus]: 12,
4101
+ [COLUMN.reviewReasons]: 40
3595
4102
  };
3596
4103
  var TEXT_NUMBER_FORMAT = "@";
3597
4104
  function styleHeader(sheet) {
@@ -3622,7 +4129,10 @@ function writeRow(sheet, sheetRow) {
3622
4129
  translationCell.numFmt = TEXT_NUMBER_FORMAT;
3623
4130
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3624
4131
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3625
- for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
4132
+ row.getCell(COLUMN.context).value = sheetRow.context;
4133
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4134
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4135
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3626
4136
  const cell = row.getCell(column);
3627
4137
  cell.protection = { locked: column !== COLUMN.translation };
3628
4138
  if (column !== COLUMN.translation) {
@@ -3647,6 +4157,26 @@ function assertValidWorksheetName(locale) {
3647
4157
  );
3648
4158
  }
3649
4159
  }
4160
+ function assertNoWorksheetNameCollisions(sheets) {
4161
+ const reservedKey = INSTRUCTIONS_SHEET_NAME.toLowerCase();
4162
+ const seen = /* @__PURE__ */ new Set();
4163
+ for (const sheet of sheets) {
4164
+ const key = sheet.locale.toLowerCase();
4165
+ if (key === reservedKey) {
4166
+ throw new ExchangeError(
4167
+ "WORKBOOK_INVALID",
4168
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with the reserved "${INSTRUCTIONS_SHEET_NAME}" sheet.`
4169
+ );
4170
+ }
4171
+ if (seen.has(key)) {
4172
+ throw new ExchangeError(
4173
+ "WORKBOOK_INVALID",
4174
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with another target locale's sheet name.`
4175
+ );
4176
+ }
4177
+ seen.add(key);
4178
+ }
4179
+ }
3650
4180
  async function buildDataSheet(workbook, sheet) {
3651
4181
  assertValidWorksheetName(sheet.locale);
3652
4182
  const worksheet = workbook.addWorksheet(sheet.locale);
@@ -3673,6 +4203,7 @@ function buildInstructionsSheet(workbook) {
3673
4203
  sheet.getRow(1).font = { bold: true };
3674
4204
  }
3675
4205
  async function buildWorkbook(model) {
4206
+ assertNoWorksheetNameCollisions(model.sheets);
3676
4207
  const workbook = new ExcelJS__default.default.Workbook();
3677
4208
  buildInstructionsSheet(workbook);
3678
4209
  for (const sheet of model.sheets) {
@@ -3773,9 +4304,14 @@ var rowSchema = zod.z.object({
3773
4304
  key: zod.z.string().min(1),
3774
4305
  source: zod.z.string(),
3775
4306
  currentTarget: zod.z.string(),
3776
- status: zod.z.enum(["new", "changed"]),
4307
+ status: zod.z.enum(["new", "changed", "unchanged"]),
3777
4308
  sourceHash: zod.z.string(),
3778
- translation: zod.z.string()
4309
+ translation: zod.z.string(),
4310
+ context: zod.z.string(),
4311
+ // A legacy workbook exported before these columns existed has no such cell; cellString then yields
4312
+ // "", and .catch maps it to "ok" / "" rather than rejecting the row.
4313
+ reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
4314
+ reviewReasons: zod.z.string().catch("")
3779
4315
  });
3780
4316
  function cellString(cell) {
3781
4317
  const value = cell.value;
@@ -3808,7 +4344,13 @@ function parseRow(sheet, row) {
3808
4344
  currentTarget: cellString(row.getCell(COLUMN.current)),
3809
4345
  status: cellString(row.getCell(COLUMN.status)),
3810
4346
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3811
- translation: cellString(row.getCell(COLUMN.translation))
4347
+ translation: cellString(row.getCell(COLUMN.translation)),
4348
+ // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
4349
+ // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
4350
+ context: cellString(row.getCell(COLUMN.context)),
4351
+ // Same backward-compatible pattern for a workbook built before the Review columns existed.
4352
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4353
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3812
4354
  };
3813
4355
  const result = rowSchema.safeParse(candidate);
3814
4356
  if (!result.success) {
@@ -3882,7 +4424,34 @@ async function readTarget3(cwd, config, adapter, fs, locale) {
3882
4424
  }
3883
4425
  return (await adapter.read(path, locale)).resource;
3884
4426
  }
3885
- function buildRows(source, target, baseline, includeUnchanged) {
4427
+ function reasonLabel(reason) {
4428
+ return reason.toLowerCase().replace(/_/g, "-");
4429
+ }
4430
+ function reviewColumns(flag) {
4431
+ if (flag === void 0) {
4432
+ return { reviewStatus: "ok", reviewReasons: "" };
4433
+ }
4434
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4435
+ }
4436
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
4437
+ if (currentTarget === "") {
4438
+ return { reviewStatus: "ok", reviewReasons: "" };
4439
+ }
4440
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
4441
+ adapter.extractPlaceholders(sourceValue),
4442
+ adapter.extractPlaceholders(currentTarget)
4443
+ );
4444
+ const flag = computeReviewFlags({
4445
+ sourceValue,
4446
+ translatedValue: currentTarget,
4447
+ sourceLocale,
4448
+ targetLocale,
4449
+ integrity,
4450
+ glossary
4451
+ });
4452
+ return reviewColumns(flag);
4453
+ }
4454
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
3886
4455
  const diff2 = diffResources(source, target, { baseline });
3887
4456
  const rows = [];
3888
4457
  const add = (keys, status) => {
@@ -3891,20 +4460,30 @@ function buildRows(source, target, baseline, includeUnchanged) {
3891
4460
  if (sourceEntry === void 0) {
3892
4461
  continue;
3893
4462
  }
4463
+ const currentTarget = target.entries.get(key)?.value ?? "";
3894
4464
  rows.push({
3895
4465
  key,
3896
4466
  source: sourceEntry.value,
3897
- currentTarget: target.entries.get(key)?.value ?? "",
4467
+ currentTarget,
3898
4468
  status,
3899
4469
  sourceHash: contentHash(sourceEntry),
3900
- translation: ""
4470
+ translation: "",
4471
+ context: sourceEntry.description ?? "",
4472
+ ...computeRowReview(
4473
+ adapter,
4474
+ sourceEntry.value,
4475
+ currentTarget,
4476
+ source.locale,
4477
+ target.locale,
4478
+ glossary
4479
+ )
3901
4480
  });
3902
4481
  }
3903
4482
  };
3904
4483
  add(diff2.missing, "new");
3905
4484
  add(diff2.changed, "changed");
3906
4485
  if (includeUnchanged) {
3907
- add(diff2.unchanged, "changed");
4486
+ add(diff2.unchanged, "unchanged");
3908
4487
  }
3909
4488
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
3910
4489
  }
@@ -3923,7 +4502,9 @@ async function exportWorkbook(input, deps = {}) {
3923
4502
  source.resource,
3924
4503
  target,
3925
4504
  baselineFor(lock, locale),
3926
- input.includeUnchanged ?? false
4505
+ input.includeUnchanged ?? false,
4506
+ adapter,
4507
+ config.glossary
3927
4508
  );
3928
4509
  return { locale, rows };
3929
4510
  })
@@ -4024,9 +4605,14 @@ function importLocale(params) {
4024
4605
  integrityMismatches: [...buckets.mismatches].sort(),
4025
4606
  // A workbook import never calls a provider, so a provider-call failure cannot occur here.
4026
4607
  providerFailures: [],
4608
+ // A workbook import never calls a provider, so the budget guardrail never withholds anything here.
4609
+ budgetWithheld: [],
4027
4610
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
4028
4611
  generated: [],
4029
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
4612
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
4613
+ // A workbook import never calls a provider and never recomputes review flags on its own path; the
4614
+ // Excel workbook's Review status/reasons columns are informational only (see export-workbook.ts).
4615
+ needsReview: []
4030
4616
  };
4031
4617
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4032
4618
  }