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

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
@@ -11,9 +11,9 @@ import { createRequire } from 'module';
11
11
  import log from 'loglevel';
12
12
  import { GoogleGenAI } from '@google/genai';
13
13
  import OpenAI from 'openai';
14
- import { parse as parse$1, TYPE } from '@formatjs/icu-messageformat-parser';
14
+ import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
15
15
  import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
- import { stringify, parse } from 'yaml';
16
+ import { stringify, parse as parse$1 } from 'yaml';
17
17
  import ExcelJS from 'exceljs';
18
18
  import { Readable } from 'stream';
19
19
  import JSZip from 'jszip';
@@ -311,6 +311,85 @@ var LOCALE_TOKEN = "{locale}";
311
311
  function localeFilePath(cwd, pattern, locale) {
312
312
  return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
313
313
  }
314
+ var RATE_LIMITED_STATUS = 429;
315
+ var TIMEOUT_STATUS = 408;
316
+ var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
317
+ var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
318
+ "RateLimitError",
319
+ // openai, @anthropic-ai/sdk
320
+ "TooManyRequestsError"
321
+ // deepl-node
322
+ ]);
323
+ var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
324
+ "AuthenticationError",
325
+ // openai, @anthropic-ai/sdk (401)
326
+ "PermissionDeniedError",
327
+ // openai, @anthropic-ai/sdk (403)
328
+ "AuthorizationError"
329
+ // deepl-node
330
+ ]);
331
+ var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
332
+ "APIConnectionTimeoutError",
333
+ // openai, @anthropic-ai/sdk
334
+ // deepl-node has no distinct timeout class; ConnectionError is its only network-failure
335
+ // class, so it is folded into TIMEOUT rather than the PROVIDER_ERROR fallback.
336
+ "ConnectionError"
337
+ ]);
338
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set([
339
+ "APIUserAbortError"
340
+ // openai, @anthropic-ai/sdk
341
+ ]);
342
+ function getErrorStatus(error) {
343
+ if (typeof error !== "object" || error === null || !("status" in error)) {
344
+ return void 0;
345
+ }
346
+ const status = error.status;
347
+ return typeof status === "number" ? status : void 0;
348
+ }
349
+ function readClassName(error) {
350
+ if (typeof error !== "object" || error === null) {
351
+ return void 0;
352
+ }
353
+ return error.constructor?.name;
354
+ }
355
+ function classifyProviderError(error) {
356
+ const status = getErrorStatus(error);
357
+ if (status === RATE_LIMITED_STATUS) {
358
+ return "RATE_LIMITED";
359
+ }
360
+ if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
361
+ return "AUTH_FAILED";
362
+ }
363
+ if (status === TIMEOUT_STATUS) {
364
+ return "TIMEOUT";
365
+ }
366
+ const className = readClassName(error);
367
+ if (className !== void 0) {
368
+ if (RATE_LIMITED_CLASS_NAMES.has(className)) {
369
+ return "RATE_LIMITED";
370
+ }
371
+ if (AUTH_FAILED_CLASS_NAMES.has(className)) {
372
+ return "AUTH_FAILED";
373
+ }
374
+ if (TIMEOUT_CLASS_NAMES.has(className)) {
375
+ return "TIMEOUT";
376
+ }
377
+ }
378
+ return "PROVIDER_ERROR";
379
+ }
380
+ function isAbortShapedError(error) {
381
+ if (error instanceof Error && error.name === "AbortError") {
382
+ return true;
383
+ }
384
+ const className = readClassName(error);
385
+ return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
386
+ }
387
+ function isAbortError(error, signal) {
388
+ if (signal !== void 0) {
389
+ return signal.aborted === true && isAbortShapedError(error);
390
+ }
391
+ return isAbortShapedError(error);
392
+ }
314
393
  var REDACTED = "[REDACTED]";
315
394
  var KEY_PATTERNS = [
316
395
  // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
@@ -345,17 +424,34 @@ var ProviderError = class extends Error {
345
424
  }
346
425
  };
347
426
  var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
