@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/README.md CHANGED
@@ -71,7 +71,7 @@ export default defineConfig({
71
71
  });
72
72
  ```
73
73
 
74
- `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, and `arb`. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`).
74
+ `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, and `arb`. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` additionally takes a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
75
75
 
76
76
  ## API reference
77
77
 
package/dist/index.cjs CHANGED
@@ -503,16 +503,14 @@ function validateRequest(request) {
503
503
  return parsed.data;
504
504
  }
505
505
  function toIntegrityInputs(entries, values) {
506
- return entries.map((entry) => {
506
+ const inputs = [];
507
+ for (const entry of entries) {
507
508
  const translatedValue = values.get(entry.key);
508
- if (translatedValue === void 0) {
509
- throw new ProviderError(
510
- "INVALID_RESPONSE",
511
- "The provider response is missing one or more keys."
512
- );
509
+ if (translatedValue !== void 0) {
510
+ inputs.push({ key: entry.key, sourceValue: entry.value, translatedValue });
513
511
  }
514
- return { key: entry.key, sourceValue: entry.value, translatedValue };
515
- });
512
+ }
513
+ return inputs;
516
514
  }
517
515
  function toItem(entry) {
518
516
  return {
@@ -544,25 +542,29 @@ function deriveJsonSchema(schema) {
544
542
  }
545
543
  return result;
546
544
  }
547
- function reconcile(translations, requestedKeys) {
548
- const requested = new Set(requestedKeys);
549
- const values = /* @__PURE__ */ new Map();
545
+ function partitionTranslations(translations, requested) {
546
+ const firstSeen = /* @__PURE__ */ new Map();
547
+ const duplicated = /* @__PURE__ */ new Set();
550
548
  for (const { key, value } of translations) {
551
- if (!requested.has(key) || values.has(key)) {
552
- throw new ProviderError(
553
- "INVALID_RESPONSE",
554
- "The provider returned an unexpected or duplicate key."
555
- );
549
+ if (!requested.has(key)) {
550
+ throw new ProviderError("INVALID_RESPONSE", "The provider returned an unexpected key.");
556
551
  }
557
- values.set(key, value);
552
+ if (firstSeen.has(key)) {
553
+ duplicated.add(key);
554
+ continue;
555
+ }
556
+ firstSeen.set(key, value);
558
557
  }
559
- if (values.size !== requested.size) {
560
- throw new ProviderError(
561
- "INVALID_RESPONSE",
562
- "The provider response is missing one or more keys."
563
- );
558
+ return { firstSeen, duplicated };
559
+ }
560
+ function reconcile(translations, requestedKeys) {
561
+ const requested = new Set(requestedKeys);
562
+ const { firstSeen, duplicated } = partitionTranslations(translations, requested);
563
+ const missingKeys = requestedKeys.filter((key) => duplicated.has(key) || !firstSeen.has(key));
564
+ for (const key of duplicated) {
565
+ firstSeen.delete(key);
564
566
  }
565
- return values;
567
+ return { accepted: firstSeen, missingKeys };
566
568
  }
567
569
  function reconcileResult(raw, requestedKeys) {
568
570
  const parsed = translationsResultSchema.safeParse(raw);
@@ -574,22 +576,54 @@ function reconcileResult(raw, requestedKeys) {
574
576
  }
575
577
  return reconcile(parsed.data.translations, requestedKeys);
576
578
  }
579
+ var MAX_REPAIR_ROUNDS = 1;
577
580
  async function runLlmTranslation(request, mechanism) {
578
581
  const data = validateRequest(request);
582
+ const signal = request.signal;
583
+ const first = await requestTranslations(mechanism, data, signal);
584
+ const values = first.outcome.accepted;
585
+ let usage = first.completion.usage;
586
+ let toRepair = entriesFor(data.entries, first.outcome.missingKeys);
587
+ for (let round = 0; round < MAX_REPAIR_ROUNDS && toRepair.length > 0; round += 1) {
588
+ const repair = await requestTranslations(mechanism, { ...data, entries: toRepair }, signal);
589
+ for (const [key, value] of repair.outcome.accepted) {
590
+ values.set(key, value);
591
+ }
592
+ usage = mergeUsage(usage, repair.completion.usage);
593
+ toRepair = entriesFor(data.entries, repair.outcome.missingKeys);
594
+ }
595
+ const integrity = checkBatchIntegrity(
596
+ toIntegrityInputs(data.entries, values),
597
+ request.extractPlaceholders,
598
+ request.comparePlaceholders
599
+ );
600
+ return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
601
+ }
602
+ async function requestTranslations(mechanism, data, signal) {
579
603
  const payloadJson = JSON.stringify(buildDataPayload(data));
580
604
  const requestedKeys = data.entries.map((entry) => entry.key);
581
605
  const completion = await mechanism.translate({
582
606
  payloadJson,
583
607
  requestedKeys,
584
- ...request.signal !== void 0 ? { signal: request.signal } : {}
608
+ ...signal !== void 0 ? { signal } : {}
585
609
  });
586
- const values = reconcileResult(completion.raw, requestedKeys);
587
- const integrity = checkBatchIntegrity(
588
- toIntegrityInputs(data.entries, values),
589
- request.extractPlaceholders,
590
- request.comparePlaceholders
591
- );
592
- return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
610
+ return { completion, outcome: reconcileResult(completion.raw, requestedKeys) };
611
+ }
612
+ function entriesFor(entries, keys) {
613
+ const wanted = new Set(keys);
614
+ return entries.filter((entry) => wanted.has(entry.key));
615
+ }
616
+ function mergeUsage(first, second) {
617
+ if (first === void 0) {
618
+ return second;
619
+ }
620
+ if (second === void 0) {
621
+ return first;
622
+ }
623
+ return {
624
+ inputTokens: first.inputTokens + second.inputTokens,
625
+ outputTokens: first.outputTokens + second.outputTokens
626
+ };
593
627
  }
594
628
  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.";
595
629
  function assertNotTruncated(truncated) {
@@ -773,6 +807,50 @@ function createDefaultClient2() {
773
807
  };
774
808
  return { client, freeAccount };
775
809
  }
810
+ var DEEPL_MAX_TEXTS_PER_REQUEST = 50;
811
+ var DEEPL_MAX_PAYLOAD_BYTES = 128 * 1024;
812
+ var DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES = 4 * 1024;
813
+ var DEEPL_MAX_TEXT_PAYLOAD_BYTES = DEEPL_MAX_PAYLOAD_BYTES - DEEPL_PAYLOAD_OVERHEAD_RESERVE_BYTES;
814
+ function estimateWireBytes(text) {
815
+ return encodeURIComponent(text).length;
816
+ }
817
+ function chunkTextsForDeepL(texts) {
818
+ const chunks = [];
819
+ let current = [];
820
+ let currentBytes = 0;
821
+ for (const text of texts) {
822
+ const textBytes = estimateWireBytes(text);
823
+ const startsNewChunk = current.length > 0 && (current.length >= DEEPL_MAX_TEXTS_PER_REQUEST || currentBytes + textBytes > DEEPL_MAX_TEXT_PAYLOAD_BYTES);
824
+ if (startsNewChunk) {
825
+ chunks.push(current);
826
+ current = [];
827
+ currentBytes = 0;
828
+ }
829
+ current.push(text);
830
+ currentBytes += textBytes;
831
+ }
832
+ if (current.length > 0) {
833
+ chunks.push(current);
834
+ }
835
+ return chunks;
836
+ }
837
+ var DEPRECATED_BARE_TARGET_CODES = /* @__PURE__ */ new Set(["en", "pt"]);
838
+ function assertValidDeepLSourceLocale(locale) {
839
+ if (locale.includes("-")) {
840
+ throw new ProviderError(
841
+ "INVALID_REQUEST",
842
+ `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").`
843
+ );
844
+ }
845
+ }
846
+ function assertValidDeepLTargetLocale(locale) {
847
+ if (DEPRECATED_BARE_TARGET_CODES.has(locale.toLowerCase())) {
848
+ throw new ProviderError(
849
+ "INVALID_REQUEST",
850
+ `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).`
851
+ );
852
+ }
853
+ }
776
854
  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.";
