@verbatra/sdk 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -37,7 +37,7 @@ type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
37
37
  * (counts matter). Always returned (never thrown) for an ordinary mismatch.
38
38
  */
39
39
  interface PlaceholderIntegrityResult {
40
- /** True when nothing is missing or extra and order is preserved. */
40
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
41
41
  readonly matches: boolean;
42
42
  /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
43
43
  readonly missing: readonly string[];
@@ -153,8 +153,11 @@ type AnthropicModel = Anthropic.Messages.Model;
153
153
  *
154
154
  * - `FORMALITY_DOWNGRADED`: a requested formality was not applied (the free tier does not support it).
155
155
  * - `GLOSSARY_IGNORED`: a supplied generic glossary was not applied.
156
+ * - `PLACEHOLDER_UNSUPPORTED`: at least one placeholder- or ICU-bearing entry was left untranslated
157
+ * because DeepL cannot preserve those tokens; such entries are withheld (absent from the result maps)
158
+ * rather than sent to DeepL and mangled.
156
159
  */
157
- type ProviderNoticeCode = "FORMALITY_DOWNGRADED" | "GLOSSARY_IGNORED";
160
+ type ProviderNoticeCode = "FORMALITY_DOWNGRADED" | "GLOSSARY_IGNORED" | "PLACEHOLDER_UNSUPPORTED";
158
161
  /**
159
162
  * An observable, structured signal that something was gracefully degraded (not an
160
163
  * error). Carries only a stable code and a static message, never a key or content.
@@ -378,6 +381,8 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
378
381
  * (thrown by `loadConfig`).
379
382
  * - `CONFIG_INVALID`: a config was found but is unparseable or fails validation (thrown by `loadConfig`).
380
383
  * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format (thrown by `translate`).
384
+ * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales (thrown by
385
+ * `check`, `diff`, and `exportWorkbook` via the shared locale selection).
381
386
  * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw; wraps the provider's own error, including
382
387
  * a missing `*_API_KEY` reported as `MISSING_API_KEY` (thrown by `translate`, non-dry-run only).
383
388
  * - `SOURCE_UNREADABLE`: the source locale file is absent (thrown by `translate`, and by `watch` at startup).
@@ -388,7 +393,7 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
388
393
  * per-locale failure carries no string code of its own. See the surfaced-not-thrown distinction on
389
394
  * `translate`.
390
395
  */
391
- type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCALE_FAILED";
396
+ type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCALE_FAILED";
392
397
  /** The single structured error the SDK throws or records. Never carries a secret. */
393
398
  declare class SdkError extends Error {
394
399
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
@@ -648,7 +653,8 @@ interface CheckDeps {
648
653
  * @param deps - Optional composition seams (registry, file system) for tests.
649
654
  * @returns The aggregate and per-locale drift status.
650
655
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
651
- * with the same meanings as in `translate`.
656
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
657
+ * among the configured target locales.
652
658
  */
653
659
  declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
654
660
 
@@ -697,7 +703,8 @@ interface DiffDeps {
697
703
  * @param deps - Optional composition seams (registry, file system) for tests.
698
704
  * @returns The aggregate and per-locale pending change.
699
705
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
700
- * with the same meanings as in `translate`.
706
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
707
+ * among the configured target locales.
701
708
  */
702
709
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
703
710
 
@@ -897,7 +904,8 @@ interface ExportWorkbookResult {
897
904
  * @param deps - Optional composition seams (registry, file system) for tests.
898
905
  * @returns Where the workbook was written and the per-locale row counts.
899
906
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
900
- * with the same meanings as in `translate`.
907
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
908
+ * among the configured target locales.
901
909
  */
902
910
  declare function exportWorkbook(input: ExportWorkbookInput, deps?: ExportWorkbookDeps): Promise<ExportWorkbookResult>;
903
911
 
package/dist/index.d.ts CHANGED
@@ -37,7 +37,7 @@ type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
37
37
  * (counts matter). Always returned (never thrown) for an ordinary mismatch.
38
38
  */
39
39
  interface PlaceholderIntegrityResult {
40
- /** True when nothing is missing or extra and order is preserved. */
40
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
41
41
  readonly matches: boolean;
42
42
  /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
43
43
  readonly missing: readonly string[];
@@ -153,8 +153,11 @@ type AnthropicModel = Anthropic.Messages.Model;
153
153
  *
154
154
  * - `FORMALITY_DOWNGRADED`: a requested formality was not applied (the free tier does not support it).
155
155
  * - `GLOSSARY_IGNORED`: a supplied generic glossary was not applied.
156
+ * - `PLACEHOLDER_UNSUPPORTED`: at least one placeholder- or ICU-bearing entry was left untranslated
157
+ * because DeepL cannot preserve those tokens; such entries are withheld (absent from the result maps)
158
+ * rather than sent to DeepL and mangled.
156
159
  */
157
- type ProviderNoticeCode = "FORMALITY_DOWNGRADED" | "GLOSSARY_IGNORED";
160
+ type ProviderNoticeCode = "FORMALITY_DOWNGRADED" | "GLOSSARY_IGNORED" | "PLACEHOLDER_UNSUPPORTED";
158
161
  /**
159
162
  * An observable, structured signal that something was gracefully degraded (not an
160
163
  * error). Carries only a stable code and a static message, never a key or content.
@@ -378,6 +381,8 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
378
381
  * (thrown by `loadConfig`).
379
382
  * - `CONFIG_INVALID`: a config was found but is unparseable or fails validation (thrown by `loadConfig`).
380
383
  * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format (thrown by `translate`).
384
+ * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales (thrown by
385
+ * `check`, `diff`, and `exportWorkbook` via the shared locale selection).
381
386
  * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw; wraps the provider's own error, including
382
387
  * a missing `*_API_KEY` reported as `MISSING_API_KEY` (thrown by `translate`, non-dry-run only).
383
388
  * - `SOURCE_UNREADABLE`: the source locale file is absent (thrown by `translate`, and by `watch` at startup).
@@ -388,7 +393,7 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
388
393
  * per-locale failure carries no string code of its own. See the surfaced-not-thrown distinction on
389
394
  * `translate`.
390
395
  */
391
- type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCALE_FAILED";
396
+ type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCALE_FAILED";
392
397
  /** The single structured error the SDK throws or records. Never carries a secret. */
393
398
  declare class SdkError extends Error {
394
399
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
@@ -648,7 +653,8 @@ interface CheckDeps {
648
653
  * @param deps - Optional composition seams (registry, file system) for tests.
649
654
  * @returns The aggregate and per-locale drift status.
650
655
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
651
- * with the same meanings as in `translate`.
656
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
657
+ * among the configured target locales.
652
658
  */
653
659
  declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
654
660
 
@@ -697,7 +703,8 @@ interface DiffDeps {
697
703
  * @param deps - Optional composition seams (registry, file system) for tests.
698
704
  * @returns The aggregate and per-locale pending change.
699
705
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
700
- * with the same meanings as in `translate`.
706
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
707
+ * among the configured target locales.
701
708
  */
702
709
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
703
710
 
@@ -897,7 +904,8 @@ interface ExportWorkbookResult {
897
904
  * @param deps - Optional composition seams (registry, file system) for tests.
898
905
  * @returns Where the workbook was written and the per-locale row counts.
899
906
  * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
900
- * with the same meanings as in `translate`.
907
+ * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
908
+ * among the configured target locales.
901
909
  */
902
910
  declare function exportWorkbook(input: ExportWorkbookInput, deps?: ExportWorkbookDeps): Promise<ExportWorkbookResult>;
903
911
 
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { parse as parse$1, TYPE } from '@formatjs/icu-messageformat-parser';
15
15
  import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
16
  import { stringify, parse } from 'yaml';
17
17
  import ExcelJS from 'exceljs';
18
+ import { Readable } from 'stream';
18
19
  import JSZip from 'jszip';
19
20
  import { watch as watch$1 } from 'chokidar';
20
21
 
@@ -151,7 +152,7 @@ function checkPlaceholders(source, translated) {
151
152
  const extra = multisetExcess(translatedCounts, sourceCounts);
152
153
  const reordered = missing.length === 0 && extra.length === 0 && !sameOrder(source, translated);
153
154
  return {
154
- matches: missing.length === 0 && extra.length === 0 && !reordered,
155
+ matches: missing.length === 0 && extra.length === 0,
155
156
  missing,
156
157
  extra,
157
158
  reordered
@@ -472,6 +473,19 @@ function createDefaultClient2() {
472
473
  };
473
474
  return { client, freeAccount };
474
475
  }
476
+ var PLACEHOLDER_UNSUPPORTED_MESSAGE = "Some entries contain placeholders or ICU syntax that DeepL cannot preserve; they were left untranslated. Use an LLM provider to translate placeholder-bearing strings.";
477
+ function partitionByPlaceholders(entries) {
478
+ const protectable = [];
479
+ const unprotectable = [];
480
+ for (const entry of entries) {
481
+ if (entry.placeholders.length > 0) {
482
+ unprotectable.push(entry);
483
+ } else {
484
+ protectable.push(entry);
485
+ }
486
+ }
487
+ return { protectable, unprotectable };
488
+ }
475
489
  var FORMALITY_DOWNGRADED_MESSAGE = "Formality was not applied: the configured DeepL key is a free-tier key, which does not support formality.";
476
490
  var GLOSSARY_IGNORED_MESSAGE = "The supplied glossary term map was not applied: DeepL uses configured glossary IDs, not term maps.";
477
491
  function buildTranslateOptions(input) {
@@ -535,6 +549,7 @@ function resolveClient(deps) {
535
549
  }
536
550
  async function translate(bundle, config, request) {
537
551
  const data = validateRequest(request);
552
+ const { protectable, unprotectable } = partitionByPlaceholders(data.entries);
538
553
  const genericGlossarySupplied = request.glossary !== void 0 && Object.keys(request.glossary).length > 0;
539
554
  const { options, notices } = buildTranslateOptions({
540
555
  freeAccount: bundle.freeAccount,
@@ -542,18 +557,28 @@ async function translate(bundle, config, request) {
542
557
  ...data.tone !== void 0 ? { tone: data.tone } : {},
543
558
  ...config.glossaryId !== void 0 ? { glossaryId: config.glossaryId } : {}
544
559
  });
545
- const texts = data.entries.map((entry) => entry.value);
546
- const results = await callClient2(
560
+ const { values, integrity } = await translateProtectable(
547
561
  bundle.client,
548
- texts,
549
- data.sourceLocale,
550
- data.targetLocale,
551
- options
562
+ data,
563
+ protectable,
564
+ options,
565
+ request.extractPlaceholders
552
566
  );
553
- const { values, integrityInputs } = zipResults(data.entries, results);
554
- const integrity = checkBatchIntegrity(integrityInputs, request.extractPlaceholders);
567
+ if (unprotectable.length > 0) {
568
+ notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
569
+ }
555
570
  return { values, integrity, notices };
556
571
  }
572
+ async function translateProtectable(client, data, protectable, options, extract) {
573
+ if (protectable.length === 0) {
574
+ return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
575
+ }
576
+ const texts = protectable.map((entry) => entry.value);
577
+ const results = await callClient2(client, texts, data.sourceLocale, data.targetLocale, options);
578
+ const { values, integrityInputs } = zipResults(protectable, results);
579
+ const integrity = checkBatchIntegrity(integrityInputs, extract);
580
+ return { values, integrity };
581
+ }
557
582
  function callClient2(client, texts, sourceLang, targetLang, options) {
558
583
  return guardProviderCall(() => client.translateText(texts, sourceLang, targetLang, options));
559
584
  }
@@ -2057,6 +2082,24 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
2057
2082
  );
2058
2083
  }
2059
2084
 
2085
+ // src/flow/select-locales.ts
2086
+ function selectLocales(config, requested) {
2087
+ if (requested === void 0) {
2088
+ return config.targetLocales;
2089
+ }
2090
+ const configured = new Set(config.targetLocales);
2091
+ const unknown = requested.filter((locale) => !configured.has(locale));
2092
+ if (unknown.length > 0) {
2093
+ const label = unknown.length === 1 ? "locale" : "locales";
2094
+ throw new SdkError(
2095
+ "UNKNOWN_LOCALE",
2096
+ `Requested ${label} not in the configured target locales: ${unknown.join(", ")}. Configured targets: ${config.targetLocales.join(", ")}.`
2097
+ );
2098
+ }
2099
+ const wanted = new Set(requested);
2100
+ return config.targetLocales.filter((locale) => wanted.has(locale));
2101
+ }
2102
+
2060
2103
  // src/flow/source.ts
2061
2104
  async function readSource(config, cwd, fs, adapter) {
2062
2105
  const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
@@ -2085,13 +2128,6 @@ async function readTarget(cwd, config, adapter, fs, locale) {
2085
2128
  }
2086
2129
  return (await adapter.read(path, locale)).resource;
2087
2130
  }
2088
- function selectedLocales(config, requested) {
2089
- if (requested === void 0) {
2090
- return config.targetLocales;
2091
- }
2092
- const wanted = new Set(requested);
2093
- return config.targetLocales.filter((locale) => wanted.has(locale));
2094
- }
2095
2131
  async function diffLocales(input, deps = {}) {
2096
2132
  const config = input.config;
2097
2133
  const cwd = input.cwd ?? process.cwd();
@@ -2100,7 +2136,7 @@ async function diffLocales(input, deps = {}) {
2100
2136
  const source = await readSource(config, cwd, fs, adapter);
2101
2137
  const lock = await readLockFile(lockFilePath(cwd), fs);
2102
2138
  return Promise.all(
2103
- selectedLocales(config, input.locales).map(async (locale) => {
2139
+ selectLocales(config, input.locales).map(async (locale) => {
2104
2140
  const target = await readTarget(cwd, config, adapter, fs, locale);
2105
2141
  const diff2 = diffResources(source.resource, target, { baseline: baselineFor(lock, locale) });
2106
2142
  return { locale, diff: diff2 };
@@ -2826,6 +2862,34 @@ function declaredSize(file) {
2826
2862
  const size = data?.uncompressedSize;
2827
2863
  return typeof size === "number" && Number.isFinite(size) ? size : void 0;
2828
2864
  }
2865
+ async function streamEntryBounded(file, remaining) {
2866
+ const chunks = [];
2867
+ let raw = 0;
2868
+ let oversize = false;
2869
+ const source = new Readable({ objectMode: true }).wrap(
2870
+ file.nodeStream("nodebuffer")
2871
+ );
2872
+ try {
2873
+ for await (const chunk2 of source) {
2874
+ const buf = Buffer.isBuffer(chunk2) ? chunk2 : Buffer.from(chunk2);
2875
+ raw += buf.length;
2876
+ if (raw > remaining) {
2877
+ oversize = true;
2878
+ break;
2879
+ }
2880
+ chunks.push(buf);
2881
+ }
2882
+ } catch {
2883
+ throw new ExchangeError("WORKBOOK_INVALID", "A workbook entry could not be decompressed.");
2884
+ }
2885
+ if (oversize) {
2886
+ throw new ExchangeError(
2887
+ "WORKBOOK_INVALID",
2888
+ "The workbook decompresses to more than the permitted maximum bytes."
2889
+ );
2890
+ }
2891
+ return Buffer.concat(chunks).toString("utf8");
2892
+ }
2829
2893
  async function guardWorkbookBytes(bytes, limits) {
2830
2894
  let zip;
2831
2895
  try {
@@ -2855,12 +2919,8 @@ async function guardWorkbookBytes(bytes, limits) {
2855
2919
  }
2856
2920
  let actualTotal = 0;
2857
2921
  for (const file of files) {
2858
- let content;
2859
- try {
2860
- content = await file.async("string");
2861
- } catch {
2862
- throw new ExchangeError("WORKBOOK_INVALID", "A workbook entry could not be decompressed.");
2863
- }
2922
+ const remaining = limits.maxDecompressedBytes - actualTotal;
2923
+ const content = await streamEntryBounded(file, remaining);
2864
2924
  actualTotal += Buffer.byteLength(content, "utf8");
2865
2925
  if (actualTotal > limits.maxDecompressedBytes) {
2866
2926
  throw new ExchangeError(
@@ -3010,13 +3070,6 @@ function buildRows(source, target, baseline, includeUnchanged) {
3010
3070
  }
3011
3071
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
3012
3072
  }
3013
- function selectedLocales2(config, requested) {
3014
- if (requested === void 0) {
3015
- return config.targetLocales;
3016
- }
3017
- const wanted = new Set(requested);
3018
- return config.targetLocales.filter((locale) => wanted.has(locale));
3019
- }
3020
3073
  async function exportWorkbook(input, deps = {}) {
3021
3074
  const config = input.config;
3022
3075
  const cwd = input.cwd ?? process.cwd();
@@ -3024,7 +3077,7 @@ async function exportWorkbook(input, deps = {}) {
3024
3077
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3025
3078
  const source = await readSource(config, cwd, fs, adapter);
3026
3079
  const lock = await readLockFile(lockFilePath(cwd), fs);
3027
- const locales = selectedLocales2(config, input.locales);
3080
+ const locales = selectLocales(config, input.locales);
3028
3081
  const sheets = await Promise.all(
3029
3082
  locales.map(async (locale) => {
3030
3083
  const target = await readTarget3(cwd, config, adapter, fs, locale);