348
- async function guardProviderCall(call) {
427
+ var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
428
+ var TIMEOUT_MESSAGE = "The translation provider request timed out.";
429
+ var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
430
+ var MESSAGE_BY_CODE = {
431
+ RATE_LIMITED: RATE_LIMITED_MESSAGE,
432
+ TIMEOUT: TIMEOUT_MESSAGE,
433
+ AUTH_FAILED: AUTH_FAILED_MESSAGE,
434
+ PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
435
+ };
436
+ async function guardProviderCall(call, signal) {
437
+ signal?.throwIfAborted();
349
438
  try {
350
439
  return await call();
351
- } catch {
352
- throw new ProviderError("PROVIDER_ERROR", PROVIDER_CALL_FAILED_MESSAGE);
440
+ } catch (error) {
441
+ if (isAbortError(error, signal)) {
442
+ throw error;
443
+ }
444
+ const code = classifyProviderError(error);
445
+ throw new ProviderError(code, MESSAGE_BY_CODE[code]);
353
446
  }
354
447
  }
355
- function checkBatchIntegrity(inputs, extract) {
448
+ function checkBatchIntegrity(inputs, extract, compare) {
356
449
  const integrity = /* @__PURE__ */ new Map();
357
- for (const { key, sourcePlaceholders, translatedValue } of inputs) {
358
- integrity.set(key, checkPlaceholders(sourcePlaceholders, extract(translatedValue)));
450
+ for (const { key, sourceValue, translatedValue } of inputs) {
451
+ integrity.set(
452
+ key,
453
+ compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
454
+ );
359
455
  }
360
456
  return integrity;
361
457
  }
@@ -377,16 +473,14 @@ function validateRequest(request) {
377
473
  return parsed.data;
378
474
  }
379
475
  function toIntegrityInputs(entries, values) {
380
- return entries.map((entry) => {
476
+ const inputs = [];
477
+ for (const entry of entries) {
381
478
  const translatedValue = values.get(entry.key);
382
- if (translatedValue === void 0) {
383
- throw new ProviderError(
384
- "INVALID_RESPONSE",
385
- "The provider response is missing one or more keys."
386
- );
479
+ if (translatedValue !== void 0) {
480
+ inputs.push({ key: entry.key, sourceValue: entry.value, translatedValue });
387
481
  }
388
- return { key: entry.key, sourcePlaceholders: entry.placeholders, translatedValue };
389
- });
482
+ }
483
+ return inputs;
390
484
  }
391
485
  function toItem(entry) {
392
486
  return {
@@ -418,25 +512,29 @@ function deriveJsonSchema(schema) {
418
512
  }
419
513
  return result;
420
514
  }
421
- function reconcile(translations, requestedKeys) {
422
- const requested = new Set(requestedKeys);
423
- const values = /* @__PURE__ */ new Map();
515
+ function partitionTranslations(translations, requested) {
516
+ const firstSeen = /* @__PURE__ */ new Map();
517
+ const duplicated = /* @__PURE__ */ new Set();
424
518
  for (const { key, value } of translations) {
425
- if (!requested.has(key) || values.has(key)) {
426
- throw new ProviderError(
427
- "INVALID_RESPONSE",
428
- "The provider returned an unexpected or duplicate key."
429
- );
519
+ if (!requested.has(key)) {
520
+ throw new ProviderError("INVALID_RESPONSE", "The provider returned an unexpected key.");
521
+ }
522
+ if (firstSeen.has(key)) {
523
+ duplicated.add(key);
524
+ continue;
430
525
  }
431
- values.set(key, value);
526
+ firstSeen.set(key, value);
432
527
  }
433
- if (values.size !== requested.size) {
434
- throw new ProviderError(
435
- "INVALID_RESPONSE",
436
- "The provider response is missing one or more keys."
437
- );
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);
438
536
  }
439
- return values;
537
+ return { accepted: firstSeen, missingKeys };
440
538
  }
441
539
  function reconcileResult(raw, requestedKeys) {
442
540
  const parsed = translationsResultSchema.safeParse(raw);
@@ -448,17 +546,54 @@ function reconcileResult(raw, requestedKeys) {
448
546
  }
449
547
  return reconcile(parsed.data.translations, requestedKeys);
450
548
  }
549
+ var MAX_REPAIR_ROUNDS = 1;
451
550
  async function runLlmTranslation(request, mechanism) {
452
551
  const data = validateRequest(request);
453
- const payloadJson = JSON.stringify(buildDataPayload(data));
454
- const requestedKeys = data.entries.map((entry) => entry.key);
455
- const completion = await mechanism.translate({ payloadJson, requestedKeys });
456
- const values = reconcileResult(completion.raw, requestedKeys);
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
+ }
457
565
  const integrity = checkBatchIntegrity(
458
566
  toIntegrityInputs(data.entries, values),
459
- request.extractPlaceholders
567
+ request.extractPlaceholders,
568
+ request.comparePlaceholders
460
569
  );
461
- return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
570
+ return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
571
+ }
572
+ async function requestTranslations(mechanism, data, signal) {
573
+ const payloadJson = JSON.stringify(buildDataPayload(data));
574
+ const requestedKeys = data.entries.map((entry) => entry.key);
575
+ const completion = await mechanism.translate({
576
+ payloadJson,
577
+ requestedKeys,
578
+ ...signal !== void 0 ? { signal } : {}
579
+ });
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
+ };
462
597
  }
463
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.";
464
599
  function assertNotTruncated(truncated) {
@@ -491,12 +626,29 @@ function requireGeminiKey() {
491
626
  function requireDeepLKey() {
492
627
  return readRequiredEnv(PROVIDER_ENV.deepl);
493
628
  }
629
+ var OPENAI_COMPATIBLE_ENV_VAR = "OPENAI_COMPATIBLE_API_KEY";
630
+ var OPENAI_COMPATIBLE_KEY_PLACEHOLDER = "local";
631
+ function resolveOpenAiCompatibleKey(customEnvVar) {
632
+ const varName = customEnvVar ?? OPENAI_COMPATIBLE_ENV_VAR;
633
+ const value = process.env[varName];
634
+ if (value !== void 0 && value.length > 0) {
635
+ return value;
636
+ }
637
+ if (customEnvVar !== void 0) {
638
+ throw new ProviderError(
639
+ "MISSING_API_KEY",
640
+ `The ${customEnvVar} environment variable is not set.`
641
+ );
642
+ }
643
+ return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
644
+ }
494
645
  function createDefaultClient() {
495
646
  const sdk = new Anthropic({ apiKey: requireAnthropicKey(), logLevel: "off" });
496
647
  return {
497
648
  messages: {
498
- create: async (body) => await sdk.messages.create(
499
- body
649
+ create: async (body, options) => await sdk.messages.create(
650
+ body,
651
+ options
500
652
  )
501
653
  }
502
654
  };
@@ -564,9 +716,9 @@ function createAnthropicProvider(config, deps = {}) {
564
716
  }
565
717
  function createMechanism(client, config) {
566
718
  return {
567
- translate: async ({ payloadJson }) => {
719
+ translate: async ({ payloadJson, signal }) => {
568
720
  const body = buildRequest(config, payloadJson);
569
- const message = await callClient(client, body);
721
+ const message = await callClient(client, body, signal);
570
722
  assertNotTruncated(message.stop_reason === "max_tokens");
571
723
  const raw = requireToolInput(message.content);
572
724
  const usage = toUsage(message.usage);
@@ -574,8 +726,11 @@ function createMechanism(client, config) {
574
726
  }
575
727
  };
576
728
  }
577
- function callClient(client, body) {
578
- return guardProviderCall(() => client.messages.create(body));
729
+ function callClient(client, body, signal) {
730
+ return guardProviderCall(
731
+ () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
732
+ signal
733
+ );
579
734
  }
580
735
  function toUsage(usage) {
581
736
  if (usage === void 0) {
@@ -670,7 +825,7 @@ function zipResults(entries, results) {
670
825
  values.set(entry.key, translatedValue);
671
826
  integrityInputs.push({
672
827
  key: entry.key,
673
- sourcePlaceholders: entry.placeholders,
828
+ sourceValue: entry.value,
674
829
  translatedValue
675
830
  });
676
831
  }
@@ -686,7 +841,10 @@ function createDeepLProvider(config, deps = {}) {
686
841
  return {
687
842
  id: PROVIDER_ID2,
688
843
  kind: "machine-translation",
689
- supportsGlossary: true,
844
+ // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
845
+ // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
846
+ // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
847
+ supportsGlossary: validConfig.glossaryId !== void 0,
690
848
  translateBatch: (request) => translate(bundle, validConfig, request)
691
849
  };
692
850
  }
@@ -711,36 +869,97 @@ async function translate(bundle, config, request) {
711
869
  data,
712
870
  protectable,
713
871
  options,
714
- request.extractPlaceholders
872
+ request.extractPlaceholders,
873
+ request.comparePlaceholders,
874
+ request.signal
715
875
  );
716
876
  if (unprotectable.length > 0) {
717
877
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
718
878
  }
719
879
  return { values, integrity, notices };
720
880
  }
721
- async function translateProtectable(client, data, protectable, options, extract) {
881
+ async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
722
882
  if (protectable.length === 0) {
723
883
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
724
884
  }
725
885
  const texts = protectable.map((entry) => entry.value);
726
- const results = await callClient2(client, texts, data.sourceLocale, data.targetLocale, options);
886
+ const results = await callClient2(
887
+ client,
888
+ texts,
889
+ data.sourceLocale,
890
+ data.targetLocale,
891
+ options,
892
+ signal
893
+ );
727
894
  const { values, integrityInputs } = zipResults(protectable, results);
728
- const integrity = checkBatchIntegrity(integrityInputs, extract);
895
+ const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
729
896
  return { values, integrity };
730
897
  }
731
- function callClient2(client, texts, sourceLang, targetLang, options) {
732
- return guardProviderCall(() => client.translateText(texts, sourceLang, targetLang, options));
898
+ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
899
+ return guardProviderCall(
900
+ () => client.translateText(texts, sourceLang, targetLang, options),
901
+ signal
902
+ );
733
903
  }
734
904
  var geminiConfigSchema = z.object({
735
905
  model: z.string().min(1),
736
906
  maxOutputTokens: z.number().int().positive()
737
907
  });
908
+ var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
909
+ function isRetryableStatus(error) {
910
+ const status = getErrorStatus(error);
911
+ return status === 429 || status !== void 0 && status >= 500 && status < 600;
912
+ }
913
+ function isAborted(signal) {
914
+ return signal?.aborted === true;
915
+ }
916
+ function throwAbort(signal) {
917
+ signal?.throwIfAborted();
918
+ throw new DOMException("This operation was aborted.", "AbortError");
919
+ }
920
+ function delay(ms, signal) {
921
+ return new Promise((resolve7) => {
922
+ const onAbort = () => {
923
+ clearTimeout(timer);
924
+ resolve7();
925
+ };
926
+ const timer = setTimeout(() => {
927
+ signal?.removeEventListener("abort", onAbort);
928
+ resolve7();
929
+ }, ms);
930
+ signal?.addEventListener("abort", onAbort, { once: true });
931
+ });
932
+ }
933
+ async function withGeminiRetry(call, signal, config = DEFAULT_GEMINI_RETRY) {
934
+ let attempt = 1;
935
+ while (true) {
936
+ try {
937
+ return await call();
938
+ } catch (error) {
939
+ if (isAborted(signal)) {
940
+ throwAbort(signal);
941
+ }
942
+ const exhausted = attempt >= config.attempts;
943
+ if (exhausted || !isRetryableStatus(error)) {
944
+ throw error;
945
+ }
946
+ await delay(config.baseDelayMs * 2 ** (attempt - 1), signal);
947
+ if (isAborted(signal)) {
948
+ throwAbort(signal);
949
+ }
950
+ attempt += 1;
951
+ }
952
+ }
953
+ }
738
954
  function createDefaultClient3() {
739
955
  const ai = new GoogleGenAI({ apiKey: requireGeminiKey() });
740
956
  return {
741
957
  models: {
742
- generateContent: async (request) => await ai.models.generateContent(
743
- request
958
+ generateContent: (request) => withGeminiRetry(
959
+ async () => await ai.models.generateContent(
960
+ request
961
+ ),
962
+ request.config.abortSignal
744
963
  )
745
964
  }
746
965
  };
@@ -804,7 +1023,7 @@ var GEMINI_SYSTEM_RULES = [
804
1023
  "When a tone is provided, honor it.",
805
1024
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
806
1025
  ].join("\n");
807
- function buildGeminiRequest(config, payloadJson) {
1026
+ function buildGeminiRequest(config, payloadJson, signal) {
808
1027
  return {
809
1028
  model: config.model,
810
1029
  contents: [{ role: "user", parts: [{ text: payloadJson }] }],
@@ -812,7 +1031,8 @@ function buildGeminiRequest(config, payloadJson) {
812
1031
  systemInstruction: GEMINI_SYSTEM_RULES,
813
1032
  responseMimeType: "application/json",
814
1033
  responseSchema: toGeminiSchema(deriveJsonSchema(translationsResultSchema)),
815
- maxOutputTokens: config.maxOutputTokens
1034
+ maxOutputTokens: config.maxOutputTokens,
1035
+ ...signal !== void 0 ? { abortSignal: signal } : {}
816
1036
  }
817
1037
  };
818
1038
  }
@@ -876,15 +1096,15 @@ function createGeminiProvider(config, deps = {}) {
876
1096
  }
877
1097
  function createMechanism2(client, config) {
878
1098
  return {
879
- translate: async ({ payloadJson }) => {
880
- const request = buildGeminiRequest(config, payloadJson);
881
- const response = await callClient3(client, request);
1099
+ translate: async ({ payloadJson, signal }) => {
1100
+ const request = buildGeminiRequest(config, payloadJson, signal);
1101
+ const response = await callClient3(client, request, signal);
882
1102
  return extractGeminiResult(response);
883
1103
  }
884
1104
  };
885
1105
  }
886
- function callClient3(client, request) {
887
- return guardProviderCall(() => client.models.generateContent(request));
1106
+ function callClient3(client, request, signal) {
1107
+ return guardProviderCall(() => client.models.generateContent(request), signal);
888
1108
  }
889
1109
  var openAiConfigSchema = z.object({
890
1110
  model: z.string().min(1),
@@ -895,8 +1115,9 @@ function createDefaultClient4() {
895
1115
  return {
896
1116
  chat: {
897
1117
  completions: {
898
- create: async (body) => await sdk.chat.completions.create(
899
- body
1118
+ create: async (body, options) => await sdk.chat.completions.create(
1119
+ body,
1120
+ options
900
1121
  )
901
1122
  }
902
1123
  }
@@ -914,27 +1135,90 @@ var OPENAI_SYSTEM_RULES = [
914
1135
  "When a tone is provided, honor it.",
915
1136
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
916
1137
  ].join("\n");
917
- function buildOpenAiRequest(config, payloadJson) {
1138
+ function buildResponseFormat(mode) {
1139
+ if (mode === "json-object") {
1140
+ return { type: "json_object" };
1141
+ }
918
1142
  return {
919
- model: config.model,
920
- max_completion_tokens: config.maxOutputTokens,
1143
+ type: "json_schema",
1144
+ json_schema: {
1145
+ name: RESULT_SCHEMA_NAME,
1146
+ strict: true,
1147
+ schema: deriveJsonSchema(translationsResultSchema)
1148
+ }
1149
+ };
1150
+ }
1151
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema", tokenLimitField = "max_completion_tokens") {
1152
+ const base = {
921
1153
  messages: [
922
1154
  { role: "system", content: OPENAI_SYSTEM_RULES },
923
1155
  { role: "user", content: payloadJson }
924
1156
  ],
925
- response_format: {
926
- type: "json_schema",
927
- json_schema: {
928
- name: RESULT_SCHEMA_NAME,
929
- strict: true,
930
- schema: deriveJsonSchema(translationsResultSchema)
1157
+ model: config.model,
1158
+ response_format: buildResponseFormat(mode)
1159
+ };
1160
+ if (tokenLimitField === "max_tokens") {
1161
+ return { ...base, max_tokens: config.maxOutputTokens };
1162
+ }
1163
+ return { ...base, max_completion_tokens: config.maxOutputTokens };
1164
+ }
1165
+ function advanceStringScan(char, escaped) {
1166
+ if (escaped) {
1167
+ return { inString: true, escaped: false };
1168
+ }
1169
+ if (char === "\\") {
1170
+ return { inString: true, escaped: true };
1171
+ }
1172
+ return { inString: char !== '"', escaped: false };
1173
+ }
1174
+ function scanBalancedObjectEnd(content, start) {
1175
+ let depth = 0;
1176
+ let inString = false;
1177
+ let escaped = false;
1178
+ for (let i = start; i < content.length; i += 1) {
1179
+ const char = content[i];
1180
+ if (inString) {
1181
+ ({ inString, escaped } = advanceStringScan(char, escaped));
1182
+ continue;
1183
+ }
1184
+ if (char === '"') {
1185
+ inString = true;
1186
+ } else if (char === "{") {
1187
+ depth += 1;
1188
+ } else if (char === "}") {
1189
+ depth -= 1;
1190
+ if (depth === 0) {
1191
+ return i;
931
1192
  }
932
1193
  }
933
- };
1194
+ }
1195
+ return null;
934
1196
  }
935
- function parseContent2(content) {
1197
+ function extractBalancedJson(content) {
1198
+ let searchFrom = 0;
1199
+ while (searchFrom < content.length) {
1200
+ const start = content.indexOf("{", searchFrom);
1201
+ if (start === -1) {
1202
+ return null;
1203
+ }
1204
+ const end = scanBalancedObjectEnd(content, start);
1205
+ if (end === null) {
1206
+ return null;
1207
+ }
1208
+ const candidate = content.slice(start, end + 1);
1209
+ try {
1210
+ JSON.parse(candidate);
1211
+ return candidate;
1212
+ } catch {
1213
+ searchFrom = end + 1;
1214
+ }
1215
+ }
1216
+ return null;
1217
+ }
1218
+ function parseContent2(content, tolerant) {
1219
+ const candidate = tolerant ? extractBalancedJson(content) ?? content : content;
936
1220
  try {
937
- return JSON.parse(content);
1221
+ return JSON.parse(candidate);
938
1222
  } catch {
939
1223
  throw new ProviderError("INVALID_RESPONSE", "The provider returned unparseable content.");
940
1224
  }
@@ -949,7 +1233,7 @@ function toUsage3(usage) {
949
1233
  }
950
1234
  return { inputTokens: prompt_tokens, outputTokens: completion_tokens };
951
1235
  }
952
- function extractOpenAiResult(completion) {
1236
+ function extractOpenAiResult(completion, tolerant = false) {
953
1237
  const choice = completion.choices[0];
954
1238
  if (choice === void 0) {
955
1239
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no message.");
@@ -962,7 +1246,7 @@ function extractOpenAiResult(completion) {
962
1246
  if (message.content === void 0 || message.content === null) {
963
1247
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no translation content.");
964
1248
  }
965
- const raw = parseContent2(message.content);
1249
+ const raw = parseContent2(message.content, tolerant);
966
1250
  const usage = toUsage3(completion.usage);
967
1251
  return usage === void 0 ? { raw } : { raw, usage };
968
1252
  }
@@ -980,15 +1264,84 @@ function createOpenAiProvider(config, deps = {}) {
980
1264
  }
981
1265
  function createMechanism3(client, config) {
982
1266
  return {
983
- translate: async ({ payloadJson }) => {
1267
+ translate: async ({ payloadJson, signal }) => {
984
1268
  const body = buildOpenAiRequest(config, payloadJson);
985
- const completion = await callClient4(client, body);
1269
+ const completion = await callClient4(client, body, signal);
986
1270
  return extractOpenAiResult(completion);
987
1271
  }
988
1272
  };
989
1273
  }
990
- function callClient4(client, body) {
991
- return guardProviderCall(() => client.chat.completions.create(body));
1274
+ function callClient4(client, body, signal) {
1275
+ return guardProviderCall(
1276
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1277
+ signal
1278
+ );
1279
+ }
1280
+ var HOSTED_PROVIDER_ENV_VARS = new Set(
1281
+ Object.values(PROVIDER_ENV).map((name) => name.toUpperCase())
1282
+ );
1283
+ function isNotHostedProviderEnvVar(value) {
1284
+ return !HOSTED_PROVIDER_ENV_VARS.has(value.toUpperCase());
1285
+ }
1286
+ function isHttpOrHttpsUrl(value) {
1287
+ try {
1288
+ const { protocol } = new URL(value);
1289
+ return protocol === "http:" || protocol === "https:";
1290
+ } catch {
1291
+ return false;
1292
+ }
1293
+ }
1294
+ var openAiCompatibleConfigSchema = z.object({
1295
+ baseUrl: z.url({ message: "baseUrl must be a valid absolute URL." }).refine(isHttpOrHttpsUrl, { message: "baseUrl must use the http or https scheme." }),
1296
+ model: z.string().min(1),
1297
+ maxOutputTokens: z.number().int().positive(),
1298
+ apiKeyEnvVar: z.string().min(1).refine(isNotHostedProviderEnvVar, {
1299
+ message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1300
+ }).optional()
1301
+ });
1302
+ function createDefaultClient5(config) {
1303
+ const sdk = new OpenAI({
1304
+ apiKey: resolveOpenAiCompatibleKey(config.apiKeyEnvVar),
1305
+ baseURL: config.baseUrl,
1306
+ logLevel: "off"
1307
+ });
1308
+ return {
1309
+ chat: {
1310
+ completions: {
1311
+ create: async (body, options) => await sdk.chat.completions.create(
1312
+ body,
1313
+ options
1314
+ )
1315
+ }
1316
+ }
1317
+ };
1318
+ }
1319
+ var PROVIDER_ID5 = "openai-compatible";
1320
+ function createOpenAiCompatibleProvider(config, deps = {}) {
1321
+ const validConfig = openAiCompatibleConfigSchema.parse(config);
1322
+ const client = deps.client ?? createDefaultClient5(validConfig);
1323
+ const mechanism = createMechanism4(client, validConfig);
1324
+ return {
1325
+ id: PROVIDER_ID5,
1326
+ kind: "llm",
1327
+ supportsGlossary: true,
1328
+ translateBatch: (request) => runLlmTranslation(request, mechanism)
1329
+ };
1330
+ }
1331
+ function createMechanism4(client, config) {
1332
+ return {
1333
+ translate: async ({ payloadJson, signal }) => {
1334
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1335
+ const completion = await callClient5(client, body, signal);
1336
+ return extractOpenAiResult(completion, true);
1337
+ }
1338
+ };
1339
+ }
1340
+ function callClient5(client, body, signal) {
1341
+ return guardProviderCall(
1342
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1343
+ signal
1344
+ );
992
1345
  }
993
1346
  var SCAFFOLD_MODELS = {
994
1347
  anthropic: "claude-sonnet-4-6",
@@ -999,13 +1352,18 @@ var providerConfigSchema = z.discriminatedUnion("id", [
999
1352
  z.object({ id: z.literal("anthropic"), options: anthropicConfigSchema.strict() }),
1000
1353
  z.object({ id: z.literal("openai"), options: openAiConfigSchema.strict() }),
1001
1354
  z.object({ id: z.literal("gemini"), options: geminiConfigSchema.strict() }),
1002
- z.object({ id: z.literal("deepl"), options: deepLConfigSchema.strict() })
1355
+ z.object({ id: z.literal("deepl"), options: deepLConfigSchema.strict() }),
1356
+ z.object({
1357
+ id: z.literal("openai-compatible"),
1358
+ options: openAiCompatibleConfigSchema.strict()
1359
+ })
1003
1360
  ]);
1004
1361
  var providerFactories = {
1005
1362
  anthropic: (options) => createAnthropicProvider(options),
1006
1363
  openai: (options) => createOpenAiProvider(options),
1007
1364
  gemini: (options) => createGeminiProvider(options),
1008
- deepl: (options) => createDeepLProvider(options)
1365
+ deepl: (options) => createDeepLProvider(options),
1366
+ "openai-compatible": (options) => createOpenAiCompatibleProvider(options)
1009
1367
  };
1010
1368
  function buildProvider(config) {
1011
1369
  const create = providerFactories[config.id];
@@ -1184,6 +1542,12 @@ async function readLockFile(path, fs) {
1184
1542
  if (!result.success) {
1185
1543
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1186
1544
  }
1545
+ if (result.data.version !== CURRENT_VERSION) {
1546
+ throw new SdkError(
1547
+ "LOCK_FILE_INVALID",
1548
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
1549
+ );
1550
+ }
1187
1551
  return result.data;
1188
1552
  }
1189
1553
  function baselineFor(lock, locale) {
@@ -1289,7 +1653,7 @@ function analyzeIcuValue(value) {
1289
1653
  return VALID_EMPTY;
1290
1654
  }
1291
1655
  try {
1292
- const ast = parse$1(value);
1656
+ const ast = parse(value);
1293
1657
  const state = { isPlural: false };
1294
1658
  const multiset = collect(ast, state);
1295
1659
  return { placeholders: flatten(multiset), isPlural: state.isPlural, valid: true };
@@ -1316,6 +1680,150 @@ function icuInvalidKeys(entries) {
1316
1680
  }
1317
1681
  return invalid;
1318
1682
  }
1683
+ function tokenOf2(element) {
1684
+ switch (element.type) {
1685
+ case TYPE.argument:
1686
+ case TYPE.number:
1687
+ case TYPE.date:
1688
+ case TYPE.time:
1689
+ case TYPE.select:
1690
+ case TYPE.plural:
1691
+ return `{${element.value}}`;
1692
+ case TYPE.tag:
1693
+ return `<${element.value}>`;
1694
+ default:
1695
+ return void 0;
1696
+ }
1697
+ }
1698
+ function isBranching(element) {
1699
+ return element.type === TYPE.plural || element.type === TYPE.select;
1700
+ }
1701
+ function isTag(element) {
1702
+ return element.type === TYPE.tag;
1703
+ }
1704
+ function layerTokens(elements) {
1705
+ const tokens = [];
1706
+ for (const element of elements) {
1707
+ const token = tokenOf2(element);
1708
+ if (token !== void 0) {
1709
+ tokens.push(token);
1710
+ }
1711
+ }
1712
+ return tokens;
1713
+ }
1714
+ function deepTokens(elements) {
1715
+ const tokens = [];
1716
+ for (const element of elements) {
1717
+ const token = tokenOf2(element);
1718
+ if (token !== void 0) {
1719
+ tokens.push(token);
1720
+ }
1721
+ if (isTag(element)) {
1722
+ tokens.push(...deepTokens(element.children));
1723
+ } else if (isBranching(element)) {
1724
+ for (const option of Object.values(element.options)) {
1725
+ tokens.push(...deepTokens(option.value));
1726
+ }
1727
+ }
1728
+ }
1729
+ return tokens;
1730
+ }
1731
+ function branchesByCategory(element) {
1732
+ return new Map(
1733
+ Object.entries(element.options).map(([category, option]) => [category, option.value])
1734
+ );
1735
+ }
1736
+ function findUnconsumed(target, consumed, isMatch) {
1737
+ for (let index = 0; index < target.length; index += 1) {
1738
+ if (consumed.has(index)) {
1739
+ continue;
1740
+ }
1741
+ const candidate = target[index];
1742
+ if (candidate !== void 0 && isMatch(candidate)) {
1743
+ return { element: candidate, index };
1744
+ }
1745
+ }
1746
+ return void 0;
1747
+ }
1748
+ function findMatchingBranching(source, target, consumed) {
1749
+ return findUnconsumed(
1750
+ target,
1751
+ consumed,
1752
+ (candidate) => isBranching(candidate) && candidate.value === source.value
1753
+ );
1754
+ }
1755
+ function findMatchingTag(source, target, consumed) {
1756
+ return findUnconsumed(
1757
+ target,
1758
+ consumed,
1759
+ (candidate) => isTag(candidate) && candidate.value === source.value
1760
+ );
1761
+ }
1762
+ function combineResults(results) {
1763
+ const missing = results.flatMap((result) => result.missing).sort();
1764
+ const extra = results.flatMap((result) => result.extra).sort();
1765
+ return { matches: missing.length === 0 && extra.length === 0, missing, extra, reordered: false };
1766
+ }
1767
+ function compareAgainstSourceUnion(sourceBranches, targetBranch) {
1768
+ const union = /* @__PURE__ */ new Set();
1769
+ for (const branch of sourceBranches.values()) {
1770
+ for (const token of deepTokens(branch)) {
1771
+ union.add(token);
1772
+ }
1773
+ }
1774
+ const extra = deepTokens(targetBranch).filter((token) => !union.has(token)).sort();
1775
+ return { matches: extra.length === 0, missing: [], extra, reordered: false };
1776
+ }
1777
+ function compareBranching(source, target) {
1778
+ const sourceBranches = branchesByCategory(source);
1779
+ const targetBranches = branchesByCategory(target);
1780
+ const categories = /* @__PURE__ */ new Set([...sourceBranches.keys(), ...targetBranches.keys()]);
1781
+ const results = [];
1782
+ for (const category of categories) {
1783
+ const sourceBranch = sourceBranches.get(category);
1784
+ const targetBranch = targetBranches.get(category);
1785
+ if (sourceBranch !== void 0 && targetBranch !== void 0) {
1786
+ results.push(compareElements(sourceBranch, targetBranch));
1787
+ } else if (targetBranch !== void 0) {
1788
+ results.push(compareAgainstSourceUnion(sourceBranches, targetBranch));
1789
+ }
1790
+ }
1791
+ return combineResults(results);
1792
+ }
1793
+ function nestedResults(source, target) {
1794
+ const results = [];
1795
+ const consumed = /* @__PURE__ */ new Set();
1796
+ for (const element of source) {
1797
+ if (isBranching(element)) {
1798
+ const found = findMatchingBranching(element, target, consumed);
1799
+ if (found !== void 0) {
1800
+ consumed.add(found.index);
1801
+ results.push(compareBranching(element, found.element));
1802
+ }
1803
+ } else if (isTag(element)) {
1804
+ const found = findMatchingTag(element, target, consumed);
1805
+ if (found !== void 0) {
1806
+ consumed.add(found.index);
1807
+ results.push(compareElements(element.children, found.element.children));
1808
+ }
1809
+ }
1810
+ }
1811
+ return results;
1812
+ }
1813
+ function compareElements(source, target) {
1814
+ const layer = checkPlaceholders(layerTokens(source), layerTokens(target));
1815
+ const nested = nestedResults(source, target);
1816
+ return nested.length === 0 ? layer : combineResults([layer, ...nested]);
1817
+ }
1818
+ function compareIcuPlaceholders(sourceValue, targetValue) {
1819
+ try {
1820
+ const sourceAst = parse(sourceValue);
1821
+ const targetAst = parse(targetValue);
1822
+ return compareElements(sourceAst, targetAst);
1823
+ } catch {
1824
+ return checkPlaceholders(icuPlaceholders(sourceValue), icuPlaceholders(targetValue));
1825
+ }
1826
+ }
1319
1827
  var AdapterError = class extends Error {
1320
1828
  code;
1321
1829
  constructor(code, message) {
@@ -1424,6 +1932,9 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1424
1932
  throw error;
1425
1933
  }
1426
1934
  }
1935
+ function stripLeadingBom(content) {
1936
+ return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
1937
+ }
1427
1938
  async function readBoundedUtf82(handle, size) {
1428
1939
  const buffer = Buffer.allocUnsafe(size);
1429
1940
  let offset = 0;
@@ -1434,7 +1945,7 @@ async function readBoundedUtf82(handle, size) {
1434
1945
  }
1435
1946
  offset += bytesRead;
1436
1947
  }
1437
- return buffer.toString("utf8", 0, offset);
1948
+ return stripLeadingBom(buffer.toString("utf8", 0, offset));
1438
1949
  }
1439
1950
  async function readBounded2(filePath) {
1440
1951
  const handle = await open(filePath, "r");
@@ -1617,9 +2128,9 @@ function unflattenEntries(entries) {
1617
2128
  }
1618
2129
  return root;
1619
2130
  }
1620
- function toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree) {
2131
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
1621
2132
  try {
1622
- const tree = parse2(content);
2133
+ const tree = parse3(content);
1623
2134
  validateTree?.(tree);
1624
2135
  return flattenTree(tree, namespace, deriveEntry, keyMode);
1625
2136
  } catch (error) {
@@ -1631,7 +2142,7 @@ function createTreeFileAdapter(options) {
1631
2142
  format,
1632
2143
  extensions,
1633
2144
  sniff,
1634
- parse: parse2,
2145
+ parse: parse3,
1635
2146
  serialize,
1636
2147
  deriveEntry,
1637
2148
  extractPlaceholders,
@@ -1639,6 +2150,7 @@ function createTreeFileAdapter(options) {
1639
2150
  validateMessage,
1640
2151
  validateTree,
1641
2152
  buildWriteTree,
2153
+ comparePlaceholders,
1642
2154
  keyMode = "literal-leaf"
1643
2155
  } = options;
1644
2156
  return {
@@ -1646,10 +2158,11 @@ function createTreeFileAdapter(options) {
1646
2158
  canHandle: buildCanHandle(extensions, sniff),
1647
2159
  extractPlaceholders,
1648
2160
  validateMessage: validateMessage ?? (() => true),
2161
+ ...comparePlaceholders !== void 0 ? { comparePlaceholders } : {},
1649
2162
  async read(filePath, locale) {
1650
2163
  const content = await readFileContent(filePath);
1651
2164
  const namespace = namespaceOf(filePath);
1652
- const entries = toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree);
2165
+ const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
1653
2166
  const resource = { locale, namespace, format, entries };
1654
2167
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1655
2168
  return { resource, invalidIcuKeys };
@@ -1745,6 +2258,7 @@ function createArbAdapter() {
1745
2258
  deriveEntry: icuDeriveEntry,
1746
2259
  computeInvalidIcuKeys: icuInvalidKeys,
1747
2260
  validateMessage: icuIsValid,
2261
+ comparePlaceholders: compareIcuPlaceholders,
1748
2262
  buildWriteTree: buildArbWriteTree
1749
2263
  });
1750
2264
  }
@@ -1808,7 +2322,8 @@ function createNextIntlJsonAdapter() {
1808
2322
  extractPlaceholders: icuPlaceholders,
1809
2323
  deriveEntry: icuDeriveEntry,
1810
2324
  computeInvalidIcuKeys: icuInvalidKeys,
1811
- validateMessage: icuIsValid
2325
+ validateMessage: icuIsValid,
2326
+ comparePlaceholders: compareIcuPlaceholders
1812
2327
  });
1813
2328
  }
1814
2329
  function assertNoDottedNestedKey(tree) {
@@ -2010,6 +2525,7 @@ function extractXliffPlaceholders(value) {
2010
2525
  return result;
2011
2526
  }
2012
2527
  var ELEMENT_NODE = 1;
2528
+ var TEXT_NODE = 3;
2013
2529
  function isElement(node) {
2014
2530
  return node.nodeType === ELEMENT_NODE;
2015
2531
  }
@@ -2100,6 +2616,12 @@ function parseXliffEntries(content, namespace) {
2100
2616
  const serializer = new XMLSerializer();
2101
2617
  const out = /* @__PURE__ */ new Map();
2102
2618
  for (const unit of walkUnits(root)) {
2619
+ if (out.has(unit.key)) {
2620
+ throw new AdapterError(
2621
+ "INVALID_STRUCTURE",
2622
+ "The XLIFF file has two trans-units with the same id."
2623
+ );
2624
+ }
2103
2625
  const value = unitValue(serializer, unit);
2104
2626
  out.set(unit.key, {
2105
2627
  key: unit.key,
@@ -2126,10 +2648,67 @@ async function readDestination(filePath) {
2126
2648
  }
2127
2649
  return outcome.content;
2128
2650
  }
2651
+ var XLIFF_INLINE_ELEMENTS = /* @__PURE__ */ new Set(["x", "g", "bx", "ex", "ph", "it", "mrk"]);
2652
+ var XLIFF_NAMESPACES = /* @__PURE__ */ new Set([
2653
+ "urn:oasis:names:tc:xliff:document:1.2",
2654
+ "urn:oasis:names:tc:xliff:document:2.0"
2655
+ ]);
2656
+ var INLINE_ELEMENT_ATTRIBUTES = {
2657
+ x: ["id", "ctype"],
2658
+ ph: ["id", "ctype"],
2659
+ g: ["id", "ctype"],
2660
+ bx: ["id", "rid", "ctype"],
2661
+ ex: ["id", "rid"],
2662
+ it: ["id", "pos", "ctype"],
2663
+ mrk: ["id", "mtype"]
2664
+ };
2665
+ function isAllowedInlineElement(element) {
2666
+ const namespace = element.namespaceURI;
2667
+ const hasAllowedNamespace = namespace === null || XLIFF_NAMESPACES.has(namespace);
2668
+ return hasAllowedNamespace && XLIFF_INLINE_ELEMENTS.has(element.localName ?? "");
2669
+ }
2670
+ function isAllowedFragmentNode(node) {
2671
+ if (node.nodeType === TEXT_NODE) {
2672
+ return true;
2673
+ }
2674
+ return isElement(node) && isAllowedInlineElement(node);
2675
+ }
2676
+ function allDescendantNodes(node) {
2677
+ const children = Array.from(node.childNodes);
2678
+ return children.flatMap((child) => [child, ...allDescendantNodes(child)]);
2679
+ }
2680
+ function hasDisallowedNode(root) {
2681
+ return allDescendantNodes(root).some((node) => !isAllowedFragmentNode(node));
2682
+ }
2683
+ function attributeNames(element) {
2684
+ const names = [];
2685
+ for (let i = 0; i < element.attributes.length; i += 1) {
2686
+ const attr = element.attributes.item(i);
2687
+ if (attr !== null) {
2688
+ names.push(attr.name);
2689
+ }
2690
+ }
2691
+ return names;
2692
+ }
2693
+ function sanitizeInlineAttributes(root) {
2694
+ for (const el of collectByTag(root, "*")) {
2695
+ const allowed = INLINE_ELEMENT_ATTRIBUTES[el.localName ?? ""] ?? [];
2696
+ for (const name of attributeNames(el)) {
2697
+ if (!allowed.includes(name)) {
2698
+ el.removeAttribute(name);
2699
+ }
2700
+ }
2701
+ }
2702
+ }
2129
2703
  function fragmentNodes(parser, value) {
2704
+ assertNoDoctype(value);
2130
2705
  try {
2131
2706
  const root = parser.parseFromString(`<wrapper>${value}</wrapper>`, "text/xml").documentElement;
2132
- return root === null ? null : Array.from(root.childNodes);
2707
+ if (root === null || hasDisallowedNode(root)) {
2708
+ return null;
2709
+ }
2710
+ sanitizeInlineAttributes(root);
2711
+ return Array.from(root.childNodes);
2133
2712
  } catch {
2134
2713
  return null;
2135
2714
  }
@@ -2179,7 +2758,7 @@ function createXliffAdapter() {
2179
2758
  function parseYamlObject(content) {
2180
2759
  let parsed;
2181
2760
  try {
2182
- parsed = parse(content, { maxAliasCount: 100 });
2761
+ parsed = parse$1(content, { maxAliasCount: 100 });
2183
2762
  } catch {
2184
2763
  throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2185
2764
  }
@@ -2409,15 +2988,8 @@ function subBatchFailedNotice(count, error) {
2409
2988
  }
2410
2989
 
2411
2990
  // src/flow/notices.ts
2412
- function isNotice(value) {
2413
- return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
2414
- }
2415
2991
  function readNotices(result) {
2416
- const candidate = result.notices;
2417
- if (!Array.isArray(candidate)) {
2418
- return [];
2419
- }
2420
- return candidate.filter(isNotice);
2992
+ return result.notices ?? [];
2421
2993
  }
2422
2994
 
2423
2995
  // src/flow/plural-categories.ts
@@ -2583,7 +3155,8 @@ function buildRequest2(context, entries) {
2583
3155
  entries,
2584
3156
  extractPlaceholders: context.adapter.extractPlaceholders,
2585
3157
  ...context.glossary !== void 0 ? { glossary: context.glossary } : {},
2586
- ...context.tone !== void 0 ? { tone: context.tone } : {}
3158
+ ...context.tone !== void 0 ? { tone: context.tone } : {},
3159
+ ...context.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: context.adapter.comparePlaceholders } : {}
2587
3160
  };
2588
3161
  }
2589
3162
  async function generatePluralForms(context) {
@@ -2620,20 +3193,26 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
2620
3193
  return [subBatchFailedNotice(batch.length, error)];
2621
3194
  }
2622
3195
  for (const item of batch) {
2623
- const value = result.values.get(item.targetKey);
2624
- const integrity = result.integrity.get(item.targetKey);
2625
- if (value !== void 0 && integrity?.matches === true) {
2626
- accepted.push({
2627
- targetKey: item.targetKey,
2628
- entry: { ...syntheticEntry(item), value },
2629
- lockHash: generatedLockHash(item.governingEntries, item.category)
2630
- });
2631
- } else {
2632
- withheld.push(item.targetKey);
2633
- }
3196
+ foldGenerationItem(item, result, accepted, withheld, providerFailures);
2634
3197
  }
2635
3198
  return readNotices(result);
2636
3199
  }
3200
+ function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3201
+ const value = result.values.get(item.targetKey);
3202
+ if (value === void 0) {
3203
+ providerFailures.push(item.targetKey);
3204
+ return;
3205
+ }
3206
+ if (result.integrity.get(item.targetKey)?.matches === true) {
3207
+ accepted.push({
3208
+ targetKey: item.targetKey,
3209
+ entry: { ...syntheticEntry(item), value },
3210
+ lockHash: generatedLockHash(item.governingEntries, item.category)
3211
+ });
3212
+ } else {
3213
+ withheld.push(item.targetKey);
3214
+ }
3215
+ }
2637
3216
 
2638
3217
  // src/flow/locale-run.ts
2639
3218
  function emptyResource(locale, format) {
@@ -2653,7 +3232,8 @@ function buildRequest3(params, entries) {
2653
3232
  entries,
2654
3233
  extractPlaceholders: params.adapter.extractPlaceholders,
2655
3234
  ...params.glossary !== void 0 ? { glossary: params.glossary } : {},
2656
- ...params.tone !== void 0 ? { tone: params.tone } : {}
3235
+ ...params.tone !== void 0 ? { tone: params.tone } : {},
3236
+ ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
2657
3237
  };
2658
3238
  }
2659
3239
  async function runLocale(params) {
@@ -2820,16 +3400,22 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
2820
3400
  return [subBatchFailedNotice(batch.length, error)];
2821
3401
  }
2822
3402
  for (const entry of batch) {
2823
- const value = result.values.get(entry.key);
2824
- const integrity = result.integrity.get(entry.key);
2825
- if (value !== void 0 && integrity?.matches === true) {
2826
- accepted.set(entry.key, { value, source: entry });
2827
- } else {
2828
- integrityMismatches.push(entry.key);
2829
- }
3403
+ foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
2830
3404
  }
2831
3405
  return readNotices(result);
2832
3406
  }
3407
+ function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3408
+ const value = result.values.get(entry.key);
3409
+ if (value === void 0) {
3410
+ providerFailures.push(entry.key);
3411
+ return;
3412
+ }
3413
+ if (result.integrity.get(entry.key)?.matches === true) {
3414
+ accepted.set(entry.key, { value, source: entry });
3415
+ } else {
3416
+ integrityMismatches.push(entry.key);
3417
+ }
3418
+ }
2833
3419
  function computeLockEntries(params, merged, withheld, generated) {
2834
3420
  const lockEntries = {};
2835
3421
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -3116,7 +3702,7 @@ async function streamEntryBounded(file, remaining) {
3116
3702
  "The workbook decompresses to more than the permitted maximum bytes."
3117
3703
  );
3118
3704
  }
3119
- return Buffer.concat(chunks).toString("utf8");
3705
+ return { raw, content: Buffer.concat(chunks).toString("utf8") };
3120
3706
  }
3121
3707
  async function guardWorkbookBytes(bytes, limits) {
3122
3708
  let zip;
@@ -3148,14 +3734,8 @@ async function guardWorkbookBytes(bytes, limits) {
3148
3734
  let actualTotal = 0;
3149
3735
  for (const file of files) {
3150
3736
  const remaining = limits.maxDecompressedBytes - actualTotal;
3151
- const content = await streamEntryBounded(file, remaining);
3152
- actualTotal += Buffer.byteLength(content, "utf8");
3153
- if (actualTotal > limits.maxDecompressedBytes) {
3154
- throw new ExchangeError(
3155
- "WORKBOOK_INVALID",
3156
- `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
3157
- );
3158
- }
3737
+ const { raw, content } = await streamEntryBounded(file, remaining);
3738
+ actualTotal += raw;
3159
3739
  assertNoDoctype2(file.name, content);
3160
3740
  }
3161
3741
  }
@@ -3344,10 +3924,7 @@ function judge(row, sourceEntry, adapter) {
3344
3924
  if (contentHash(sourceEntry) !== row.sourceHash) {
3345
3925
  return "drift";
3346
3926
  }
3347
- const integrity = checkPlaceholders(
3348
- sourceEntry.placeholders,
3349
- adapter.extractPlaceholders(row.translation)
3350
- );
3927
+ const integrity = adapter.comparePlaceholders !== void 0 ? adapter.comparePlaceholders(sourceEntry.value, row.translation) : checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(row.translation));
3351
3928
  if (!integrity.matches) {
3352
3929
  return "placeholder";
3353
3930
  }