@verbatra/sdk 0.4.4 → 0.5.0-next.1

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
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'fs';
2
- import { resolve, join, dirname, basename, extname } from 'path';
2
+ import { dirname, resolve, join, basename, extname } from 'path';
3
3
  import { cosmiconfig } from 'cosmiconfig';
4
4
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
+ import { randomUUID } from 'crypto';
6
+ import { access, writeFile, rename, rm, open } from 'fs/promises';
5
7
  import { z } from 'zod';
6
8
  import Anthropic from '@anthropic-ai/sdk';
7
9
  import * as deepl from 'deepl-node';
@@ -9,11 +11,9 @@ import { createRequire } from 'module';
9
11
  import log from 'loglevel';
10
12
  import { GoogleGenAI } from '@google/genai';
11
13
  import OpenAI from 'openai';
12
- import { randomUUID } from 'crypto';
13
- import { access, writeFile, rename, rm, open } from 'fs/promises';
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';
@@ -38,6 +38,155 @@ var SdkError = class extends Error {
38
38
  this.code = code;
39
39
  }
40
40
  };
41
+ async function readBoundedUtf8(handle, size) {
42
+ const buffer = Buffer.allocUnsafe(size);
43
+ let offset = 0;
44
+ while (offset < size) {
45
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
46
+ if (bytesRead === 0) {
47
+ break;
48
+ }
49
+ offset += bytesRead;
50
+ }
51
+ return buffer.toString("utf8", 0, offset);
52
+ }
53
+ async function readBounded(path, maxBytes) {
54
+ let handle;
55
+ try {
56
+ handle = await open(path, "r");
57
+ } catch {
58
+ return { kind: "missing" };
59
+ }
60
+ try {
61
+ const info = await handle.stat();
62
+ if (!info.isFile()) {
63
+ return { kind: "missing" };
64
+ }
65
+ if (info.size > maxBytes) {
66
+ return { kind: "too-large" };
67
+ }
68
+ return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+ async function readBoundedBytesInto(handle, size) {
74
+ const buffer = Buffer.allocUnsafeSlow(size);
75
+ let offset = 0;
76
+ while (offset < size) {
77
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
78
+ if (bytesRead === 0) {
79
+ break;
80
+ }
81
+ offset += bytesRead;
82
+ }
83
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
84
+ }
85
+ async function readBoundedBytes(path, maxBytes) {
86
+ let handle;
87
+ try {
88
+ handle = await open(path, "r");
89
+ } catch {
90
+ return { kind: "missing" };
91
+ }
92
+ try {
93
+ const info = await handle.stat();
94
+ if (!info.isFile()) {
95
+ return { kind: "missing" };
96
+ }
97
+ if (info.size > maxBytes) {
98
+ return { kind: "too-large" };
99
+ }
100
+ return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
101
+ } finally {
102
+ await handle.close();
103
+ }
104
+ }
105
+ function tempFileName(path) {
106
+ return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
107
+ }
108
+ async function atomicWrite(path, data) {
109
+ const tmp = tempFileName(path);
110
+ await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
111
+ try {
112
+ await rename(tmp, path);
113
+ } catch (error) {
114
+ await rm(tmp, { force: true });
115
+ throw error;
116
+ }
117
+ }
118
+ var defaultFs = {
119
+ async fileExists(path) {
120
+ try {
121
+ await access(path);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ },
127
+ readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
128
+ readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
129
+ writeFile: (path, data) => atomicWrite(path, data),
130
+ writeBytes: (path, data) => atomicWrite(path, data)
131
+ };
132
+ var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
133
+ var glossaryRecordSchema = z.record(z.string(), z.string());
134
+ var BOM = "\uFEFF";
135
+ var REPLACEMENT_CHARACTER = "\uFFFD";
136
+ var NUL = "\0";
137
+ function stripBom(content) {
138
+ return content.startsWith(BOM) ? content.slice(BOM.length) : content;
139
+ }
140
+ function looksLikeInvalidEncoding(content) {
141
+ return content.startsWith(REPLACEMENT_CHARACTER) || content.includes(NUL);
142
+ }
143
+ async function readGlossaryFile(resolvedPath, fs) {
144
+ const read = await fs.readFileBounded(resolvedPath, MAX_GLOSSARY_FILE_BYTES);
145
+ if (read.kind === "missing") {
146
+ throw new SdkError(
147
+ "CONFIG_INVALID",
148
+ `The glossary file at ${resolvedPath} was not found or could not be read.`
149
+ );
150
+ }
151
+ if (read.kind === "too-large") {
152
+ throw new SdkError(
153
+ "CONFIG_INVALID",
154
+ `The glossary file at ${resolvedPath} exceeds the maximum allowed size of ${MAX_GLOSSARY_FILE_BYTES} bytes.`
155
+ );
156
+ }
157
+ const content = stripBom(read.content);
158
+ if (looksLikeInvalidEncoding(content)) {
159
+ throw new SdkError(
160
+ "CONFIG_INVALID",
161
+ `The glossary file at ${resolvedPath} must be UTF-8 encoded.`
162
+ );
163
+ }
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(content);
167
+ } catch {
168
+ throw new SdkError("CONFIG_INVALID", `The glossary file at ${resolvedPath} is not valid JSON.`);
169
+ }
170
+ const result = glossaryRecordSchema.safeParse(parsed);
171
+ if (!result.success) {
172
+ throw new SdkError(
173
+ "CONFIG_INVALID",
174
+ `The glossary file at ${resolvedPath} must contain a flat object of string keys to string values.`
175
+ );
176
+ }
177
+ return result.data;
178
+ }
179
+ async function resolveGlossary(glossary, baseDir, fs) {
180
+ if (glossary === void 0) {
181
+ return { glossary: void 0, provenance: { source: "none" } };
182
+ }
183
+ if (typeof glossary === "string") {
184
+ const resolvedPath = resolve(baseDir, glossary);
185
+ const record = await readGlossaryFile(resolvedPath, fs);
186
+ return { glossary: record, provenance: { source: "file", path: resolvedPath } };
187
+ }
188
+ return { glossary, provenance: { source: "inline" } };
189
+ }
41
190
  var FNV_OFFSET_BASIS = 14695981039346656037n;
42
191
  var FNV_PRIME = 1099511628211n;
43
192
  var U64_MASK = (1n << 64n) - 1n;
@@ -162,6 +311,85 @@ var LOCALE_TOKEN = "{locale}";
162
311
  function localeFilePath(cwd, pattern, locale) {
163
312
  return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
164
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
+ }
165
393
  var REDACTED = "[REDACTED]";
166
394
  var KEY_PATTERNS = [
167
395
  // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
@@ -196,17 +424,34 @@ var ProviderError = class extends Error {
196
424
  }
197
425
  };
198
426
  var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
199
- 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();
200
438
  try {
201
439
  return await call();
202
- } catch {
203
- 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]);
204
446
  }
205
447
  }