777
855
  function partitionByPlaceholders(entries) {
778
856
  const protectable = [];
@@ -837,7 +915,10 @@ function createDeepLProvider(config, deps = {}) {
837
915
  return {
838
916
  id: PROVIDER_ID2,
839
917
  kind: "machine-translation",
840
- supportsGlossary: true,
918
+ // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
919
+ // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
920
+ // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
921
+ supportsGlossary: validConfig.glossaryId !== void 0,
841
922
  translateBatch: (request) => translate(bundle, validConfig, request)
842
923
  };
843
924
  }
@@ -849,6 +930,8 @@ function resolveClient(deps) {
849
930
  }
850
931
  async function translate(bundle, config, request) {
851
932
  const data = validateRequest(request);
933
+ assertValidDeepLSourceLocale(data.sourceLocale);
934
+ assertValidDeepLTargetLocale(data.targetLocale);
852
935
  const { protectable, unprotectable } = partitionByPlaceholders(data.entries);
853
936
  const genericGlossarySupplied = request.glossary !== void 0 && Object.keys(request.glossary).length > 0;
854
937
  const { options, notices } = buildTranslateOptions({
@@ -876,7 +959,7 @@ async function translateProtectable(client, data, protectable, options, extract,
876
959
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
877
960
  }
878
961
  const texts = protectable.map((entry) => entry.value);
879
- const results = await callClient2(
962
+ const results = await callClientChunked(
880
963
  client,
881
964
  texts,
882
965
  data.sourceLocale,
@@ -894,6 +977,14 @@ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
894
977
  signal
895
978
  );
896
979
  }
980
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
981
+ const results = [];
982
+ for (const chunk2 of chunkTextsForDeepL(texts)) {
983
+ const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
984
+ results.push(...chunkResults);
985
+ }
986
+ return results;
987
+ }
897
988
  var geminiConfigSchema = zod.z.object({
898
989
  model: zod.z.string().min(1),
899
990
  maxOutputTokens: zod.z.number().int().positive()
@@ -1141,16 +1232,19 @@ function buildResponseFormat(mode) {
1141
1232
  }
1142
1233
  };
1143
1234
  }
1144
- function buildOpenAiRequest(config, payloadJson, mode = "strict-schema") {
1145
- return {
1146
- model: config.model,
1147
- max_completion_tokens: config.maxOutputTokens,
1235
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema", tokenLimitField = "max_completion_tokens") {
1236
+ const base = {
1148
1237
  messages: [
1149
1238
  { role: "system", content: OPENAI_SYSTEM_RULES },
1150
1239
  { role: "user", content: payloadJson }
1151
1240
  ],
1241
+ model: config.model,
1152
1242
  response_format: buildResponseFormat(mode)
1153
1243
  };
1244
+ if (tokenLimitField === "max_tokens") {
1245
+ return { ...base, max_tokens: config.maxOutputTokens };
1246
+ }
1247
+ return { ...base, max_completion_tokens: config.maxOutputTokens };
1154
1248
  }
1155
1249
  function advanceStringScan(char, escaped) {
1156
1250
  if (escaped) {
@@ -1321,7 +1415,7 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1321
1415
  function createMechanism4(client, config) {
1322
1416
  return {
1323
1417
  translate: async ({ payloadJson, signal }) => {
1324
- const body = buildOpenAiRequest(config, payloadJson, "strict-schema");
1418
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1325
1419
  const completion = await callClient5(client, body, signal);
1326
1420
  return extractOpenAiResult(completion, true);
1327
1421
  }
@@ -1362,6 +1456,17 @@ function buildProvider(config) {
1362
1456
 
1363
1457
  // src/config/schema.ts
1364
1458
  var DEFAULT_MAX_BATCH_SIZE = 50;
1459
+ function findCaseInsensitiveDuplicate(locales) {
1460
+ const seen = /* @__PURE__ */ new Set();
1461
+ for (const locale of locales) {
1462
+ const key = locale.toLowerCase();
1463
+ if (seen.has(key)) {
1464
+ return locale;
1465
+ }
1466
+ seen.add(key);
1467
+ }
1468
+ return void 0;
1469
+ }
1365
1470
  var verbatraConfigSchema = zod.z.strictObject({
1366
1471
  sourceLocale: zod.z.string().min(1),
1367
1472
  targetLocales: zod.z.array(zod.z.string().min(1)).min(1),
@@ -1403,6 +1508,14 @@ var verbatraConfigSchema = zod.z.strictObject({
1403
1508
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1404
1509
  message: "targetLocales must not include the source locale",
1405
1510
  path: ["targetLocales"]
1511
+ }).refine((config) => findCaseInsensitiveDuplicate(config.targetLocales) === void 0, {
1512
+ error: (issue) => {
1513
+ const duplicate = findCaseInsensitiveDuplicate(
1514
+ issue.input.targetLocales
1515
+ );
1516
+ return `targetLocales must not contain case-insensitively duplicate locales: "${duplicate}"`;
1517
+ },
1518
+ path: ["targetLocales"]
1406
1519
  }).refine((config) => config.files.pattern.includes(LOCALE_TOKEN), {
1407
1520
  message: `files.pattern must contain the ${LOCALE_TOKEN} token`,
1408
1521
  path: ["files", "pattern"]
@@ -1922,6 +2035,9 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1922
2035
  throw error;
1923
2036
  }
1924
2037
  }
2038
+ function stripLeadingBom(content) {
2039
+ return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
2040
+ }
1925
2041
  async function readBoundedUtf82(handle, size) {
1926
2042
  const buffer = Buffer.allocUnsafe(size);
1927
2043
  let offset = 0;
@@ -1932,7 +2048,7 @@ async function readBoundedUtf82(handle, size) {
1932
2048
  }
1933
2049
  offset += bytesRead;
1934
2050
  }
1935
- return buffer.toString("utf8", 0, offset);
2051
+ return stripLeadingBom(buffer.toString("utf8", 0, offset));
1936
2052
  }
1937
2053
  async function readBounded2(filePath) {
1938
2054
  const handle = await promises.open(filePath, "r");
@@ -2115,11 +2231,24 @@ function unflattenEntries(entries) {
2115
2231
  }
2116
2232
  return root;
2117
2233
  }
2118
- function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
2234
+ function mergeDescriptions(entries, content, deriveDescriptions) {
2235
+ if (!deriveDescriptions) {
2236
+ return;
2237
+ }
2238
+ for (const [key, description] of deriveDescriptions(content)) {
2239
+ const entry = entries.get(key);
2240
+ if (entry !== void 0) {
2241
+ entries.set(key, { ...entry, description });
2242
+ }
2243
+ }
2244
+ }
2245
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree, deriveDescriptions) {
2119
2246
  try {
2120
2247
  const tree = parse3(content);
2121
2248
  validateTree?.(tree);
2122
- return flattenTree(tree, namespace, deriveEntry, keyMode);
2249
+ const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2250
+ mergeDescriptions(entries, content, deriveDescriptions);
2251
+ return entries;
2123
2252
  } catch (error) {
2124
2253
  rethrowStructured(error, "The file could not be parsed.");
2125
2254
  }
@@ -2138,6 +2267,7 @@ function createTreeFileAdapter(options) {
2138
2267
  validateTree,
2139
2268
  buildWriteTree,
2140
2269
  comparePlaceholders,
2270
+ deriveDescriptions,
2141
2271
  keyMode = "literal-leaf"
2142
2272
  } = options;
2143
2273
  return {
@@ -2149,7 +2279,15 @@ function createTreeFileAdapter(options) {
2149
2279
  async read(filePath, locale) {
2150
2280
  const content = await readFileContent(filePath);
2151
2281
  const namespace = namespaceOf(filePath);
2152
- const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
2282
+ const entries = toEntries(
2283
+ content,
2284
+ namespace,
2285
+ parse3,
2286
+ deriveEntry,
2287
+ keyMode,
2288
+ validateTree,
2289
+ deriveDescriptions
2290
+ );
2153
2291
  const resource = { locale, namespace, format, entries };
2154
2292
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2155
2293
  return { resource, invalidIcuKeys };
@@ -2190,6 +2328,31 @@ function stripArbMetadata(tree) {
2190
2328
  function originalKey(encoded) {
2191
2329
  return decodeKeyToSegments(encoded).join(".");
2192
2330
  }
2331
+ function messageKeyForMetadata(key) {
2332
+ return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2333
+ }
2334
+ function descriptionOf(value) {
2335
+ if (typeof value !== "object" || value === null) {
2336
+ return void 0;
2337
+ }
2338
+ const description = value.description;
2339
+ return typeof description === "string" ? description : void 0;
2340
+ }
2341
+ function extractArbDescriptions(content) {
2342
+ const tree = parseArbObject(content);
2343
+ const out = /* @__PURE__ */ new Map();
2344
+ for (const [key, value] of Object.entries(tree)) {
2345
+ const messageKey = messageKeyForMetadata(key);
2346
+ if (messageKey === null) {
2347
+ continue;
2348
+ }
2349
+ const description = descriptionOf(value);
2350
+ if (description !== void 0) {
2351
+ out.set(encodeSegment(messageKey), description);
2352
+ }
2353
+ }
2354
+ return out;
2355
+ }
2193
2356
  function messagesFromEntries(entries) {
2194
2357
  const out = /* @__PURE__ */ new Map();
2195
2358
  for (const [key, entry] of entries) {
@@ -2246,7 +2409,8 @@ function createArbAdapter() {
2246
2409
  computeInvalidIcuKeys: icuInvalidKeys,
2247
2410
  validateMessage: icuIsValid,
2248
2411
  comparePlaceholders: compareIcuPlaceholders,
2249
- buildWriteTree: buildArbWriteTree
2412
+ buildWriteTree: buildArbWriteTree,
2413
+ deriveDescriptions: extractArbDescriptions
2250
2414
  });
2251
2415
  }
2252
2416
  function createJsonFileAdapter(options) {
@@ -2552,16 +2716,33 @@ function parseXml(content) {
2552
2716
  }
2553
2717
  return { doc, root };
2554
2718
  }
2719
+ function firstNoteText(parent) {
2720
+ const note = childByName(parent, "note");
2721
+ if (note === null) {
2722
+ return void 0;
2723
+ }
2724
+ const text = (note.textContent ?? "").trim();
2725
+ return text === "" ? void 0 : text;
2726
+ }
2727
+ function transUnitDescription(tu) {
2728
+ return firstNoteText(tu);
2729
+ }
2730
+ function unitDescription(unit) {
2731
+ const notes = childByName(unit, "notes");
2732
+ return notes === null ? void 0 : firstNoteText(notes);
2733
+ }
2555
2734
  function walkXliff12(root) {
2556
2735
  const units = [];
2557
2736
  collectByTag(root, "trans-unit").forEach((tu, index) => {
2558
2737
  const source = childByName(tu, "source");
2559
2738
  if (source !== null) {
2739
+ const description = transUnitDescription(tu);
2560
2740
  units.push({
2561
2741
  key: unitKey(tu, index),
2562
2742
  source,
2563
2743
  target: childByName(tu, "target"),
2564
- container: tu
2744
+ container: tu,
2745
+ ...description !== void 0 ? { description } : {}
2565
2746
  });
2566
2747
  }
2567
2748
  });
@@ -2571,12 +2752,19 @@ function walkXliff20(root) {
2571
2752
  const units = [];
2572
2753
  collectByTag(root, "unit").forEach((unit, index) => {
2573
2754
  const baseKey = unitKey(unit, index);
2755
+ const description = unitDescription(unit);
2574
2756
  const segments = elementChildren(unit).filter((el) => el.localName === "segment");
2575
2757
  segments.forEach((segment, segIndex) => {
2576
2758
  const source = childByName(segment, "source");
2577
2759
  if (source !== null) {
2578
2760
  const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
2579
- units.push({ key, source, target: childByName(segment, "target"), container: segment });
2761
+ units.push({
2762
+ key,
2763
+ source,
2764
+ target: childByName(segment, "target"),
2765
+ container: segment,
2766
+ ...description !== void 0 ? { description } : {}
2767
+ });
2580
2768
  }
2581
2769
  });
2582
2770
  });
@@ -2615,7 +2803,8 @@ function parseXliffEntries(content, namespace) {
2615
2803
  namespace,
2616
2804
  value,
2617
2805
  placeholders: extractXliffPlaceholders(value),
2618
- isPlural: false
2806
+ isPlural: false,
2807
+ ...unit.description !== void 0 ? { description: unit.description } : {}
2619
2808
  });
2620
2809
  }
2621
2810
  return out;
@@ -2975,15 +3164,8 @@ function subBatchFailedNotice(count, error) {
2975
3164
  }
2976
3165
 
2977
3166
  // src/flow/notices.ts
2978
- function isNotice(value) {
2979
- return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
2980
- }
2981
3167
  function readNotices(result) {
2982
- const candidate = result.notices;
2983
- if (!Array.isArray(candidate)) {
2984
- return [];
2985
- }
2986
- return candidate.filter(isNotice);
3168
+ return result.notices ?? [];
2987
3169
  }
2988
3170
 
2989
3171
  // src/flow/plural-categories.ts
@@ -3187,20 +3369,26 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3187
3369
  return [subBatchFailedNotice(batch.length, error)];
3188
3370
  }
3189
3371
  for (const item of batch) {
3190
- const value = result.values.get(item.targetKey);
3191
- const integrity = result.integrity.get(item.targetKey);
3192
- if (value !== void 0 && integrity?.matches === true) {
3193
- accepted.push({
3194
- targetKey: item.targetKey,
3195
- entry: { ...syntheticEntry(item), value },
3196
- lockHash: generatedLockHash(item.governingEntries, item.category)
3197
- });
3198
- } else {
3199
- withheld.push(item.targetKey);
3200
- }
3372
+ foldGenerationItem(item, result, accepted, withheld, providerFailures);
3201
3373
  }
3202
3374
  return readNotices(result);
3203
3375
  }
3376
+ function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3377
+ const value = result.values.get(item.targetKey);
3378
+ if (value === void 0) {
3379
+ providerFailures.push(item.targetKey);
3380
+ return;
3381
+ }
3382
+ if (result.integrity.get(item.targetKey)?.matches === true) {
3383
+ accepted.push({
3384
+ targetKey: item.targetKey,
3385
+ entry: { ...syntheticEntry(item), value },
3386
+ lockHash: generatedLockHash(item.governingEntries, item.category)
3387
+ });
3388
+ } else {
3389
+ withheld.push(item.targetKey);
3390
+ }
3391
+ }
3204
3392
 
3205
3393
  // src/flow/locale-run.ts
3206
3394
  function emptyResource(locale, format) {
@@ -3388,16 +3576,22 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3388
3576
  return [subBatchFailedNotice(batch.length, error)];
3389
3577
  }
3390
3578
  for (const entry of batch) {
3391
- const value = result.values.get(entry.key);
3392
- const integrity = result.integrity.get(entry.key);
3393
- if (value !== void 0 && integrity?.matches === true) {
3394
- accepted.set(entry.key, { value, source: entry });
3395
- } else {
3396
- integrityMismatches.push(entry.key);
3397
- }
3579
+ foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3398
3580
  }
3399
3581
  return readNotices(result);
3400
3582
  }
3583
+ function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3584
+ const value = result.values.get(entry.key);
3585
+ if (value === void 0) {
3586
+ providerFailures.push(entry.key);
3587
+ return;
3588
+ }
3589
+ if (result.integrity.get(entry.key)?.matches === true) {
3590
+ accepted.set(entry.key, { value, source: entry });
3591
+ } else {
3592
+ integrityMismatches.push(entry.key);
3593
+ }
3594
+ }
3401
3595
  function computeLockEntries(params, merged, withheld, generated) {
3402
3596
  const lockEntries = {};
3403
3597
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -3501,10 +3695,13 @@ var INSTRUCTIONS_LINES = [
3501
3695
  " structure and the argument names exactly. Translate only the human-readable text.",
3502
3696
  "6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
3503
3697
  " back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
3698
+ "7. The 'Context' column, when present, is reference text from the source file explaining what the",
3699
+ " string means or where it is used. It is read-only; leave it as-is.",
3504
3700
  "",
3505
3701
  "Status values:",
3506
3702
  " new - this string has no translation yet.",
3507
3703
  " changed - the source string changed and the translation needs updating.",
3704
+ " unchanged - already up to date, included for reference. No action needed.",
3508
3705
  "",
3509
3706
  "When you are done, save the file and send it back. verbatra checks every value on import:",
3510
3707
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
@@ -3516,7 +3713,8 @@ var COLUMN = {
3516
3713
  current: 3,
3517
3714
  status: 4,
3518
3715
  translation: 5,
3519
- sourceHash: 6
3716
+ sourceHash: 6,
3717
+ context: 7
3520
3718
  };
3521
3719
  var HEADERS = [
3522
3720
  "Key",
@@ -3524,7 +3722,8 @@ var HEADERS = [
3524
3722
  "Current translation",
3525
3723
  "Status",
3526
3724
  "Translation",
3527
- "Source hash"
3725
+ "Source hash",
3726
+ "Context"
3528
3727
  ];
3529
3728
  var HEADER_ROW = 1;
3530
3729
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3543,7 +3742,8 @@ var COLUMN_WIDTHS = {
3543
3742
  [COLUMN.source]: 50,
3544
3743
  [COLUMN.current]: 50,
3545
3744
  [COLUMN.status]: 12,
3546
- [COLUMN.translation]: 50
3745
+ [COLUMN.translation]: 50,
3746
+ [COLUMN.context]: 50
3547
3747
  };
3548
3748
  var TEXT_NUMBER_FORMAT = "@";
3549
3749
  function styleHeader(sheet) {
@@ -3574,7 +3774,8 @@ function writeRow(sheet, sheetRow) {
3574
3774
  translationCell.numFmt = TEXT_NUMBER_FORMAT;
3575
3775
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3576
3776
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3577
- for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
3777
+ row.getCell(COLUMN.context).value = sheetRow.context;
3778
+ for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
3578
3779
  const cell = row.getCell(column);
3579
3780
  cell.protection = { locked: column !== COLUMN.translation };
3580
3781
  if (column !== COLUMN.translation) {
@@ -3599,6 +3800,26 @@ function assertValidWorksheetName(locale) {
3599
3800
  );
3600
3801
  }
3601
3802
  }
3803
+ function assertNoWorksheetNameCollisions(sheets) {
3804
+ const reservedKey = INSTRUCTIONS_SHEET_NAME.toLowerCase();
3805
+ const seen = /* @__PURE__ */ new Set();
3806
+ for (const sheet of sheets) {
3807
+ const key = sheet.locale.toLowerCase();
3808
+ if (key === reservedKey) {
3809
+ throw new ExchangeError(
3810
+ "WORKBOOK_INVALID",
3811
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with the reserved "${INSTRUCTIONS_SHEET_NAME}" sheet.`
3812
+ );
3813
+ }
3814
+ if (seen.has(key)) {
3815
+ throw new ExchangeError(
3816
+ "WORKBOOK_INVALID",
3817
+ `The locale "${sheet.locale}" cannot be an Excel worksheet name: it collides with another target locale's sheet name.`
3818
+ );
3819
+ }
3820
+ seen.add(key);
3821
+ }
3822
+ }
3602
3823
  async function buildDataSheet(workbook, sheet) {
3603
3824
  assertValidWorksheetName(sheet.locale);
3604
3825
  const worksheet = workbook.addWorksheet(sheet.locale);
@@ -3625,6 +3846,7 @@ function buildInstructionsSheet(workbook) {
3625
3846
  sheet.getRow(1).font = { bold: true };
3626
3847
  }
3627
3848
  async function buildWorkbook(model) {
3849
+ assertNoWorksheetNameCollisions(model.sheets);
3628
3850
  const workbook = new ExcelJS__default.default.Workbook();
3629
3851
  buildInstructionsSheet(workbook);
3630
3852
  for (const sheet of model.sheets) {
@@ -3725,9 +3947,10 @@ var rowSchema = zod.z.object({
3725
3947
  key: zod.z.string().min(1),
3726
3948
  source: zod.z.string(),
3727
3949
  currentTarget: zod.z.string(),
3728
- status: zod.z.enum(["new", "changed"]),
3950
+ status: zod.z.enum(["new", "changed", "unchanged"]),
3729
3951
  sourceHash: zod.z.string(),
3730
- translation: zod.z.string()
3952
+ translation: zod.z.string(),
3953
+ context: zod.z.string()
3731
3954
  });
3732
3955
  function cellString(cell) {
3733
3956
  const value = cell.value;
@@ -3760,7 +3983,10 @@ function parseRow(sheet, row) {
3760
3983
  currentTarget: cellString(row.getCell(COLUMN.current)),
3761
3984
  status: cellString(row.getCell(COLUMN.status)),
3762
3985
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3763
- translation: cellString(row.getCell(COLUMN.translation))
3986
+ translation: cellString(row.getCell(COLUMN.translation)),
3987
+ // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3988
+ // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3989
+ context: cellString(row.getCell(COLUMN.context))
3764
3990
  };
3765
3991
  const result = rowSchema.safeParse(candidate);
3766
3992
  if (!result.success) {
@@ -3849,14 +4075,15 @@ function buildRows(source, target, baseline, includeUnchanged) {
3849
4075
  currentTarget: target.entries.get(key)?.value ?? "",
3850
4076
  status,
3851
4077
  sourceHash: contentHash(sourceEntry),
3852
- translation: ""
4078
+ translation: "",
4079
+ context: sourceEntry.description ?? ""
3853
4080
  });
3854
4081
  }
3855
4082
  };
3856
4083
  add(diff2.missing, "new");
3857
4084
  add(diff2.changed, "changed");
3858
4085
  if (includeUnchanged) {
3859
- add(diff2.unchanged, "changed");
4086
+ add(diff2.unchanged, "unchanged");
3860
4087
  }
3861
4088
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
3862
4089
  }