@verbatra/sdk 0.5.0-next.1 → 0.5.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -473,16 +473,14 @@ function validateRequest(request) {
473
473
  return parsed.data;
474
474
  }
475
475
  function toIntegrityInputs(entries, values) {
476
- return entries.map((entry) => {
476
+ const inputs = [];
477
+ for (const entry of entries) {
477
478
  const translatedValue = values.get(entry.key);
478
- if (translatedValue === void 0) {
479
- throw new ProviderError(
480
- "INVALID_RESPONSE",
481
- "The provider response is missing one or more keys."
482
- );
479
+ if (translatedValue !== void 0) {
480
+ inputs.push({ key: entry.key, sourceValue: entry.value, translatedValue });
483
481
  }
484
- return { key: entry.key, sourceValue: entry.value, translatedValue };
485
- });
482
+ }
483
+ return inputs;
486
484
  }
487
485
  function toItem(entry) {
488
486
  return {
@@ -514,25 +512,29 @@ function deriveJsonSchema(schema) {
514
512
  }
515
513
  return result;
516
514
  }
517
- function reconcile(translations, requestedKeys) {
518
- const requested = new Set(requestedKeys);
519
- const values = /* @__PURE__ */ new Map();
515
+ function partitionTranslations(translations, requested) {
516
+ const firstSeen = /* @__PURE__ */ new Map();
517
+ const duplicated = /* @__PURE__ */ new Set();
520
518
  for (const { key, value } of translations) {
521
- if (!requested.has(key) || values.has(key)) {
522
- throw new ProviderError(
523
- "INVALID_RESPONSE",
524
- "The provider returned an unexpected or duplicate key."
525
- );
519
+ if (!requested.has(key)) {
520
+ throw new ProviderError("INVALID_RESPONSE", "The provider returned an unexpected key.");
526
521
  }
527
- values.set(key, value);
522
+ if (firstSeen.has(key)) {
523
+ duplicated.add(key);
524
+ continue;
525
+ }
526
+ firstSeen.set(key, value);
528
527
  }
529
- if (values.size !== requested.size) {
530
- throw new ProviderError(
531
- "INVALID_RESPONSE",
532
- "The provider response is missing one or more keys."
533
- );
528
+ return { firstSeen, duplicated };
529
+ }
530
+ function reconcile(translations, requestedKeys) {
531
+ const requested = new Set(requestedKeys);
532
+ const { firstSeen, duplicated } = partitionTranslations(translations, requested);
533
+ const missingKeys = requestedKeys.filter((key) => duplicated.has(key) || !firstSeen.has(key));
534
+ for (const key of duplicated) {
535
+ firstSeen.delete(key);
534
536
  }
535
- return values;
537
+ return { accepted: firstSeen, missingKeys };
536
538
  }
537
539
  function reconcileResult(raw, requestedKeys) {
538
540
  const parsed = translationsResultSchema.safeParse(raw);
@@ -544,22 +546,54 @@ function reconcileResult(raw, requestedKeys) {
544
546
  }
545
547
  return reconcile(parsed.data.translations, requestedKeys);
546
548
  }
549
+ var MAX_REPAIR_ROUNDS = 1;
547
550
  async function runLlmTranslation(request, mechanism) {
548
551
  const data = validateRequest(request);
552
+ const signal = request.signal;
553
+ const first = await requestTranslations(mechanism, data, signal);
554
+ const values = first.outcome.accepted;
555
+ let usage = first.completion.usage;
556
+ let toRepair = entriesFor(data.entries, first.outcome.missingKeys);
557
+ for (let round = 0; round < MAX_REPAIR_ROUNDS && toRepair.length > 0; round += 1) {
558
+ const repair = await requestTranslations(mechanism, { ...data, entries: toRepair }, signal);
559
+ for (const [key, value] of repair.outcome.accepted) {
560
+ values.set(key, value);
561
+ }
562
+ usage = mergeUsage(usage, repair.completion.usage);
563
+ toRepair = entriesFor(data.entries, repair.outcome.missingKeys);
564
+ }
565
+ const integrity = checkBatchIntegrity(
566
+ toIntegrityInputs(data.entries, values),
567
+ request.extractPlaceholders,
568
+ request.comparePlaceholders
569
+ );
570
+ return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
571
+ }
572
+ async function requestTranslations(mechanism, data, signal) {
549
573
  const payloadJson = JSON.stringify(buildDataPayload(data));
550
574
  const requestedKeys = data.entries.map((entry) => entry.key);
551
575
  const completion = await mechanism.translate({
552
576
  payloadJson,
553
577
  requestedKeys,
554
- ...request.signal !== void 0 ? { signal: request.signal } : {}
578
+ ...signal !== void 0 ? { signal } : {}
555
579
  });
556
- const values = reconcileResult(completion.raw, requestedKeys);
557
- const integrity = checkBatchIntegrity(
558
- toIntegrityInputs(data.entries, values),
559
- request.extractPlaceholders,
560
- request.comparePlaceholders
561
- );
562
- return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
580
+ return { completion, outcome: reconcileResult(completion.raw, requestedKeys) };
581
+ }
582
+ function entriesFor(entries, keys) {
583
+ const wanted = new Set(keys);
584
+ return entries.filter((entry) => wanted.has(entry.key));
585
+ }
586
+ function mergeUsage(first, second) {
587
+ if (first === void 0) {
588
+ return second;
589
+ }
590
+ if (second === void 0) {
591
+ return first;
592
+ }
593
+ return {
594
+ inputTokens: first.inputTokens + second.inputTokens,
595
+ outputTokens: first.outputTokens + second.outputTokens
596
+ };
563
597
  }
564
598
  var OUTPUT_TRUNCATED_MESSAGE = "The provider stopped because the output-token limit was reached. Reduce the batch size or raise the configured max output tokens.";
565
599
  function assertNotTruncated(truncated) {
@@ -743,6 +777,50 @@ function createDefaultClient2() {
743
777
  };
744
778
  return { client, freeAccount };
745
779
  }
780
+ var DEEPL_MAX_TEXTS_PER_REQUEST = 50;
781
+ var DEEPL_MAX_PAYLOAD_BYTES = 128 * 1024;
782
+ var DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES = 4 * 1024;
783
+ var DEEPL_MAX_TEXT_PAYLOAD_BYTES = DEEPL_MAX_PAYLOAD_BYTES - DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES;
784
+ function estimateWireBytes(text) {
785
+ return encodeURIComponent(text).length;
786
+ }
787
+ function chunkTextsForDeepL(texts) {
788
+ const chunks = [];
789
+ let current = [];
790
+ let currentBytes = 0;
791
+ for (const text of texts) {
792
+ const textBytes = estimateWireBytes(text);
793
+ const startsNewChunk = current.length > 0 && (current.length >= DEEPL_MAX_TEXTS_PER_REQUEST || currentBytes + textBytes > DEEPL_MAX_TEXT_PAYLOAD_BYTES);
794
+ if (startsNewChunk) {
795
+ chunks.push(current);
796
+ current = [];
797
+ currentBytes = 0;
798
+ }
799
+ current.push(text);
800
+ currentBytes += textBytes;
801
+ }
802
+ if (current.length > 0) {
803
+ chunks.push(current);
804
+ }
805
+ return chunks;
806
+ }
807
+ var DEPRECATED_BARE_TARGET_CODES = /* @__PURE__ */ new Set(["en", "pt"]);
808
+ function assertValidDeepLSourceLocale(locale) {
809
+ if (locale.includes("-")) {
810
+ throw new ProviderError(
811
+ "INVALID_REQUEST",
812
+ `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").`
813
+ );
814
+ }
815
+ }
816
+ function assertValidDeepLTargetLocale(locale) {
817
+ if (DEPRECATED_BARE_TARGET_CODES.has(locale.toLowerCase())) {
818
+ throw new ProviderError(
819
+ "INVALID_REQUEST",
820
+ `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).`
821
+ );
822
+ }
823
+ }
746
824
  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.";
747
825
  function partitionByPlaceholders(entries) {
748
826
  const protectable = [];
@@ -807,7 +885,10 @@ function createDeepLProvider(config, deps = {}) {
807
885
  return {
808
886
  id: PROVIDER_ID2,
809
887
  kind: "machine-translation",
810
- supportsGlossary: true,
888
+ // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
889
+ // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
890
+ // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
891
+ supportsGlossary: validConfig.glossaryId !== void 0,
811
892
  translateBatch: (request) => translate(bundle, validConfig, request)
812
893
  };
813
894
  }
@@ -819,6 +900,8 @@ function resolveClient(deps) {
819
900
  }
820
901
  async function translate(bundle, config, request) {
821
902
  const data = validateRequest(request);
903
+ assertValidDeepLSourceLocale(data.sourceLocale);
904
+ assertValidDeepLTargetLocale(data.targetLocale);
822
905
  const { protectable, unprotectable } = partitionByPlaceholders(data.entries);
823
906
  const genericGlossarySupplied = request.glossary !== void 0 && Object.keys(request.glossary).length > 0;
824
907
  const { options, notices } = buildTranslateOptions({
@@ -846,7 +929,7 @@ async function translateProtectable(client, data, protectable, options, extract,
846
929
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
847
930
  }
848
931
  const texts = protectable.map((entry) => entry.value);
849
- const results = await callClient2(
932
+ const results = await callClientChunked(
850
933
  client,
851
934
  texts,
852
935
  data.sourceLocale,
@@ -864,6 +947,14 @@ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
864
947
  signal
865
948
  );
866
949
  }
950
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
951
+ const results = [];
952
+ for (const chunk2 of chunkTextsForDeepL(texts)) {
953
+ const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
954
+ results.push(...chunkResults);
955
+ }
956
+ return results;
957
+ }
867
958
  var geminiConfigSchema = z.object({
868
959
  model: z.string().min(1),
869
960
  maxOutputTokens: z.number().int().positive()
@@ -1111,16 +1202,19 @@ function buildResponseFormat(mode) {
1111
1202
  }
1112
1203
  };
1113
1204
  }
1114
- function buildOpenAiRequest(config, payloadJson, mode = "strict-schema") {
1115
- return {
1116
- model: config.model,
1117
- max_completion_tokens: config.maxOutputTokens,
1205
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema", tokenLimitField = "max_completion_tokens") {
1206
+ const base = {
1118
1207
  messages: [
1119
1208
  { role: "system", content: OPENAI_SYSTEM_RULES },
1120
1209
  { role: "user", content: payloadJson }
1121
1210
  ],
1211
+ model: config.model,
1122
1212
  response_format: buildResponseFormat(mode)
1123
1213
  };
1214
+ if (tokenLimitField === "max_tokens") {
1215
+ return { ...base, max_tokens: config.maxOutputTokens };
1216
+ }
1217
+ return { ...base, max_completion_tokens: config.maxOutputTokens };
1124
1218
  }
1125
1219
  function advanceStringScan(char, escaped) {
1126
1220
  if (escaped) {
@@ -1291,7 +1385,7 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1291
1385
  function createMechanism4(client, config) {
1292
1386
  return {
1293
1387
  translate: async ({ payloadJson, signal }) => {
1294
- const body = buildOpenAiRequest(config, payloadJson, "strict-schema");
1388
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1295
1389
  const completion = await callClient5(client, body, signal);
1296
1390
  return extractOpenAiResult(completion, true);
1297
1391
  }
@@ -1332,6 +1426,17 @@ function buildProvider(config) {
1332
1426
 
1333
1427
  // src/config/schema.ts
1334
1428
  var DEFAULT_MAX_BATCH_SIZE = 50;
1429
+ function findCaseInsensitiveDuplicate(locales) {
1430
+ const seen = /* @__PURE__ */ new Set();
1431
+ for (const locale of locales) {
1432
+ const key = locale.toLowerCase();
1433
+ if (seen.has(key)) {
1434
+ return locale;
1435
+ }
1436
+ seen.add(key);
1437
+ }
1438
+ return void 0;
1439
+ }
1335
1440
  var verbatraConfigSchema = z.strictObject({
1336
1441
  sourceLocale: z.string().min(1),
1337
1442
  targetLocales: z.array(z.string().min(1)).min(1),
@@ -1373,6 +1478,14 @@ var verbatraConfigSchema = z.strictObject({
1373
1478
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1374
1479
  message: "targetLocales must not include the source locale",
1375
1480
  path: ["targetLocales"]
1481
+ }).refine((config) => findCaseInsensitiveDuplicate(config.targetLocales) === void 0, {
1482
+ error: (issue) => {
1483
+ const duplicate = findCaseInsensitiveDuplicate(
1484
+ issue.input.targetLocales
1485
+ );
1486
+ return `targetLocales must not contain case-insensitively duplicate locales: "${duplicate}"`;
1487
+ },
1488
+ path: ["targetLocales"]
1376
1489
  }).refine((config) => config.files.pattern.includes(LOCALE_TOKEN), {
1377
1490
  message: `files.pattern must contain the ${LOCALE_TOKEN} token`,
1378
1491
  path: ["files", "pattern"]
@@ -1892,6 +2005,9 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1892
2005
  throw error;
1893
2006
  }
1894
2007
  }
2008
+ function stripLeadingBom(content) {
2009
+ return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
2010
+ }
1895
2011
  async function readBoundedUtf82(handle, size) {
1896
2012
  const buffer = Buffer.allocUnsafe(size);
1897
2013
  let offset = 0;
@@ -1902,7 +2018,7 @@ async function readBoundedUtf82(handle, size) {
1902
2018
  }
1903
2019
  offset += bytesRead;
1904
2020
  }
1905
- return buffer.toString("utf8", 0, offset);
2021
+ return stripLeadingBom(buffer.toString("utf8", 0, offset));
1906
2022
  }
1907
2023
  async function readBounded2(filePath) {
1908
2024
  const handle = await open(filePath, "r");
@@ -2085,11 +2201,24 @@ function unflattenEntries(entries) {
2085
2201
  }
2086
2202
  return root;
2087
2203
  }
2088
- function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
2204
+ function mergeDescriptions(entries, content, deriveDescriptions) {
2205
+ if (!deriveDescriptions) {
2206
+ return;
2207
+ }
2208
+ for (const [key, description] of deriveDescriptions(content)) {
2209
+ const entry = entries.get(key);
2210
+ if (entry !== void 0) {
2211
+ entries.set(key, { ...entry, description });
2212
+ }
2213
+ }
2214
+ }
2215
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree, deriveDescriptions) {
2089
2216
  try {
2090
2217
  const tree = parse3(content);
2091
2218
  validateTree?.(tree);
2092
- return flattenTree(tree, namespace, deriveEntry, keyMode);
2219
+ const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2220
+ mergeDescriptions(entries, content, deriveDescriptions);
2221
+ return entries;
2093
2222
  } catch (error) {
2094
2223
  rethrowStructured(error, "The file could not be parsed.");
2095
2224
  }
@@ -2108,6 +2237,7 @@ function createTreeFileAdapter(options) {
2108
2237
  validateTree,
2109
2238
  buildWriteTree,
2110
2239
  comparePlaceholders,
2240
+ deriveDescriptions,
2111
2241
  keyMode = "literal-leaf"
2112
2242
  } = options;
2113
2243
  return {
@@ -2119,7 +2249,15 @@ function createTreeFileAdapter(options) {
2119
2249
  async read(filePath, locale) {
2120
2250
  const content = await readFileContent(filePath);
2121
2251
  const namespace = namespaceOf(filePath);
2122
- const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
2252
+ const entries = toEntries(
2253
+ content,
2254
+ namespace,
2255
+ parse3,
2256
+ deriveEntry,
2257
+ keyMode,
2258
+ validateTree,
2259
+ deriveDescriptions
2260
+ );
2123
2261
  const resource = { locale, namespace, format, entries };
2124
2262
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2125
2263
  return { resource, invalidIcuKeys };
@@ -2160,6 +2298,31 @@ function stripArbMetadata(tree) {
2160
2298
  function originalKey(encoded) {
2161
2299
  return decodeKeyToSegments(encoded).join(".");
2162
2300
  }
2301
+ function messageKeyForMetadata(key) {
2302
+ return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2303
+ }
2304
+ function descriptionOf(value) {
2305
+ if (typeof value !== "object" || value === null) {
2306
+ return void 0;
2307
+ }
2308
+ const description = value.description;
2309
+ return typeof description === "string" ? description : void 0;
2310
+ }
2311
+ function extractArbDescriptions(content) {
2312
+ const tree = parseArbObject(content);
2313
+ const out = /* @__PURE__ */ new Map();
2314
+ for (const [key, value] of Object.entries(tree)) {
2315
+ const messageKey = messageKeyForMetadata(key);
2316
+ if (messageKey === null) {
2317
+ continue;
2318
+ }
2319
+ const description = descriptionOf(value);
2320
+ if (description !== void 0) {
2321
+ out.set(encodeSegment(messageKey), description);
2322
+ }
2323
+ }
2324
+ return out;
2325
+ }
2163
2326
  function messagesFromEntries(entries) {
2164
2327
  const out = /* @__PURE__ */ new Map();
2165
2328
  for (const [key, entry] of entries) {
@@ -2216,7 +2379,8 @@ function createArbAdapter() {
2216
2379
  computeInvalidIcuKeys: icuInvalidKeys,
2217
2380
  validateMessage: icuIsValid,
2218
2381
  comparePlaceholders: compareIcuPlaceholders,
2219
- buildWriteTree: buildArbWriteTree
2382
+ buildWriteTree: buildArbWriteTree,
2383
+ deriveDescriptions: extractArbDescriptions
2220
2384
  });
2221
2385
  }
2222
2386
  function createJsonFileAdapter(options) {
@@ -2522,16 +2686,33 @@ function parseXml(content) {
2522
2686
  }
2523
2687
  return { doc, root };
2524
2688
  }
2689
+ function firstNoteText(parent) {
2690
+ const note = childByName(parent, "note");
2691
+ if (note === null) {
2692
+ return void 0;
2693
+ }
2694
+ const text = (note.textContent ?? "").trim();
2695
+ return text === "" ? void 0 : text;
2696
+ }
2697
+ function transUnitDescription(tu) {
2698
+ return firstNoteText(tu);
2699
+ }
2700
+ function unitDescription(unit) {
2701
+ const notes = childByName(unit, "notes");
2702
+ return notes === null ? void 0 : firstNoteText(notes);
2703
+ }
2525
2704
  function walkXliff12(root) {
2526
2705
  const units = [];
2527
2706
  collectByTag(root, "trans-unit").forEach((tu, index) => {
2528
2707
  const source = childByName(tu, "source");
2529
2708
  if (source !== null) {
2709
+ const description = transUnitDescription(tu);
2530
2710
  units.push({
2531
2711
  key: unitKey(tu, index),
2532
2712
  source,
2533
2713
  target: childByName(tu, "target"),
2534
- container: tu
2714
+ container: tu,
2715
+ ...description !== void 0 ? { description } : {}
2535
2716
  });
2536
2717
  }
2537
2718
  });
@@ -2541,12 +2722,19 @@ function walkXliff20(root) {
2541
2722
  const units = [];
2542
2723
  collectByTag(root, "unit").forEach((unit, index) => {
2543
2724
  const baseKey = unitKey(unit, index);
2725
+ const description = unitDescription(unit);
2544
2726
  const segments = elementChildren(unit).filter((el) => el.localName === "segment");
2545
2727
  segments.forEach((segment, segIndex) => {
2546
2728
  const source = childByName(segment, "source");
2547
2729
  if (source !== null) {
2548
2730
  const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
2549
- units.push({ key, source, target: childByName(segment, "target"), container: segment });
2731
+ units.push({
2732
+ key,
2733
+ source,
2734
+ target: childByName(segment, "target"),
2735
+ container: segment,
2736
+ ...description !== void 0 ? { description } : {}
2737
+ });
2550
2738
  }
2551
2739
  });
2552
2740
  });
@@ -2585,7 +2773,8 @@ function parseXliffEntries(content, namespace) {
2585
2773
  namespace,
2586
2774
  value,
2587
2775
  placeholders: extractXliffPlaceholders(value),
2588
- isPlural: false
2776
+ isPlural: false,
2777
+ ...unit.description !== void 0 ? { description: unit.description } : {}
2589
2778
  });
2590
2779
  }
2591
2780
  return out;
@@ -2945,15 +3134,8 @@ function subBatchFailedNotice(count, error) {
2945
3134
  }
2946
3135
 
2947
3136
  // src/flow/notices.ts
2948
- function isNotice(value) {
2949
- return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
2950
- }
2951
3137
  function readNotices(result) {
2952
- const candidate = result.notices;
2953
- if (!Array.isArray(candidate)) {
2954
- return [];
2955
- }
2956
- return candidate.filter(isNotice);
3138
+ return result.notices ?? [];
2957
3139
  }
2958
3140
 
2959
3141
  // src/flow/plural-categories.ts
@@ -3157,20 +3339,26 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3157
3339
  return [subBatchFailedNotice(batch.length, error)];
3158
3340
  }
3159
3341
  for (const item of batch) {
3160
- const value = result.values.get(item.targetKey);
3161
- const integrity = result.integrity.get(item.targetKey);
3162
- if (value !== void 0 && integrity?.matches === true) {
3163
- accepted.push({
3164
- targetKey: item.targetKey,
3165
- entry: { ...syntheticEntry(item), value },
3166
- lockHash: generatedLockHash(item.governingEntries, item.category)
3167
- });
3168
- } else {
3169
- withheld.push(item.targetKey);
3170
- }
3342
+ foldGenerationItem(item, result, accepted, withheld, providerFailures);
3171
3343
  }
3172
3344
  return readNotices(result);
3173
3345
  }
3346
+ function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3347
+ const value = result.values.get(item.targetKey);
3348
+ if (value === void 0) {
3349
+ providerFailures.push(item.targetKey);
3350
+ return;
3351
+ }
3352
+ if (result.integrity.get(item.targetKey)?.matches === true) {
3353
+ accepted.push({
3354
+ targetKey: item.targetKey,
3355
+ entry: { ...syntheticEntry(item), value },
3356
+ lockHash: generatedLockHash(item.governingEntries, item.category)
3357
+ });
3358
+ } else {
3359
+ withheld.push(item.targetKey);
3360
+ }
3361
+ }
3174
3362
 
3175
3363
  // src/flow/locale-run.ts
3176
3364
  function emptyResource(locale, format) {
@@ -3358,16 +3546,22 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3358
3546
  return [subBatchFailedNotice(batch.length, error)];
3359
3547
  }
3360
3548
  for (const entry of batch) {
3361
- const value = result.values.get(entry.key);
3362
- const integrity = result.integrity.get(entry.key);
3363
- if (value !== void 0 && integrity?.matches === true) {
3364
- accepted.set(entry.key, { value, source: entry });
3365
- } else {
3366
- integrityMismatches.push(entry.key);
3367
- }
3549
+ foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3368
3550
  }
3369
3551
  return readNotices(result);
3370
3552
  }
3553
+ function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3554
+ const value = result.values.get(entry.key);
3555
+ if (value === void 0) {
3556
+ providerFailures.push(entry.key);
3557
+ return;
3558
+ }
3559
+ if (result.integrity.get(entry.key)?.matches === true) {
3560
+ accepted.set(entry.key, { value, source: entry });
3561
+ } else {
3562
+ integrityMismatches.push(entry.key);
3563
+ }
3564
+ }
3371
3565
  function computeLockEntries(params, merged, withheld, generated) {
3372
3566
  const lockEntries = {};
3373
3567
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -3471,10 +3665,13 @@ var INSTRUCTIONS_LINES = [
3471
3665
  " structure and the argument names exactly. Translate only the human-readable text.",
3472
3666
  "6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
3473
3667
  " back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
3668
+ "7. The 'Context' column, when present, is reference text from the source file explaining what the",
3669
+ " string means or where it is used. It is read-only; leave it as-is.",
3474
3670
  "",
3475
3671
  "Status values:",
3476
3672
  " new - this string has no translation yet.",
3477
3673
  " changed - the source string changed and the translation needs updating.",
3674
+ " unchanged - already up to date, included for reference. No action needed.",
3478
3675
  "",
3479
3676
  "When you are done, save the file and send it back. verbatra checks every value on import:",
3480
3677
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
@@ -3486,7 +3683,8 @@ var COLUMN = {
3486
3683
  current: 3,
3487
3684
  status: 4,
3488
3685
  translation: 5,
3489
- sourceHash: 6
3686
+ sourceHash: 6,
3687
+ context: 7
3490
3688
  };
3491
3689
  var HEADERS = [
3492
3690
  "Key",
@@ -3494,7 +3692,8 @@ var HEADERS = [
3494
3692
  "Current translation",
3495
3693
  "Status",
3496
3694
  "Translation",
3497
- "Source hash"
3695
+ "Source hash",
3696
+ "Context"
3498
3697
  ];
3499
3698
  var HEADER_ROW = 1;
3500
3699
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3513,7 +3712,8 @@ var COLUMN_WIDTHS = {
3513
3712
  [COLUMN.source]: 50,
3514
3713
  [COLUMN.current]: 50,
3515
3714
  [COLUMN.status]: 12,
3516
- [COLUMN.translation]: 50
3715
+ [COLUMN.translation]: 50,
3716
+ [COLUMN.context]: 50
3517
3717
  };
3518
3718
  var TEXT_NUMBER_FORMAT = "@";
3519
3719
  function styleHeader(sheet) {
@@ -3544,7 +3744,8 @@ function writeRow(sheet, sheetRow) {
3544
3744
  translationCell.numFmt = TEXT_NUMBER_FORMAT;
3545
3745
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3546
3746
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3547
- for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
3747
+ row.getCell(COLUMN.context).value = sheetRow.context;
3748
+ for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
3548
3749
  const cell = row.getCell(column);
3549
3750
  cell.protection = { locked: column !== COLUMN.translation };
3550
3751
  if (column !== COLUMN.translation) {
@@ -3569,6 +3770,26 @@ function assertValidWorksheetName(locale) {
3569
3770
  );
3570
3771
  }
3571
3772
  }
3773
+ function assertNoWorksheetNameCollisions(sheets) {
3774
+ const reservedKey = INSTRUCTIONS_SHEET_NAME.toLowerCase();
3775
+ const seen = /* @__PURE__ */ new Set();
3776
+ for (const sheet of sheets) {
3777
+ const key = sheet.locale.toLowerCase();
3778
+ if (key === reservedKey) {
3779
+ throw new ExchangeError(
3780
+ "WORKBOOK_INVALID",
3781
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with the reserved "${INSTRUCTIONS_SHEET_NAME}" sheet.`
3782
+ );
3783
+ }
3784
+ if (seen.has(key)) {
3785
+ throw new ExchangeError(
3786
+ "WORKBOOK_INVALID",
3787
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with another target locale's sheet name.`
3788
+ );
3789
+ }
3790
+ seen.add(key);
3791
+ }
3792
+ }
3572
3793
  async function buildDataSheet(workbook, sheet) {
3573
3794
  assertValidWorksheetName(sheet.locale);
3574
3795
  const worksheet = workbook.addWorksheet(sheet.locale);
@@ -3595,6 +3816,7 @@ function buildInstructionsSheet(workbook) {
3595
3816
  sheet.getRow(1).font = { bold: true };
3596
3817
  }
3597
3818
  async function buildWorkbook(model) {
3819
+ assertNoWorksheetNameCollisions(model.sheets);
3598
3820
  const workbook = new ExcelJS.Workbook();
3599
3821
  buildInstructionsSheet(workbook);
3600
3822
  for (const sheet of model.sheets) {
@@ -3695,9 +3917,10 @@ var rowSchema = z.object({
3695
3917
  key: z.string().min(1),
3696
3918
  source: z.string(),
3697
3919
  currentTarget: z.string(),
3698
- status: z.enum(["new", "changed"]),
3920
+ status: z.enum(["new", "changed", "unchanged"]),
3699
3921
  sourceHash: z.string(),
3700
- translation: z.string()
3922
+ translation: z.string(),
3923
+ context: z.string()
3701
3924
  });
3702
3925
  function cellString(cell) {
3703
3926
  const value = cell.value;
@@ -3730,7 +3953,10 @@ function parseRow(sheet, row) {
3730
3953
  currentTarget: cellString(row.getCell(COLUMN.current)),
3731
3954
  status: cellString(row.getCell(COLUMN.status)),
3732
3955
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3733
- translation: cellString(row.getCell(COLUMN.translation))
3956
+ translation: cellString(row.getCell(COLUMN.translation)),
3957
+ // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3958
+ // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3959
+ context: cellString(row.getCell(COLUMN.context))
3734
3960
  };
3735
3961
  const result = rowSchema.safeParse(candidate);
3736
3962
  if (!result.success) {
@@ -3819,14 +4045,15 @@ function buildRows(source, target, baseline, includeUnchanged) {
3819
4045
  currentTarget: target.entries.get(key)?.value ?? "",
3820
4046
  status,
3821
4047
  sourceHash: contentHash(sourceEntry),
3822
- translation: ""
4048
+ translation: "",
4049
+ context: sourceEntry.description ?? ""
3823
4050
  });
3824
4051
  }
3825
4052
  };
3826
4053
  add(diff2.missing, "new");
3827
4054
  add(diff2.changed, "changed");
3828
4055
  if (includeUnchanged) {
3829
- add(diff2.unchanged, "changed");
4056
+ add(diff2.unchanged, "unchanged");
3830
4057
  }
3831
4058
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
3832
4059
  }