206
- function checkBatchIntegrity(inputs, extract) {
448
+ function checkBatchIntegrity(inputs, extract, compare) {
207
449
  const integrity = /* @__PURE__ */ new Map();
208
- for (const { key, sourcePlaceholders, translatedValue } of inputs) {
209
- 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
+ );
210
455
  }
211
456
  return integrity;
212
457
  }
@@ -236,7 +481,7 @@ function toIntegrityInputs(entries, values) {
236
481
  "The provider response is missing one or more keys."
237
482
  );
238
483
  }
239
- return { key: entry.key, sourcePlaceholders: entry.placeholders, translatedValue };
484
+ return { key: entry.key, sourceValue: entry.value, translatedValue };
240
485
  });
241
486
  }
242
487
  function toItem(entry) {
@@ -303,11 +548,16 @@ async function runLlmTranslation(request, mechanism) {
303
548
  const data = validateRequest(request);
304
549
  const payloadJson = JSON.stringify(buildDataPayload(data));
305
550
  const requestedKeys = data.entries.map((entry) => entry.key);
306
- const completion = await mechanism.translate({ payloadJson, requestedKeys });
551
+ const completion = await mechanism.translate({
552
+ payloadJson,
553
+ requestedKeys,
554
+ ...request.signal !== void 0 ? { signal: request.signal } : {}
555
+ });
307
556
  const values = reconcileResult(completion.raw, requestedKeys);
308
557
  const integrity = checkBatchIntegrity(
309
558
  toIntegrityInputs(data.entries, values),
310
- request.extractPlaceholders
559
+ request.extractPlaceholders,
560
+ request.comparePlaceholders
311
561
  );
312
562
  return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
313
563
  }
@@ -342,12 +592,29 @@ function requireGeminiKey() {
342
592
  function requireDeepLKey() {
343
593
  return readRequiredEnv(PROVIDER_ENV.deepl);
344
594
  }
595
+ var OPENAI_COMPATIBLE_ENV_VAR = "OPENAI_COMPATIBLE_API_KEY";
596
+ var OPENAI_COMPATIBLE_KEY_PLACEHOLDER = "local";
597
+ function resolveOpenAiCompatibleKey(customEnvVar) {
598
+ const varName = customEnvVar ?? OPENAI_COMPATIBLE_ENV_VAR;
599
+ const value = process.env[varName];
600
+ if (value !== void 0 && value.length > 0) {
601
+ return value;
602
+ }
603
+ if (customEnvVar !== void 0) {
604
+ throw new ProviderError(
605
+ "MISSING_API_KEY",
606
+ `The ${customEnvVar} environment variable is not set.`
607
+ );
608
+ }
609
+ return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
610
+ }
345
611
  function createDefaultClient() {
346
612
  const sdk = new Anthropic({ apiKey: requireAnthropicKey(), logLevel: "off" });
347
613
  return {
348
614
  messages: {
349
- create: async (body) => await sdk.messages.create(
350
- body
615
+ create: async (body, options) => await sdk.messages.create(
616
+ body,
617
+ options
351
618
  )
352
619
  }
353
620
  };
@@ -415,9 +682,9 @@ function createAnthropicProvider(config, deps = {}) {
415
682
  }
416
683
  function createMechanism(client, config) {
417
684
  return {
418
- translate: async ({ payloadJson }) => {
685
+ translate: async ({ payloadJson, signal }) => {
419
686
  const body = buildRequest(config, payloadJson);
420
- const message = await callClient(client, body);
687
+ const message = await callClient(client, body, signal);
421
688
  assertNotTruncated(message.stop_reason === "max_tokens");
422
689
  const raw = requireToolInput(message.content);
423
690
  const usage = toUsage(message.usage);
@@ -425,8 +692,11 @@ function createMechanism(client, config) {
425
692
  }
426
693
  };
427
694
  }
428
- function callClient(client, body) {
429
- return guardProviderCall(() => client.messages.create(body));
695
+ function callClient(client, body, signal) {
696
+ return guardProviderCall(
697
+ () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
698
+ signal
699
+ );
430
700
  }
431
701
  function toUsage(usage) {
432
702
  if (usage === void 0) {
@@ -521,7 +791,7 @@ function zipResults(entries, results) {
521
791
  values.set(entry.key, translatedValue);
522
792
  integrityInputs.push({
523
793
  key: entry.key,
524
- sourcePlaceholders: entry.placeholders,
794
+ sourceValue: entry.value,
525
795
  translatedValue
526
796
  });
527
797
  }
@@ -562,36 +832,97 @@ async function translate(bundle, config, request) {
562
832
  data,
563
833
  protectable,
564
834
  options,
565
- request.extractPlaceholders
835
+ request.extractPlaceholders,
836
+ request.comparePlaceholders,
837
+ request.signal
566
838
  );
567
839
  if (unprotectable.length > 0) {
568
840
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
569
841
  }
570
842
  return { values, integrity, notices };
571
843
  }
572
- async function translateProtectable(client, data, protectable, options, extract) {
844
+ async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
573
845
  if (protectable.length === 0) {
574
846
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
575
847
  }
576
848
  const texts = protectable.map((entry) => entry.value);
577
- const results = await callClient2(client, texts, data.sourceLocale, data.targetLocale, options);
849
+ const results = await callClient2(
850
+ client,
851
+ texts,
852
+ data.sourceLocale,
853
+ data.targetLocale,
854
+ options,
855
+ signal
856
+ );
578
857
  const { values, integrityInputs } = zipResults(protectable, results);
579
- const integrity = checkBatchIntegrity(integrityInputs, extract);
858
+ const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
580
859
  return { values, integrity };
581
860
  }
582
- function callClient2(client, texts, sourceLang, targetLang, options) {
583
- return guardProviderCall(() => client.translateText(texts, sourceLang, targetLang, options));
861
+ function callClient2(client, texts, sourceLang, targetLang, options, signal) {
862
+ return guardProviderCall(
863
+ () => client.translateText(texts, sourceLang, targetLang, options),
864
+ signal
865
+ );
584
866
  }
585
867
  var geminiConfigSchema = z.object({
586
868
  model: z.string().min(1),
587
869
  maxOutputTokens: z.number().int().positive()
588
870
  });
871
+ var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
872
+ function isRetryableStatus(error) {
873
+ const status = getErrorStatus(error);
874
+ return status === 429 || status !== void 0 && status >= 500 && status < 600;
875
+ }
876
+ function isAborted(signal) {
877
+ return signal?.aborted === true;
878
+ }
879
+ function throwAbort(signal) {
880
+ signal?.throwIfAborted();
881
+ throw new DOMException("This operation was aborted.", "AbortError");
882
+ }
883
+ function delay(ms, signal) {
884
+ return new Promise((resolve7) => {
885
+ const onAbort = () => {
886
+ clearTimeout(timer);
887
+ resolve7();
888
+ };
889
+ const timer = setTimeout(() => {
890
+ signal?.removeEventListener("abort", onAbort);
891
+ resolve7();
892
+ }, ms);
893
+ signal?.addEventListener("abort", onAbort, { once: true });
894
+ });
895
+ }
896
+ async function withGeminiRetry(call, signal, config = DEFAULT_GEMINI_RETRY) {
897
+ let attempt = 1;
898
+ while (true) {
899
+ try {
900
+ return await call();
901
+ } catch (error) {
902
+ if (isAborted(signal)) {
903
+ throwAbort(signal);
904
+ }
905
+ const exhausted = attempt >= config.attempts;
906
+ if (exhausted || !isRetryableStatus(error)) {
907
+ throw error;
908
+ }
909
+ await delay(config.baseDelayMs * 2 ** (attempt - 1), signal);
910
+ if (isAborted(signal)) {
911
+ throwAbort(signal);
912
+ }
913
+ attempt += 1;
914
+ }
915
+ }
916
+ }
589
917
  function createDefaultClient3() {
590
918
  const ai = new GoogleGenAI({ apiKey: requireGeminiKey() });
591
919
  return {
592
920
  models: {
593
- generateContent: async (request) => await ai.models.generateContent(
594
- request
921
+ generateContent: (request) => withGeminiRetry(
922
+ async () => await ai.models.generateContent(
923
+ request
924
+ ),
925
+ request.config.abortSignal
595
926
  )
596
927
  }
597
928
  };
@@ -655,7 +986,7 @@ var GEMINI_SYSTEM_RULES = [
655
986
  "When a tone is provided, honor it.",
656
987
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
657
988
  ].join("\n");
658
- function buildGeminiRequest(config, payloadJson) {
989
+ function buildGeminiRequest(config, payloadJson, signal) {
659
990
  return {
660
991
  model: config.model,
661
992
  contents: [{ role: "user", parts: [{ text: payloadJson }] }],
@@ -663,7 +994,8 @@ function buildGeminiRequest(config, payloadJson) {
663
994
  systemInstruction: GEMINI_SYSTEM_RULES,
664
995
  responseMimeType: "application/json",
665
996
  responseSchema: toGeminiSchema(deriveJsonSchema(translationsResultSchema)),
666
- maxOutputTokens: config.maxOutputTokens
997
+ maxOutputTokens: config.maxOutputTokens,
998
+ ...signal !== void 0 ? { abortSignal: signal } : {}
667
999
  }
668
1000
  };
669
1001
  }
@@ -727,15 +1059,15 @@ function createGeminiProvider(config, deps = {}) {
727
1059
  }
728
1060
  function createMechanism2(client, config) {
729
1061
  return {
730
- translate: async ({ payloadJson }) => {
731
- const request = buildGeminiRequest(config, payloadJson);
732
- const response = await callClient3(client, request);
1062
+ translate: async ({ payloadJson, signal }) => {
1063
+ const request = buildGeminiRequest(config, payloadJson, signal);
1064
+ const response = await callClient3(client, request, signal);
733
1065
  return extractGeminiResult(response);
734
1066
  }
735
1067
  };
736
1068
  }
737
- function callClient3(client, request) {
738
- return guardProviderCall(() => client.models.generateContent(request));
1069
+ function callClient3(client, request, signal) {
1070
+ return guardProviderCall(() => client.models.generateContent(request), signal);
739
1071
  }
740
1072
  var openAiConfigSchema = z.object({
741
1073
  model: z.string().min(1),
@@ -746,8 +1078,9 @@ function createDefaultClient4() {
746
1078
  return {
747
1079
  chat: {
748
1080
  completions: {
749
- create: async (body) => await sdk.chat.completions.create(
750
- body
1081
+ create: async (body, options) => await sdk.chat.completions.create(
1082
+ body,
1083
+ options
751
1084
  )
752
1085
  }
753
1086
  }
@@ -765,7 +1098,20 @@ var OPENAI_SYSTEM_RULES = [
765
1098
  "When a tone is provided, honor it.",
766
1099
  "Respond only with the structured object: exactly one entry per requested key, no commentary, no extra keys, and no key that was not requested."
767
1100
  ].join("\n");
768
- function buildOpenAiRequest(config, payloadJson) {
1101
+ function buildResponseFormat(mode) {
1102
+ if (mode === "json-object") {
1103
+ return { type: "json_object" };
1104
+ }
1105
+ return {
1106
+ type: "json_schema",
1107
+ json_schema: {
1108
+ name: RESULT_SCHEMA_NAME,
1109
+ strict: true,
1110
+ schema: deriveJsonSchema(translationsResultSchema)
1111
+ }
1112
+ };
1113
+ }
1114
+ function buildOpenAiRequest(config, payloadJson, mode = "strict-schema") {
769
1115
  return {
770
1116
  model: config.model,
771
1117
  max_completion_tokens: config.maxOutputTokens,
@@ -773,19 +1119,66 @@ function buildOpenAiRequest(config, payloadJson) {
773
1119
  { role: "system", content: OPENAI_SYSTEM_RULES },
774
1120
  { role: "user", content: payloadJson }
775
1121
  ],
776
- response_format: {
777
- type: "json_schema",
778
- json_schema: {
779
- name: RESULT_SCHEMA_NAME,
780
- strict: true,
781
- schema: deriveJsonSchema(translationsResultSchema)
1122
+ response_format: buildResponseFormat(mode)
1123
+ };
1124
+ }
1125
+ function advanceStringScan(char, escaped) {
1126
+ if (escaped) {
1127
+ return { inString: true, escaped: false };
1128
+ }
1129
+ if (char === "\\") {
1130
+ return { inString: true, escaped: true };
1131
+ }
1132
+ return { inString: char !== '"', escaped: false };
1133
+ }
1134
+ function scanBalancedObjectEnd(content, start) {
1135
+ let depth = 0;
1136
+ let inString = false;
1137
+ let escaped = false;
1138
+ for (let i = start; i < content.length; i += 1) {
1139
+ const char = content[i];
1140
+ if (inString) {
1141
+ ({ inString, escaped } = advanceStringScan(char, escaped));
1142
+ continue;
1143
+ }
1144
+ if (char === '"') {
1145
+ inString = true;
1146
+ } else if (char === "{") {
1147
+ depth += 1;
1148
+ } else if (char === "}") {
1149
+ depth -= 1;
1150
+ if (depth === 0) {
1151
+ return i;
782
1152
  }
783
1153
  }
784
- };
1154
+ }
1155
+ return null;
1156
+ }
1157
+ function extractBalancedJson(content) {
1158
+ let searchFrom = 0;
1159
+ while (searchFrom < content.length) {
1160
+ const start = content.indexOf("{", searchFrom);
1161
+ if (start === -1) {
1162
+ return null;
1163
+ }
1164
+ const end = scanBalancedObjectEnd(content, start);
1165
+ if (end === null) {
1166
+ return null;
1167
+ }
1168
+ const candidate = content.slice(start, end + 1);
1169
+ try {
1170
+ JSON.parse(candidate);
1171
+ return candidate;
1172
+ } catch {
1173
+ searchFrom = end + 1;
1174
+ }
1175
+ }
1176
+ return null;
785
1177
  }
786
- function parseContent2(content) {
1178
+ function parseContent2(content, tolerant) {
1179
+ const candidate = tolerant ? extractBalancedJson(content) ?? content : content;
787
1180
  try {
788
- return JSON.parse(content);
1181
+ return JSON.parse(candidate);
789
1182
  } catch {
790
1183
  throw new ProviderError("INVALID_RESPONSE", "The provider returned unparseable content.");
791
1184
  }
@@ -800,7 +1193,7 @@ function toUsage3(usage) {
800
1193
  }
801
1194
  return { inputTokens: prompt_tokens, outputTokens: completion_tokens };
802
1195
  }
803
- function extractOpenAiResult(completion) {
1196
+ function extractOpenAiResult(completion, tolerant = false) {
804
1197
  const choice = completion.choices[0];
805
1198
  if (choice === void 0) {
806
1199
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no message.");
@@ -813,7 +1206,7 @@ function extractOpenAiResult(completion) {
813
1206
  if (message.content === void 0 || message.content === null) {
814
1207
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no translation content.");
815
1208
  }
816
- const raw = parseContent2(message.content);
1209
+ const raw = parseContent2(message.content, tolerant);
817
1210
  const usage = toUsage3(completion.usage);
818
1211
  return usage === void 0 ? { raw } : { raw, usage };
819
1212
  }
@@ -831,15 +1224,84 @@ function createOpenAiProvider(config, deps = {}) {
831
1224
  }
832
1225
  function createMechanism3(client, config) {
833
1226
  return {
834
- translate: async ({ payloadJson }) => {
1227
+ translate: async ({ payloadJson, signal }) => {
835
1228
  const body = buildOpenAiRequest(config, payloadJson);
836
- const completion = await callClient4(client, body);
1229
+ const completion = await callClient4(client, body, signal);
837
1230
  return extractOpenAiResult(completion);
838
1231
  }
839
1232
  };
840
1233
  }
841
- function callClient4(client, body) {
842
- return guardProviderCall(() => client.chat.completions.create(body));
1234
+ function callClient4(client, body, signal) {
1235
+ return guardProviderCall(
1236
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1237
+ signal
1238
+ );
1239
+ }
1240
+ var HOSTED_PROVIDER_ENV_VARS = new Set(
1241
+ Object.values(PROVIDER_ENV).map((name) => name.toUpperCase())
1242
+ );
1243
+ function isNotHostedProviderEnvVar(value) {
1244
+ return !HOSTED_PROVIDER_ENV_VARS.has(value.toUpperCase());
1245
+ }
1246
+ function isHttpOrHttpsUrl(value) {
1247
+ try {
1248
+ const { protocol } = new URL(value);
1249
+ return protocol === "http:" || protocol === "https:";
1250
+ } catch {
1251
+ return false;
1252
+ }
1253
+ }
1254
+ var openAiCompatibleConfigSchema = z.object({
1255
+ baseUrl: z.url({ message: "baseUrl must be a valid absolute URL." }).refine(isHttpOrHttpsUrl, { message: "baseUrl must use the http or https scheme." }),
1256
+ model: z.string().min(1),
1257
+ maxOutputTokens: z.number().int().positive(),
1258
+ apiKeyEnvVar: z.string().min(1).refine(isNotHostedProviderEnvVar, {
1259
+ message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1260
+ }).optional()
1261
+ });
1262
+ function createDefaultClient5(config) {
1263
+ const sdk = new OpenAI({
1264
+ apiKey: resolveOpenAiCompatibleKey(config.apiKeyEnvVar),
1265
+ baseURL: config.baseUrl,
1266
+ logLevel: "off"
1267
+ });
1268
+ return {
1269
+ chat: {
1270
+ completions: {
1271
+ create: async (body, options) => await sdk.chat.completions.create(
1272
+ body,
1273
+ options
1274
+ )
1275
+ }
1276
+ }
1277
+ };
1278
+ }
1279
+ var PROVIDER_ID5 = "openai-compatible";
1280
+ function createOpenAiCompatibleProvider(config, deps = {}) {
1281
+ const validConfig = openAiCompatibleConfigSchema.parse(config);
1282
+ const client = deps.client ?? createDefaultClient5(validConfig);
1283
+ const mechanism = createMechanism4(client, validConfig);
1284
+ return {
1285
+ id: PROVIDER_ID5,
1286
+ kind: "llm",
1287
+ supportsGlossary: true,
1288
+ translateBatch: (request) => runLlmTranslation(request, mechanism)
1289
+ };
1290
+ }
1291
+ function createMechanism4(client, config) {
1292
+ return {
1293
+ translate: async ({ payloadJson, signal }) => {
1294
+ const body = buildOpenAiRequest(config, payloadJson, "strict-schema");
1295
+ const completion = await callClient5(client, body, signal);
1296
+ return extractOpenAiResult(completion, true);
1297
+ }
1298
+ };
1299
+ }
1300
+ function callClient5(client, body, signal) {
1301
+ return guardProviderCall(
1302
+ () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1303
+ signal
1304
+ );
843
1305
  }
844
1306
  var SCAFFOLD_MODELS = {
845
1307
  anthropic: "claude-sonnet-4-6",
@@ -850,13 +1312,18 @@ var providerConfigSchema = z.discriminatedUnion("id", [
850
1312
  z.object({ id: z.literal("anthropic"), options: anthropicConfigSchema.strict() }),
851
1313
  z.object({ id: z.literal("openai"), options: openAiConfigSchema.strict() }),
852
1314
  z.object({ id: z.literal("gemini"), options: geminiConfigSchema.strict() }),
853
- z.object({ id: z.literal("deepl"), options: deepLConfigSchema.strict() })
1315
+ z.object({ id: z.literal("deepl"), options: deepLConfigSchema.strict() }),
1316
+ z.object({
1317
+ id: z.literal("openai-compatible"),
1318
+ options: openAiCompatibleConfigSchema.strict()
1319
+ })
854
1320
  ]);
855
1321
  var providerFactories = {
856
1322
  anthropic: (options) => createAnthropicProvider(options),
857
1323
  openai: (options) => createOpenAiProvider(options),
858
1324
  gemini: (options) => createGeminiProvider(options),
859
- deepl: (options) => createDeepLProvider(options)
1325
+ deepl: (options) => createDeepLProvider(options),
1326
+ "openai-compatible": (options) => createOpenAiCompatibleProvider(options)
860
1327
  };
861
1328
  function buildProvider(config) {
862
1329
  const create = providerFactories[config.id];
@@ -873,7 +1340,13 @@ var verbatraConfigSchema = z.strictObject({
873
1340
  pattern: z.string().min(1)
874
1341
  }),
875
1342
  provider: providerConfigSchema,
876
- glossary: z.record(z.string(), z.string()).optional(),
1343
+ /**
1344
+ * A glossary of source terms to preferred target terms, either inline or as a path to a JSON file
1345
+ * carrying the same shape. The two forms are mutually exclusive by type; there is no merge or
1346
+ * precedence between them. A path is resolved and validated by the loader, not by this schema; see
1347
+ * `resolve-glossary.ts`.
1348
+ */
1349
+ glossary: z.union([z.record(z.string(), z.string()), z.string().min(1)]).optional(),
877
1350
  tone: z.enum(["formal", "informal", "neutral"]).optional(),
878
1351
  /**
879
1352
  * Opt-in orphan pruning, off by default. When true, keys present in a target file but absent from
@@ -927,7 +1400,7 @@ function formatIssues(error) {
927
1400
  return issue.code === "unrecognized_keys" ? `${base} (API keys are read from the environment, not the config)` : base;
928
1401
  }).join("; ");
929
1402
  }
930
- function validate(input) {
1403
+ function parseConfig(input) {
931
1404
  const parsed = verbatraConfigSchema.safeParse(input);
932
1405
  if (!parsed.success) {
933
1406
  throw new SdkError(
@@ -937,7 +1410,16 @@ function validate(input) {
937
1410
  }
938
1411
  return parsed.data;
939
1412
  }
940
- async function loadExplicit(explorer, configPath, cwd) {
1413
+ async function finalizeConfig(parsed, baseDir, fs) {
1414
+ const { glossary: glossaryInput, ...rest } = parsed;
1415
+ const resolved = await resolveGlossary(glossaryInput, baseDir, fs);
1416
+ const config = {
1417
+ ...rest,
1418
+ ...resolved.glossary !== void 0 ? { glossary: resolved.glossary } : {}
1419
+ };
1420
+ return { config, glossary: resolved.provenance };
1421
+ }
1422
+ async function loadExplicitWithMeta(explorer, configPath, cwd, fs) {
941
1423
  const resolved = resolve(cwd ?? process.cwd(), configPath);
942
1424
  if (!existsSync(resolved)) {
943
1425
  throw new SdkError("CONFIG_NOT_FOUND", `No verbatra configuration file at ${resolved}.`);
@@ -949,18 +1431,23 @@ async function loadExplicit(explorer, configPath, cwd) {
949
1431
  const detail = error instanceof Error ? error.message : String(error);
950
1432
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
951
1433
  }
952
- return validate(result?.config);
1434
+ const parsed = parseConfig(result?.config);
1435
+ const { config, glossary } = await finalizeConfig(parsed, dirname(resolved), fs);
1436
+ return { config, source: { kind: "explicit", filepath: resolved }, glossary };
953
1437
  }
954
- async function loadConfig(options = {}) {
1438
+ async function loadConfigWithMeta(options = {}) {
1439
+ const fs = options.fs ?? defaultFs;
955
1440
  if (options.configOverride !== void 0) {
956
- return validate(options.configOverride);
1441
+ const parsed2 = parseConfig(options.configOverride);
1442
+ const { config: config2, glossary: glossary2 } = await finalizeConfig(parsed2, options.cwd ?? process.cwd(), fs);
1443
+ return { config: config2, source: { kind: "override" }, glossary: glossary2 };
957
1444
  }
958
1445
  const explorer = cosmiconfig(MODULE_NAME, {
959
1446
  searchPlaces: SEARCH_PLACES,
960
1447
  loaders: { ".ts": TypeScriptLoader() }
961
1448
  });
962
1449
  if (options.configPath !== void 0) {
963
- return loadExplicit(explorer, options.configPath, options.cwd);
1450
+ return loadExplicitWithMeta(explorer, options.configPath, options.cwd, fs);
964
1451
  }
965
1452
  let result;
966
1453
  try {
@@ -975,99 +1462,14 @@ async function loadConfig(options = {}) {
975
1462
  "No verbatra configuration found. Create a verbatra.config.ts, a .verbatrarc.json, or a 'verbatra' property in package.json."
976
1463
  );
977
1464
  }
978
- return validate(result.config);
979
- }
980
- async function readBoundedUtf8(handle, size) {
981
- const buffer = Buffer.allocUnsafe(size);
982
- let offset = 0;
983
- while (offset < size) {
984
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
985
- if (bytesRead === 0) {
986
- break;
987
- }
988
- offset += bytesRead;
989
- }
990
- return buffer.toString("utf8", 0, offset);
991
- }
992
- async function readBounded(path, maxBytes) {
993
- let handle;
994
- try {
995
- handle = await open(path, "r");
996
- } catch {
997
- return { kind: "missing" };
998
- }
999
- try {
1000
- const info = await handle.stat();
1001
- if (!info.isFile()) {
1002
- return { kind: "missing" };
1003
- }
1004
- if (info.size > maxBytes) {
1005
- return { kind: "too-large" };
1006
- }
1007
- return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
1008
- } finally {
1009
- await handle.close();
1010
- }
1011
- }
1012
- async function readBoundedBytesInto(handle, size) {
1013
- const buffer = Buffer.allocUnsafeSlow(size);
1014
- let offset = 0;
1015
- while (offset < size) {
1016
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
1017
- if (bytesRead === 0) {
1018
- break;
1019
- }
1020
- offset += bytesRead;
1021
- }
1022
- return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
1023
- }
1024
- async function readBoundedBytes(path, maxBytes) {
1025
- let handle;
1026
- try {
1027
- handle = await open(path, "r");
1028
- } catch {
1029
- return { kind: "missing" };
1030
- }
1031
- try {
1032
- const info = await handle.stat();
1033
- if (!info.isFile()) {
1034
- return { kind: "missing" };
1035
- }
1036
- if (info.size > maxBytes) {
1037
- return { kind: "too-large" };
1038
- }
1039
- return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
1040
- } finally {
1041
- await handle.close();
1042
- }
1465
+ const parsed = parseConfig(result.config);
1466
+ const { config, glossary } = await finalizeConfig(parsed, dirname(result.filepath), fs);
1467
+ return { config, source: { kind: "search", filepath: result.filepath }, glossary };
1043
1468
  }
1044
- function tempFileName(path) {
1045
- return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
1046
- }
1047
- async function atomicWrite(path, data) {
1048
- const tmp = tempFileName(path);
1049
- await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
1050
- try {
1051
- await rename(tmp, path);
1052
- } catch (error) {
1053
- await rm(tmp, { force: true });
1054
- throw error;
1055
- }
1469
+ async function loadConfig(options = {}) {
1470
+ const { config } = await loadConfigWithMeta(options);
1471
+ return config;
1056
1472
  }
1057
- var defaultFs = {
1058
- async fileExists(path) {
1059
- try {
1060
- await access(path);
1061
- return true;
1062
- } catch {
1063
- return false;
1064
- }
1065
- },
1066
- readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
1067
- readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
1068
- writeFile: (path, data) => atomicWrite(path, data),
1069
- writeBytes: (path, data) => atomicWrite(path, data)
1070
- };
1071
1473
  var LOCK_FILE_NAME = "verbatra.lock.json";
1072
1474
  var CURRENT_VERSION = 1;
1073
1475
  var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
@@ -1100,6 +1502,12 @@ async function readLockFile(path, fs) {
1100
1502
  if (!result.success) {
1101
1503
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1102
1504
  }
1505
+ if (result.data.version !== CURRENT_VERSION) {
1506
+ throw new SdkError(
1507
+ "LOCK_FILE_INVALID",
1508
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
1509
+ );
1510
+ }
1103
1511
  return result.data;
1104
1512
  }
1105
1513
  function baselineFor(lock, locale) {
@@ -1205,7 +1613,7 @@ function analyzeIcuValue(value) {
1205
1613
  return VALID_EMPTY;
1206
1614
  }
1207
1615
  try {
1208
- const ast = parse$1(value);
1616
+ const ast = parse(value);
1209
1617
  const state = { isPlural: false };
1210
1618
  const multiset = collect(ast, state);
1211
1619
  return { placeholders: flatten(multiset), isPlural: state.isPlural, valid: true };
@@ -1232,6 +1640,150 @@ function icuInvalidKeys(entries) {
1232
1640
  }
1233
1641
  return invalid;
1234
1642
  }
1643
+ function tokenOf2(element) {
1644
+ switch (element.type) {
1645
+ case TYPE.argument:
1646
+ case TYPE.number:
1647
+ case TYPE.date:
1648
+ case TYPE.time:
1649
+ case TYPE.select:
1650
+ case TYPE.plural:
1651
+ return `{${element.value}}`;
1652
+ case TYPE.tag:
1653
+ return `<${element.value}>`;
1654
+ default:
1655
+ return void 0;
1656
+ }
1657
+ }
1658
+ function isBranching(element) {
1659
+ return element.type === TYPE.plural || element.type === TYPE.select;
1660
+ }
1661
+ function isTag(element) {
1662
+ return element.type === TYPE.tag;
1663
+ }
1664
+ function layerTokens(elements) {
1665
+ const tokens = [];
1666
+ for (const element of elements) {
1667
+ const token = tokenOf2(element);
1668
+ if (token !== void 0) {
1669
+ tokens.push(token);
1670
+ }
1671
+ }
1672
+ return tokens;
1673
+ }
1674
+ function deepTokens(elements) {
1675
+ const tokens = [];
1676
+ for (const element of elements) {
1677
+ const token = tokenOf2(element);
1678
+ if (token !== void 0) {
1679
+ tokens.push(token);
1680
+ }
1681
+ if (isTag(element)) {
1682
+ tokens.push(...deepTokens(element.children));
1683
+ } else if (isBranching(element)) {
1684
+ for (const option of Object.values(element.options)) {
1685
+ tokens.push(...deepTokens(option.value));
1686
+ }
1687
+ }
1688
+ }
1689
+ return tokens;
1690
+ }
1691
+ function branchesByCategory(element) {
1692
+ return new Map(
1693
+ Object.entries(element.options).map(([category, option]) => [category, option.value])
1694
+ );
1695
+ }
1696
+ function findUnconsumed(target, consumed, isMatch) {
1697
+ for (let index = 0; index < target.length; index += 1) {
1698
+ if (consumed.has(index)) {
1699
+ continue;
1700
+ }
1701
+ const candidate = target[index];
1702
+ if (candidate !== void 0 && isMatch(candidate)) {
1703
+ return { element: candidate, index };
1704
+ }
1705
+ }
1706
+ return void 0;
1707
+ }
1708
+ function findMatchingBranching(source, target, consumed) {
1709
+ return findUnconsumed(
1710
+ target,
1711
+ consumed,
1712
+ (candidate) => isBranching(candidate) && candidate.value === source.value
1713
+ );
1714
+ }
1715
+ function findMatchingTag(source, target, consumed) {
1716
+ return findUnconsumed(
1717
+ target,
1718
+ consumed,
1719
+ (candidate) => isTag(candidate) && candidate.value === source.value
1720
+ );
1721
+ }
1722
+ function combineResults(results) {
1723
+ const missing = results.flatMap((result) => result.missing).sort();
1724
+ const extra = results.flatMap((result) => result.extra).sort();
1725
+ return { matches: missing.length === 0 && extra.length === 0, missing, extra, reordered: false };
1726
+ }
1727
+ function compareAgainstSourceUnion(sourceBranches, targetBranch) {
1728
+ const union = /* @__PURE__ */ new Set();
1729
+ for (const branch of sourceBranches.values()) {
1730
+ for (const token of deepTokens(branch)) {
1731
+ union.add(token);
1732
+ }
1733
+ }
1734
+ const extra = deepTokens(targetBranch).filter((token) => !union.has(token)).sort();
1735
+ return { matches: extra.length === 0, missing: [], extra, reordered: false };
1736
+ }
1737
+ function compareBranching(source, target) {
1738
+ const sourceBranches = branchesByCategory(source);
1739
+ const targetBranches = branchesByCategory(target);
1740
+ const categories = /* @__PURE__ */ new Set([...sourceBranches.keys(), ...targetBranches.keys()]);
1741
+ const results = [];
1742
+ for (const category of categories) {
1743
+ const sourceBranch = sourceBranches.get(category);
1744
+ const targetBranch = targetBranches.get(category);
1745
+ if (sourceBranch !== void 0 && targetBranch !== void 0) {
1746
+ results.push(compareElements(sourceBranch, targetBranch));
1747
+ } else if (targetBranch !== void 0) {
1748
+ results.push(compareAgainstSourceUnion(sourceBranches, targetBranch));
1749
+ }
1750
+ }
1751
+ return combineResults(results);
1752
+ }
1753
+ function nestedResults(source, target) {
1754
+ const results = [];
1755
+ const consumed = /* @__PURE__ */ new Set();
1756
+ for (const element of source) {
1757
+ if (isBranching(element)) {
1758
+ const found = findMatchingBranching(element, target, consumed);
1759
+ if (found !== void 0) {
1760
+ consumed.add(found.index);
1761
+ results.push(compareBranching(element, found.element));
1762
+ }
1763
+ } else if (isTag(element)) {
1764
+ const found = findMatchingTag(element, target, consumed);
1765
+ if (found !== void 0) {
1766
+ consumed.add(found.index);
1767
+ results.push(compareElements(element.children, found.element.children));
1768
+ }
1769
+ }
1770
+ }
1771
+ return results;
1772
+ }
1773
+ function compareElements(source, target) {
1774
+ const layer = checkPlaceholders(layerTokens(source), layerTokens(target));
1775
+ const nested = nestedResults(source, target);
1776
+ return nested.length === 0 ? layer : combineResults([layer, ...nested]);
1777
+ }
1778
+ function compareIcuPlaceholders(sourceValue, targetValue) {
1779
+ try {
1780
+ const sourceAst = parse(sourceValue);
1781
+ const targetAst = parse(targetValue);
1782
+ return compareElements(sourceAst, targetAst);
1783
+ } catch {
1784
+ return checkPlaceholders(icuPlaceholders(sourceValue), icuPlaceholders(targetValue));
1785
+ }
1786
+ }
1235
1787
  var AdapterError = class extends Error {
1236
1788
  code;
1237
1789
  constructor(code, message) {
@@ -1533,9 +2085,9 @@ function unflattenEntries(entries) {
1533
2085
  }
1534
2086
  return root;
1535
2087
  }
1536
- function toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree) {
2088
+ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree) {
1537
2089
  try {
1538
- const tree = parse2(content);
2090
+ const tree = parse3(content);
1539
2091
  validateTree?.(tree);
1540
2092
  return flattenTree(tree, namespace, deriveEntry, keyMode);
1541
2093
  } catch (error) {
@@ -1547,7 +2099,7 @@ function createTreeFileAdapter(options) {
1547
2099
  format,
1548
2100
  extensions,
1549
2101
  sniff,
1550
- parse: parse2,
2102
+ parse: parse3,
1551
2103
  serialize,
1552
2104
  deriveEntry,
1553
2105
  extractPlaceholders,
@@ -1555,6 +2107,7 @@ function createTreeFileAdapter(options) {
1555
2107
  validateMessage,
1556
2108
  validateTree,
1557
2109
  buildWriteTree,
2110
+ comparePlaceholders,
1558
2111
  keyMode = "literal-leaf"
1559
2112
  } = options;
1560
2113
  return {
@@ -1562,10 +2115,11 @@ function createTreeFileAdapter(options) {
1562
2115
  canHandle: buildCanHandle(extensions, sniff),
1563
2116
  extractPlaceholders,
1564
2117
  validateMessage: validateMessage ?? (() => true),
2118
+ ...comparePlaceholders !== void 0 ? { comparePlaceholders } : {},
1565
2119
  async read(filePath, locale) {
1566
2120
  const content = await readFileContent(filePath);
1567
2121
  const namespace = namespaceOf(filePath);
1568
- const entries = toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree);
2122
+ const entries = toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTree);
1569
2123
  const resource = { locale, namespace, format, entries };
1570
2124
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1571
2125
  return { resource, invalidIcuKeys };
@@ -1661,6 +2215,7 @@ function createArbAdapter() {
1661
2215
  deriveEntry: icuDeriveEntry,
1662
2216
  computeInvalidIcuKeys: icuInvalidKeys,
1663
2217
  validateMessage: icuIsValid,
2218
+ comparePlaceholders: compareIcuPlaceholders,
1664
2219
  buildWriteTree: buildArbWriteTree
1665
2220
  });
1666
2221
  }
@@ -1724,7 +2279,8 @@ function createNextIntlJsonAdapter() {
1724
2279
  extractPlaceholders: icuPlaceholders,
1725
2280
  deriveEntry: icuDeriveEntry,
1726
2281
  computeInvalidIcuKeys: icuInvalidKeys,
1727
- validateMessage: icuIsValid
2282
+ validateMessage: icuIsValid,
2283
+ comparePlaceholders: compareIcuPlaceholders
1728
2284
  });
1729
2285
  }
1730
2286
  function assertNoDottedNestedKey(tree) {
@@ -1926,6 +2482,7 @@ function extractXliffPlaceholders(value) {
1926
2482
  return result;
1927
2483
  }
1928
2484
  var ELEMENT_NODE = 1;
2485
+ var TEXT_NODE = 3;
1929
2486
  function isElement(node) {
1930
2487
  return node.nodeType === ELEMENT_NODE;
1931
2488
  }
@@ -2016,6 +2573,12 @@ function parseXliffEntries(content, namespace) {
2016
2573
  const serializer = new XMLSerializer();
2017
2574
  const out = /* @__PURE__ */ new Map();
2018
2575
  for (const unit of walkUnits(root)) {
2576
+ if (out.has(unit.key)) {
2577
+ throw new AdapterError(
2578
+ "INVALID_STRUCTURE",
2579
+ "The XLIFF file has two trans-units with the same id."
2580
+ );
2581
+ }
2019
2582
  const value = unitValue(serializer, unit);
2020
2583
  out.set(unit.key, {
2021
2584
  key: unit.key,
@@ -2042,10 +2605,67 @@ async function readDestination(filePath) {
2042
2605
  }
2043
2606
  return outcome.content;
2044
2607
  }
2608
+ var XLIFF_INLINE_ELEMENTS = /* @__PURE__ */ new Set(["x", "g", "bx", "ex", "ph", "it", "mrk"]);
2609
+ var XLIFF_NAMESPACES = /* @__PURE__ */ new Set([
2610
+ "urn:oasis:names:tc:xliff:document:1.2",
2611
+ "urn:oasis:names:tc:xliff:document:2.0"
2612
+ ]);
2613
+ var INLINE_ELEMENT_ATTRIBUTES = {
2614
+ x: ["id", "ctype"],
2615
+ ph: ["id", "ctype"],
2616
+ g: ["id", "ctype"],
2617
+ bx: ["id", "rid", "ctype"],
2618
+ ex: ["id", "rid"],
2619
+ it: ["id", "pos", "ctype"],
2620
+ mrk: ["id", "mtype"]
2621
+ };
2622
+ function isAllowedInlineElement(element) {
2623
+ const namespace = element.namespaceURI;
2624
+ const hasAllowedNamespace = namespace === null || XLIFF_NAMESPACES.has(namespace);
2625
+ return hasAllowedNamespace && XLIFF_INLINE_ELEMENTS.has(element.localName ?? "");
2626
+ }
2627
+ function isAllowedFragmentNode(node) {
2628
+ if (node.nodeType === TEXT_NODE) {
2629
+ return true;
2630
+ }
2631
+ return isElement(node) && isAllowedInlineElement(node);
2632
+ }
2633
+ function allDescendantNodes(node) {
2634
+ const children = Array.from(node.childNodes);
2635
+ return children.flatMap((child) => [child, ...allDescendantNodes(child)]);
2636
+ }
2637
+ function hasDisallowedNode(root) {
2638
+ return allDescendantNodes(root).some((node) => !isAllowedFragmentNode(node));
2639
+ }
2640
+ function attributeNames(element) {
2641
+ const names = [];
2642
+ for (let i = 0; i < element.attributes.length; i += 1) {
2643
+ const attr = element.attributes.item(i);
2644
+ if (attr !== null) {
2645
+ names.push(attr.name);
2646
+ }
2647
+ }
2648
+ return names;
2649
+ }
2650
+ function sanitizeInlineAttributes(root) {
2651
+ for (const el of collectByTag(root, "*")) {
2652
+ const allowed = INLINE_ELEMENT_ATTRIBUTES[el.localName ?? ""] ?? [];
2653
+ for (const name of attributeNames(el)) {
2654
+ if (!allowed.includes(name)) {
2655
+ el.removeAttribute(name);
2656
+ }
2657
+ }
2658
+ }
2659
+ }
2045
2660
  function fragmentNodes(parser, value) {
2661
+ assertNoDoctype(value);
2046
2662
  try {
2047
2663
  const root = parser.parseFromString(`<wrapper>${value}</wrapper>`, "text/xml").documentElement;
2048
- return root === null ? null : Array.from(root.childNodes);
2664
+ if (root === null || hasDisallowedNode(root)) {
2665
+ return null;
2666
+ }
2667
+ sanitizeInlineAttributes(root);
2668
+ return Array.from(root.childNodes);
2049
2669
  } catch {
2050
2670
  return null;
2051
2671
  }
@@ -2095,7 +2715,7 @@ function createXliffAdapter() {
2095
2715
  function parseYamlObject(content) {
2096
2716
  let parsed;
2097
2717
  try {
2098
- parsed = parse(content, { maxAliasCount: 100 });
2718
+ parsed = parse$1(content, { maxAliasCount: 100 });
2099
2719
  } catch {
2100
2720
  throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2101
2721
  }
@@ -2224,6 +2844,40 @@ async function diff(input, deps = {}) {
2224
2844
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
2225
2845
  }
2226
2846
 
2847
+ // src/flow/lock-state.ts
2848
+ function toLockLocaleState(locale, keyCount, diff2) {
2849
+ return {
2850
+ locale,
2851
+ keyCount,
2852
+ missing: diff2.missing.length,
2853
+ stale: diff2.changed.length,
2854
+ upToDate: diff2.unchanged.length
2855
+ };
2856
+ }
2857
+ async function lockState(input, deps = {}) {
2858
+ const config = input.config;
2859
+ const cwd = input.cwd ?? process.cwd();
2860
+ const fs = deps.fs ?? defaultFs;
2861
+ const locales = selectLocales(config, input.locales);
2862
+ const path = lockFilePath(cwd);
2863
+ const exists = await fs.fileExists(path);
2864
+ if (!exists) {
2865
+ return { exists: false };
2866
+ }
2867
+ const lock = await readLockFile(path, fs);
2868
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2869
+ const source = await readSource(config, cwd, fs, adapter);
2870
+ const localeStates = await Promise.all(
2871
+ locales.map(async (locale) => {
2872
+ const target = await readTarget(cwd, config, adapter, fs, locale);
2873
+ const baseline = baselineFor(lock, locale);
2874
+ const diff2 = diffResources(source.resource, target, { baseline });
2875
+ return toLockLocaleState(locale, baseline.size, diff2);
2876
+ })
2877
+ );
2878
+ return { exists: true, version: lock.version, locales: localeStates };
2879
+ }
2880
+
2227
2881
  // src/selection/select-provider.ts
2228
2882
  function selectProvider(config, createProvider = buildProvider) {
2229
2883
  try {
@@ -2465,7 +3119,8 @@ function buildRequest2(context, entries) {
2465
3119
  entries,
2466
3120
  extractPlaceholders: context.adapter.extractPlaceholders,
2467
3121
  ...context.glossary !== void 0 ? { glossary: context.glossary } : {},
2468
- ...context.tone !== void 0 ? { tone: context.tone } : {}
3122
+ ...context.tone !== void 0 ? { tone: context.tone } : {},
3123
+ ...context.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: context.adapter.comparePlaceholders } : {}
2469
3124
  };
2470
3125
  }
2471
3126
  async function generatePluralForms(context) {
@@ -2535,7 +3190,8 @@ function buildRequest3(params, entries) {
2535
3190
  entries,
2536
3191
  extractPlaceholders: params.adapter.extractPlaceholders,
2537
3192
  ...params.glossary !== void 0 ? { glossary: params.glossary } : {},
2538
- ...params.tone !== void 0 ? { tone: params.tone } : {}
3193
+ ...params.tone !== void 0 ? { tone: params.tone } : {},
3194
+ ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
2539
3195
  };
2540
3196
  }
2541
3197
  async function runLocale(params) {
@@ -2998,7 +3654,7 @@ async function streamEntryBounded(file, remaining) {
2998
3654
  "The workbook decompresses to more than the permitted maximum bytes."
2999
3655
  );
3000
3656
  }
3001
- return Buffer.concat(chunks).toString("utf8");
3657
+ return { raw, content: Buffer.concat(chunks).toString("utf8") };
3002
3658
  }
3003
3659
  async function guardWorkbookBytes(bytes, limits) {
3004
3660
  let zip;
@@ -3030,14 +3686,8 @@ async function guardWorkbookBytes(bytes, limits) {
3030
3686
  let actualTotal = 0;
3031
3687
  for (const file of files) {
3032
3688
  const remaining = limits.maxDecompressedBytes - actualTotal;
3033
- const content = await streamEntryBounded(file, remaining);
3034
- actualTotal += Buffer.byteLength(content, "utf8");
3035
- if (actualTotal > limits.maxDecompressedBytes) {
3036
- throw new ExchangeError(
3037
- "WORKBOOK_INVALID",
3038
- `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
3039
- );
3040
- }
3689
+ const { raw, content } = await streamEntryBounded(file, remaining);
3690
+ actualTotal += raw;
3041
3691
  assertNoDoctype2(file.name, content);
3042
3692
  }
3043
3693
  }
@@ -3226,10 +3876,7 @@ function judge(row, sourceEntry, adapter) {
3226
3876
  if (contentHash(sourceEntry) !== row.sourceHash) {
3227
3877
  return "drift";
3228
3878
  }
3229
- const integrity = checkPlaceholders(
3230
- sourceEntry.placeholders,
3231
- adapter.extractPlaceholders(row.translation)
3232
- );
3879
+ const integrity = adapter.comparePlaceholders !== void 0 ? adapter.comparePlaceholders(sourceEntry.value, row.translation) : checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(row.translation));
3233
3880
  if (!integrity.matches) {
3234
3881
  return "placeholder";
3235
3882
  }
@@ -3429,6 +4076,13 @@ async function importWorkbook(input, deps = {}) {
3429
4076
  return { dryRun, locales: summaries, succeeded, failed };
3430
4077
  }
3431
4078
 
4079
+ // src/lock/load-lock-file.ts
4080
+ async function loadLockFile(input = {}, deps = {}) {
4081
+ const cwd = input.cwd ?? process.cwd();
4082
+ const fs = deps.fs ?? defaultFs;
4083
+ return readLockFile(lockFilePath(cwd), fs);
4084
+ }
4085
+
3432
4086
  // src/scaffolding.ts
3433
4087
  var scaffoldingMetadata = {
3434
4088
  /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
@@ -3544,6 +4198,6 @@ async function watch(input, deps = {}) {
3544
4198
  return { stop };
3545
4199
  }
3546
4200
 
3547
- export { DEFAULT_WORKBOOK_PATH, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
4201
+ export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, loadConfigWithMeta, loadLockFile, lockState, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
3548
4202
  //# sourceMappingURL=index.js.map
3549
4203
  //# sourceMappingURL=index.js.map