@verbatra/sdk 0.10.0 → 0.11.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.d.cts CHANGED
@@ -17,7 +17,7 @@ import OpenAI from 'openai';
17
17
  * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
18
18
  * decides and which feeds the content hash.
19
19
  */
20
- declare const translationEntrySchema$1: z.ZodObject<{
20
+ declare const translationEntrySchema$2: z.ZodObject<{
21
21
  key: z.ZodString;
22
22
  namespace: z.ZodString;
23
23
  value: z.ZodString;
@@ -30,14 +30,14 @@ declare const translationEntrySchema$1: z.ZodObject<{
30
30
  * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
31
31
  * made deeply readonly. See that schema for what each field carries.
32
32
  */
33
- type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
33
+ type TranslationEntry$2 = Readonly<z.infer<typeof translationEntrySchema$2>>;
34
34
 
35
35
  /**
36
36
  * Result of comparing source placeholders against translated-output placeholders as MULTISETS
37
37
  * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
38
38
  * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
39
39
  */
40
- interface PlaceholderIntegrityResult$1 {
40
+ interface PlaceholderIntegrityResult$2 {
41
41
  /** True when nothing is missing or extra: the multisets match, regardless of order. */
42
42
  readonly matches: boolean;
43
43
  /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
@@ -74,7 +74,7 @@ type PlaceholderExtractor = (value: string) => readonly string[];
74
74
  * defines one) so it matches the entries' format; ai-providers never derives it itself and never parses
75
75
  * a specific format's message syntax.
76
76
  */
77
- type PlaceholderComparator = (source: string, translated: string) => PlaceholderIntegrityResult$1;
77
+ type PlaceholderComparator = (source: string, translated: string) => PlaceholderIntegrityResult$2;
78
78
  /**
79
79
  * A batch translation request. Format- and provider-neutral: it carries no prompt,
80
80
  * model, key, or other provider-specific field. The placeholder extractor is
@@ -86,11 +86,18 @@ interface TranslateRequest {
86
86
  /** BCP-47 target locale to translate into (for example, "de"). */
87
87
  readonly targetLocale: string;
88
88
  /** The entries to translate; at least one is required. */
89
- readonly entries: readonly TranslationEntry$1[];
89
+ readonly entries: readonly TranslationEntry$2[];
90
90
  /** Optional source-term to target-term map applied by glossary-capable providers. */
91
91
  readonly glossary?: Readonly<Record<string, string>>;
92
92
  /** Optional target {@link Tone}; machine-translation providers map it to formality. */
93
93
  readonly tone?: Tone;
94
+ /**
95
+ * Optional per-key maximum length budget, measured in grapheme clusters. A key present in the map
96
+ * whose translation comes back longer than its budget is flagged `MAX_LENGTH_EXCEEDED`; a key
97
+ * absent from the map is never flagged. Advisory only: it never changes what is sent to the
98
+ * provider and never withholds a value.
99
+ */
100
+ readonly maxLength?: ReadonlyMap<string, number>;
94
101
  /** Mandatory placeholder extractor; the output integrity check runs against it. */
95
102
  readonly extractPlaceholders: PlaceholderExtractor;
96
103
  /**
@@ -108,7 +115,10 @@ interface TranslateRequest {
108
115
  */
109
116
  readonly signal?: AbortSignal;
110
117
  }
111
- /** Token usage, when the provider reports it. Absent for providers without tokens (DeepL). */
118
+ /**
119
+ * Token usage, when the provider reports it. Absent for the providers that do not bill by token
120
+ * (DeepL and Google Cloud Translation).
121
+ */
112
122
  interface Usage {
113
123
  /** Tokens consumed by the request, summed across the initial call and any repair round. */
114
124
  readonly inputTokens: number;
@@ -119,10 +129,10 @@ interface Usage {
119
129
  * Stable codes for a provider's graceful-degradation notices. These are returned DATA on a
120
130
  * successful result, NOT thrown:
121
131
  *
122
- * - `FORMALITY_DOWNGRADED`: a requested formality was not applied (DeepL's free tier does not
123
- * support it).
132
+ * - `FORMALITY_DOWNGRADED`: a requested `formal` or `informal` tone was not applied (DeepL's free
133
+ * tier does not support formality, and Google Cloud Translation Basic has no formality control).
124
134
  * - `GLOSSARY_IGNORED`: a supplied generic glossary term map was not applied (DeepL only applies a
125
- * native glossary id, never a term map).
135
+ * native glossary id, never a term map, and Google Cloud Translation Basic supports no glossary).
126
136
  * - `PLACEHOLDER_UNSUPPORTED`: at least one placeholder- or ICU-bearing entry was left untranslated
127
137
  * because the provider cannot preserve those tokens; such entries are withheld (absent from the
128
138
  * result maps) rather than sent to the provider and mangled.
@@ -141,24 +151,65 @@ interface ProviderNotice {
141
151
  }
142
152
  /**
143
153
  * Stable codes for a derived, per-key "needs review" signal. This is verbatra's own computed
144
- * assessment, never a raw model self-score: four are recomputable from plain source and translated
145
- * values, and `PROVIDER_DEGRADED` is layered on afterwards from the batch's notices.
154
+ * assessment, never a raw model self-score: five are recomputable from plain source and translated
155
+ * values, and `FUZZY_CACHE_REUSE` and `PROVIDER_DEGRADED` are layered on afterwards, the first by
156
+ * the SDK from where the value came, the second from the batch's notices.
146
157
  *
147
158
  * - `LENGTH_RATIO_OUTLIER`: the translated value's length is far shorter or longer than the source's.
148
- * Only considered once the trimmed source is long enough for the ratio to mean anything.
159
+ * Only considered once the trimmed source is long enough for the ratio to mean anything. Relative
160
+ * to the source, and measured in UTF-16 code units; contrast `MAX_LENGTH_EXCEEDED`.
161
+ * - `MAX_LENGTH_EXCEEDED`: the translated value is longer than the absolute budget configured for
162
+ * its key. Measured in grapheme clusters (user-perceived characters), on the value exactly as
163
+ * written with no trimming, and the comparison is inclusive, so a value of exactly the budget is
164
+ * not flagged. A key with no configured budget is never flagged. Advisory only: an over-budget
165
+ * value is still written to the locale file and still recorded in the lock file, because a
166
+ * correct translation that overruns a layout budget is more useful than no translation at all.
149
167
  * - `EQUALS_SOURCE`: the translated value equals the source value once both are trimmed, so a
150
168
  * difference in leading or trailing whitespace alone still counts as equal. Also requires that
151
169
  * the locales differ and that the value contains at least one letter (so a bare symbol or number
152
170
  * is not flagged).
153
171
  * - `GLOSSARY_TERM_MISSED`: a configured glossary source term appeared in the source but its target
154
- * term did not appear in the translation.
172
+ * term did not appear in the translation. Matching is case-insensitive, and the two sides are
173
+ * held to deliberately different standards. The source side requires a whole-word occurrence, so
174
+ * the term "AI" is not found inside "Airport" and cannot raise an expectation the translator was
175
+ * never given; a source term whose edge character belongs to a script written without word
176
+ * separators (Han, kana, Thai and similar) has no boundary to anchor to and falls back to
177
+ * containment. The target side requires containment only, because a translated term legitimately
178
+ * fuses with the text around it: German compounds ("Benutzerkonto"), Korean particles ("계정을")
179
+ * and Japanese loanwords all carry the term with no boundary around it, and demanding one there
180
+ * would flag correct translations in most languages a glossary is used for.
155
181
  * - `INTEGRITY_REORDERED`: the placeholder set matched but landed in a different order.
156
182
  * - `PROVIDER_DEGRADED`: the batch this key came from carried a `FORMALITY_DOWNGRADED` or
157
183
  * `GLOSSARY_IGNORED` notice, either of which can silently change wording. A
158
184
  * `PLACEHOLDER_UNSUPPORTED` notice does not raise it, since the affected entries are withheld
159
185
  * rather than degraded.
186
+ * - `FUZZY_CACHE_REUSE`: the value was not translated for this source text at all. It was reused
187
+ * from the translation memory for an earlier, near-identical source that has since been edited,
188
+ * so it is a translation of text that is no longer the source text. Similarity is measured in
189
+ * characters and meaning is not a function of character distance: a dropped negation, an
190
+ * inverted modal or a swapped proper noun are all small edits with large consequences, and no
191
+ * threshold separates them from a typo fix. Every such reuse therefore carries this reason, on
192
+ * every run, regardless of score.
193
+ *
194
+ * This tuple is the single source of truth for the set. {@link ReviewReasonCode} is derived from
195
+ * it, so build any runtime validator or exhaustive lookup from this value rather than retyping the
196
+ * members; a hand-copied list silently falls behind the next addition.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * import { REVIEW_REASON_CODES } from "@verbatra/sdk";
201
+ * import { z } from "zod";
202
+ *
203
+ * const reasonSchema = z.enum(REVIEW_REASON_CODES);
204
+ * ```
160
205
  */
161
- type ReviewReasonCode = "LENGTH_RATIO_OUTLIER" | "EQUALS_SOURCE" | "GLOSSARY_TERM_MISSED" | "INTEGRITY_REORDERED" | "PROVIDER_DEGRADED";
206
+ declare const REVIEW_REASON_CODES: readonly ["LENGTH_RATIO_OUTLIER", "MAX_LENGTH_EXCEEDED", "EQUALS_SOURCE", "GLOSSARY_TERM_MISSED", "INTEGRITY_REORDERED", "PROVIDER_DEGRADED", "FUZZY_CACHE_REUSE"];
207
+ /**
208
+ * One of {@link REVIEW_REASON_CODES}. The union is derived from that tuple rather than written out
209
+ * again, so a code can only be added in one place and every consumer that builds a runtime schema
210
+ * or an exhaustive map from the tuple stays in step automatically.
211
+ */
212
+ type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
162
213
  /** A key flagged for human review, carrying every reason code that applies. */
163
214
  interface ReviewFlag {
164
215
  /** Always "review": the flag exists only for flagged keys, so there is no "ok" member. */
@@ -175,13 +226,13 @@ interface TranslateResult {
175
226
  */
176
227
  readonly values: ReadonlyMap<string, string>;
177
228
  /** The placeholder-integrity outcome for each key (source vs translated placeholder sets). */
178
- readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult$1>;
229
+ readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult$2>;
179
230
  /** Token usage when the provider reports it; absent for token-less providers. */
180
231
  readonly usage?: Usage;
181
232
  /**
182
233
  * Graceful-degradation notices for this batch. Every provider populates this as a present array:
183
- * DeepL reports real notices (for example `GLOSSARY_IGNORED`); an LLM provider with nothing to
184
- * report returns an empty array rather than omitting the field.
234
+ * DeepL and Google Cloud Translation report real notices (for example `GLOSSARY_IGNORED`); an LLM
235
+ * provider with nothing to report returns an empty array rather than omitting the field.
185
236
  */
186
237
  readonly notices?: readonly ProviderNotice[];
187
238
  /**
@@ -260,21 +311,26 @@ type GeminiModel = "gemini-2.5-flash" | "gemini-2.5-pro" | "gemma-4-26b-a4b-it"
260
311
  type OpenAiModel = OpenAI.ChatModel;
261
312
 
262
313
  /**
263
- * Zod schema accepting exactly one of the twelve source formats verbatra can read and write. Each
314
+ * Zod schema accepting exactly one of the fourteen source formats verbatra can read and write. Each
264
315
  * member names the ecosystem it round-trips, not just a file extension, because several of them share
265
316
  * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
266
317
  * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
267
- * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining eight are `xliff` (the XLIFF
318
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining ten are `xliff` (the XLIFF
268
319
  * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle),
269
320
  * `properties` (Java and Spring `.properties` files), `apple-strings` (Apple's flat `.strings`
270
321
  * localization format for iOS and macOS), `apple-xcstrings` (Apple's Xcode String Catalog, a single
271
322
  * JSON document holding every locale of a `.xcstrings` catalogue together), `android-xml` (Android's
272
323
  * `res/values/strings.xml` resource format, including `<plurals>` and `translatable="false"` entries),
273
- * and `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
274
- * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count).
275
- *
276
- * The set is closed: a format outside it cannot be represented, so adding one means adding a member
277
- * here and an adapter that claims it.
324
+ * `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
325
+ * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count),
326
+ * `ini` (classic INI configuration files, one level of `[section]` headers over `key=value` lines,
327
+ * whose entries are addressed as `section.key`), and `resx` (.NET's XML resource format; its `<data>`
328
+ * elements carrying a `type` or `mimetype` attribute, and its designer metadata names, are preserved
329
+ * untouched rather than translated).
330
+ *
331
+ * The set is closed: adding a built-in format means adding a member here and an adapter that claims
332
+ * it. A format shipped by a package outside verbatra is named by a `custom:` identifier instead and
333
+ * never joins this set; see `FormatId`.
278
334
  */
279
335
  declare const supportedFormatSchema$1: z.ZodEnum<{
280
336
  "i18next-json": "i18next-json";
@@ -289,206 +345,1307 @@ declare const supportedFormatSchema$1: z.ZodEnum<{
289
345
  "apple-xcstrings": "apple-xcstrings";
290
346
  "android-xml": "android-xml";
291
347
  "gettext-po": "gettext-po";
348
+ ini: "ini";
349
+ resx: "resx";
292
350
  }>;
293
- /** One of the twelve supported source formats; the inferred type of {@link supportedFormatSchema}. */
351
+ /** One of the fourteen supported source formats; the inferred type of {@link supportedFormatSchema}. */
294
352
  type SupportedFormat$1 = z.infer<typeof supportedFormatSchema$1>;
295
-
296
353
  /**
297
- * The outcome of a size-bounded text read. A file that is absent or larger than the caller's limit
298
- * is reported as a state rather than thrown, so a caller can treat "no file yet" as normal and an
299
- * oversized file as a distinct, actionable condition.
354
+ * A format identifier claimed by an adapter that ships outside verbatra: the reserved
355
+ * `custom:` prefix followed by a name of lowercase ASCII letters and digits in hyphen-separated
356
+ * segments, for example `custom:my-format`. The type itself accepts any text after the prefix;
357
+ * {@link isCustomFormatId} and adapter registration enforce the name's shape at runtime.
358
+ *
359
+ * No built-in format name contains a colon, so a third-party identifier can never shadow a
360
+ * built-in one.
300
361
  */
301
- type BoundedFileRead = {
302
- /** The file was read in full. */
303
- readonly kind: "ok";
304
- /** The file's content, decoded as UTF-8. */
305
- readonly content: string;
306
- } | {
307
- /** No readable file exists at the path. */
308
- readonly kind: "missing";
309
- } | {
310
- /** The file exists but exceeds the requested byte limit, so nothing was read. */
311
- readonly kind: "too-large";
312
- };
362
+ type CustomFormatId$1 = `custom:${string}`;
313
363
  /**
314
- * The outcome of a size-bounded binary read, used for interchange workbooks. Mirrors
315
- * {@link BoundedFileRead} but yields raw bytes rather than decoded text.
364
+ * Narrow a format identifier to a third-party one.
365
+ *
366
+ * @param format - The identifier to classify.
367
+ * @returns True when the identifier is a well-formed `custom:` identifier. A built-in format
368
+ * name always returns false, and so does a `custom:` identifier whose name is empty, holds any
369
+ * character other than a lowercase ASCII letter, a digit, or a hyphen, or has a leading,
370
+ * trailing, or doubled hyphen.
371
+ *
372
+ * @example
373
+ * ```ts
374
+ * isCustomFormatId("custom:toml"); // true
375
+ * isCustomFormatId("i18next-json"); // false
376
+ * ```
316
377
  */
317
- type BoundedBytesRead = {
318
- /** The file was read in full. */
319
- readonly kind: "ok";
320
- /** The file's raw bytes. */
321
- readonly bytes: Uint8Array;
322
- } | {
323
- /** No readable file exists at the path. */
324
- readonly kind: "missing";
325
- } | {
326
- /** The file exists but exceeds the requested byte limit, so nothing was read. */
327
- readonly kind: "too-large";
328
- };
378
+ declare function isCustomFormatId(format: string): format is CustomFormatId$1;
329
379
  /**
330
- * The file-system port the SDK's own I/O goes through. Supplying your own implementation as a
331
- * `deps.fs` option redirects that I/O, which is how the SDK's tests avoid touching disk and how an
332
- * embedding application can back part of a project with something other than a local disk.
380
+ * Any format verbatra can be asked to handle: one of the built-in {@link SupportedFormat}
381
+ * members, or a third-party {@link CustomFormatId}.
333
382
  *
334
- * The seam carries every file the SDK touches: the run-status file, the lock-file, the config
335
- * glossary, workbook and interchange I/O, and the locale files themselves, which the adapters read
336
- * and write through a port built from this one. The single exception is a caller-supplied
337
- * `deps.adapterRegistry`: those adapters were constructed by the caller, so their file access is
338
- * whatever the caller wired into them, and supplying both means the caller owns that wiring.
383
+ * A built-in name is checked against the closed set at compile time. A `custom:` identifier is
384
+ * only checked for shape, because the adapter behind it lives outside this package; if no
385
+ * registered adapter claims it, the run fails with a structured error naming the format.
386
+ */
387
+ type FormatId$1 = SupportedFormat$1 | CustomFormatId$1;
388
+
389
+ /**
390
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
391
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
392
+ * a placeholder in that format.
339
393
  *
340
- * Reads are size-bounded by contract so that a hostile or accidentally huge file cannot exhaust
341
- * memory. Writes are expected to be atomic: the default implementation writes to a temporary file
342
- * and renames it into place, so a crash mid-write never leaves a half-written file behind.
343
- * Directory creation is the caller's job, so an implementation whose `writeFile` targets a real
344
- * directory tree must also implement `mkdir`.
394
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
395
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
396
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
397
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
398
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
399
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
400
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
401
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
402
+ * decides and which feeds the content hash.
345
403
  */
346
- interface SdkFs {
347
- /** Reports whether a readable file exists at the path. */
348
- fileExists(path: string): Promise<boolean>;
349
- /** Reads a file as UTF-8 text, refusing to read more than `maxBytes`. */
350
- readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
351
- /** Reads a file as raw bytes, refusing to read more than `maxBytes`. */
352
- readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
353
- /** Writes UTF-8 text to the path, replacing any existing file atomically. */
354
- writeFile(path: string, data: string): Promise<void>;
355
- /** Writes raw bytes to the path, replacing any existing file atomically. */
356
- writeBytes(path: string, data: Uint8Array): Promise<void>;
404
+ declare const translationEntrySchema$1: z.ZodObject<{
405
+ key: z.ZodString;
406
+ namespace: z.ZodString;
407
+ value: z.ZodString;
408
+ description: z.ZodOptional<z.ZodString>;
409
+ meaning: z.ZodOptional<z.ZodString>;
410
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
411
+ isPlural: z.ZodBoolean;
412
+ }, z.core.$strip>;
413
+ /**
414
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
415
+ * made deeply readonly. See that schema for what each field carries.
416
+ */
417
+ type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
418
+
419
+ /**
420
+ * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
421
+ * intermediate representation every adapter reads into and writes back out of, so nothing downstream
422
+ * of an adapter parses a format's own syntax.
423
+ */
424
+ interface LocaleResource$1 {
425
+ /** The locale these entries belong to (for example, "en" or "de"). */
426
+ readonly locale: string;
427
+ /** The namespace these entries belong to; empty when the format has no namespacing. */
428
+ readonly namespace: string;
429
+ /** The source format the resource came from, retained for round-trip fidelity on write. */
430
+ readonly format: FormatId$1;
431
+ /** Entries addressable by key, in the order the source file defined them. */
432
+ readonly entries: ReadonlyMap<string, TranslationEntry$1>;
433
+ }
434
+
435
+ /** One distinct translated value inside an {@link InconsistencyGroup}, with every key holding it. */
436
+ interface InconsistentTranslation {
357
437
  /**
358
- * Creates a file only if it does not already exist, returning `false` when it does. This is the
359
- * primitive behind the per-locale write lock, so it must be atomic against other processes.
438
+ * The translated value in its compared form: Unicode NFC, line endings folded to `\n`, and
439
+ * leading and trailing whitespace trimmed. Internal whitespace and letter case are kept.
360
440
  */
361
- createExclusive(path: string, data: string): Promise<boolean>;
362
- /** Removes the file at the path, succeeding even when it is already absent. */
363
- deleteFile(path: string): Promise<void>;
364
- /** Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. */
365
- mkdir?(path: string): Promise<void>;
441
+ readonly value: string;
442
+ /** Every key in this locale holding this value, sorted by UTF-16 code unit. */
443
+ readonly keys: readonly string[];
444
+ }
445
+ /**
446
+ * One source string that is translated more than one way within a single target locale. Keys are
447
+ * grouped only when their source value, description, meaning, plural flag, plural form, and context
448
+ * all agree, so a string that deliberately carries different disambiguation metadata is never
449
+ * reported.
450
+ */
451
+ interface InconsistencyGroup {
452
+ /** The shared source value in its compared form (see {@link InconsistentTranslation.value}). */
453
+ readonly source: string;
454
+ /** The disambiguation context every key in the group shares, when the format has one. */
455
+ readonly context?: string;
456
+ /** The description every key in the group shares, when there is one. */
457
+ readonly description?: string;
458
+ /** The meaning every key in the group shares, when there is one. */
459
+ readonly meaning?: string;
460
+ /** Whether the grouped source entries carry plural forms. */
461
+ readonly isPlural: boolean;
462
+ /**
463
+ * The plural form every key in the group shares (a CLDR category such as `one`, or a format's own
464
+ * plural index), when the format encodes one per key. Different forms of one key are never grouped.
465
+ */
466
+ readonly pluralForm?: string;
467
+ /** Every distinct translation, at least two, sorted by value in UTF-16 code unit order. */
468
+ readonly translations: readonly InconsistentTranslation[];
366
469
  }
367
470
 
368
471
  /**
369
- * The on-disk translation memory, read from and written to `verbatra.cache.json` (see
370
- * {@link CACHE_FILE_NAME}). A run consults it before calling the provider, so a string that was
371
- * already translated under the same configuration is reused instead of paid for again.
372
- *
373
- * Entries are keyed by configuration fingerprint, then locale, then source-content hash. The
374
- * fingerprint layer means that changing the provider, model, tone, or glossary does not silently
375
- * reuse translations produced under the old settings; those entries simply stop matching.
472
+ * Result of comparing source placeholders against translated-output placeholders as MULTISETS
473
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
474
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
376
475
  */
377
- interface TranslationMemory {
378
- /** The cache schema version. A file at an unrecognized version is ignored rather than trusted. */
379
- readonly version: number;
380
- /** Cached values, nested as configuration fingerprint, then locale, then source-content hash. */
381
- readonly entries: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>>;
476
+ interface PlaceholderIntegrityResult$1 {
477
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
478
+ readonly matches: boolean;
479
+ /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
480
+ readonly missing: readonly string[];
481
+ /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
482
+ readonly extra: readonly string[];
483
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
484
+ readonly reordered: boolean;
382
485
  }
383
486
 
384
487
  /**
385
- * The file name of the project's translation memory, resolved against the run's working directory.
386
- * Commit it to reuse translations across machines and CI runs, or add it to `.gitignore` to treat
387
- * the cache as purely local. Deleting it is always safe: the next run simply repays for the strings
388
- * it would have reused.
488
+ * Every translation framework a source scan can target, as a tuple; {@link SourceFramework} is
489
+ * derived from it. It holds `i18next` alone today, the one framework an extractor ships for.
389
490
  */
390
- declare const CACHE_FILE_NAME = "verbatra.cache.json";
491
+ declare const SOURCE_FRAMEWORKS: readonly ["i18next"];
492
+ /**
493
+ * The translation framework whose call sites a source scan looks for. The set is closed: a
494
+ * framework is only nameable here once an extractor ships for it, so a configured value that
495
+ * resolves to nothing cannot exist.
496
+ *
497
+ * `i18next` covers the `t(...)`, `$t(...)`, and `<object>.t(...)` shapes, each also through an
498
+ * optional call (`t?.(...)`) and an explicit type-argument list (`t<string>(...)`), with the key as
499
+ * the first argument and an optional default as either the second argument or a `defaultValue`
500
+ * field on the options object. A namespace-qualified key such as `t("common:nav.home")` is
501
+ * reported as a dynamic call site rather than extracted, because one configuration addresses one
502
+ * catalog file.
503
+ */
504
+ type SourceFramework = (typeof SOURCE_FRAMEWORKS)[number];
391
505
 
506
+ /** One application source file, already read, as it is handed to a {@link SourceExtractor}. */
507
+ interface SourceFile {
508
+ /** The file's absolute path, used only for reporting. */
509
+ readonly path: string;
510
+ /** The file's full text. It is untrusted input and is never echoed into a result. */
511
+ readonly content: string;
512
+ }
513
+ /** One translation call site whose key argument was a static string. */
514
+ interface ExtractedCallSite {
515
+ /** The key exactly as the call site spelled it. */
516
+ readonly key: string;
517
+ /** The default value the call site supplied, if any. Absent when the call named only a key. */
518
+ readonly defaultValue?: string;
519
+ /** The one-based line the call site sits on. */
520
+ readonly line: number;
521
+ }
392
522
  /**
393
- * The zod schema for the `provider` block, discriminated on `id`. Each variant's options are
394
- * validated strictly, so an option that belongs to a different provider is reported as an error
395
- * rather than ignored. It is embedded in {@link verbatraConfigSchema} and produces
396
- * {@link ProviderConfig}.
523
+ * One translation call site whose key argument could not be resolved statically: a variable, a
524
+ * member expression, or a template literal carrying an expression. It is reported rather than
525
+ * guessed at, so no invented key ever reaches a catalog.
397
526
  */
398
- declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
399
- id: z.ZodLiteral<"anthropic">;
400
- options: z.ZodObject<{
401
- model: z.ZodString;
402
- maxTokens: z.ZodNumber;
403
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
404
- }, z.core.$strict>;
405
- }, z.core.$strip>, z.ZodObject<{
406
- id: z.ZodLiteral<"openai">;
407
- options: z.ZodObject<{
408
- model: z.ZodString;
409
- maxOutputTokens: z.ZodNumber;
410
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
411
- }, z.core.$strict>;
412
- }, z.core.$strip>, z.ZodObject<{
413
- id: z.ZodLiteral<"gemini">;
414
- options: z.ZodObject<{
415
- model: z.ZodString;
416
- maxOutputTokens: z.ZodNumber;
417
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
418
- }, z.core.$strict>;
419
- }, z.core.$strip>, z.ZodObject<{
420
- id: z.ZodLiteral<"deepl">;
421
- options: z.ZodObject<{
422
- glossaryId: z.ZodOptional<z.ZodString>;
423
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
424
- }, z.core.$strict>;
425
- }, z.core.$strip>, z.ZodObject<{
426
- id: z.ZodLiteral<"google-translate">;
427
- options: z.ZodObject<{
428
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
429
- }, z.core.$strict>;
430
- }, z.core.$strip>, z.ZodObject<{
431
- id: z.ZodLiteral<"openai-compatible">;
432
- options: z.ZodObject<{
433
- baseUrl: z.ZodURL;
434
- model: z.ZodString;
435
- maxOutputTokens: z.ZodNumber;
436
- apiKeyEnvVar: z.ZodOptional<z.ZodString>;
437
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
438
- }, z.core.$strict>;
439
- }, z.core.$strip>], "id">;
527
+ interface DynamicCallSite {
528
+ /** The one-based line the call site sits on. */
529
+ readonly line: number;
530
+ }
531
+ /** A catalog key a file names statically, wherever it names it. */
532
+ interface ReferencedKeySite {
533
+ /** The key as the source can reach it. */
534
+ readonly key: string;
535
+ /** The one-based line it is named on. */
536
+ readonly line: number;
537
+ }
440
538
  /**
441
- * The `provider` block of a verbatra config: a discriminated union on `id`, so each provider
442
- * carries exactly the options it supports and nothing else.
443
- *
444
- * No variant has a field for an API key. Keys are read from the environment by the provider itself
445
- * (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`, or
446
- * `GOOGLE_TRANSLATE_API_KEY`), which is what keeps them out of config files and out of version
447
- * control. The `openai-compatible` variant may name a different environment variable through
448
- * `apiKeyEnvVar`, but still never holds the key itself.
539
+ * A key argument with a static head and a dynamic rest, such as the template literal `nav.${page}`.
449
540
  */
450
- type ProviderConfig = z.infer<typeof providerConfigSchema>;
541
+ interface KeyPrefixSite {
542
+ /** The static head every key this site can reach starts with. */
543
+ readonly prefix: string;
544
+ /** The one-based line the site sits on. */
545
+ readonly line: number;
546
+ }
451
547
  /**
452
- * The identifier of a supported translation provider: `anthropic`, `openai`, `gemini`, `deepl`,
453
- * `google-translate`, or `openai-compatible`. The last one targets a local or self-hosted server
454
- * that speaks the OpenAI chat-completions API.
548
+ * Why a place in the source reaches keys a static reading cannot bound.
549
+ *
550
+ * - `dynamic-key-prefix`: a key prefix (a `keyPrefix` option or a `getFixedT` prefix argument) is
551
+ * not a static string.
552
+ * - `trans-without-key`: a `Trans` element carries no static `i18nKey`, so its key is its children.
553
+ * - `aliased-translate-function`: the translate function is assigned somewhere its calls cannot be
554
+ * followed, such as an object property.
555
+ * - `translate-function-escapes`: a translate function bound from a recognised source appears
556
+ * somewhere other than a direct call, its own binding, a local alias, or the `t` attribute of a
557
+ * `Trans` or `Translation` element (for example as an argument, a property, an export, or a
558
+ * ternary operand), so other code can call it with keys this file never names.
559
+ * - `unrecognised-translate-source`: a translate source (`useTranslation`, `withTranslation`,
560
+ * `getFixedT`, a `Translation` render prop, a member `t`, or a `t` import) is used in a shape the
561
+ * scan does not recognise, so the keys and prefix the resulting function uses are unknown.
455
562
  */
456
- type ProviderId = ProviderConfig["id"];
457
-
563
+ type UnresolvedKeySiteReason = "dynamic-key-prefix" | "trans-without-key" | "aliased-translate-function" | "translate-function-escapes" | "unrecognised-translate-source";
564
+ /** A place whose reachable keys cannot be bounded statically. */
565
+ interface UnresolvedKeySite {
566
+ /** Why the keys it reaches are unknown. */
567
+ readonly reason: UnresolvedKeySiteReason;
568
+ /** The one-based line it sits on. */
569
+ readonly line: number;
570
+ }
571
+ /** Everything one file tells a caller about which catalog keys it can reach. */
572
+ interface KeyUsage {
573
+ /** Every static key the file names, including the extracted calls, in line order. */
574
+ readonly references: readonly ReferencedKeySite[];
575
+ /** Key arguments with no static part at all. */
576
+ readonly dynamic: readonly DynamicCallSite[];
577
+ /** Key arguments with a static head. */
578
+ readonly prefixes: readonly KeyPrefixSite[];
579
+ /** Places whose reachable keys cannot be bounded. */
580
+ readonly unresolved: readonly UnresolvedKeySite[];
581
+ }
582
+ /** Everything one {@link SourceExtractor} found in one file. */
583
+ interface FileExtraction {
584
+ /** Call sites whose key was a static string. */
585
+ readonly calls: readonly ExtractedCallSite[];
586
+ /** Call sites whose key could not be resolved statically. */
587
+ readonly dynamic: readonly DynamicCallSite[];
588
+ /**
589
+ * What the file tells a caller asking which catalog keys the source can reach, beyond the calls
590
+ * above: static keys named some other way, template keys with a static head, and constructs
591
+ * whose keys cannot be bounded. Absent for an extractor that does not report it, in which case
592
+ * the calls and dynamic sites above are all that is known.
593
+ */
594
+ readonly usage?: KeyUsage;
595
+ /**
596
+ * Whether the file could not be read to its end, so everything above it is partial. An
597
+ * unterminated block comment or template literal abandons the rest of the file. In a file read as
598
+ * markup (`.tsx`, `.jsx`, `.js`), so does a JSX element that never closes or is closed by an
599
+ * enclosing element's tag, and a quoted string in code that runs into the end of its line. The
600
+ * scan records it as an `unparseable` diagnostic and carries on, rather than reporting a short
601
+ * result as if it were the whole file.
602
+ */
603
+ readonly truncated?: boolean;
604
+ }
458
605
  /**
459
- * The zod schema every verbatra config is validated against. {@link loadConfig} applies it, so most
460
- * consumers never call it directly; reach for it when you build a config programmatically and want
461
- * to validate it before handing it to {@link translate}, or when you surface config errors in your
462
- * own tooling.
463
- *
464
- * The object is strict, so an unrecognized key is an error rather than being silently ignored: a
465
- * typo in a config file is reported instead of quietly doing nothing. The one exception is the
466
- * optional `$schema` key, accepted so a JSON or YAML config can point an editor at the JSON Schema
467
- * document the package ships as `@verbatra/sdk/config-schema.json`.
606
+ * The contract every framework's source extractor implements: given one file's text, report the
607
+ * translation call sites it contains. One implementation per framework, selected by the configured
608
+ * `extract.framework`.
468
609
  *
469
- * Beyond the per-field checks, two whole-config rules are enforced: `targetLocales` must not
470
- * contain the source locale, and it must not contain two locales that differ only in case (they
471
- * would collide on a case-insensitive file system). The `{locale}` token requirement is a
472
- * per-field check on `files.pattern`.
610
+ * It is pure: it reads no file, makes no network call, and holds no state between files. Supplying
611
+ * your own as `deps.createExtractor` is how you teach `extract` a call shape verbatra does not
612
+ * ship yet.
473
613
  */
474
- declare const verbatraConfigSchema: z.ZodObject<{
475
- $schema: z.ZodOptional<z.ZodString>;
476
- sourceLocale: z.ZodString;
477
- targetLocales: z.ZodArray<z.ZodString>;
478
- format: z.ZodEnum<{
479
- "i18next-json": "i18next-json";
480
- "vue-i18n-json": "vue-i18n-json";
481
- "next-intl-json": "next-intl-json";
482
- "ngx-translate-json": "ngx-translate-json";
483
- xliff: "xliff";
484
- yaml: "yaml";
485
- arb: "arb";
486
- properties: "properties";
487
- "apple-strings": "apple-strings";
614
+ interface SourceExtractor {
615
+ /** The framework whose call shapes this extractor knows. */
616
+ readonly framework: SourceFramework;
617
+ /** The file extensions this extractor can read, each including its leading dot. */
618
+ readonly extensions: readonly string[];
619
+ /**
620
+ * Find every translation call site in one file.
621
+ *
622
+ * @param file - The file's path and full text.
623
+ * @returns The static and dynamic call sites found, in document order.
624
+ */
625
+ extract(file: SourceFile): FileExtraction;
626
+ }
627
+
628
+ /** Where something was found: a source file and a line inside it. Never a file's contents. */
629
+ interface SourceLocation {
630
+ /** The source file, relative to the run's working directory, with forward slashes. */
631
+ readonly file: string;
632
+ /** The one-based line number. */
633
+ readonly line: number;
634
+ }
635
+ /**
636
+ * One key found at two or more call sites that disagree on its default value. Neither value is
637
+ * written: resolving it last-write-wins would make the catalog depend on directory traversal order,
638
+ * so the disagreement is reported for a person to settle at the call sites.
639
+ */
640
+ interface KeyConflict {
641
+ /** The key the call sites disagree about. */
642
+ readonly key: string;
643
+ /** Every distinct default value found, in the order the scan met them. */
644
+ readonly values: readonly string[];
645
+ /** Where each of those values was found, index-aligned with `values`. */
646
+ readonly locations: readonly SourceLocation[];
647
+ }
648
+ /**
649
+ * Why a {@link ScanDiagnostic} was reported.
650
+ *
651
+ * - `unreadable`: the file could not be opened, or the path is not a regular file.
652
+ * - `too-large`: the file is larger than 2,000,000 bytes, so it was skipped without being read.
653
+ * - `unparseable`: the file was read, but the scan either failed on it outright, in which case
654
+ * nothing from it is reported, or could not follow it to its end, in which case what was found
655
+ * before that point is still reported.
656
+ * - `unreadable-directory`: a directory under a scan root could not be listed, so nothing beneath
657
+ * it was scanned.
658
+ */
659
+ type ScanDiagnosticReason = "unreadable" | "too-large" | "unparseable" | "unreadable-directory";
660
+ /**
661
+ * One file or directory the scan could not read, or could not read to its end, reported as data so
662
+ * one bad file never aborts a run.
663
+ */
664
+ interface ScanDiagnostic {
665
+ /** The file or directory, relative to the run's working directory, with forward slashes. */
666
+ readonly file: string;
667
+ /** Why it was skipped or only partly scanned. */
668
+ readonly reason: ScanDiagnosticReason;
669
+ }
670
+
671
+ /**
672
+ * One hardcoded string literal that reads as user-facing text and never went through a translation
673
+ * call. It names where the literal sits and a bounded excerpt of it, never the surrounding source.
674
+ */
675
+ interface LiteralFinding {
676
+ /** The source file, relative to the run's working directory, with forward slashes. */
677
+ readonly file: string;
678
+ /** The one-based line the literal starts on. */
679
+ readonly line: number;
680
+ /** The one-based column the literal starts at: its opening quote, or its first character of JSX text. */
681
+ readonly column: number;
682
+ /**
683
+ * The literal's text with whitespace collapsed, cut to at most 80 characters. In JSX text and a
684
+ * quoted JSX attribute value, character references such as `&amp;` are decoded.
685
+ */
686
+ readonly text: string;
687
+ /** Whether `text` was cut short. A cut excerpt ends in `...`. */
688
+ readonly truncated: boolean;
689
+ }
690
+ /**
691
+ * Why a literal that would have been reported was held back.
692
+ *
693
+ * - `directive`: a `verbatra-ignore-next-line` comment above it, or a `verbatra-ignore-line`
694
+ * comment on the same line. A next-line directive targets the next line that holds code, skipping
695
+ * blank and comment-only lines, and covers every literal that starts on that line plus every
696
+ * attribute of a JSX opening tag that starts there, even when the tag spans several lines. Text and
697
+ * nested elements on later lines are not covered.
698
+ * - `ignore-list`: its text is listed in `extract.literals.ignore`.
699
+ */
700
+ type LiteralSuppressionReason = "directive" | "ignore-list";
701
+ /** A literal the scan found and did not report, kept so a suppression is visible rather than silent. */
702
+ interface SuppressedLiteral extends LiteralFinding {
703
+ /** Which suppression mechanism held it back. */
704
+ readonly reason: LiteralSuppressionReason;
705
+ }
706
+ /** Everything one untranslated-literal scan found across a project's source. */
707
+ interface LiteralScan {
708
+ /** How many source files were read and scanned. */
709
+ readonly scannedFiles: number;
710
+ /** Literals that look user-facing and were not suppressed, in file then document order. */
711
+ readonly findings: readonly LiteralFinding[];
712
+ /** Literals that were found but suppressed, each with the reason. */
713
+ readonly suppressed: readonly SuppressedLiteral[];
714
+ /**
715
+ * Files and directories the scan could not read to the end. A file listed here was not fully
716
+ * checked, so a scan with any diagnostic is never a clean one.
717
+ */
718
+ readonly diagnostics: readonly ScanDiagnostic[];
719
+ }
720
+
721
+ /**
722
+ * Zod schema accepting exactly one of the fourteen source formats verbatra can read and write. Each
723
+ * member names the ecosystem it round-trips, not just a file extension, because several of them share
724
+ * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
725
+ * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
726
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining ten are `xliff` (the XLIFF
727
+ * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle),
728
+ * `properties` (Java and Spring `.properties` files), `apple-strings` (Apple's flat `.strings`
729
+ * localization format for iOS and macOS), `apple-xcstrings` (Apple's Xcode String Catalog, a single
730
+ * JSON document holding every locale of a `.xcstrings` catalogue together), `android-xml` (Android's
731
+ * `res/values/strings.xml` resource format, including `<plurals>` and `translatable="false"` entries),
732
+ * `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
733
+ * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count),
734
+ * `ini` (classic INI configuration files, one level of `[section]` headers over `key=value` lines,
735
+ * whose entries are addressed as `section.key`), and `resx` (.NET's XML resource format; its `<data>`
736
+ * elements carrying a `type` or `mimetype` attribute, and its designer metadata names, are preserved
737
+ * untouched rather than translated).
738
+ *
739
+ * The set is closed: adding a built-in format means adding a member here and an adapter that claims
740
+ * it. A format shipped by a package outside verbatra is named by a `custom:` identifier instead and
741
+ * never joins this set; see `FormatId`.
742
+ */
743
+ declare const supportedFormatSchema: z.ZodEnum<{
744
+ "i18next-json": "i18next-json";
745
+ "vue-i18n-json": "vue-i18n-json";
746
+ "next-intl-json": "next-intl-json";
747
+ "ngx-translate-json": "ngx-translate-json";
748
+ xliff: "xliff";
749
+ yaml: "yaml";
750
+ arb: "arb";
751
+ properties: "properties";
752
+ "apple-strings": "apple-strings";
753
+ "apple-xcstrings": "apple-xcstrings";
754
+ "android-xml": "android-xml";
755
+ "gettext-po": "gettext-po";
756
+ ini: "ini";
757
+ resx: "resx";
758
+ }>;
759
+ /** One of the fourteen supported source formats; the inferred type of {@link supportedFormatSchema}. */
760
+ type SupportedFormat = z.infer<typeof supportedFormatSchema>;
761
+ /**
762
+ * A format identifier claimed by an adapter that ships outside verbatra: the reserved
763
+ * `custom:` prefix followed by a name of lowercase ASCII letters and digits in hyphen-separated
764
+ * segments, for example `custom:my-format`. The type itself accepts any text after the prefix;
765
+ * {@link isCustomFormatId} and adapter registration enforce the name's shape at runtime.
766
+ *
767
+ * No built-in format name contains a colon, so a third-party identifier can never shadow a
768
+ * built-in one.
769
+ */
770
+ type CustomFormatId = `custom:${string}`;
771
+ /**
772
+ * Any format verbatra can be asked to handle: one of the built-in {@link SupportedFormat}
773
+ * members, or a third-party {@link CustomFormatId}.
774
+ *
775
+ * A built-in name is checked against the closed set at compile time. A `custom:` identifier is
776
+ * only checked for shape, because the adapter behind it lives outside this package; if no
777
+ * registered adapter claims it, the run fails with a structured error naming the format.
778
+ */
779
+ type FormatId = SupportedFormat | CustomFormatId;
780
+
781
+ /**
782
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
783
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
784
+ * a placeholder in that format.
785
+ *
786
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
787
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
788
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
789
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
790
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
791
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
792
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
793
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
794
+ * decides and which feeds the content hash.
795
+ */
796
+ declare const translationEntrySchema: z.ZodObject<{
797
+ key: z.ZodString;
798
+ namespace: z.ZodString;
799
+ value: z.ZodString;
800
+ description: z.ZodOptional<z.ZodString>;
801
+ meaning: z.ZodOptional<z.ZodString>;
802
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
803
+ isPlural: z.ZodBoolean;
804
+ }, z.core.$strip>;
805
+ /**
806
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
807
+ * made deeply readonly. See that schema for what each field carries.
808
+ */
809
+ type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
810
+
811
+ /**
812
+ * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
813
+ * intermediate representation every adapter reads into and writes back out of, so nothing downstream
814
+ * of an adapter parses a format's own syntax.
815
+ */
816
+ interface LocaleResource {
817
+ /** The locale these entries belong to (for example, "en" or "de"). */
818
+ readonly locale: string;
819
+ /** The namespace these entries belong to; empty when the format has no namespacing. */
820
+ readonly namespace: string;
821
+ /** The source format the resource came from, retained for round-trip fidelity on write. */
822
+ readonly format: FormatId;
823
+ /** Entries addressable by key, in the order the source file defined them. */
824
+ readonly entries: ReadonlyMap<string, TranslationEntry>;
825
+ }
826
+
827
+ /**
828
+ * Result of comparing source placeholders against translated-output placeholders as MULTISETS
829
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
830
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
831
+ */
832
+ interface PlaceholderIntegrityResult {
833
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
834
+ readonly matches: boolean;
835
+ /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
836
+ readonly missing: readonly string[];
837
+ /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
838
+ readonly extra: readonly string[];
839
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
840
+ readonly reordered: boolean;
841
+ }
842
+
843
+ /**
844
+ * The result of reading a file into core's intermediate representation. The two diagnostic lists are
845
+ * reported as data rather than thrown, so one bad leaf never fails a whole read.
846
+ */
847
+ interface ReadResult {
848
+ /** The parsed locale resource. */
849
+ readonly resource: LocaleResource;
850
+ /**
851
+ * Keys whose values are invalid for the format's message syntax. Empty for a format with no
852
+ * message-syntax check, which treats every value as valid.
853
+ */
854
+ readonly invalidIcuKeys: readonly string[];
855
+ /**
856
+ * Keys or dotted paths that were present in the source but excluded from translation: a stray
857
+ * non-string leaf (a number, boolean, or null) for a tree-based format, or a key the format's own
858
+ * metadata marks as not translatable, such as an Android `translatable="false"` resource. These
859
+ * are never translated, hashed, diffed, or checked for placeholder or ICU integrity.
860
+ *
861
+ * Reporting them is the adapter's choice, so an empty list means this adapter reported none, not
862
+ * necessarily that the file carried none. A tree-based adapter reports its non-string leaves
863
+ * automatically; a flat adapter reports by returning `{ entries, excludedLeafPaths }` from
864
+ * `parseEntries` instead of a bare map.
865
+ *
866
+ * Whether one survives a later rewrite of the same file depends on the adapter: an adapter that
867
+ * reconstructs the whole file from `resource.entries` alone drops it, while an adapter that
868
+ * patches only the entries it touches into the existing document (for example `apple-xcstrings`)
869
+ * preserves it verbatim.
870
+ */
871
+ readonly excludedLeafPaths: readonly string[];
872
+ }
873
+ /**
874
+ * The contract every format adapter implements. A new format attaches by implementing this interface
875
+ * and registering it in an {@link AdapterRegistry}.
876
+ *
877
+ * Implement it through one of the two shared factories rather than from scratch:
878
+ * `createTreeFileAdapter` for a nested-tree format and `createFlatFileAdapter` for a flat
879
+ * key/value format. A format that fits neither shape implements this interface directly.
880
+ *
881
+ * An adapter shipped outside verbatra names itself with a `custom:` identifier and is attached by
882
+ * registering it in a registry the caller hands to the SDK. It is ordinary trusted code: verbatra
883
+ * does not sandbox it.
884
+ */
885
+ interface FormatAdapter {
886
+ /**
887
+ * The single format this adapter handles: one of core's built-in `SupportedFormat` members, or a
888
+ * third-party `custom:` identifier for an adapter that ships outside verbatra.
889
+ */
890
+ readonly format: FormatId;
891
+ /**
892
+ * Detect whether this adapter can handle a file, by path extension and an optional content sample.
893
+ * Best-effort and side-effect-free; it reads nothing from disk.
894
+ *
895
+ * @param filePath - The path of the file under consideration.
896
+ * @param sample - An optional leading content sample to aid detection.
897
+ * @returns True if this adapter could handle the file. Several adapters may return true for the same
898
+ * file (all JSON adapters claim `.json`); the caller disambiguates by explicit format.
899
+ */
900
+ canHandle(filePath: string, sample?: string): boolean;
901
+ /**
902
+ * Read a file into a {@link LocaleResource} plus its message-validity result.
903
+ *
904
+ * @param filePath - The file to read.
905
+ * @param locale - The locale to tag the resource with.
906
+ * @returns The parsed resource and the keys whose values are invalid for the format.
907
+ * @throws `AdapterError` when the content is malformed, oversized, or structurally invalid
908
+ * (the implementation names the specific codes). A missing or unopenable path instead rejects with
909
+ * the underlying filesystem error.
910
+ */
911
+ read(filePath: string, locale: string): Promise<ReadResult>;
912
+ /**
913
+ * Write a {@link LocaleResource} back to a file, preserving key order and structure, atomically.
914
+ *
915
+ * @param resource - The resource to serialize.
916
+ * @param filePath - The destination file.
917
+ * @throws `AdapterError` if the resource cannot be represented in the format; rejects with the
918
+ * underlying filesystem error on a write failure.
919
+ */
920
+ write(resource: LocaleResource, filePath: string): Promise<void>;
921
+ /**
922
+ * Extract the format's placeholder tokens from a single value, resolving nothing.
923
+ *
924
+ * @param value - The translatable string to scan.
925
+ * @returns The placeholder tokens found, in document order. Does not throw.
926
+ */
927
+ extractPlaceholders(value: string): readonly string[];
928
+ /**
929
+ * Validate a single value against the format's message syntax before it is written. Only the
930
+ * ICU-message formats carry a real check today (next-intl and ARB); every other adapter has no
931
+ * message syntax to violate and returns true for any value.
932
+ *
933
+ * @param value - The candidate translated value to validate.
934
+ * @returns True when the value is valid for the format's message syntax. Does not throw; an
935
+ * unparseable value returns false.
936
+ */
937
+ validateMessage(value: string): boolean;
938
+ /**
939
+ * Optional whole-value placeholder comparison, used by callers instead of independently extracting
940
+ * each side's placeholders with {@link extractPlaceholders} and diffing the flat lists. Both
941
+ * adapter factories accept one, under the `comparePlaceholders` option. Among the shipped
942
+ * adapters two families define one, for different reasons:
943
+ *
944
+ * - The ICU formats (next-intl and ARB) compare branch by branch, because flattening a
945
+ * plural/select value loses which branch a placeholder came from.
946
+ * - The double-brace formats (i18next, ngx-translate, and YAML) add a one-directional
947
+ * single-brace check on top of their flat multiset comparison: a `{name}`-shaped token present
948
+ * in the translated value but absent from the source is reported as `extra`. Single-brace text
949
+ * is ordinary literal content under these formats' default delimiters, so it is never required
950
+ * to survive a translation, but inventing one is never a legitimate translation either.
951
+ *
952
+ * Absent for the remaining adapters, which are compared via `extractPlaceholders` plus a flat
953
+ * multiset check.
954
+ *
955
+ * @param sourceValue - The source value.
956
+ * @param targetValue - The translated value to check against it.
957
+ * @returns The merged placeholder-integrity result. Does not throw.
958
+ */
959
+ comparePlaceholders?(sourceValue: string, targetValue: string): PlaceholderIntegrityResult;
960
+ }
961
+
962
+ /**
963
+ * The outcome of a size-bounded read through the {@link AdapterFs} port. A path that exists but is
964
+ * not a regular file, or a file larger than the caller's limit, is reported as a state rather than
965
+ * thrown, so an adapter can treat "not a file" and "too large to trust" as ordinary branches. A
966
+ * missing path is not one of these states: the port rejects instead (see
967
+ * {@link AdapterFs.readBounded}).
968
+ */
969
+ type BoundedReadOutcome = {
970
+ /** The file was read in full. */
971
+ readonly kind: "ok";
972
+ /** The file's content, decoded as UTF-8. */
973
+ readonly content: string;
974
+ } | {
975
+ /** The path exists but is not a regular file, a directory for example, so nothing was read. */
976
+ readonly kind: "not-a-file";
977
+ } | {
978
+ /** The file is larger than the requested byte limit, so nothing was read. */
979
+ readonly kind: "too-large";
980
+ };
981
+ /**
982
+ * The file-system port every format adapter reads and writes through. An adapter that takes this
983
+ * port instead of importing `node:fs` can be tested against an in-memory implementation and stays
984
+ * honest about every path it touches.
985
+ *
986
+ * This is the supported path for an adapter shipped outside verbatra, and the one verbatra hands it.
987
+ * It is a convention, not a sandbox: nothing stops third-party code from reaching the file system
988
+ * another way.
989
+ */
990
+ interface AdapterFs {
991
+ /**
992
+ * Read a UTF-8 text file, refusing anything larger than the caller's limit.
993
+ *
994
+ * @param path - The file to read.
995
+ * @param maxBytes - The largest file size to read; a larger file yields `too-large` and is not read.
996
+ * @returns The file's content, or the state that prevented reading it.
997
+ * @throws An `Error` whose `code` is `"ENOENT"` when nothing exists at `path`, as `node:fs` does.
998
+ * Several adapters re-read an existing destination before writing so they can preserve its
999
+ * layout, and they rely on exactly that code to mean "no file yet, write a fresh one"; a port
1000
+ * that reports a missing file any other way fails those writes.
1001
+ */
1002
+ readBounded(path: string, maxBytes: number): Promise<BoundedReadOutcome>;
1003
+ /**
1004
+ * Write a UTF-8 text file atomically, creating missing parent directories.
1005
+ *
1006
+ * @param path - The destination file.
1007
+ * @param data - The content to write.
1008
+ * @returns Nothing, once the content is durably in place under `path`.
1009
+ */
1010
+ writeFileAtomic(path: string, data: string): Promise<void>;
1011
+ }
1012
+ /**
1013
+ * The {@link AdapterFs} implementation backed by the real file system, and the default every adapter
1014
+ * factory uses when no port is supplied. It writes through a temporary file and a rename, so an
1015
+ * interrupted write never leaves a half-finished locale file.
1016
+ *
1017
+ * @example
1018
+ * ```ts
1019
+ * const adapter = createFlatFileAdapter({ ...options, fs: nodeAdapterFs });
1020
+ * ```
1021
+ */
1022
+ declare const nodeAdapterFs: AdapterFs;
1023
+
1024
+ /**
1025
+ * Outcome of resolving an adapter for a file. Structured, never thrown: an unresolvable file is a
1026
+ * status the caller branches on, not an exception.
1027
+ *
1028
+ * `resolved` carries the single {@link FormatAdapter} to use. `no-match` means nothing claimed the
1029
+ * file, and reports the formats that were tried (every registered format for a detection attempt, or
1030
+ * just the one requested when an explicit format was given). `ambiguous` means more than one adapter
1031
+ * claimed the file, which is routine since every JSON adapter claims `.json`; it reports the competing
1032
+ * candidates instead of guessing, and the caller resolves it by naming a format explicitly.
1033
+ */
1034
+ type AdapterResolution = {
1035
+ /** Exactly one adapter claimed the file. */
1036
+ readonly status: "resolved";
1037
+ /** The adapter to read and write the file with. */
1038
+ readonly adapter: FormatAdapter;
1039
+ } | {
1040
+ /** No registered adapter claimed the file. */
1041
+ readonly status: "no-match";
1042
+ /** The file that could not be matched. */
1043
+ readonly filePath: string;
1044
+ /**
1045
+ * The formats that were tried: every registered format for a detection attempt, or just the
1046
+ * one requested when an explicit format was given.
1047
+ */
1048
+ readonly triedFormats: readonly FormatId[];
1049
+ } | {
1050
+ /** More than one adapter claimed the file, so detection cannot choose between them. */
1051
+ readonly status: "ambiguous";
1052
+ /** The file that several adapters claimed. */
1053
+ readonly filePath: string;
1054
+ /** The competing formats. Resolve the ambiguity by naming one of them explicitly. */
1055
+ readonly candidates: readonly FormatId[];
1056
+ };
1057
+ /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
1058
+ interface ResolveOptions {
1059
+ /** A leading content sample to aid detection. Ignored when `format` is given. */
1060
+ readonly sample?: string;
1061
+ /** Bypass detection and select this format explicitly. */
1062
+ readonly format?: FormatId;
1063
+ }
1064
+ /**
1065
+ * Holds the registered adapters and resolves one for a file. Open for extension: adapters attach
1066
+ * through {@link AdapterRegistry.register} without changing resolution logic, and an unresolvable
1067
+ * file is a structured status rather than an exception.
1068
+ *
1069
+ * @example
1070
+ * ```ts
1071
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1072
+ * const resolution = registry.resolve("locales/de.toml");
1073
+ * if (resolution.status === "resolved") {
1074
+ * const { resource } = await resolution.adapter.read("locales/de.toml", "de");
1075
+ * }
1076
+ * ```
1077
+ */
1078
+ declare class AdapterRegistry {
1079
+ private readonly adapters;
1080
+ /**
1081
+ * Register an adapter. Registration order is the order detection consults them.
1082
+ *
1083
+ * A format identifier may be registered only once: a second adapter claiming an identifier the
1084
+ * registry already holds is rejected, so neither a third-party adapter nor a second copy of one
1085
+ * can silently shadow what is already there.
1086
+ *
1087
+ * An adapter whose format is a third-party `custom:` identifier is wrapped so that an unexpected
1088
+ * throw from any of its contract methods surfaces as an {@link AdapterError} with code
1089
+ * `ADAPTER_FAILED` naming the format. An `AdapterError` the adapter raises itself, and a Node.js
1090
+ * system error carrying an errno-style code such as `ENOENT`, pass through unchanged. Built-in
1091
+ * adapters are registered as they are.
1092
+ *
1093
+ * @param adapter - The adapter to add.
1094
+ * @returns This registry, for chaining.
1095
+ * @throws `AdapterError` with code `INVALID_FORMAT_ID` when the format carries the `custom:`
1096
+ * prefix but is not a well-formed identifier. Such an adapter is refused rather than registered
1097
+ * without the containment wrapper, which would silently lose its failure attribution.
1098
+ * @throws `AdapterError` with code `DUPLICATE_FORMAT` when the registry already holds an adapter
1099
+ * for this format.
1100
+ *
1101
+ * @example
1102
+ * ```ts
1103
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1104
+ * await translate({ config }, { adapterRegistry: registry });
1105
+ * ```
1106
+ */
1107
+ register(adapter: FormatAdapter): this;
1108
+ private formats;
1109
+ private resolveByFormat;
1110
+ private resolveByDetection;
1111
+ /**
1112
+ * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
1113
+ *
1114
+ * @param filePath - The file to resolve an adapter for.
1115
+ * @param options - `format` selects explicitly and skips detection; `sample` aids detection.
1116
+ * @returns A structured {@link AdapterResolution}: `resolved`, `no-match`, or `ambiguous`. An
1117
+ * unresolvable file is a status, not an exception.
1118
+ * @throws `AdapterError` with code `ADAPTER_FAILED` when a registered third-party adapter throws
1119
+ * from its own `canHandle` during detection. Resolution itself raises nothing.
1120
+ */
1121
+ resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
1122
+ }
1123
+
1124
+ /**
1125
+ * Build a registry holding every format adapter verbatra ships, in detection order. This is the
1126
+ * starting point for adding an adapter of your own: register it on the returned registry and hand
1127
+ * that registry to an SDK flow as its `adapterRegistry` dependency.
1128
+ *
1129
+ * @param fs - The file-system port every built-in adapter reads and writes through. Defaults to
1130
+ * {@link nodeAdapterFs}.
1131
+ * @returns A registry of the built-in adapters, ready for further registrations.
1132
+ *
1133
+ * @example
1134
+ * ```ts
1135
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1136
+ * await translate({ config }, { adapterRegistry: registry });
1137
+ * ```
1138
+ */
1139
+ declare function createDefaultRegistry(fs?: AdapterFs): AdapterRegistry;
1140
+
1141
+ /**
1142
+ * The stable, secret-free codes an {@link AdapterError} carries.
1143
+ *
1144
+ * The first seven describe a file verbatra could not handle: `INVALID_JSON`, `INVALID_YAML` and
1145
+ * `INVALID_XML` for malformed syntax, `INVALID_STRUCTURE` for a parseable file of the wrong shape,
1146
+ * `MAX_DEPTH_EXCEEDED` and `INPUT_TOO_LARGE` for the read caps, and `MIXED_STRUCTURE` for a file
1147
+ * that mixes flat and nested keys where the format forbids it.
1148
+ *
1149
+ * The last three describe an adapter rather than a file. `ADAPTER_FAILED` attributes an unexpected
1150
+ * throw to the third-party adapter it came from, so a plugin defect never surfaces as an
1151
+ * unattributed verbatra stack trace. `DUPLICATE_FORMAT` rejects registering a second adapter for a
1152
+ * format identifier a registry already holds. `INVALID_FORMAT_ID` rejects registering an adapter
1153
+ * whose `custom:` identifier is malformed, which would otherwise be registered without the
1154
+ * containment wrapper and silently lose its failure attribution.
1155
+ *
1156
+ * The set is closed. A third-party adapter raises `INVALID_STRUCTURE` (or another member that fits)
1157
+ * rather than minting its own code.
1158
+ */
1159
+ type AdapterErrorCode = "INVALID_JSON" | "INVALID_YAML" | "INVALID_XML" | "INVALID_STRUCTURE" | "MAX_DEPTH_EXCEEDED" | "INPUT_TOO_LARGE" | "MIXED_STRUCTURE" | "ADAPTER_FAILED" | "DUPLICATE_FORMAT" | "INVALID_FORMAT_ID";
1160
+ /**
1161
+ * A structured, secret-free failure raised by a format adapter or by the adapter registry. Catch it
1162
+ * to branch on {@link AdapterErrorCode} instead of matching on message text.
1163
+ *
1164
+ * @example
1165
+ * ```ts
1166
+ * try {
1167
+ * await adapter.read("locales/de.json", "de");
1168
+ * } catch (error) {
1169
+ * if (error instanceof AdapterError && error.code === "INVALID_JSON") {
1170
+ * console.error(error.message);
1171
+ * }
1172
+ * }
1173
+ * ```
1174
+ */
1175
+ declare class AdapterError extends Error {
1176
+ /** The stable code for this failure. */
1177
+ readonly code: AdapterErrorCode;
1178
+ /**
1179
+ * @param code - The stable code for this failure.
1180
+ * @param message - A human-readable description. Never include a secret or a file's contents.
1181
+ */
1182
+ constructor(code: AdapterErrorCode, message: string);
1183
+ }
1184
+
1185
+ /** A value in a locale tree about to be serialized, with key order preserved at every level. */
1186
+ type OrderedValue = string | number | boolean | null | OrderedRecord | readonly OrderedValue[];
1187
+ /** A locale tree about to be serialized, keyed in the order it will be written out. */
1188
+ type OrderedRecord = ReadonlyMap<string, OrderedValue>;
1189
+
1190
+ /** A terminal value in a parsed locale tree: everything that is not a nested object. */
1191
+ type JsonLeaf = string | number | boolean | null;
1192
+ /** Any node in a parsed locale tree: a leaf, or a nested record of further nodes. */
1193
+ type JsonTree = JsonLeaf | JsonRecord;
1194
+ /** A parsed locale tree node, keyed in the order the source document defined its keys. */
1195
+ type JsonRecord = ReadonlyMap<string, JsonTree>;
1196
+
1197
+ /**
1198
+ * Finds a format's placeholder tokens in one translatable value, in document order. Resolves
1199
+ * nothing and never throws; a value with no placeholders yields an empty list.
1200
+ */
1201
+ type ExtractPlaceholders = (value: string) => readonly string[];
1202
+ /**
1203
+ * Reports the keys whose values are invalid for a format's message syntax, given every entry read
1204
+ * from one file. Only the ICU-message formats need one; a format with no message syntax omits it.
1205
+ */
1206
+ type ComputeInvalidIcuKeys = (entries: ReadonlyMap<string, TranslationEntry>) => readonly string[];
1207
+ /**
1208
+ * Decides whether one candidate value is valid for a format's message syntax before it is written.
1209
+ * Never throws: an unparseable value returns false.
1210
+ */
1211
+ type ValidateMessage = (value: string) => boolean;
1212
+ /**
1213
+ * Compares a source value against its translation as whole values rather than as two flat token
1214
+ * lists, for a format whose structure (an ICU plural or select branch, for example) would be lost by
1215
+ * flattening. Never throws: a mismatch is data.
1216
+ */
1217
+ type ComparePlaceholders = (sourceValue: string, targetValue: string) => PlaceholderIntegrityResult;
1218
+ /**
1219
+ * Rejects a parsed tree that is structurally wrong for a format, by throwing an `AdapterError`.
1220
+ * Returns nothing when the tree is acceptable.
1221
+ */
1222
+ type ValidateTree = (tree: JsonRecord) => void;
1223
+ /**
1224
+ * Decides whether a leading content sample looks like this format, so two adapters claiming the same
1225
+ * file extension can still be told apart. Consulted only when a sample is available.
1226
+ */
1227
+ type Sniff = (sample: string) => boolean;
1228
+
1229
+ /**
1230
+ * What a flat format's `parseEntries` returns when it has content to report as skipped. Returning a
1231
+ * bare map instead is equivalent to returning one of these with no skipped paths.
1232
+ */
1233
+ interface FlatParseResult {
1234
+ /** The entries read from the file, keyed by entry key, in document order. */
1235
+ readonly entries: Map<string, TranslationEntry>;
1236
+ /**
1237
+ * Keys the file carried but that are excluded from translation, for example one the format's own
1238
+ * metadata marks as not translatable. Omit it when the format has no such concept.
1239
+ */
1240
+ readonly excludedLeafPaths?: readonly string[];
1241
+ }
1242
+ /** What a flat format's `parseEntries` may return: the entries alone, or entries plus diagnostics. */
1243
+ type FlatParseOutcome = Map<string, TranslationEntry> | FlatParseResult;
1244
+ /** Everything {@link createFlatFileAdapter} needs to turn a flat key/value format into an adapter. */
1245
+ interface FlatFileAdapterOptions {
1246
+ /** The format this adapter claims: a built-in name, or a `custom:` identifier of your own. */
1247
+ readonly format: FormatId;
1248
+ /**
1249
+ * The file extensions this format owns, each including the leading dot. Matched against a path's
1250
+ * extension case-insensitively.
1251
+ */
1252
+ readonly extensions: readonly string[];
1253
+ /**
1254
+ * Optional content check, consulted when a sample is available, so this adapter does not claim
1255
+ * every file that merely shares one of its extensions.
1256
+ */
1257
+ readonly sniff?: Sniff;
1258
+ /**
1259
+ * Parse one file's text into entries keyed by entry key, in document order. Throw an
1260
+ * `AdapterError` for content this format cannot represent; anything else thrown is reported as a
1261
+ * structural failure.
1262
+ */
1263
+ readonly parseEntries: (content: string, namespace: string, filePath: string, fs: AdapterFs) => FlatParseOutcome | Promise<FlatParseOutcome>;
1264
+ /**
1265
+ * Render entries back to the format's text, preserving key order. Receives the destination path
1266
+ * and the port, for a format that has to consult the existing file to write in place.
1267
+ */
1268
+ readonly serializeEntries: (entries: ReadonlyMap<string, TranslationEntry>, filePath: string, fs: AdapterFs) => Promise<string> | string;
1269
+ /** Find this format's placeholder tokens in one value. */
1270
+ readonly extractPlaceholders: ExtractPlaceholders;
1271
+ /** Optional message-syntax check applied to a candidate value before it is written. */
1272
+ readonly validateMessage?: ValidateMessage;
1273
+ /** Optional per-file report of the keys whose values are invalid for this format's message syntax. */
1274
+ readonly computeInvalidIcuKeys?: ComputeInvalidIcuKeys;
1275
+ /**
1276
+ * Optional whole-value placeholder comparison, for a format whose structure a flat token list
1277
+ * would lose. Omitted adapters are compared by `extractPlaceholders` plus a flat multiset check.
1278
+ */
1279
+ readonly comparePlaceholders?: ComparePlaceholders;
1280
+ /** The file-system port to read and write through. Defaults to `nodeAdapterFs`. */
1281
+ readonly fs?: AdapterFs;
1282
+ }
1283
+ /**
1284
+ * Build a {@link FormatAdapter} for a flat key/value format: one where every entry is addressed by a
1285
+ * single key with no nesting, such as Java `.properties` or Android `strings.xml`. The factory
1286
+ * supplies the bounded read, the atomic write, extension detection and the structured error
1287
+ * handling; you supply only the format's own parsing and serialization.
1288
+ *
1289
+ * The returned adapter's `read` refuses a file larger than 16 MiB with `INPUT_TOO_LARGE` and a path
1290
+ * that is not a regular file with `INVALID_STRUCTURE`, strips a leading byte order mark before
1291
+ * parsing, and uses the file's base name without its extension as the namespace.
1292
+ *
1293
+ * @param options - The format's parsing, serialization and detection behaviour.
1294
+ * @returns A complete adapter, ready to register on an `AdapterRegistry`.
1295
+ * @throws Nothing itself. The returned adapter's `read` and `write` raise `AdapterError` for content
1296
+ * this format cannot represent, and reject with the underlying filesystem error otherwise.
1297
+ *
1298
+ * @example
1299
+ * ```ts
1300
+ * const tomlAdapter = createFlatFileAdapter({
1301
+ * format: "custom:toml",
1302
+ * extensions: [".toml"],
1303
+ * parseEntries: (content, namespace) => parseToml(content, namespace),
1304
+ * serializeEntries: (entries) => serializeToml(entries),
1305
+ * extractPlaceholders: (value) => [...value.matchAll(/\{\w+\}/g)].map((m) => m[0]),
1306
+ * });
1307
+ * ```
1308
+ */
1309
+ declare function createFlatFileAdapter(options: FlatFileAdapterOptions): FormatAdapter;
1310
+
1311
+ /**
1312
+ * Derives the per-entry facts a format decides for itself from one key and its string value: the
1313
+ * placeholder tokens the value carries, and whether the key is one plural form of a set.
1314
+ */
1315
+ type DeriveEntry = (key: string, value: string) => {
1316
+ readonly placeholders: readonly string[];
1317
+ readonly isPlural: boolean;
1318
+ };
1319
+ /**
1320
+ * How a dotted key in the source document is addressed. `literal-leaf` keeps `"a.b"` as one key
1321
+ * whose name contains a dot; `path-notation` reads it as the nested path `a` then `b`.
1322
+ */
1323
+ type KeyMode = "literal-leaf" | "path-notation";
1324
+
1325
+ /**
1326
+ * Builds the tree to serialize from the entries about to be written, for a format that patches an
1327
+ * existing document rather than rebuilding it from the entries alone.
1328
+ */
1329
+ type BuildWriteTree = (entries: ReadonlyMap<string, TranslationEntry>, filePath: string, fs: AdapterFs) => OrderedRecord | Promise<OrderedRecord>;
1330
+ /**
1331
+ * Pulls per-key translator descriptions out of the raw file text, for a format that carries them as
1332
+ * metadata alongside the values.
1333
+ */
1334
+ type DeriveDescriptions = (content: string) => ReadonlyMap<string, string>;
1335
+ /** Everything {@link createTreeFileAdapter} needs to turn a nested-tree format into an adapter. */
1336
+ interface TreeFileAdapterOptions {
1337
+ /** The format this adapter claims: a built-in name, or a `custom:` identifier of your own. */
1338
+ readonly format: FormatId;
1339
+ /**
1340
+ * The file extensions this format owns, each including the leading dot. Matched against a path's
1341
+ * extension case-insensitively.
1342
+ */
1343
+ readonly extensions: readonly string[];
1344
+ /**
1345
+ * Optional content check, consulted when a sample is available, so this adapter does not claim
1346
+ * every file that merely shares one of its extensions.
1347
+ */
1348
+ readonly sniff?: Sniff;
1349
+ /**
1350
+ * Parse one file's text into a nested tree, preserving key order at every level. Throw an
1351
+ * `AdapterError` for content this format cannot represent; anything else thrown is reported as a
1352
+ * structural failure.
1353
+ */
1354
+ readonly parse: (content: string) => JsonRecord;
1355
+ /** Render a nested tree back to the format's text, preserving key order at every level. */
1356
+ readonly serialize: (tree: OrderedRecord) => string;
1357
+ /** Derive the placeholder tokens and the plural flag for one leaf. */
1358
+ readonly deriveEntry: DeriveEntry;
1359
+ /** Find this format's placeholder tokens in one value. */
1360
+ readonly extractPlaceholders: ExtractPlaceholders;
1361
+ /** Optional per-file report of the keys whose values are invalid for this format's message syntax. */
1362
+ readonly computeInvalidIcuKeys?: ComputeInvalidIcuKeys;
1363
+ /** Optional message-syntax check applied to a candidate value before it is written. */
1364
+ readonly validateMessage?: ValidateMessage;
1365
+ /** Optional structural check on the parsed tree, throwing an `AdapterError` for a shape this format forbids. */
1366
+ readonly validateTree?: ValidateTree;
1367
+ /** Optional override for how the tree to write is assembled, for a format that patches the existing document. */
1368
+ readonly buildWriteTree?: BuildWriteTree;
1369
+ /** How a dotted key is addressed. Defaults to `"literal-leaf"`. */
1370
+ readonly keyMode?: KeyMode;
1371
+ /** Optional whole-value placeholder comparison, for a format whose structure flattening would lose. */
1372
+ readonly comparePlaceholders?: ComparePlaceholders;
1373
+ /** Optional extraction of per-key translator descriptions from the raw file text. */
1374
+ readonly deriveDescriptions?: DeriveDescriptions;
1375
+ /** The file-system port to read and write through. Defaults to `nodeAdapterFs`. */
1376
+ readonly fs?: AdapterFs;
1377
+ }
1378
+ /**
1379
+ * Build a {@link FormatAdapter} for a nested-tree format: one whose entries live at paths through
1380
+ * nested objects, such as i18next JSON or YAML. The factory supplies the bounded read, the atomic
1381
+ * write, tree flattening and unflattening, extension detection and the structured error handling;
1382
+ * you supply only the format's own parsing, serialization and per-leaf facts.
1383
+ *
1384
+ * The returned adapter's `read` refuses a file larger than 16 MiB with `INPUT_TOO_LARGE` and a path
1385
+ * that is not a regular file with `INVALID_STRUCTURE`, strips a leading byte order mark before
1386
+ * parsing, and uses the file's base name without its extension as the namespace.
1387
+ *
1388
+ * @param options - The format's parsing, serialization, detection and per-entry behaviour.
1389
+ * @returns A complete adapter, ready to register on an `AdapterRegistry`.
1390
+ * @throws Nothing itself. The returned adapter's `read` and `write` raise `AdapterError` for content
1391
+ * this format cannot represent, and reject with the underlying filesystem error otherwise.
1392
+ *
1393
+ * @example
1394
+ * ```ts
1395
+ * const hoconAdapter = createTreeFileAdapter({
1396
+ * format: "custom:hocon",
1397
+ * extensions: [".conf"],
1398
+ * parse: (content) => parseHocon(content),
1399
+ * serialize: (tree) => serializeHocon(tree),
1400
+ * deriveEntry: (_key, value) => ({ placeholders: tokensIn(value), isPlural: false }),
1401
+ * extractPlaceholders: (value) => tokensIn(value),
1402
+ * });
1403
+ * ```
1404
+ */
1405
+ declare function createTreeFileAdapter(options: TreeFileAdapterOptions): FormatAdapter;
1406
+
1407
+ /**
1408
+ * The outcome of a size-bounded text read. A file that is absent or larger than the caller's limit
1409
+ * is reported as a state rather than thrown, so a caller can treat "no file yet" as normal and an
1410
+ * oversized file as a distinct, actionable condition.
1411
+ */
1412
+ type BoundedFileRead = {
1413
+ /** The file was read in full. */
1414
+ readonly kind: "ok";
1415
+ /** The file's content, decoded as UTF-8. */
1416
+ readonly content: string;
1417
+ } | {
1418
+ /** No readable file exists at the path. */
1419
+ readonly kind: "missing";
1420
+ } | {
1421
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
1422
+ readonly kind: "too-large";
1423
+ };
1424
+ /**
1425
+ * The outcome of a size-bounded binary read, used for interchange workbooks. Mirrors
1426
+ * {@link BoundedFileRead} but yields raw bytes rather than decoded text.
1427
+ */
1428
+ type BoundedBytesRead = {
1429
+ /** The file was read in full. */
1430
+ readonly kind: "ok";
1431
+ /** The file's raw bytes. */
1432
+ readonly bytes: Uint8Array;
1433
+ } | {
1434
+ /** No readable file exists at the path. */
1435
+ readonly kind: "missing";
1436
+ } | {
1437
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
1438
+ readonly kind: "too-large";
1439
+ };
1440
+ /**
1441
+ * What one entry in a directory listing is: a regular file, a directory, or anything else. A
1442
+ * symbolic link is reported as `other`, never followed, which is what keeps a source scan inside
1443
+ * the roots it was given.
1444
+ */
1445
+ interface DirectoryEntry {
1446
+ /** The entry's own name, with no directory part. */
1447
+ readonly name: string;
1448
+ /** What the entry is. Anything that is not a regular file or a directory is `other`. */
1449
+ readonly kind: "file" | "directory" | "other";
1450
+ }
1451
+ /**
1452
+ * The file-system port the SDK's own I/O goes through. Supplying your own implementation as a
1453
+ * `deps.fs` option redirects that I/O, which is how the SDK's tests avoid touching disk and how an
1454
+ * embedding application can back part of a project with something other than a local disk.
1455
+ *
1456
+ * The seam carries every file the SDK touches once a config is loaded: the run-status file, the
1457
+ * lock-file and the write locks, the translation-memory cache, the config glossary, workbook and TMX
1458
+ * I/O, generated declaration and pseudolocale files, the source scan, and the locale files
1459
+ * themselves, which the adapters read and write through a port built from this one. Two exceptions
1460
+ * remain. {@link loadConfig} finds and loads the config file itself directly from disk; only its
1461
+ * glossary goes through the port. And a caller-supplied `deps.adapterRegistry` holds adapters the
1462
+ * caller constructed, so their file access is whatever the caller wired into them, and supplying
1463
+ * both means the caller owns that wiring.
1464
+ *
1465
+ * Reads are size-bounded by contract so that a hostile or accidentally huge file cannot exhaust
1466
+ * memory. Writes are expected to be atomic: the default implementation writes to a temporary file
1467
+ * and renames it into place, so a crash mid-write never leaves a half-written file behind.
1468
+ * Directory creation is the caller's job, so an implementation whose `writeFile` targets a real
1469
+ * directory tree must also implement `mkdir`.
1470
+ */
1471
+ interface SdkFs {
1472
+ /** Reports whether a file exists at the path. The default implementation checks existence only. */
1473
+ fileExists(path: string): Promise<boolean>;
1474
+ /** Reads a file as UTF-8 text, refusing to read more than `maxBytes`. */
1475
+ readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
1476
+ /** Reads a file as raw bytes, refusing to read more than `maxBytes`. */
1477
+ readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
1478
+ /** Writes UTF-8 text to the path, replacing any existing file atomically. */
1479
+ writeFile(path: string, data: string): Promise<void>;
1480
+ /** Writes raw bytes to the path, replacing any existing file atomically. */
1481
+ writeBytes(path: string, data: Uint8Array): Promise<void>;
1482
+ /**
1483
+ * Creates a file only if it does not already exist, returning `false` when it does. This is the
1484
+ * primitive behind the per-locale write lock, so it must be atomic against other processes.
1485
+ */
1486
+ createExclusive(path: string, data: string): Promise<boolean>;
1487
+ /** Removes the file at the path, succeeding even when it is already absent. */
1488
+ deleteFile(path: string): Promise<void>;
1489
+ /** Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. */
1490
+ mkdir?(path: string): Promise<void>;
1491
+ /**
1492
+ * Lists one directory's immediate entries, without recursing and without following symbolic
1493
+ * links. Optional, so an implementation written before source extraction existed keeps working;
1494
+ * {@link extract} is the only entry point that needs it and reports its absence as
1495
+ * `EXTRACT_FS_UNSUPPORTED`.
1496
+ *
1497
+ * @param path - The directory to list.
1498
+ * @returns Every immediate entry with its kind. Rejects when the path is not a readable directory.
1499
+ */
1500
+ readDirectory?(path: string): Promise<readonly DirectoryEntry[]>;
1501
+ }
1502
+
1503
+ /**
1504
+ * The on-disk translation memory, read from and written to `verbatra.cache.json` (see
1505
+ * {@link CACHE_FILE_NAME}). A run consults it before calling the provider, so a string that was
1506
+ * already translated under the same configuration is reused instead of paid for again.
1507
+ *
1508
+ * Entries are keyed by configuration fingerprint, then locale, then source-content hash. The
1509
+ * fingerprint layer means that changing the provider, model, tone, or glossary does not silently
1510
+ * reuse translations produced under the old settings; those entries simply stop matching.
1511
+ *
1512
+ * `sources` sits beside them as a flat hash-to-text index. A hash cannot be compared for
1513
+ * similarity, so the source text itself has to be on file for a fuzzy match to have anything to
1514
+ * measure a changed string against. It is deliberately outside the fingerprint layer: the source
1515
+ * text a hash stands for does not depend on which provider translated it.
1516
+ */
1517
+ interface TranslationMemory {
1518
+ /** The cache schema version. A file from a newer version is ignored rather than trusted. */
1519
+ readonly version: number;
1520
+ /** Cached values, nested as configuration fingerprint, then locale, then source-content hash. */
1521
+ readonly entries: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>>;
1522
+ /**
1523
+ * The source text each content hash stands for. Empty for a cache carried forward from schema
1524
+ * version 1, which stored no source text; it fills in as later runs touch those hashes again.
1525
+ */
1526
+ readonly sources: Readonly<Record<string, string>>;
1527
+ }
1528
+
1529
+ /**
1530
+ * The file name of the project's translation memory, resolved against the run's working directory.
1531
+ * Commit it to reuse translations across machines and CI runs, or add it to `.gitignore` to treat
1532
+ * the cache as purely local. Deleting it is always safe: the next run simply repays for the strings
1533
+ * it would have reused.
1534
+ */
1535
+ declare const CACHE_FILE_NAME = "verbatra.cache.json";
1536
+
1537
+ /**
1538
+ * The zod schema for the `provider` block, discriminated on `id`. Each variant's options are
1539
+ * validated strictly, so an option that belongs to a different provider is reported as an error
1540
+ * rather than ignored. It is embedded in {@link verbatraConfigSchema} and produces
1541
+ * {@link ProviderConfig}.
1542
+ */
1543
+ declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1544
+ id: z.ZodLiteral<"anthropic">;
1545
+ options: z.ZodObject<{
1546
+ model: z.ZodString;
1547
+ maxTokens: z.ZodNumber;
1548
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1549
+ }, z.core.$strict>;
1550
+ }, z.core.$strip>, z.ZodObject<{
1551
+ id: z.ZodLiteral<"openai">;
1552
+ options: z.ZodObject<{
1553
+ model: z.ZodString;
1554
+ maxOutputTokens: z.ZodNumber;
1555
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1556
+ }, z.core.$strict>;
1557
+ }, z.core.$strip>, z.ZodObject<{
1558
+ id: z.ZodLiteral<"gemini">;
1559
+ options: z.ZodObject<{
1560
+ model: z.ZodString;
1561
+ maxOutputTokens: z.ZodNumber;
1562
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1563
+ }, z.core.$strict>;
1564
+ }, z.core.$strip>, z.ZodObject<{
1565
+ id: z.ZodLiteral<"deepl">;
1566
+ options: z.ZodObject<{
1567
+ glossaryId: z.ZodOptional<z.ZodString>;
1568
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1569
+ }, z.core.$strict>;
1570
+ }, z.core.$strip>, z.ZodObject<{
1571
+ id: z.ZodLiteral<"google-translate">;
1572
+ options: z.ZodObject<{
1573
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1574
+ }, z.core.$strict>;
1575
+ }, z.core.$strip>, z.ZodObject<{
1576
+ id: z.ZodLiteral<"openai-compatible">;
1577
+ options: z.ZodObject<{
1578
+ baseUrl: z.ZodURL;
1579
+ model: z.ZodString;
1580
+ maxOutputTokens: z.ZodNumber;
1581
+ apiKeyEnvVar: z.ZodOptional<z.ZodString>;
1582
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1583
+ }, z.core.$strict>;
1584
+ }, z.core.$strip>], "id">;
1585
+ /**
1586
+ * The `provider` block of a verbatra config: a discriminated union on `id`, so each provider
1587
+ * carries exactly the options it supports and nothing else.
1588
+ *
1589
+ * No variant has a field for an API key. Keys are read from the environment by the provider itself
1590
+ * (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`, or
1591
+ * `GOOGLE_TRANSLATE_API_KEY`), which is what keeps them out of config files and out of version
1592
+ * control. The `openai-compatible` variant reads `OPENAI_COMPATIBLE_API_KEY` when it is set and
1593
+ * sends no real key otherwise, since a local server usually needs none; naming a different variable
1594
+ * through `apiKeyEnvVar` makes that variable required. It still never holds the key itself.
1595
+ */
1596
+ type ProviderConfig = z.infer<typeof providerConfigSchema>;
1597
+ /**
1598
+ * The identifier of a supported translation provider: `anthropic`, `openai`, `gemini`, `deepl`,
1599
+ * `google-translate`, or `openai-compatible`. The last one targets a local or self-hosted server
1600
+ * that speaks the OpenAI chat-completions API.
1601
+ */
1602
+ type ProviderId = ProviderConfig["id"];
1603
+
1604
+ /**
1605
+ * The zod schema every verbatra config is validated against. {@link loadConfig} applies it, so most
1606
+ * consumers never call it directly; reach for it when you build a config programmatically and want
1607
+ * to validate it before handing it to {@link translate}, or when you surface config errors in your
1608
+ * own tooling.
1609
+ *
1610
+ * The object is strict, so an unrecognized key is an error rather than being silently ignored: a
1611
+ * typo in a config file is reported instead of quietly doing nothing. The one exception is the
1612
+ * optional `$schema` key, accepted so a JSON or YAML config can point an editor at the JSON Schema
1613
+ * document the package ships as `@verbatra/sdk/config-schema.json`.
1614
+ *
1615
+ * `format` accepts a built-in format name or a `custom:` identifier such as `custom:toml`. A
1616
+ * `custom:` identifier is checked for shape only; the adapter behind it is supplied through a
1617
+ * flow's `adapterRegistry` dependency, and a run whose registry holds none fails with
1618
+ * `UNKNOWN_FORMAT`.
1619
+ *
1620
+ * `fuzzyCache` is off unless `enabled` is set. With it on, a source string whose earlier form is in
1621
+ * the translation memory close enough to clear `threshold` reuses that translation instead of
1622
+ * paying the provider for it. `threshold` is a similarity ratio from `0.5` to `1` and defaults to `0.9`.
1623
+ *
1624
+ * Beyond the per-field checks, two whole-config rules are enforced: `targetLocales` must not
1625
+ * contain the source locale, and it must not contain two locales that differ only in case (they
1626
+ * would collide on a case-insensitive file system). The `{locale}` token requirement is a
1627
+ * per-field check on `files.pattern`.
1628
+ */
1629
+ declare const verbatraConfigSchema: z.ZodObject<{
1630
+ $schema: z.ZodOptional<z.ZodString>;
1631
+ sourceLocale: z.ZodString;
1632
+ targetLocales: z.ZodArray<z.ZodString>;
1633
+ format: z.ZodUnion<readonly [z.ZodEnum<{
1634
+ "i18next-json": "i18next-json";
1635
+ "vue-i18n-json": "vue-i18n-json";
1636
+ "next-intl-json": "next-intl-json";
1637
+ "ngx-translate-json": "ngx-translate-json";
1638
+ xliff: "xliff";
1639
+ yaml: "yaml";
1640
+ arb: "arb";
1641
+ properties: "properties";
1642
+ "apple-strings": "apple-strings";
488
1643
  "apple-xcstrings": "apple-xcstrings";
489
1644
  "android-xml": "android-xml";
490
1645
  "gettext-po": "gettext-po";
491
- }>;
1646
+ ini: "ini";
1647
+ resx: "resx";
1648
+ }>, z.ZodTemplateLiteral<`custom:${string}`>]>;
492
1649
  files: z.ZodObject<{
493
1650
  pattern: z.ZodString;
494
1651
  localeStyle: z.ZodOptional<z.ZodEnum<{
@@ -547,12 +1704,40 @@ declare const verbatraConfigSchema: z.ZodObject<{
547
1704
  }>>;
548
1705
  prune: z.ZodOptional<z.ZodBoolean>;
549
1706
  generatePlurals: z.ZodOptional<z.ZodBoolean>;
1707
+ fuzzyCache: z.ZodOptional<z.ZodObject<{
1708
+ enabled: z.ZodBoolean;
1709
+ threshold: z.ZodOptional<z.ZodNumber>;
1710
+ }, z.core.$strict>>;
550
1711
  maxBatchSize: z.ZodOptional<z.ZodNumber>;
1712
+ maxLength: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
551
1713
  maxTokens: z.ZodOptional<z.ZodNumber>;
552
1714
  budgetBehavior: z.ZodOptional<z.ZodEnum<{
553
1715
  warn: "warn";
554
1716
  stop: "stop";
555
1717
  }>>;
1718
+ rates: z.ZodOptional<z.ZodObject<{
1719
+ asOf: z.ZodISODate;
1720
+ currency: z.ZodString;
1721
+ table: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
1722
+ inputPerMillionTokens: z.ZodNumber;
1723
+ outputPerMillionTokens: z.ZodNumber;
1724
+ }, z.core.$strict>, z.ZodObject<{
1725
+ perMillionCharacters: z.ZodNumber;
1726
+ }, z.core.$strict>]>>;
1727
+ }, z.core.$strict>>;
1728
+ extract: z.ZodOptional<z.ZodObject<{
1729
+ framework: z.ZodEnum<{
1730
+ i18next: "i18next";
1731
+ }>;
1732
+ roots: z.ZodArray<z.ZodString>;
1733
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
1734
+ literals: z.ZodOptional<z.ZodObject<{
1735
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1736
+ }, z.core.$strict>>;
1737
+ unused: z.ZodOptional<z.ZodObject<{
1738
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1739
+ }, z.core.$strict>>;
1740
+ }, z.core.$strict>>;
556
1741
  }, z.core.$strict>;
557
1742
  /**
558
1743
  * A config exactly as it is written in a `verbatra.config.ts` file, before the SDK resolves
@@ -720,6 +1905,46 @@ declare function defineConfig(config: AuthoringConfigFor<"google-translate">): V
720
1905
  */
721
1906
  declare function defineConfig(config: AuthoringConfig): VerbatraConfigInput;
722
1907
 
1908
+ /**
1909
+ * The zod schema for the optional `extract` block, validated strictly so a misspelled field is
1910
+ * reported rather than silently ignored. It is embedded in {@link verbatraConfigSchema} and
1911
+ * produces {@link ExtractionConfig}.
1912
+ */
1913
+ declare const extractionConfigSchema: z.ZodObject<{
1914
+ framework: z.ZodEnum<{
1915
+ i18next: "i18next";
1916
+ }>;
1917
+ roots: z.ZodArray<z.ZodString>;
1918
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
1919
+ literals: z.ZodOptional<z.ZodObject<{
1920
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1921
+ }, z.core.$strict>>;
1922
+ unused: z.ZodOptional<z.ZodObject<{
1923
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1924
+ }, z.core.$strict>>;
1925
+ }, z.core.$strict>;
1926
+ /**
1927
+ * The `extract` block of a verbatra config: which translation framework's call sites the source
1928
+ * scan looks for, which directories it walks, and which directory names it skips on the way.
1929
+ *
1930
+ * `roots` are resolved against the run's working directory, and the scan never reads outside them.
1931
+ * `exclude` adds directory names to the set that is always skipped, which already covers
1932
+ * `node_modules`, `.git`, and the usual build output directories.
1933
+ *
1934
+ * `literals.ignore` lists string literals the untranslated-literal scan (`verbatra doctor
1935
+ * --literals`) holds back project-wide, matched against the whole literal text as it is reported:
1936
+ * whitespace collapsed and, in JSX text or a quoted JSX attribute value, character references such
1937
+ * as `&amp;` decoded, so an entry is written `Tom & Jerry`, not `Tom &amp; Jerry`. A held-back
1938
+ * literal is still reported, under `suppressed`, so nothing vanishes silently.
1939
+ *
1940
+ * `unused.ignore` names catalog keys the unused-key report must not count as unused, for a key
1941
+ * referenced only from outside the scanned source (a server template, a CMS, a test fixture). Each
1942
+ * entry is an exact key, or a pattern in which `*` matches any run of characters, such as
1943
+ * `emails.*`. It matches the decoded key as the report shows it (`Welcome. Enjoy`, not the escaped
1944
+ * catalog spelling). A matched key is reported as ignored, never dropped from the report.
1945
+ */
1946
+ type ExtractionConfig = z.infer<typeof extractionConfigSchema>;
1947
+
723
1948
  /**
724
1949
  * Where a loaded config's glossary came from, reported by {@link loadConfigWithMeta}. The resolved
725
1950
  * {@link VerbatraConfig} holds only the final term map, so this is the one place a tool can tell an
@@ -844,7 +2069,9 @@ interface LoadConfigOptions {
844
2069
  type ConfigSource = {
845
2070
  /**
846
2071
  * `search` means cosmiconfig found the file by walking up from the working directory, up to
847
- * the nearest `.git` directory; `explicit` means the caller named it through `configPath`.
2072
+ * the nearest `.git` directory or, failing that, the home directory or the working directory
2073
+ * itself (see {@link loadConfigWithMeta}); `explicit` means the caller named it through
2074
+ * `configPath`.
848
2075
  */
849
2076
  readonly kind: "search" | "explicit";
850
2077
  /** The absolute path of the config file that was loaded. */
@@ -868,8 +2095,8 @@ interface LoadedConfig {
868
2095
  * config file is in effect, or to distinguish an inline glossary from a glossary file.
869
2096
  *
870
2097
  * Resolution order is: an explicit `configOverride`, then an explicit `configPath`, then a
871
- * cosmiconfig search upward from `cwd` across `verbatra.config.ts`, the `.verbatrarc` family, and a
872
- * `verbatra` property in `package.json`. The upward search stops at the nearest ancestor directory
2098
+ * cosmiconfig search upward from `cwd` across `verbatra.config.ts`, `.js` and `.cjs`, the
2099
+ * `.verbatrarc` family, and a `verbatra` property in `package.json`. The upward search stops at the nearest ancestor directory
873
2100
  * containing a `.git` entry; if none is found, it stops at the user's home directory when that is an
874
2101
  * ancestor of `cwd`, and otherwise it does not search above `cwd` at all, so a nested workspace
875
2102
  * package finds a config at its monorepo root without wandering above it, and a project outside the
@@ -929,6 +2156,84 @@ declare function loadConfigWithMeta(options?: LoadConfigOptions): Promise<Loaded
929
2156
  */
930
2157
  declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig>;
931
2158
 
2159
+ /**
2160
+ * What a provider charges for: `tokens` for the prompt-driven LLMs, which bill the prompt and the
2161
+ * completion separately, and `characters` for the machine-translation APIs, which bill the source
2162
+ * text they are handed and report no token usage at all.
2163
+ */
2164
+ type BillingUnit = "tokens" | "characters";
2165
+ /**
2166
+ * How one provider bills. `billedByApi` is false for a self-hosted endpoint, which still consumes
2167
+ * tokens but sends no invoice, so an estimate reports its quantity and no currency figure.
2168
+ */
2169
+ interface ProviderBilling {
2170
+ /** The quantity this provider charges for. */
2171
+ readonly unit: BillingUnit;
2172
+ /** Whether a hosted API bills for the work at all. */
2173
+ readonly billedByApi: boolean;
2174
+ }
2175
+
2176
+ /**
2177
+ * The zod schema for one token-billed rate entry: an input and an output price per million
2178
+ * tokens, each at most `MAX_RATE` currency units.
2179
+ */
2180
+ declare const tokenRateSchema: z.ZodObject<{
2181
+ inputPerMillionTokens: z.ZodNumber;
2182
+ outputPerMillionTokens: z.ZodNumber;
2183
+ }, z.core.$strict>;
2184
+ /**
2185
+ * The zod schema for one character-billed rate entry (DeepL, Google Cloud Translation): a
2186
+ * price per million source characters.
2187
+ */
2188
+ declare const characterRateSchema: z.ZodObject<{
2189
+ perMillionCharacters: z.ZodNumber;
2190
+ }, z.core.$strict>;
2191
+ /**
2192
+ * The zod schema for one `rates.table` entry: either {@link tokenRateSchema} or
2193
+ * {@link characterRateSchema}, never a mix of the two shapes.
2194
+ */
2195
+ declare const modelRateSchema: z.ZodUnion<readonly [z.ZodObject<{
2196
+ inputPerMillionTokens: z.ZodNumber;
2197
+ outputPerMillionTokens: z.ZodNumber;
2198
+ }, z.core.$strict>, z.ZodObject<{
2199
+ perMillionCharacters: z.ZodNumber;
2200
+ }, z.core.$strict>]>;
2201
+ /**
2202
+ * The zod schema for the optional `rates` block: the `asOf` date and `currency` printed beside
2203
+ * every priced estimate, plus a `table` of {@link modelRateSchema} entries keyed by rate key
2204
+ * (`provider/model`, or the bare provider id for a provider without a model).
2205
+ */
2206
+ declare const rateCardSchema: z.ZodObject<{
2207
+ asOf: z.ZodISODate;
2208
+ currency: z.ZodString;
2209
+ table: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
2210
+ inputPerMillionTokens: z.ZodNumber;
2211
+ outputPerMillionTokens: z.ZodNumber;
2212
+ }, z.core.$strict>, z.ZodObject<{
2213
+ perMillionCharacters: z.ZodNumber;
2214
+ }, z.core.$strict>]>>;
2215
+ }, z.core.$strict>;
2216
+ /**
2217
+ * A rate charging by tokens, with the prompt and the completion priced separately. Every hosted LLM
2218
+ * provider bills this way.
2219
+ */
2220
+ type TokenRate = z.infer<typeof tokenRateSchema>;
2221
+ /** A rate charging by source characters, which is how the machine-translation APIs bill. */
2222
+ type CharacterRate = z.infer<typeof characterRateSchema>;
2223
+ /** One entry of a {@link RateCard}: either a token rate or a character rate, never both. */
2224
+ type ModelRate = z.infer<typeof modelRateSchema>;
2225
+ /**
2226
+ * The rates a project supplies so an estimate can be expressed in money. verbatra ships no prices of
2227
+ * its own: they are a third party's, they change without notice, and a number baked into a release
2228
+ * would quietly go stale. The card carries the date it was read and the currency it is written in,
2229
+ * so any figure derived from it is visibly as old as `asOf` says it is.
2230
+ *
2231
+ * `table` is keyed by rate key: `provider/model` for a provider configured with a model (for
2232
+ * example `gemini/gemini-2.5-flash`), and the bare provider id for one without (`deepl`,
2233
+ * `google-translate`).
2234
+ */
2235
+ type RateCard = z.infer<typeof rateCardSchema>;
2236
+
932
2237
  /**
933
2238
  * The stable, structured failure codes the SDK throws. Branch on {@link SdkError.code} rather than
934
2239
  * on a message; the messages are written for humans and are not part of the contract.
@@ -945,17 +2250,20 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
945
2250
  * file could not be resolved or parsed. Thrown by {@link loadConfig} and
946
2251
  * {@link loadConfigWithMeta}, by {@link readGlossaryFile}, and by {@link updateGlossaryTerm},
947
2252
  * which additionally throws it for a blank term or translation and for an edit whose result would
948
- * exceed the glossary file size limit. {@link importWorkbook} does not throw it: when a handoff
949
- * sheet or file names a locale that is not a configured target locale, it records this code on
950
- * that locale's {@link LocaleSummary} instead.
2253
+ * exceed the glossary file size limit. {@link importTmx} and {@link exportTmx} throw it when the
2254
+ * source locale and a target locale are the same language tag once case and separators are
2255
+ * normalized, since a TMX segment could not be attributed to either. {@link importWorkbook} does
2256
+ * not throw it: when a handoff sheet or file names a locale that is not a configured target
2257
+ * locale, it records this code on that locale's {@link LocaleSummary} instead.
951
2258
  * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format. Thrown by every entry
952
2259
  * point that selects an adapter, before any file is read. {@link doctor} is the exception: it
953
2260
  * reports an unresolvable format as a failed `format-adapter` check instead, since reporting that
954
2261
  * is the command's job.
955
2262
  * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales. Thrown through
956
2263
  * the shared locale selection by {@link translate}, {@link watch}, {@link check}, {@link diff},
957
- * {@link keyIntegrity}, {@link lockState}, {@link exportWorkbook}, {@link keyValue},
958
- * {@link editEntry}, and {@link retranslateEntry}. {@link translate} throws it before anything is
2264
+ * {@link keyIntegrity}, {@link lockState}, {@link localeValues}, {@link exportWorkbook},
2265
+ * {@link exportTmx}, {@link importTmx}, {@link keyValue}, {@link editEntry}, and
2266
+ * {@link retranslateEntry}. {@link translate} throws it before anything is
959
2267
  * read or spent, and {@link watch} once at startup, before any watching begins.
960
2268
  * - `UNKNOWN_KEY`: the requested key is not present in the source resource. Thrown by
961
2269
  * {@link keyValue}, {@link editEntry}, and {@link retranslateEntry}.
@@ -964,9 +2272,12 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
964
2272
  * {@link translate} and by {@link retranslateEntry}.
965
2273
  * - `SOURCE_UNREADABLE`: the source locale file is absent. Thrown by every entry point that reads
966
2274
  * the source, including {@link importWorkbook}, and by {@link watch} at startup.
967
- * {@link importWorkbook} additionally throws it when the handoff file itself is missing.
2275
+ * {@link importWorkbook} and {@link importTmx} additionally throw it when the handoff or TMX file
2276
+ * itself is missing.
968
2277
  * - `SOURCE_INVALID`: the source locale file, or an interchange file, could not be parsed. Wraps
969
- * the adapter or reader error.
2278
+ * the adapter or reader error. {@link pseudolocalize} also throws it when, for a format whose
2279
+ * writer only patches an existing document, the source file could not be copied to seed the
2280
+ * output.
970
2281
  * - `LOCK_FILE_INVALID`: the lock-file exists but is corrupt, oversized, or at an unsupported
971
2282
  * version. Thrown wherever the lock-file is read or updated: {@link translate}, {@link check},
972
2283
  * {@link diff}, {@link keyIntegrity}, {@link lockState}, {@link loadLockFile},
@@ -997,306 +2308,573 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
997
2308
  * {@link translate} before any locale runs, and by {@link watch} once at startup, before any
998
2309
  * watching begins, since the value is fixed for the session rather than re-read per run.
999
2310
  * - `CONCURRENCY_BUDGET_CONFLICT`: a live run requested a `concurrency` above 1 while a token
1000
- * budget is configured. The two are mutually exclusive because concurrency makes the budget's
1001
- * stop guarantee nondeterministic. A dry run is exempt, since it never consults the budget.
2311
+ * budget is configured. The ceiling itself would still hold, but which locale loses its
2312
+ * remaining work would depend on the order the locales interleave, so the same project would not
2313
+ * produce the same run twice. A dry run is exempt, since it never consults the budget.
1002
2314
  * - `TARGET_UNWRITABLE`: a target locale file could not be written, because its directory is not
1003
2315
  * writable, does not exist, is read-only, or is out of space. The message names the target file
1004
2316
  * relative to `cwd` and the underlying file-system code, never the internal temporary file the
1005
2317
  * atomic write uses. Thrown by {@link editEntry} and {@link retranslateEntry}, which act on one
1006
- * locale. {@link translate} and {@link importWorkbook} do not throw it: they record it on that
2318
+ * locale, and by {@link pseudolocalize} for the pseudolocale file. {@link translate} and {@link importWorkbook} do not throw it: they record it on that
1007
2319
  * locale's {@link LocaleSummary} and carry on with the other locales.
2320
+ * - `PSEUDO_OUTPUT_CONFLICT`: {@link pseudolocalize} was asked to generate a pseudolocale that
2321
+ * names a configured locale, or to write one onto a configured locale file. Refused before
2322
+ * anything is read or written, so a generated pseudolocale can never overwrite a real
2323
+ * translation or stand in for one.
2324
+ * - `SOURCE_UNWRITABLE`: the source locale file could not be written. Thrown by {@link extract}
2325
+ * alone, since it is the only entry point that writes the source locale. The `xliff` and
2326
+ * `apple-xcstrings` formats reach it when no catalog exists yet, because neither is created from
2327
+ * nothing.
2328
+ * - `EXTRACT_NOT_CONFIGURED`: {@link extract} was called with a config that carries no `extract`
2329
+ * block, so there is no framework to look for and no source root to walk.
2330
+ * - `EXTRACT_FS_UNSUPPORTED`: {@link extract} was given a `deps.fs` that implements no
2331
+ * `readDirectory`, so no source file can be discovered. The member is optional on {@link SdkFs}
2332
+ * precisely so an implementation written before extraction existed keeps compiling; this is the
2333
+ * error it gets if it is then handed to {@link extract}.
2334
+ * - `TYPES_OUTPUT_CONFLICT`: {@link generateTypes} refused its output path. Before anything is
2335
+ * read or written, it refuses a path that names no file, is absolute, climbs out of the working
2336
+ * directory, or does not end in `.ts`, `.mts` or `.cts`, and one naming a configured locale
2337
+ * file, the lock file, the translation-memory cache, a file verbatra searches for its
2338
+ * configuration, or the configuration file the run loaded, compared case-insensitively. A
2339
+ * generating run, never a `check` run, also refuses to replace an existing file there that does
2340
+ * not begin with the header line verbatra writes, and leaves that file untouched.
2341
+ * - `TYPES_UNWRITABLE`: the declaration file {@link generateTypes} produces could not be written,
2342
+ * because its directory is not writable, does not exist, or the disk is out of space.
1008
2343
  * - `LOCALE_FAILED`: never thrown. It is the fallback code recorded on a failed
1009
2344
  * {@link LocaleSummary} when a per-locale failure carries no code of its own.
1010
2345
  */
1011
- type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "UNKNOWN_KEY" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCK_CONTENDED" | "GLOSSARY_NOT_FILE_BACKED" | "GLOSSARY_UNWRITABLE" | "LOCALE_LAYOUT_INVALID" | "LOCALE_PATH_COLLISION" | "CONCURRENCY_INVALID" | "CONCURRENCY_BUDGET_CONFLICT" | "TARGET_UNWRITABLE" | "LOCALE_FAILED";
2346
+ type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "UNKNOWN_KEY" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCK_CONTENDED" | "GLOSSARY_NOT_FILE_BACKED" | "GLOSSARY_UNWRITABLE" | "LOCALE_LAYOUT_INVALID" | "LOCALE_PATH_COLLISION" | "CONCURRENCY_INVALID" | "CONCURRENCY_BUDGET_CONFLICT" | "TARGET_UNWRITABLE" | "PSEUDO_OUTPUT_CONFLICT" | "SOURCE_UNWRITABLE" | "EXTRACT_NOT_CONFIGURED" | "EXTRACT_FS_UNSUPPORTED" | "TYPES_OUTPUT_CONFLICT" | "TYPES_UNWRITABLE" | "LOCALE_FAILED";
2347
+ /**
2348
+ * The single structured error the SDK throws. Every whole-run failure surfaces as an `SdkError`
2349
+ * carrying a stable {@link SdkErrorCode}, except the few a flow's `@throws` names as passed through
2350
+ * unwrapped (a failed workbook write, a watcher factory that throws); per-locale failures, provider notices, and integrity
2351
+ * findings are reported as data on the {@link RunSummary} instead of being thrown.
2352
+ *
2353
+ * An `SdkError` never carries a secret in its message.
2354
+ */
2355
+ declare class SdkError extends Error {
2356
+ /** The stable {@link SdkErrorCode} for this failure. Branch on this, not on the message. */
2357
+ readonly code: SdkErrorCode;
2358
+ /**
2359
+ * @param code - The stable failure code.
2360
+ * @param message - A human-readable description of the failure. Never contains a secret.
2361
+ * @param options - `cause` carries the error this one wraps, such as the interchange reader's
2362
+ * error for a `SOURCE_INVALID` TMX file. Read that file's line, column and unit with
2363
+ * `tmxErrorLocation` rather than from the cause directly.
2364
+ */
2365
+ constructor(code: SdkErrorCode, message: string, options?: {
2366
+ readonly cause?: unknown;
2367
+ });
2368
+ }
2369
+
2370
+ /**
2371
+ * Conditions the SDK itself reports on a locale, as opposed to the {@link ProviderNotice} codes a
2372
+ * provider raises. Each marks a run that completed but did something a caller may want to know
2373
+ * about.
2374
+ *
2375
+ * - `PLURAL_CATEGORIES_INCOMPLETE`: `i18next-json` only. The target language needs CLDR plural
2376
+ * categories the source does not supply and that plural generation did not produce (it is off,
2377
+ * the provider is not an LLM, or a generated form was withheld), so the missing forms have to be
2378
+ * added by hand.
2379
+ * - `SUB_BATCH_FAILED`: one provider sub-batch of a locale failed. Its keys are withheld and retried
2380
+ * on the next run, while the results of the other sub-batches are kept.
2381
+ * - `BLANK_ROW_BASELINE_RETAINED`: an imported handoff row was blank, so the existing translation
2382
+ * and its lock-file baseline were kept rather than being erased.
2383
+ * - `BUDGET_TOKENS_EXCEEDED`: the configured token budget was reached. Under `warn` the run
2384
+ * continues and the overrun is reported; under `stop` the request that would have crossed the
2385
+ * ceiling is withheld before it is sent, and so is every request after it.
2386
+ * - `CACHE_VERSION_UNRECOGNIZED`: the translation memory is at a version this release does not
2387
+ * understand, so the run used no cache and left the file untouched.
2388
+ */
2389
+ type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
2390
+ /**
2391
+ * Token usage as reported by the provider. Absent when the provider does not report usage, which is
2392
+ * the case for machine-translation APIs such as DeepL that do not bill in tokens.
2393
+ */
2394
+ interface UsageSummary {
2395
+ /** Tokens consumed by the prompts sent to the provider. */
2396
+ readonly inputTokens: number;
2397
+ /** Tokens consumed by the provider's responses. */
2398
+ readonly outputTokens: number;
2399
+ }
2400
+ /**
2401
+ * What a run does about its token budget. `warn`, the default, counts the spend and reports the
2402
+ * overrun without withholding anything. `stop` turns the figure into a ceiling: before each
2403
+ * provider request the run projects what that request will cost, and withholds it, and everything
2404
+ * after it, rather than send a request that would take the run past `maxTokens`.
2405
+ */
2406
+ type BudgetBehavior = "warn" | "stop";
1012
2407
  /**
1013
- * The single structured error the SDK throws. Every whole-run failure surfaces as an `SdkError`
1014
- * carrying a stable {@link SdkErrorCode}; per-locale failures, provider notices, and integrity
1015
- * findings are reported as data on the {@link RunSummary} instead of being thrown.
1016
- *
1017
- * An `SdkError` never carries a secret in its message.
2408
+ * The token budget in force for one run, and how much of it that run consumed.
2409
+ *
2410
+ * It is enforced for every provider, including the machine-translation APIs that report no usage of
2411
+ * their own: those are counted from the same projection the pre-run estimate uses, so a ceiling set
2412
+ * against DeepL or Google Cloud Translation withholds work rather than sitting inert.
2413
+ * {@link supported} says which of the two the figure came from.
2414
+ *
2415
+ * It bounds one run, not a session. {@link watch} starts a fresh budget for every run it triggers,
2416
+ * so a watch session can spend up to `maxTokens` again on each source edit. It also covers
2417
+ * {@link translate} and nothing else: `retranslateEntry` constructs its own provider on a separate
2418
+ * path, reached from the Studio dashboard and the agent tools, and no budget here caps that spend.
2419
+ *
2420
+ * Under `stop` the ceiling is checked before every request the run sends, each half of a re-split
2421
+ * request included, and a refused request is never sent. What a reservation cannot bound is what
2422
+ * happens inside a request it already admitted. The provider layer sends one repair call of its own
2423
+ * when keys come back missing, so one admitted batch can cost up to about twice its projection; and
2424
+ * the count is only as good as what the provider reports, so a provider under-reporting its usage
2425
+ * would leave the ceiling further away than it looks. Both land at reconciliation, and nothing
2426
+ * further is admitted once they do. A request that fails or comes back truncated keeps its whole
2427
+ * projection charged rather than being refunded, because such a call has usually already billed for
2428
+ * its prompt.
1018
2429
  */
1019
- declare class SdkError extends Error {
1020
- /** The stable {@link SdkErrorCode} for this failure. Branch on this, not on the message. */
1021
- readonly code: SdkErrorCode;
2430
+ interface RunBudget {
2431
+ /** The configured ceiling, in tokens. */
2432
+ readonly maxTokens: number;
2433
+ /** Whether passing the ceiling only warns, or withholds work to stay under it. */
2434
+ readonly behavior: BudgetBehavior;
1022
2435
  /**
1023
- * @param code - The stable failure code.
1024
- * @param message - A human-readable description of the failure. Never contains a secret.
2436
+ * Whether {@link tokensUsed} is entirely the provider's own reported usage. False as soon as one
2437
+ * counted request came back without a usable figure, so the total is partly verbatra's own
2438
+ * projection: a machine-translation API reports none at all, and a failed or truncated request
2439
+ * reports none either. Also false when the run sent no request at all, a dry run included, in
2440
+ * which case nothing was counted. The budget is enforced either way.
2441
+ */
2442
+ readonly supported: boolean;
2443
+ /**
2444
+ * Tokens counted across the run: reported usage for every request that gave one, and the
2445
+ * projection for every request that did not.
1025
2446
  */
1026
- constructor(code: SdkErrorCode, message: string);
2447
+ readonly tokensUsed: number;
2448
+ /**
2449
+ * True once the run reached its ceiling: either `tokensUsed` reached or passed `maxTokens`, or,
2450
+ * under `stop`, a request was withheld because sending it would have passed it.
2451
+ */
2452
+ readonly exceeded: boolean;
1027
2453
  }
1028
-
1029
2454
  /**
1030
- * Zod schema accepting exactly one of the twelve source formats verbatra can read and write. Each
1031
- * member names the ecosystem it round-trips, not just a file extension, because several of them share
1032
- * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
1033
- * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
1034
- * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining eight are `xliff` (the XLIFF
1035
- * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle),
1036
- * `properties` (Java and Spring `.properties` files), `apple-strings` (Apple's flat `.strings`
1037
- * localization format for iOS and macOS), `apple-xcstrings` (Apple's Xcode String Catalog, a single
1038
- * JSON document holding every locale of a `.xcstrings` catalogue together), `android-xml` (Android's
1039
- * `res/values/strings.xml` resource format, including `<plurals>` and `translatable="false"` entries),
1040
- * and `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
1041
- * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count).
1042
- *
1043
- * The set is closed: a format outside it cannot be represented, so adding one means adding a member
1044
- * here and an adapter that claims it.
2455
+ * Why a {@link RunEstimate} does or does not carry a currency figure.
2456
+ *
2457
+ * - `priced`: a rate for this provider and model was found on the configured rate card and applied.
2458
+ * - `no-rate-on-file`: no rate was configured for {@link RunEstimate.rateKey}. The quantity is still
2459
+ * reported; the money is not, because verbatra ships no prices of its own and will not guess one.
2460
+ * - `rate-unit-mismatch`: a rate exists but is written in the wrong unit for this provider, for
2461
+ * instance a per-character price against a token-billed model. It is refused rather than applied.
2462
+ * - `not-billed`: the provider is a self-hosted endpoint, so no API bills for the run at all.
1045
2463
  */
1046
- declare const supportedFormatSchema: z.ZodEnum<{
1047
- "i18next-json": "i18next-json";
1048
- "vue-i18n-json": "vue-i18n-json";
1049
- "next-intl-json": "next-intl-json";
1050
- "ngx-translate-json": "ngx-translate-json";
1051
- xliff: "xliff";
1052
- yaml: "yaml";
1053
- arb: "arb";
1054
- properties: "properties";
1055
- "apple-strings": "apple-strings";
1056
- "apple-xcstrings": "apple-xcstrings";
1057
- "android-xml": "android-xml";
1058
- "gettext-po": "gettext-po";
1059
- }>;
1060
- /** One of the twelve supported source formats; the inferred type of {@link supportedFormatSchema}. */
1061
- type SupportedFormat = z.infer<typeof supportedFormatSchema>;
1062
-
2464
+ type EstimatePricing = "priced" | "no-rate-on-file" | "rate-unit-mismatch" | "not-billed";
1063
2465
  /**
1064
- * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
1065
- * derives them from the value, so an adapter that reads a file is the one authority on what counts as
1066
- * a placeholder in that format.
1067
- *
1068
- * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
1069
- * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
1070
- * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
1071
- * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
1072
- * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
1073
- * placeholder tokens in document order, used for the output integrity check and to decide which entries
1074
- * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
1075
- * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
1076
- * decides and which feeds the content hash.
2466
+ * What a pre-run estimate deliberately leaves out. Each code names a way the real run can land
2467
+ * below the figure, above it, or away from it, so an estimate is never mistaken for an invoice.
2468
+ * The first three apply to every estimate; the last three only to a token-billed provider.
2469
+ *
2470
+ * - `CACHE_NOT_CONSULTED`: the estimate does not read the translation memory, so keys a live run
2471
+ * would serve from cache are still counted.
2472
+ * - `SOURCE_DUPLICATES_NOT_DEDUPLICATED`: a live run sends one representative per identical source
2473
+ * string; the estimate counts every key.
2474
+ * - `TRANSPORT_RETRIES_NOT_COUNTED`: the estimate counts one billable call per planned request. The
2475
+ * provider SDKs retry a failed call underneath that, so one planned request can become several
2476
+ * attempts on the wire, and a retried attempt that reached the model is still billed.
2477
+ * - `TRANSLATION_LENGTH_IS_ESTIMATED`: the translation does not exist yet, so the response is sized
2478
+ * from the source value plus a fixed expansion allowance. A target language that runs longer than
2479
+ * the allowance reports more completion tokens than this figure predicts.
2480
+ * - `TOKEN_COUNT_IS_HEURISTIC`: tokens are derived from character counts, not from the provider's
2481
+ * own tokenizer.
2482
+ * - `REPAIR_REQUESTS_NOT_COUNTED`: the extra requests an incomplete response can trigger are not
2483
+ * counted: one bounded repair round when keys came back missing, and a retry in halves, which
2484
+ * recurses, when the output was cut off. A repair round re-sends the system rules, the glossary
2485
+ * and the tone in full, so repairing one key in a large-glossary batch costs close to a whole
2486
+ * extra request.
1077
2487
  */
1078
- declare const translationEntrySchema: z.ZodObject<{
1079
- key: z.ZodString;
1080
- namespace: z.ZodString;
1081
- value: z.ZodString;
1082
- description: z.ZodOptional<z.ZodString>;
1083
- meaning: z.ZodOptional<z.ZodString>;
1084
- placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
1085
- isPlural: z.ZodBoolean;
1086
- }, z.core.$strip>;
2488
+ type EstimateCaveatCode = "CACHE_NOT_CONSULTED" | "SOURCE_DUPLICATES_NOT_DEDUPLICATED" | "TRANSPORT_RETRIES_NOT_COUNTED" | "TRANSLATION_LENGTH_IS_ESTIMATED" | "TOKEN_COUNT_IS_HEURISTIC" | "REPAIR_REQUESTS_NOT_COUNTED";
1087
2489
  /**
1088
- * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
1089
- * made deeply readonly. See that schema for what each field carries.
2490
+ * The estimated billing quantity for one target locale, without the money. `inputTokens` and
2491
+ * `outputTokens` are present only for a token-billed provider, and `sourceCharacters` only for a
2492
+ * character-billed one, so a machine-translation API is never handed a token figure it does not
2493
+ * bill by.
1090
2494
  */
1091
- type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
1092
-
2495
+ interface LocaleEstimateQuantity {
2496
+ /** The target locale this estimate covers. */
2497
+ readonly locale: string;
2498
+ /** How many keys would be sent for this locale, translation and plural generation together. */
2499
+ readonly keys: number;
2500
+ /** How many provider requests those keys would be split into. */
2501
+ readonly requests: number;
2502
+ /** Estimated prompt tokens. Present only when the provider bills by tokens. */
2503
+ readonly inputTokens?: number;
2504
+ /** Estimated completion tokens. Present only when the provider bills by tokens. */
2505
+ readonly outputTokens?: number;
2506
+ /** Estimated source characters. Present only when the provider bills by characters. */
2507
+ readonly sourceCharacters?: number;
2508
+ }
2509
+ /** One locale of a {@link PricedRunEstimate}: the quantity plus the money it comes to. */
2510
+ interface PricedLocaleEstimate extends LocaleEstimateQuantity {
2511
+ /** The estimated cost for this locale, in {@link PricedRunEstimate.currency}. */
2512
+ readonly cost: number;
2513
+ }
2514
+ /** One locale of an {@link UnpricedRunEstimate}: the quantity, and deliberately no money. */
2515
+ interface UnpricedLocaleEstimate extends LocaleEstimateQuantity {
2516
+ /** Never present: the run carries no rate, so no locale of it carries a figure. */
2517
+ readonly cost?: undefined;
2518
+ }
1093
2519
  /**
1094
- * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
1095
- * intermediate representation every adapter reads into and writes back out of, so nothing downstream
1096
- * of an adapter parses a format's own syntax.
2520
+ * One target locale of a {@link RunEstimate}. It carries a `cost` exactly when the run it belongs
2521
+ * to was priced, so there is no such thing as a priced estimate with a costless locale.
1097
2522
  */
1098
- interface LocaleResource {
1099
- /** The locale these entries belong to (for example, "en" or "de"). */
1100
- readonly locale: string;
1101
- /** The namespace these entries belong to; empty when the format has no namespacing. */
1102
- readonly namespace: string;
1103
- /** The source format the resource came from, retained for round-trip fidelity on write. */
1104
- readonly format: SupportedFormat;
1105
- /** Entries addressable by key, in the order the source file defined them. */
1106
- readonly entries: ReadonlyMap<string, TranslationEntry>;
2523
+ type LocaleEstimate = PricedLocaleEstimate | UnpricedLocaleEstimate;
2524
+ /**
2525
+ * Everything a {@link RunEstimate} reports regardless of whether it could be priced: which provider
2526
+ * the figure was computed for, how much would be sent, and what the figure leaves out.
2527
+ */
2528
+ interface EstimateIdentity {
2529
+ /** The provider the estimate was computed for. */
2530
+ readonly provider: ProviderId;
2531
+ /** The configured model, absent for a provider that takes none (`deepl`, `google-translate`). */
2532
+ readonly model?: string;
2533
+ /**
2534
+ * The key a rate is filed under in the config's `rates.table`: `provider/model` for a provider
2535
+ * configured with a model, and the bare provider id otherwise.
2536
+ */
2537
+ readonly rateKey: string;
2538
+ /**
2539
+ * Keys that would be sent across every locale, counting both the keys that would be translated
2540
+ * and the plural forms that would be generated.
2541
+ */
2542
+ readonly keys: number;
2543
+ /** Provider requests across every locale, generation batches included. */
2544
+ readonly requests: number;
2545
+ /** What this figure leaves out. Always present, never empty. See {@link EstimateCaveatCode}. */
2546
+ readonly caveats: readonly EstimateCaveatCode[];
2547
+ }
2548
+ /** The quantity a token-billed provider charges for: the prompt and the completion, separately. */
2549
+ interface TokenRunQuantity extends EstimateIdentity {
2550
+ /** Always `tokens` for a prompt-driven LLM. */
2551
+ readonly unit: "tokens";
2552
+ /** Estimated prompt tokens across every locale. */
2553
+ readonly inputTokens: number;
2554
+ /** Estimated completion tokens across every locale. */
2555
+ readonly outputTokens: number;
2556
+ /** Never present: a token-billed provider does not bill source characters. */
2557
+ readonly sourceCharacters?: undefined;
2558
+ }
2559
+ /** The quantity a character-billed provider charges for: the source text handed to it. */
2560
+ interface CharacterRunQuantity extends EstimateIdentity {
2561
+ /** Always `characters` for a machine-translation API. */
2562
+ readonly unit: "characters";
2563
+ /** Estimated source characters across every locale. */
2564
+ readonly sourceCharacters: number;
2565
+ /** Never present: a machine-translation API reports no token usage at all. */
2566
+ readonly inputTokens?: undefined;
2567
+ /** Never present: a machine-translation API reports no token usage at all. */
2568
+ readonly outputTokens?: undefined;
1107
2569
  }
1108
-
1109
2570
  /**
1110
- * Result of comparing source placeholders against translated-output placeholders as MULTISETS
1111
- * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
1112
- * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
2571
+ * What a run would send, discriminated on {@link unit} so the billed dimension is always present
2572
+ * and the other one is never readable as a zero.
1113
2573
  */
1114
- interface PlaceholderIntegrityResult {
1115
- /** True when nothing is missing or extra: the multisets match, regardless of order. */
1116
- readonly matches: boolean;
1117
- /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
1118
- readonly missing: readonly string[];
1119
- /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
1120
- readonly extra: readonly string[];
1121
- /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
1122
- readonly reordered: boolean;
2574
+ type RunEstimateQuantity = TokenRunQuantity | CharacterRunQuantity;
2575
+ /** The money half of a {@link PricedRunEstimate}: the figure, its currency, and its date. */
2576
+ interface PricedEstimateMoney {
2577
+ /** Always `priced`: a rate was found and applied. */
2578
+ readonly pricing: "priced";
2579
+ /** The rate card's currency code. */
2580
+ readonly currency: string;
2581
+ /**
2582
+ * The date the rates were read, as the config declared it. Print it beside any figure: a rate
2583
+ * card is exactly as current as this date.
2584
+ */
2585
+ readonly asOf: string;
2586
+ /** The per-locale breakdown, in the same order as {@link RunSummary.locales}. */
2587
+ readonly locales: readonly PricedLocaleEstimate[];
2588
+ /** The estimated total, in {@link currency}. */
2589
+ readonly cost: number;
2590
+ }
2591
+ /** The money half of an {@link UnpricedRunEstimate}: why there is no figure, and no figure. */
2592
+ interface UnpricedEstimateMoney {
2593
+ /** Why no currency figure is present. See {@link EstimatePricing}. */
2594
+ readonly pricing: Exclude<EstimatePricing, "priced">;
2595
+ /** The per-locale breakdown, in the same order as {@link RunSummary.locales}. */
2596
+ readonly locales: readonly UnpricedLocaleEstimate[];
2597
+ /** Never present: there is no rate to express a figure in. */
2598
+ readonly currency?: undefined;
2599
+ /** Never present: there is no rate, so there is no date it was read on. */
2600
+ readonly asOf?: undefined;
2601
+ /** Never present: an absent rate is reported as absent, never as a cost of zero. */
2602
+ readonly cost?: undefined;
2603
+ }
2604
+ /** A priced estimate for a token-billed provider: prompt and completion tokens, and their cost. */
2605
+ interface TokenPricedRunEstimate extends TokenRunQuantity, PricedEstimateMoney {
2606
+ }
2607
+ /** A priced estimate for a character-billed provider: source characters, and their cost. */
2608
+ interface CharacterPricedRunEstimate extends CharacterRunQuantity, PricedEstimateMoney {
2609
+ }
2610
+ /** An unpriced estimate for a token-billed provider: prompt and completion tokens, no cost. */
2611
+ interface TokenUnpricedRunEstimate extends TokenRunQuantity, UnpricedEstimateMoney {
2612
+ }
2613
+ /** An unpriced estimate for a character-billed provider: source characters, no cost. */
2614
+ interface CharacterUnpricedRunEstimate extends CharacterRunQuantity, UnpricedEstimateMoney {
1123
2615
  }
1124
-
1125
2616
  /**
1126
- * The result of reading a file into core's intermediate representation. The two diagnostic lists are
1127
- * reported as data rather than thrown, so one bad leaf never fails a whole read.
2617
+ * An estimate a rate could be applied to: the config supplied a rate for `rateKey` in the right
2618
+ * unit, so the run carries a currency figure and the date that rate was read.
1128
2619
  */
1129
- interface ReadResult {
1130
- /** The parsed locale resource. */
1131
- readonly resource: LocaleResource;
2620
+ type PricedRunEstimate = TokenPricedRunEstimate | CharacterPricedRunEstimate;
2621
+ /**
2622
+ * An estimate carrying quantity and no money, with `pricing` saying why. verbatra ships no prices
2623
+ * of its own and will not guess one, so the absence is reported rather than rendered as a cost of
2624
+ * zero.
2625
+ */
2626
+ type UnpricedRunEstimate = TokenUnpricedRunEstimate | CharacterUnpricedRunEstimate;
2627
+ /**
2628
+ * A pre-run estimate: what a run would send, and what that would cost at the rates the project
2629
+ * supplied. It is computed without constructing a provider, reading an API key, or making a network
2630
+ * call.
2631
+ *
2632
+ * It bounds the plan, not the invoice. Every provider call a live run schedules is counted,
2633
+ * including the plural-generation batches; the prompt is measured from the request payload that
2634
+ * would actually be sent rather than modelled; and the response is sized from the source value plus
2635
+ * an expansion allowance. Against that plan a live run usually spends less, because it consults the
2636
+ * translation memory and collapses identical source strings.
2637
+ *
2638
+ * It can also spend more, and {@link EstimateCaveatCode} names every way: the provider SDKs retry a
2639
+ * failed call underneath each planned request, an incomplete response triggers a repair round or a
2640
+ * recursive split, a target language can expand past the allowance, and the token figure is a
2641
+ * character heuristic rather than the provider's own tokenizer. Treat it as a planning figure, not
2642
+ * as a ceiling that cannot be crossed.
2643
+ *
2644
+ * It covers {@link translate} and nothing else. `retranslateEntry` constructs a provider and calls
2645
+ * it on its own path, reached from the Studio dashboard and the agent tools, and no estimate here
2646
+ * sees that spend.
2647
+ *
2648
+ * Branch on {@link pricing}: a {@link PricedRunEstimate} carries `cost`, `currency` and `asOf`, and
2649
+ * an {@link UnpricedRunEstimate} carries none of them and says why.
2650
+ */
2651
+ type RunEstimate = PricedRunEstimate | UnpricedRunEstimate;
2652
+ /** A condition the SDK reported on a locale. See {@link SdkNoticeCode}. */
2653
+ interface SdkNotice {
2654
+ /** The stable notice code. Branch on this, not on the message. */
2655
+ readonly code: SdkNoticeCode;
2656
+ /** A human-readable description of the condition. */
2657
+ readonly message: string;
2658
+ }
2659
+ /**
2660
+ * Any notice attached to a locale: either one the provider raised, such as a downgraded formality
2661
+ * or an ignored glossary, or one the SDK raised. Discriminate on `code`.
2662
+ */
2663
+ type LocaleNotice = ProviderNotice | SdkNotice;
2664
+ /**
2665
+ * A key written in this run that was flagged as worth a human look, whether by the provider layer's
2666
+ * quality heuristics, a configured `maxLength` budget, or a fuzzy translation-memory reuse. The
2667
+ * value was still written; these are advisory quality signals, not rejections.
2668
+ */
2669
+ interface NeedsReviewEntry {
2670
+ /** The key that was flagged. */
2671
+ readonly key: string;
2672
+ /** Why it was flagged. A key can carry more than one reason. */
2673
+ readonly reasons: readonly ReviewReasonCode[];
2674
+ }
2675
+ /**
2676
+ * A key whose translation was reused from the translation memory even though its source string had
2677
+ * changed, because the earlier source it was translated from is close enough to the current one.
2678
+ *
2679
+ * It is reported apart from {@link LocaleSummary.cacheHits} on purpose: an exact cache hit is the
2680
+ * same string it always was, while this is a translation of text that is no longer the source text.
2681
+ * {@link similarity} and {@link previousSource} are the evidence for the reuse, so a reader can
2682
+ * judge it rather than take it on trust.
2683
+ *
2684
+ * A reuse is written to the locale file but deliberately not locked: the key keeps the lock-file
2685
+ * baseline it already had, so it stays changed, is offered and flagged again on every later run at
2686
+ * no provider cost, and settles only once a real translation lands. Every key listed here also
2687
+ * carries `FUZZY_CACHE_REUSE` in {@link LocaleSummary.needsReview}.
2688
+ */
2689
+ interface FuzzyCacheHit {
2690
+ /** The key whose translation was reused. */
2691
+ readonly key: string;
2692
+ /** The key's source text at the time this translation was produced. */
2693
+ readonly previousSource: string;
1132
2694
  /**
1133
- * Keys whose values are invalid for the format's message syntax. Empty for a format with no
1134
- * message-syntax check, which treats every value as valid.
2695
+ * How alike {@link previousSource} and the current source are, from `0` to `1`: one minus the
2696
+ * character edit distance divided by the longer string's length, after Unicode normalization.
2697
+ * Always at or above the configured `fuzzyCache.threshold`.
1135
2698
  */
1136
- readonly invalidIcuKeys: readonly string[];
2699
+ readonly similarity: number;
2700
+ }
2701
+ /** A row of an imported handoff that could not be read. Reported rather than aborting the import. */
2702
+ interface MalformedRowReport {
2703
+ /** The row's 1-based index within its sheet or file. */
2704
+ readonly row: number;
1137
2705
  /**
1138
- * Keys or dotted paths that were present in the source but excluded from translation: a stray
1139
- * non-string leaf (a number, boolean, or null) for a tree-based format, or a key the format's own
1140
- * metadata marks as not translatable. These are never translated, hashed, diffed, or checked for
1141
- * placeholder or ICU integrity. Whether one survives a later rewrite of the same file depends on
1142
- * the adapter: an adapter that reconstructs the whole file from `resource.entries` alone drops it,
1143
- * while an adapter that patches only the entries it touches into the existing document (for
1144
- * example `apple-xcstrings`) preserves it verbatim. Empty for a file with no such leaves.
2706
+ * The 1-based physical line in the source file, for delimited formats where a quoted value can
2707
+ * span several lines. Absent for `.xlsx`, which has rows but no lines.
1145
2708
  */
1146
- readonly excludedLeafPaths: readonly string[];
2709
+ readonly line?: number;
2710
+ /** The column whose value was missing or unusable. */
2711
+ readonly column: string;
1147
2712
  }
1148
2713
  /**
1149
- * The contract every format adapter implements. A new format attaches by implementing this interface
1150
- * and registering it in an {@link AdapterRegistry}.
2714
+ * A key that appeared more than once in an imported handoff. The first occurrence wins and the rest
2715
+ * are reported here, so a translator who duplicated a row learns which value was actually used.
2716
+ */
2717
+ interface DuplicateKeyReport {
2718
+ /** The key that appeared more than once. */
2719
+ readonly key: string;
2720
+ /** The 1-based row index of the ignored occurrence. */
2721
+ readonly row: number;
2722
+ /** The 1-based physical line of the ignored occurrence, for delimited formats. Absent for `.xlsx`. */
2723
+ readonly line?: number;
2724
+ }
2725
+ /**
2726
+ * Everything that happened for one locale during a run. The key lists are disjoint accounts of what
2727
+ * became of each key, so a caller can reconstruct the whole run without re-reading any file.
1151
2728
  *
1152
- * Implement it through one of `@verbatra/format-adapters`' shared factories rather than from
1153
- * scratch: `createTreeFileAdapter` for a nested-tree format (with `createJsonFileAdapter` as its
1154
- * JSON specialization) and `createFlatFileAdapter` for a flat key/value format. A format that fits
1155
- * neither shape implements this interface directly, and either way it first needs its member added
1156
- * to core's {@link SupportedFormat}.
2729
+ * A locale that failed outright still appears here with `status: "failed"` and an `error`, rather
2730
+ * than the whole run throwing. That is the central contract of {@link translate}: one unreachable
2731
+ * provider or one unwritable file does not discard the locales that succeeded.
1157
2732
  */
1158
- interface FormatAdapter {
1159
- /** The single format this adapter handles (a {@link SupportedFormat} from core). */
1160
- readonly format: SupportedFormat;
2733
+ interface LocaleSummary {
2734
+ /** The target locale this summary describes. */
2735
+ readonly locale: string;
1161
2736
  /**
1162
- * Detect whether this adapter can handle a file, by path extension and an optional content sample.
1163
- * Best-effort and side-effect-free; it reads nothing from disk.
1164
- *
1165
- * @param filePath - The path of the file under consideration.
1166
- * @param sample - An optional leading content sample to aid detection.
1167
- * @returns True if this adapter could handle the file. Several adapters may return true for the same
1168
- * file (all JSON adapters claim `.json`); the caller disambiguates by explicit format.
2737
+ * `succeeded` when no key was withheld by the integrity gate, a provider failure, or the token
2738
+ * budget; `partial` when some keys were withheld and others landed; `failed` when keys were
2739
+ * withheld and none landed, or when the locale threw. Keys skipped for invalid ICU source and
2740
+ * handoff rows left blank do not change the status.
1169
2741
  */
1170
- canHandle(filePath: string, sample?: string): boolean;
2742
+ readonly status: "succeeded" | "partial" | "failed";
1171
2743
  /**
1172
- * Read a file into a {@link LocaleResource} plus its message-validity result.
1173
- *
1174
- * @param filePath - The file to read.
1175
- * @param locale - The locale to tag the resource with.
1176
- * @returns The parsed resource and the keys whose values are invalid for the format.
1177
- * @throws `AdapterError` when the content is malformed, oversized, or structurally invalid
1178
- * (the implementation names the specific codes). A missing or unopenable path instead rejects with
1179
- * the underlying filesystem error.
2744
+ * Keys that received a new value in this run: from the provider for {@link translate}, keys
2745
+ * sharing a translated key's source text included, or from the handoff for
2746
+ * {@link importWorkbook}. On a dry run, the keys a live run would send; cache hits are not
2747
+ * separated out, because a dry run does not read the translation memory.
1180
2748
  */
1181
- read(filePath: string, locale: string): Promise<ReadResult>;
2749
+ readonly translated: readonly string[];
2750
+ /** Keys already up to date against the lock-file baseline, so no provider call was made. */
2751
+ readonly unchanged: readonly string[];
2752
+ /** Keys present in this locale but no longer in the source. Reported, and removed only when pruning. */
2753
+ readonly orphaned: readonly string[];
2754
+ /** Orphaned keys that were actually removed, which happens only when the run was asked to prune. */
2755
+ readonly pruned: readonly string[];
1182
2756
  /**
1183
- * Write a {@link LocaleResource} back to a file, preserving key order and structure, atomically.
1184
- *
1185
- * @param resource - The resource to serialize.
1186
- * @param filePath - The destination file.
1187
- * @throws `AdapterError` if the resource cannot be represented in the format; rejects with the
1188
- * underlying filesystem error on a write failure.
2757
+ * Keys whose source text is not valid ICU. They are skipped rather than sent to the provider,
2758
+ * because a broken source message cannot yield a sound translation.
1189
2759
  */
1190
- write(resource: LocaleResource, filePath: string): Promise<void>;
2760
+ readonly invalidIcuSource: readonly string[];
1191
2761
  /**
1192
- * Extract the format's placeholder tokens from a single value, resolving nothing.
1193
- *
1194
- * @param value - The translatable string to scan.
1195
- * @returns The placeholder tokens found, in document order. Does not throw.
2762
+ * Keys served from the translation memory instead of the provider, and so not paid for. Only
2763
+ * exact matches: a reuse for a source string that has since changed is reported in
2764
+ * {@link fuzzyHits} instead, never here.
1196
2765
  */
1197
- extractPlaceholders(value: string): readonly string[];
2766
+ readonly cacheHits: readonly string[];
2767
+ /**
2768
+ * Keys whose translation was reused for a source string that had changed, with the score and the
2769
+ * earlier source behind each reuse. Always empty unless `fuzzyCache.enabled` was set. These keys
2770
+ * are written but not locked, so they reappear here on every run until they are really
2771
+ * translated. See {@link FuzzyCacheHit}.
2772
+ */
2773
+ readonly fuzzyHits: readonly FuzzyCacheHit[];
2774
+ /**
2775
+ * Keys whose translation was refused by the integrity gate, for instance because it dropped a
2776
+ * placeholder. The previous translation, if any, is left untouched.
2777
+ */
2778
+ readonly integrityMismatches: readonly string[];
2779
+ /** Keys the provider failed to translate, for instance because their sub-batch errored. */
2780
+ readonly providerFailures: readonly string[];
2781
+ /**
2782
+ * Keys whose plural categories were generated for the target language rather than translated one
2783
+ * by one. On a dry run, the forms a live run would generate: generation is a provider call of
2784
+ * its own, so a dry run reports it rather than reporting nothing.
2785
+ */
2786
+ readonly generated: readonly string[];
2787
+ /** Keys not translated because the token budget was exhausted under `stop` behavior. */
2788
+ readonly budgetWithheld: readonly string[];
2789
+ /** Token usage for this locale. Absent when the provider does not report usage. */
2790
+ readonly usage?: UsageSummary;
2791
+ /** Provider and SDK notices raised while running this locale. Always present, possibly empty. */
2792
+ readonly notices: readonly LocaleNotice[];
2793
+ /** Translated keys flagged as worth a human look. The translations were still written. */
2794
+ readonly needsReview: readonly NeedsReviewEntry[];
1198
2795
  /**
1199
- * Validate a single value against the format's message syntax before it is written. Only the
1200
- * ICU-message formats carry a real check today (next-intl and ARB); every other adapter has no
1201
- * message syntax to violate and returns true for any value.
1202
- *
1203
- * @param value - The candidate translated value to validate.
1204
- * @returns True when the value is valid for the format's message syntax. Does not throw; an
1205
- * unparseable value returns false.
2796
+ * Keys an imported handoff left blank while they were missing or out of date in this locale.
2797
+ * Always empty for a {@link translate} run.
1206
2798
  */
1207
- validateMessage(value: string): boolean;
2799
+ readonly unfilled: readonly string[];
2800
+ /** Unreadable rows from an imported handoff. Always empty for a {@link translate} run. */
2801
+ readonly malformedRows: readonly MalformedRowReport[];
2802
+ /** Repeated keys from an imported handoff. Always empty for a {@link translate} run. */
2803
+ readonly duplicateKeys: readonly DuplicateKeyReport[];
1208
2804
  /**
1209
- * Optional whole-value placeholder comparison, used by callers instead of independently extracting
1210
- * each side's placeholders with {@link extractPlaceholders} and diffing the flat lists. Two adapter
1211
- * families define one, for different reasons:
1212
- *
1213
- * - The ICU formats (next-intl and ARB) compare branch by branch, because flattening a
1214
- * plural/select value loses which branch a placeholder came from.
1215
- * - The double-brace formats (i18next, ngx-translate, and YAML) add a one-directional
1216
- * single-brace check on top of their flat multiset comparison: a `{name}`-shaped token present
1217
- * in the translated value but absent from the source is reported as `extra`. Single-brace text
1218
- * is ordinary literal content under these formats' default delimiters, so it is never required
1219
- * to survive a translation, but inventing one is never a legitimate translation either.
1220
- *
1221
- * Absent for the remaining adapters, which are compared via `extractPlaceholders` plus a flat
1222
- * multiset check.
2805
+ * Why this locale failed, when the failure came from a thrown error. The `code` is the failure's
2806
+ * own code where it has one, and `LOCALE_FAILED` otherwise. Never carries a secret.
1223
2807
  *
1224
- * @param sourceValue - The source value.
1225
- * @param targetValue - The translated value to check against it.
1226
- * @returns The merged placeholder-integrity result. Does not throw.
2808
+ * Absent unless `status` is `failed`, but a `failed` locale does not always carry it: a locale
2809
+ * whose every key was withheld by the integrity gate, a provider failure, or the token budget is
2810
+ * `failed` with nothing thrown, so this stays undefined and the withheld keys are the account of
2811
+ * what went wrong. Treat it as an optional detail on a failure, never as the failure test.
1227
2812
  */
1228
- comparePlaceholders?(sourceValue: string, targetValue: string): PlaceholderIntegrityResult;
2813
+ readonly error?: {
2814
+ /** The failure's own code where it had one, and `LOCALE_FAILED` otherwise. */
2815
+ readonly code: string;
2816
+ /** A human-readable description of the failure. Never contains a secret. */
2817
+ readonly message: string;
2818
+ };
1229
2819
  }
1230
-
1231
2820
  /**
1232
- * Outcome of resolving an adapter for a file. Structured, never thrown: an unresolvable file is a
1233
- * status the caller branches on, not an exception.
2821
+ * The result of a whole run, returned by {@link translate}, {@link watch}, and
2822
+ * {@link importWorkbook}.
1234
2823
  *
1235
- * `resolved` carries the single {@link FormatAdapter} to use. `no-match` means nothing claimed the
1236
- * file, and reports the formats that were tried (every registered format for a detection attempt, or
1237
- * just the one requested when an explicit format was given). `ambiguous` means more than one adapter
1238
- * claimed the file, which is routine since every JSON adapter claims `.json`; it reports the competing
1239
- * candidates instead of guessing, and the caller resolves it by naming a format explicitly.
2824
+ * Per-locale outcomes are data, not exceptions: inspect `failed` and `partial` to decide an exit
2825
+ * code rather than relying on the call to throw. Only whole-run failures, such as an invalid config
2826
+ * or an unreadable source file, throw an {@link SdkError}.
1240
2827
  */
1241
- type AdapterResolution = {
1242
- /** Exactly one adapter claimed the file. */
1243
- readonly status: "resolved";
1244
- /** The adapter to read and write the file with. */
1245
- readonly adapter: FormatAdapter;
1246
- } | {
1247
- /** No registered adapter claimed the file. */
1248
- readonly status: "no-match";
1249
- /** The file that could not be matched. */
1250
- readonly filePath: string;
2828
+ interface RunSummary {
2829
+ /** True when the run computed everything but wrote nothing and called no provider. */
2830
+ readonly dryRun: boolean;
1251
2831
  /**
1252
- * The formats that were tried: every registered format for a detection attempt, or just the
1253
- * one requested when an explicit format was given.
2832
+ * The full per-locale account. A {@link translate} or {@link watch} run reports them in
2833
+ * configured target order, whatever order a `locales` subset was given in and whatever order
2834
+ * concurrent locales finished in. An {@link importWorkbook} run does not: it reports them in the
2835
+ * order the handoff yielded them, then appends the configured locales the handoff had nothing
2836
+ * for. Match an entry on its `locale` rather than on its position.
1254
2837
  */
1255
- readonly triedFormats: readonly SupportedFormat[];
1256
- } | {
1257
- /** More than one adapter claimed the file, so detection cannot choose between them. */
1258
- readonly status: "ambiguous";
1259
- /** The file that several adapters claimed. */
1260
- readonly filePath: string;
1261
- /** The competing formats. Resolve the ambiguity by naming one of them explicitly. */
1262
- readonly candidates: readonly SupportedFormat[];
1263
- };
1264
- /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
1265
- interface ResolveOptions {
1266
- /** A leading content sample to aid detection. Ignored when `format` is given. */
1267
- readonly sample?: string;
1268
- /** Bypass detection and select this format explicitly. */
1269
- readonly format?: SupportedFormat;
1270
- }
1271
- /**
1272
- * Holds the registered adapters and resolves one for a file. Open for extension: adapters attach
1273
- * through {@link AdapterRegistry.register} without changing resolution logic, and resolution never
1274
- * throws.
1275
- */
1276
- declare class AdapterRegistry {
1277
- private readonly adapters;
2838
+ readonly locales: readonly LocaleSummary[];
2839
+ /** Names of the locales whose status is `succeeded`. */
2840
+ readonly succeeded: readonly string[];
1278
2841
  /**
1279
- * Register an adapter. Registration order is the order detection consults them, and a format may be
1280
- * registered more than once, in which case an explicit-format lookup takes the first.
1281
- *
1282
- * @param adapter - The adapter to add.
1283
- * @returns This registry, for chaining.
2842
+ * Names of the locales whose status is `partial`: written to disk, but with keys still missing.
2843
+ * Check this alongside `failed` before treating a run as clean. The CLI exits `1` on a partial
2844
+ * locale for that reason.
1284
2845
  */
1285
- register(adapter: FormatAdapter): this;
1286
- private formats;
1287
- private resolveByFormat;
1288
- private resolveByDetection;
2846
+ readonly partial: readonly string[];
2847
+ /** Names of the locales whose status is `failed`. Check this before treating a run as clean. */
2848
+ readonly failed: readonly string[];
2849
+ /** Token usage summed across every locale. Absent when the provider does not report usage. */
2850
+ readonly usage?: UsageSummary;
2851
+ /** The token budget in force, present only when the config set one. */
2852
+ readonly budget?: RunBudget;
1289
2853
  /**
1290
- * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
1291
- *
1292
- * @param filePath - The file to resolve an adapter for.
1293
- * @param options - `format` selects explicitly and skips detection; `sample` aids detection.
1294
- * @returns A structured {@link AdapterResolution}: `resolved`, `no-match`, or `ambiguous`. Never
1295
- * throws; an unresolvable file is a status, not an exception.
2854
+ * What the run would have cost, present only when it was asked for a pre-run estimate. Always a
2855
+ * dry run: an estimate constructs no provider and spends nothing.
1296
2856
  */
1297
- resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
2857
+ readonly estimate?: RunEstimate;
1298
2858
  }
1299
2859
 
2860
+ /**
2861
+ * Where a finished run stands against its token budget.
2862
+ *
2863
+ * - `within`: the run never reached its ceiling.
2864
+ * - `stopped-before-ceiling`: under `stop`, a request was withheld because sending it would have
2865
+ * passed the ceiling, while the counted total was still below it.
2866
+ * - `reached`: the counted total reached or passed the ceiling.
2867
+ */
2868
+ type BudgetStanding = "within" | "stopped-before-ceiling" | "reached";
2869
+ /**
2870
+ * Classifies a {@link RunBudget} into its {@link BudgetStanding}, so every surface that reports a
2871
+ * budget tells a `stop` run halted short of its ceiling apart from one that actually reached it.
2872
+ *
2873
+ * @param budget - The budget from {@link RunSummary.budget} or {@link RunStatusFile.budget}.
2874
+ * @returns The standing. Pure; throws nothing.
2875
+ */
2876
+ declare function budgetStanding(budget: RunBudget): BudgetStanding;
2877
+
1300
2878
  /** One locale's counts in a {@link CheckSummary}. */
1301
2879
  interface LocaleCheckSummary {
1302
2880
  /** The target locale these counts describe. */
@@ -1309,6 +2887,12 @@ interface LocaleCheckSummary {
1309
2887
  readonly upToDate: number;
1310
2888
  /** True when this locale has nothing missing and nothing stale. */
1311
2889
  readonly inSync: boolean;
2890
+ /**
2891
+ * Every source string this locale translates more than one way under different keys, present only
2892
+ * when {@link CheckInput.consistency} is true (an empty array then means the locale is
2893
+ * consistent). This is a report and nothing more: it never changes `inSync` or any count.
2894
+ */
2895
+ readonly inconsistencies?: readonly InconsistencyGroup[];
1312
2896
  }
1313
2897
  /** The result of {@link check}: per-locale counts plus one project-wide verdict. */
1314
2898
  interface CheckSummary {
@@ -1325,6 +2909,11 @@ interface CheckInput {
1325
2909
  readonly cwd?: string;
1326
2910
  /** Restrict the report to these target locales. Defaults to every configured target locale. */
1327
2911
  readonly locales?: readonly string[];
2912
+ /**
2913
+ * Also report, per locale, every source string translated more than one way under different keys
2914
+ * (see {@link LocaleCheckSummary.inconsistencies}). Defaults to false.
2915
+ */
2916
+ readonly consistency?: boolean;
1328
2917
  }
1329
2918
  /** Injectable dependencies for {@link check}. Every field has a working default. */
1330
2919
  interface CheckDeps {
@@ -1345,6 +2934,16 @@ interface CheckDeps {
1345
2934
  *
1346
2935
  * Use {@link diff} instead when you need the key names rather than the counts.
1347
2936
  *
2937
+ * With {@link CheckInput.consistency} set, each locale also lists the source strings it translates
2938
+ * more than one way under different keys. Only keys that are up to date are compared, since a stale
2939
+ * translation belongs to an older source text. Values are compared after Unicode NFC normalization,
2940
+ * folding line endings, and trimming leading and trailing whitespace; internal whitespace and letter
2941
+ * case count. Keys whose description, meaning, plural flag, or gettext `msgctxt` differ are never
2942
+ * grouped, and neither are the different plural forms of one key: a format that stores each form
2943
+ * under its own key (i18next, Apple `.stringsdict` and `.xcstrings`, Android, gettext) is compared
2944
+ * per plural category or gettext `msgstr` index. The report never affects `inSync`, a count, or any
2945
+ * file, and a translation identical to its own source is not a finding here.
2946
+ *
1348
2947
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1349
2948
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1350
2949
  * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
@@ -1366,6 +2965,121 @@ interface CheckDeps {
1366
2965
  */
1367
2966
  declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
1368
2967
 
2968
+ /**
2969
+ * Why an unused-key report could not be produced at all.
2970
+ *
2971
+ * - `EXTRACT_NOT_CONFIGURED`: the config carries no `extract` block, so there is no source to scan.
2972
+ * - `EXTRACT_FS_UNSUPPORTED`: the supplied file system implements no `readDirectory`.
2973
+ * - `FRAMEWORK_NOT_MODELED`: the configured format is loaded by a runtime (next-intl, Vue I18n, or
2974
+ * ngx-translate) whose key lookups the i18next source scan does not model.
2975
+ * - `NO_SOURCE_FILES`: there is no source file under the configured roots at all.
2976
+ * - `NO_REFERENCES_FOUND`: the scanned files name no translation key and hold no dynamic key site,
2977
+ * while the catalog is not empty, so the scan most likely looked at the wrong code.
2978
+ */
2979
+ type UnusedKeysNotRunReason = "EXTRACT_NOT_CONFIGURED" | "EXTRACT_FS_UNSUPPORTED" | "FRAMEWORK_NOT_MODELED" | "NO_SOURCE_FILES" | "NO_REFERENCES_FOUND";
2980
+ /**
2981
+ * Why a completed scan cannot rule a key unused with confidence.
2982
+ *
2983
+ * - `dynamic-keys`: a key argument has no static part (`t(key)`, a template literal with no static
2984
+ * head, a concatenation), so any catalog key could be the one it reaches.
2985
+ * - `dynamic-key-prefix`: a `keyPrefix` option or a `getFixedT` prefix argument is not a static
2986
+ * string, so every key called under it is unknown.
2987
+ * - `trans-without-key`: a `Trans` element carries no static `i18nKey`.
2988
+ * - `aliased-translate-function`: the translate function is assigned somewhere its calls cannot be
2989
+ * followed, such as an object property.
2990
+ * - `translate-function-escapes`: a translate function bound from a recognised source appears
2991
+ * somewhere other than a direct call, its own binding, a local alias, or the `t` attribute of a
2992
+ * `Trans` or `Translation` element, so code the scan cannot follow may call it with any key.
2993
+ * - `unrecognised-translate-source`: `useTranslation`, `withTranslation`, `getFixedT`, a
2994
+ * `Translation` render prop, a member `t`, or a `t` import is used in a shape the scan does not
2995
+ * recognise, so the prefix and keys of the function it yields are unknown.
2996
+ * - `template-files-not-scanned`: the roots hold template files (`.vue`, `.svelte`, `.html`, and
2997
+ * similar) the scan does not read.
2998
+ * - `incomplete-scan`: a file or directory could not be read to its end.
2999
+ */
3000
+ type UnusedKeysUnreliableReason = "dynamic-keys" | "dynamic-key-prefix" | "trans-without-key" | "aliased-translate-function" | "translate-function-escapes" | "unrecognised-translate-source" | "template-files-not-scanned" | "incomplete-scan";
3001
+ /** One place that makes an unused-key report unreliable. */
3002
+ interface UnusedKeysSite {
3003
+ /** The file or directory, relative to the run's working directory, with forward slashes. */
3004
+ readonly file: string;
3005
+ /** The one-based line, when the reason points at a line. */
3006
+ readonly line?: number;
3007
+ /** For `incomplete-scan`, why the file was skipped (for example `too-large`). */
3008
+ readonly detail?: string;
3009
+ }
3010
+ /** One reason an unused-key report is unreliable, with the places that caused it. */
3011
+ interface UnusedKeysUnreliability {
3012
+ /** The stable reason code. */
3013
+ readonly reason: UnusedKeysUnreliableReason;
3014
+ /** How many places caused it. For template files this can exceed the listed `sites`. */
3015
+ readonly count: number;
3016
+ /**
3017
+ * The places, deduplicated on file and line. Every place is listed, except template files, which
3018
+ * are capped at the first 20.
3019
+ */
3020
+ readonly sites: readonly UnusedKeysSite[];
3021
+ }
3022
+ /**
3023
+ * A source-catalog key in an unused-key report. `key` is the key as a person and the source spell
3024
+ * it, with the format's internal encoding undone (`Welcome. Enjoy` rather than `Welcome\. Enjoy`,
3025
+ * a gettext `msgctxt` shown as `context|msgid`); `extract.unused.ignore` patterns match it.
3026
+ * `catalogKey` is the key exactly as the catalog adapter and the per-locale `orphaned` lists spell
3027
+ * it, for a tool that needs to address the entry.
3028
+ */
3029
+ interface UnusedKey {
3030
+ /** The decoded key, as shown to people and matched by `extract.unused.ignore`. */
3031
+ readonly key: string;
3032
+ /** The key as the catalog adapter encodes it. */
3033
+ readonly catalogKey: string;
3034
+ }
3035
+ /** A source-catalog key no static reference names, but a template-literal key could still reach. */
3036
+ interface PossiblyDynamicKey extends UnusedKey {
3037
+ /** The static head of the template-literal key that could reach it. */
3038
+ readonly prefix: string;
3039
+ }
3040
+ /** A template-literal key argument with a static head, located in the source. */
3041
+ interface UnusedKeysPrefixSite extends SourceLocation {
3042
+ /** The static head every key the site can reach starts with. */
3043
+ readonly prefix: string;
3044
+ }
3045
+ /** An unused-key report produced from a source scan. */
3046
+ interface UnusedKeysScan {
3047
+ /**
3048
+ * `complete` when nothing in the scan casts doubt on the verdict; `unreliable` when
3049
+ * {@link UnusedKeysScan.unreliableBecause} is not empty, so a listed key may still be in use.
3050
+ */
3051
+ readonly status: "complete" | "unreliable";
3052
+ /** Every reason the verdict cannot be trusted. Empty exactly when `status` is `complete`. */
3053
+ readonly unreliableBecause: readonly UnusedKeysUnreliability[];
3054
+ /** How many source files were read and scanned. */
3055
+ readonly scannedFiles: number;
3056
+ /** Source-catalog keys no scanned reference names, in catalog order. */
3057
+ readonly unused: readonly UnusedKey[];
3058
+ /**
3059
+ * Unreferenced source-catalog keys a template-literal key with a static head could reach, in
3060
+ * catalog order. They are listed apart from `unused` and never fail a run.
3061
+ */
3062
+ readonly possiblyDynamic: readonly PossiblyDynamicKey[];
3063
+ /** Unreferenced source-catalog keys matched by `extract.unused.ignore`, in catalog order. */
3064
+ readonly ignored: readonly UnusedKey[];
3065
+ /** The template-literal key sites behind `possiblyDynamic`. */
3066
+ readonly dynamicPrefixes: readonly UnusedKeysPrefixSite[];
3067
+ }
3068
+ /**
3069
+ * The source-locale catalog keys that nothing in the scanned source references. Distinct from the
3070
+ * per-locale `orphaned` lists, which compare a target locale against the source catalog.
3071
+ */
3072
+ type UnusedKeysReport = UnusedKeysNotRun | UnusedKeysScan;
3073
+ /** An unused-key report that could not run. It carries no key list, so nothing is misreported. */
3074
+ interface UnusedKeysNotRun {
3075
+ /** Always `not-run`. */
3076
+ readonly status: "not-run";
3077
+ /** The stable reason code. Branch on this, not on the message. */
3078
+ readonly reason: UnusedKeysNotRunReason;
3079
+ /** A human-readable explanation. */
3080
+ readonly message: string;
3081
+ }
3082
+
1369
3083
  /** One locale's pending work in a {@link DiffSummary}, as key names rather than counts. */
1370
3084
  interface LocaleDiff {
1371
3085
  /** The target locale this entry describes. */
@@ -1391,22 +3105,45 @@ interface DiffSummary {
1391
3105
  readonly hasPendingChanges: boolean;
1392
3106
  /** Per-locale key lists, in configured target order. */
1393
3107
  readonly locales: readonly LocaleDiff[];
3108
+ /**
3109
+ * Source-catalog keys nothing in the scanned application source references. Present only when
3110
+ * {@link DiffInput.unused} was set. It is a separate axis from each locale's `orphaned` list and
3111
+ * never flips {@link DiffSummary.hasPendingChanges}.
3112
+ */
3113
+ readonly unused?: UnusedKeysReport;
1394
3114
  }
1395
3115
  /** Input for {@link diff}. */
1396
3116
  interface DiffInput {
1397
3117
  /** The resolved project config, normally from {@link loadConfig}. */
1398
3118
  readonly config: VerbatraConfig;
1399
- /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
3119
+ /**
3120
+ * Directory the `files.pattern` is resolved against. Defaults to the process working directory.
3121
+ */
1400
3122
  readonly cwd?: string;
1401
3123
  /** Restrict the report to these target locales. Defaults to every configured target locale. */
1402
3124
  readonly locales?: readonly string[];
3125
+ /**
3126
+ * Also scan the application source configured in the `extract` block and report the
3127
+ * source-catalog keys nothing references, as {@link DiffSummary.unused}. Off by default.
3128
+ */
3129
+ readonly unused?: boolean;
1403
3130
  }
1404
3131
  /** Injectable dependencies for {@link diff}. Every field has a working default. */
1405
3132
  interface DiffDeps {
1406
- /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
3133
+ /**
3134
+ * Format-adapter registry to resolve the configured format. Defaults to the built-in registry.
3135
+ */
1407
3136
  readonly adapterRegistry?: AdapterRegistry;
1408
- /** File-system port. Defaults to the real file system. */
3137
+ /**
3138
+ * File-system port. Defaults to the real file system. The unused-key scan needs its
3139
+ * `readDirectory` member and reports its absence as `EXTRACT_FS_UNSUPPORTED`.
3140
+ */
1409
3141
  readonly fs?: SdkFs;
3142
+ /**
3143
+ * Extractor factory for the unused-key scan. Defaults to the built-in table keyed by the
3144
+ * configured framework.
3145
+ */
3146
+ readonly createExtractor?: (framework: SourceFramework) => SourceExtractor;
1410
3147
  }
1411
3148
  /**
1412
3149
  * Reports the per-locale drift between the source and each target locale as key names, without
@@ -1417,19 +3154,34 @@ interface DiffDeps {
1417
3154
  * since the key was last translated rather than merely that the two strings differ. Orphaned keys
1418
3155
  * are reported but never removed here; pruning happens only in {@link translate}.
1419
3156
  *
3157
+ * With `unused` set, it also scans the application source named by the config's `extract` block
3158
+ * and reports, in {@link DiffSummary.unused}, the source-catalog keys no static reference names.
3159
+ * The scan models the i18next runtime: `t` calls and their aliases, `keyPrefix` and `getFixedT`
3160
+ * prefixes, `Trans` keys, namespace-qualified keys, and the plural and context variants of a
3161
+ * referenced key. It is read-only like the rest of `diff`: it never removes, rewrites, or reorders
3162
+ * a catalog, and it constructs no provider and reads no API key. Keys matched by
3163
+ * `extract.unused.ignore` are listed as `ignored`, and keys a template-literal key with a static
3164
+ * head could reach as `possiblyDynamic`, never as `unused`. The report is `complete` only when the
3165
+ * scan can bound every key the source reaches; otherwise it is `unreliable` and names each reason
3166
+ * with its sites, or `not-run` with a reason code and no key list at all when there is nothing
3167
+ * trustworthy to judge by (no `extract` block, a file system with no `readDirectory`, a format
3168
+ * whose runtime is not modeled, no source file, or no reference anywhere in the scanned files).
3169
+ *
1420
3170
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1421
3171
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1422
3172
  * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1423
3173
  * error from a target file.
1424
3174
  *
1425
- * @param input - The config and the optional locale filter.
1426
- * @param deps - Optional adapter registry and file-system overrides.
1427
- * @returns Per-locale missing, changed, and orphaned key lists.
3175
+ * @param input - The config, the optional locale filter, and whether to report unused keys.
3176
+ * @param deps - Optional adapter registry, file-system, and extractor-factory overrides.
3177
+ * @returns Per-locale missing, changed, and orphaned key lists, plus the unused-key report when
3178
+ * asked for.
1428
3179
  *
1429
3180
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1430
3181
  * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1431
3182
  * cannot be combined, or a configured locale has no valid path spelling under that style.
1432
- * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
3183
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
3184
+ * path.
1433
3185
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1434
3186
  * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1435
3187
  * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
@@ -1447,8 +3199,11 @@ declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
1447
3199
  * - `api-key`: the environment variable the configured provider reads its key from is set.
1448
3200
  * - `source-file`: the source locale file exists at its resolved path, is a regular file, and
1449
3201
  * parses under the configured format.
3202
+ * - `untranslated-literals`: the application source configured in the `extract` block holds no
3203
+ * hardcoded user-facing string literal and no file the scan could not read. It runs only when
3204
+ * {@link DoctorInput.literals} is set.
1450
3205
  */
1451
- type DoctorCheckId = "config" | "format-adapter" | "provider" | "api-key" | "source-file";
3206
+ type DoctorCheckId = "config" | "format-adapter" | "provider" | "api-key" | "source-file" | "untranslated-literals";
1452
3207
  /**
1453
3208
  * The verdict on one {@link DoctorCheck}. `skipped` is reported only for the checks that need a
1454
3209
  * loaded config when the `config` check itself failed, so a skipped check is never a problem of its
@@ -1474,8 +3229,19 @@ interface DoctorCheck {
1474
3229
  interface DoctorResult {
1475
3230
  /** True only when no check failed. This is the value a script should branch on. */
1476
3231
  readonly ok: boolean;
1477
- /** Every check, always in the same order, one entry per {@link DoctorCheckId}. */
3232
+ /**
3233
+ * Every check that ran, always in the same order. A setup run has one entry per setup check:
3234
+ * `config`, `format-adapter`, `provider`, `api-key`, and `source-file`. A literal run
3235
+ * ({@link DoctorInput.literals}) has exactly two: `config` and `untranslated-literals`.
3236
+ */
1478
3237
  readonly checks: readonly DoctorCheck[];
3238
+ /**
3239
+ * What the untranslated-literal scan found. Present only on a literal run whose scan actually
3240
+ * ran; absent when the config could not be loaded, no `extract` block is configured, the file
3241
+ * system cannot list directories, or the scan failed to start, each of which fails the
3242
+ * `untranslated-literals` check instead.
3243
+ */
3244
+ readonly literals?: LiteralScan;
1479
3245
  }
1480
3246
  /** Input for {@link doctor}. */
1481
3247
  interface DoctorInput {
@@ -1483,6 +3249,13 @@ interface DoctorInput {
1483
3249
  readonly cwd?: string;
1484
3250
  /** An explicit config file to validate, bypassing the search. A missing file is an error rather than a failed check. */
1485
3251
  readonly configPath?: string;
3252
+ /**
3253
+ * Run the untranslated-literal scan instead of the setup checks: the config is loaded, then the
3254
+ * source roots of its `extract` block are scanned for hardcoded user-facing string literals. The
3255
+ * provider, API-key, format, and source-file checks do not run, so no API key environment
3256
+ * variable is looked at and a run with no key set can pass.
3257
+ */
3258
+ readonly literals?: boolean;
1486
3259
  }
1487
3260
  /** Injectable dependencies for {@link doctor}. Every field has a working default. */
1488
3261
  interface DoctorDeps {
@@ -1490,8 +3263,8 @@ interface DoctorDeps {
1490
3263
  readonly adapterRegistry?: AdapterRegistry;
1491
3264
  /**
1492
3265
  * File-system port. Threaded into the config loader, so it backs the glossary-file read the
1493
- * `config` check performs, and used to read and parse the source locale file. Defaults to the
1494
- * real file system.
3266
+ * `config` check performs, and used to read and parse the source locale file. On a literal run it
3267
+ * backs the source scan, which needs its `readDirectory` member. Defaults to the real file system.
1495
3268
  */
1496
3269
  readonly fs?: SdkFs;
1497
3270
  /** Config loader. Defaults to {@link loadConfigWithMeta}. */
@@ -1527,7 +3300,18 @@ interface DoctorDeps {
1527
3300
  * verdict they could not reach, and {@link DoctorResult.ok} is false because the config check
1528
3301
  * itself failed.
1529
3302
  *
1530
- * @param input - The working directory and an optional explicit config path.
3303
+ * With `literals: true` it runs the untranslated-literal scan instead of the setup checks. The
3304
+ * source roots of the config's `extract` block are read, never written, and every string literal
3305
+ * or piece of JSX text that reads as user-facing text without going through a recognised
3306
+ * translation call is returned in {@link DoctorResult.literals} with its file, line, column, and
3307
+ * an excerpt of at most 80 characters. Literals held back by a `verbatra-ignore-next-line` or
3308
+ * `verbatra-ignore-line` comment, or by `extract.literals.ignore`, are returned under `suppressed`
3309
+ * rather than dropped. The check fails when anything was found or when a file could not be
3310
+ * scanned, so a partial scan is never reported as clean. No provider is constructed and no API key
3311
+ * environment variable is read.
3312
+ *
3313
+ * @param input - The working directory, an optional explicit config path, and whether to run the
3314
+ * untranslated-literal scan instead of the setup checks.
1531
3315
  * @param deps - Optional adapter registry, file-system, and config-loader overrides.
1532
3316
  * @returns Every check with its verdict, and the project-wide `ok` verdict.
1533
3317
  *
@@ -1548,9 +3332,11 @@ interface DoctorDeps {
1548
3332
  declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorResult>;
1549
3333
 
1550
3334
  /**
1551
- * Why a candidate translation was refused before it could be written. The same gate guards
1552
- * {@link editEntry} and {@link retranslateEntry}, so a hand-typed value and a provider-produced one
1553
- * are held to identical standards.
3335
+ * Every reason a candidate translation can be refused before it is written. The same gate guards
3336
+ * every write path: a provider translation, a cache or duplicate-content reuse, a generated plural
3337
+ * form, a pseudolocalized value, a workbook or TMX import, {@link editEntry}, and
3338
+ * {@link retranslateEntry}, so a hand-typed value and a provider-produced one are held to identical
3339
+ * standards.
1554
3340
  *
1555
3341
  * A rejected value is never written to the locale file and never recorded in the lock-file, so the
1556
3342
  * previous translation stays intact.
@@ -1559,6 +3345,48 @@ declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorR
1559
3345
  * interpolation would break at runtime. For the double-brace formats (i18next, ngx-translate, and
1560
3346
  * YAML) this also covers a single-brace `{name}`-shaped token the candidate invented and the
1561
3347
  * source never had, which is a fabrication whichever interpolation delimiters the project uses.
3348
+ * - `markup`: the candidate does not carry the same inline HTML or XML tags as the source, or it
3349
+ * carries them unbalanced, mis-nested, or newly nested inside another tag of the same name, any
3350
+ * of which breaks the rendering of the string the way a dropped placeholder breaks its
3351
+ * interpolation, or it would make an HTML parser create markup the source does not have. Tags
3352
+ * are read the way an HTML parser reads them (quoted attribute values may contain `>`, and the
3353
+ * content of a raw text element such as `script`, `style`, `title` or `textarea` is text up to
3354
+ * its closing tag) and compared as a multiset of names plus attribute names, so a different word
3355
+ * order and a translated attribute value are both accepted, and the two spellings of a void
3356
+ * element (`<br>` and `<br/>`) are one tag. Tag names are compared exactly, while the HTML void
3357
+ * elements are recognised as needing no closing tag in any case spelling. An unclosed bracketed
3358
+ * word that is only a name of letters, digits, hyphens, and underscores, such as `<Enter>`, is
3359
+ * set aside on both sides and counted like a tag once the source carries tags; where the source
3360
+ * has no tags, the candidate is refused for a closing tag with no opening tag, an unclosed tag
3361
+ * that carries anything beyond its name, any closed pair, void, or self-closing tag, and a
3362
+ * bracketed word the source does not carry that names a standard HTML element, `image`, or a
3363
+ * custom element (any name with a hyphen). Comments, CDATA sections, declarations, processing
3364
+ * instructions, and a `</` not followed by a letter end where an HTML parser ends them and are
3365
+ * compared as a multiset of their text, so one the candidate adds, drops, or rewrites is
3366
+ * refused. A source whose own tags are not well formed is still compared as a multiset, and no
3367
+ * source is too large to compare; a candidate is refused once it carries more than twice its
3368
+ * source's tags and constructs, or more than 256 for a smaller source. Also refused: a tag the
3369
+ * candidate never finishes when the source finishes all of its own; a `javascript:`,
3370
+ * `vbscript:`, or `data:` scheme in a URL attribute value, read after decoding character
3371
+ * references, unless the source carries that exact value on the same tag and attribute; a URL
3372
+ * in such a value whose scheme and authority no source value of the same tag and attribute has,
3373
+ * or a relative URL made absolute; a changed value of `meta` `http-equiv` or `content`, `script`
3374
+ * `type`, `nomodule`, `integrity` or `crossorigin`, `link` `rel`, `as`, `integrity`,
3375
+ * `crossorigin` or `type`, `base` `target`, SVG animation `attributeName` or `attributeType`,
3376
+ * `iframe` `sandbox`, `allow` or `allowfullscreen`, `form` `method`, `enctype` or `target`, or
3377
+ * any `style`, `srcdoc` or event handler; any change to the content of a `script`, `style`,
3378
+ * `iframe`, `noembed`, `noframes`, `noscript`, `xmp`, or `plaintext` element (`title` and
3379
+ * `textarea` content may be translated); and, unless the candidate is the source unchanged,
3380
+ * raw text an HTML parser can read two ways (a `script` whose content opens `<!--`, a `noscript`
3381
+ * whose content holds a `<`, a raw text element whose content holds a `<` after an `svg`,
3382
+ * `math`, or `select` tag) or a CDATA section or `image` tag in a value that opens `svg`, `math`,
3383
+ * or `select`. The check stands down per tag: a tag the format already reports as a placeholder
3384
+ * (an XLIFF inline element, a next-intl or ARB ICU rich-text tag) is left to the `placeholder`
3385
+ * reason together with as many closing tags as it has openings, while one the candidate leaves
3386
+ * unclosed, one that no longer nests with the other tags as in the source, and one the source
3387
+ * carries only as text are still refused, and any other tag in the same value is still
3388
+ * compared, including a second spelling of the same name. The refusal's `details` names the
3389
+ * offending tags.
1562
3390
  * - `icu`: the candidate is not a valid ICU message under the configured format's adapter.
1563
3391
  * - `degenerate`: the candidate collapsed into runaway output rather than a translation. Two shapes
1564
3392
  * are detected: the candidate is at least twelve times the length of a source of meaningful
@@ -1566,8 +3394,25 @@ declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorR
1566
3394
  * echo of the source is not degenerate by this rule; it surfaces as the `EQUALS_SOURCE` review
1567
3395
  * reason instead, which flags rather than refuses.
1568
3396
  * - `empty`: the source has text but the candidate is blank, which would silently erase a string.
3397
+ *
3398
+ * This tuple is the single source of truth for the set. {@link IntegrityGateReason} is derived from
3399
+ * it, so build any runtime validator or exhaustive lookup from this value rather than retyping the
3400
+ * members; a hand-copied list silently falls behind the next addition.
3401
+ *
3402
+ * @example
3403
+ * ```ts
3404
+ * import { INTEGRITY_GATE_REASONS } from "@verbatra/sdk";
3405
+ * import { z } from "zod";
3406
+ *
3407
+ * const reasonSchema = z.enum(INTEGRITY_GATE_REASONS);
3408
+ * ```
1569
3409
  */
1570
- type IntegrityGateReason = "placeholder" | "icu" | "degenerate" | "empty";
3410
+ declare const INTEGRITY_GATE_REASONS: readonly ["placeholder", "markup", "icu", "degenerate", "empty"];
3411
+ /**
3412
+ * One of {@link INTEGRITY_GATE_REASONS}. The union is derived from that tuple rather than written
3413
+ * out again, so a reason can only be added in one place.
3414
+ */
3415
+ type IntegrityGateReason = (typeof INTEGRITY_GATE_REASONS)[number];
1571
3416
 
1572
3417
  /** Input for {@link editEntry}. */
1573
3418
  interface EditEntryInput {
@@ -1604,6 +3449,14 @@ type EditEntryResult = {
1604
3449
  readonly accepted: false;
1605
3450
  /** Which integrity rule the value broke. */
1606
3451
  readonly reason: IntegrityGateReason;
3452
+ /**
3453
+ * The specific tags behind a `markup` refusal, each prefixed with `-` for one the source had
3454
+ * and the candidate dropped or `+` for one the candidate invented. A candidate carrying more
3455
+ * than twice its source's inline tags and constructs is named as `+more than N inline tags`
3456
+ * instead, where N is at least 256. Absent when no single tag is at fault, such as markup
3457
+ * that came back mis-nested, and absent for every other reason.
3458
+ */
3459
+ readonly details?: readonly string[];
1607
3460
  /** The rejected value, echoed back so a UI can show what was refused. */
1608
3461
  readonly value: string;
1609
3462
  };
@@ -1612,8 +3465,8 @@ type EditEntryResult = {
1612
3465
  * a human corrects one string and it is persisted without re-running the whole project.
1613
3466
  *
1614
3467
  * The edit is held to the same integrity gate as a provider translation, so a value that drops a
1615
- * placeholder, breaks ICU syntax, runs away into a hugely oversized or repetition-dominated value,
1616
- * or blanks a non-empty string is refused. A refusal comes back as `accepted: false` rather than as
3468
+ * placeholder, breaks the source's inline markup, breaks ICU syntax, runs away into a hugely
3469
+ * oversized or repetition-dominated value, or blanks a non-empty string is refused. A refusal comes back as `accepted: false` rather than as
1617
3470
  * a thrown error, and nothing is written.
1618
3471
  *
1619
3472
  * The locale's write lock is taken for the whole read-modify-write, so an edit cannot interleave
@@ -1621,7 +3474,8 @@ type EditEntryResult = {
1621
3474
  * refused edit takes and releases the lock as well and can therefore fail with `LOCK_CONTENDED`
1622
3475
  * even though it would have written nothing. An accepted edit then updates the lock-file baseline
1623
3476
  * and feeds the translation memory, which means a later run treats the key as up to date and
1624
- * reuses the edited text rather than paying the provider to translate it again.
3477
+ * reuses the edited text rather than paying the provider to translate it again. No review reason
3478
+ * is computed for a hand-edited value, so a configured `maxLength` budget is not checked here.
1625
3479
  *
1626
3480
  * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
1627
3481
  * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
@@ -1655,9 +3509,259 @@ type EditEntryResult = {
1655
3509
  * is malformed or on the write because the entries cannot be represented in the configured format.
1656
3510
  * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
1657
3511
  */
1658
- declare function editEntry(input: EditEntryInput, deps?: EditEntryDeps): Promise<EditEntryResult>;
3512
+ declare function editEntry(input: EditEntryInput, deps?: EditEntryDeps): Promise<EditEntryResult>;
3513
+
3514
+ /** One key the run added to the source catalog, and the call site it was found at. */
3515
+ interface AddedKey {
3516
+ /** The key as written at the call site. */
3517
+ readonly key: string;
3518
+ /** The value written into the catalog: the call site's default, or the empty string when it had none. */
3519
+ readonly value: string;
3520
+ /** The source file of the key's first call site, relative to the run's working directory. */
3521
+ readonly file: string;
3522
+ /** The one-based line of the key's first call site. */
3523
+ readonly line: number;
3524
+ }
3525
+ /**
3526
+ * The result of {@link extract}. Everything it reports is a key, a value, or a file and line: a
3527
+ * source file's contents never travel in it.
3528
+ */
3529
+ interface ExtractResult {
3530
+ /** The source locale file the run targeted, relative to the run's working directory. */
3531
+ readonly sourcePath: string;
3532
+ /** How many source files were read and scanned. */
3533
+ readonly scannedFiles: number;
3534
+ /** Keys that were not in the catalog and were added, in the order they were found. */
3535
+ readonly added: readonly AddedKey[];
3536
+ /** How many of the keys found were already in the catalog and were left untouched. */
3537
+ readonly existingKeys: number;
3538
+ /** Added keys whose call site carried no default, so they were written with an empty value. */
3539
+ readonly withoutDefault: readonly string[];
3540
+ /** Call sites whose key argument was not a static string, reported rather than guessed at. */
3541
+ readonly dynamic: readonly SourceLocation[];
3542
+ /** Keys found twice with two different default values. Neither value is written. */
3543
+ readonly conflicts: readonly KeyConflict[];
3544
+ /** Files and directories the scan could not read, which never abort the run. */
3545
+ readonly diagnostics: readonly ScanDiagnostic[];
3546
+ /** Whether the source catalog was actually written. False for a dry run and for a run that added nothing. */
3547
+ readonly written: boolean;
3548
+ /** Whether this was a dry run. */
3549
+ readonly dryRun: boolean;
3550
+ }
3551
+ /** Input for {@link extract}. */
3552
+ interface ExtractInput {
3553
+ /** A resolved config whose `extract` block names the framework and the source roots. */
3554
+ readonly config: VerbatraConfig;
3555
+ /** Directory the roots and locale paths are resolved against. Defaults to the process working directory. */
3556
+ readonly cwd?: string;
3557
+ /** Report what would be added without writing anything. */
3558
+ readonly dryRun?: boolean;
3559
+ }
3560
+ /** Injectable dependencies for {@link extract}. Every field has a working default. */
3561
+ interface ExtractDeps {
3562
+ /** Format-adapter registry to resolve the configured format against. Defaults to the built-in registry. */
3563
+ readonly adapterRegistry?: AdapterRegistry;
3564
+ /**
3565
+ * File-system port. It backs both the source scan and the catalog write, so its `readDirectory`
3566
+ * member must be implemented. Defaults to the real file system.
3567
+ */
3568
+ readonly fs?: SdkFs;
3569
+ /** Extractor factory. Defaults to the built-in table keyed by the configured framework. */
3570
+ readonly createExtractor?: (framework: SourceFramework) => SourceExtractor;
3571
+ }
3572
+ /**
3573
+ * Scans a project's own source for translation call sites and merges what it finds into the source
3574
+ * locale catalog. It is the one entry point that reads application code rather than a locale file,
3575
+ * which is what makes verbatra usable on a project that has no catalog yet.
3576
+ *
3577
+ * It spends nothing: no provider is constructed, no API key environment variable is read, and no
3578
+ * network request is made. A run with no key set succeeds.
3579
+ *
3580
+ * Only the source locale file is ever written, and only keys that are genuinely new are added: a
3581
+ * key already in the catalog keeps its value exactly as it stands, so an editorial fix is never
3582
+ * reverted by a stale default left at a call site. A key in the catalog that no call site mentions
3583
+ * is left alone. A run that finds nothing new writes nothing at all, so it leaves the file
3584
+ * byte-identical and its modification time untouched.
3585
+ *
3586
+ * Everything the scan cannot resolve is reported as data rather than thrown. A call site whose key
3587
+ * argument is not a static string is reported in `dynamic` and never written with a guessed key; a
3588
+ * key found twice with two different defaults is reported in `conflicts` and neither value is
3589
+ * written; a file or directory that cannot be read is reported in `diagnostics` and the scan
3590
+ * carries on. The result never carries a source file's contents, only keys, values, and locations.
3591
+ *
3592
+ * @param input - The config (whose `extract` block names the framework and roots), the working
3593
+ * directory, and whether this is a dry run.
3594
+ * @param deps - Optional adapter registry, file-system, and extractor-factory overrides.
3595
+ * @returns What was added, what was already there, and everything the scan could not resolve.
3596
+ *
3597
+ * @throws {@link SdkError} `EXTRACT_NOT_CONFIGURED`: the config carries no `extract` block.
3598
+ * @throws {@link SdkError} `EXTRACT_FS_UNSUPPORTED`: the supplied `deps.fs` implements no
3599
+ * `readDirectory`, so no source file can be discovered.
3600
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
3601
+ * @throws {@link SdkError} `SOURCE_INVALID`: a source catalog exists but could not be parsed.
3602
+ * @throws {@link SdkError} `SOURCE_UNWRITABLE`: the source catalog could not be written. The
3603
+ * `xliff` and `apple-xcstrings` formats reach this when no catalog exists yet: neither is created
3604
+ * from nothing, here or anywhere else in verbatra.
3605
+ *
3606
+ * @example
3607
+ * ```ts
3608
+ * import { extract, loadConfig } from "@verbatra/sdk";
3609
+ *
3610
+ * const config = await loadConfig();
3611
+ * const result = await extract({ config, dryRun: true });
3612
+ * console.log(`${result.added.length} new keys, ${result.dynamic.length} dynamic call sites`);
3613
+ * ```
3614
+ */
3615
+ declare function extract(input: ExtractInput, deps?: ExtractDeps): Promise<ExtractResult>;
3616
+
3617
+ /**
3618
+ * Why verbatra declined to describe a message's arguments rather than guessing at them.
3619
+ *
3620
+ * - `invalid-message-syntax`: the message does not parse as ICU under a format that reads its
3621
+ * arguments that way, or the adapter reports it as invalid.
3622
+ * - `mixed-argument-styles`: the message combines named arguments with numbered or unnumbered
3623
+ * ones, or numbered with unnumbered ones, so no single call signature fits it.
3624
+ * - `argument-index-out-of-range`: a numbered argument addresses a zero-based position below 0 or
3625
+ * of 64 or more (such as `%0$s` or `{64}`).
3626
+ */
3627
+ type UnresolvedArgumentReason = "invalid-message-syntax" | "mixed-argument-styles" | "argument-index-out-of-range";
3628
+
3629
+ /**
3630
+ * Where {@link generateTypes} writes its declaration when the caller names no path: a `.d.ts` at
3631
+ * the root of the working directory. The file is a checked-in artifact, not a local scratch file:
3632
+ * it belongs in version control so a consumer type-checks against it without running verbatra
3633
+ * first, and so `check` mode has a committed file to compare against.
3634
+ */
3635
+ declare const DEFAULT_TYPES_PATH = "verbatra-types.d.ts";
3636
+ /** One key whose arguments verbatra declined to describe, and why. */
3637
+ interface UnresolvedMessage {
3638
+ /** The key, which is still declared, with arguments nothing is claimed about. */
3639
+ readonly key: string;
3640
+ /** Why the arguments could not be determined. */
3641
+ readonly reason: UnresolvedArgumentReason;
3642
+ }
3643
+ /** Input for {@link generateTypes}. */
3644
+ interface GenerateTypesInput {
3645
+ /** The resolved project config, normally from {@link loadConfig}. */
3646
+ readonly config: VerbatraConfig;
3647
+ /** Directory the `files.pattern` and the output path are resolved against. Defaults to the process working directory. */
3648
+ readonly cwd?: string;
3649
+ /**
3650
+ * Where to write the declaration, relative to `cwd`. Defaults to {@link DEFAULT_TYPES_PATH}.
3651
+ * Refused with `TYPES_OUTPUT_CONFLICT`, before anything is read or written, when it names no
3652
+ * file, is absolute, climbs out of `cwd`, does not end in `.ts`, `.mts` or `.cts`, or names a
3653
+ * configured locale file, the lock file, the translation-memory cache, a file verbatra searches
3654
+ * for its configuration, or the {@link GenerateTypesInput.configPath} file. Names are compared
3655
+ * case-insensitively. A generating run also refuses to replace an existing file there unless
3656
+ * that file begins with the header line verbatra writes, after any leading byte order mark and
3657
+ * blank lines, and refuses one too large to verify.
3658
+ */
3659
+ readonly out?: string;
3660
+ /**
3661
+ * The configuration file `config` was loaded from, absolute or relative to `cwd`. It is refused
3662
+ * as the output path even when its name is not one verbatra searches for.
3663
+ */
3664
+ readonly configPath?: string;
3665
+ /**
3666
+ * Compare instead of writing. The run reports whether the file on disk matches what a fresh
3667
+ * generation would produce and leaves every file untouched.
3668
+ */
3669
+ readonly check?: boolean;
3670
+ }
3671
+ /** Injectable dependencies for {@link generateTypes}. Every field has a working default. */
3672
+ interface GenerateTypesDeps {
3673
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
3674
+ readonly adapterRegistry?: AdapterRegistry;
3675
+ /** File-system port. Defaults to the real file system. */
3676
+ readonly fs?: SdkFs;
3677
+ }
3678
+ /** The result of {@link generateTypes}: what was declared, and whether the file on disk matched. */
3679
+ interface GenerateTypesResult {
3680
+ /** Absolute path of the declaration file. */
3681
+ readonly path: string;
3682
+ /** The source catalog the declaration was built from, relative to the working directory. */
3683
+ readonly sourcePath: string;
3684
+ /** How many keys the declaration carries. */
3685
+ readonly keys: number;
3686
+ /**
3687
+ * How many of those keys take at least one argument verbatra could determine. A key listed in
3688
+ * {@link GenerateTypesResult.unresolved} is not counted here.
3689
+ */
3690
+ readonly withArguments: number;
3691
+ /** Keys that are declared but whose arguments could not be determined, and why. */
3692
+ readonly unresolved: readonly UnresolvedMessage[];
3693
+ /** Keys the adapter reported as excluded from translation, which are never declared. */
3694
+ readonly excluded: readonly string[];
3695
+ /** Keys the adapter marked as carrying plural forms. Each sibling is declared on its own. */
3696
+ readonly plural: readonly string[];
3697
+ /**
3698
+ * Whether the declaration file was written. False in `check` mode, and false when the file on
3699
+ * disk already matched, so an unchanged catalog rewrites nothing.
3700
+ */
3701
+ readonly written: boolean;
3702
+ /** Whether the file on disk differed from the freshly generated declaration when the run started. */
3703
+ readonly stale: boolean;
3704
+ /** Whether this was a `check` run. */
3705
+ readonly check: boolean;
3706
+ }
3707
+ /**
3708
+ * Generates a TypeScript declaration for a project's source catalog: a union of every key it holds
3709
+ * and, per key, the arguments its message interpolates. A consumer that types its translation
3710
+ * function against it turns a misspelled key and a missing interpolation argument into compile
3711
+ * errors instead of runtime lookup failures.
3712
+ *
3713
+ * It reads one file (the source locale catalog) and writes one file (the declaration). It
3714
+ * constructs no provider, reads no API key and makes no network request, so it runs on a fresh
3715
+ * checkout before any key exists.
3716
+ *
3717
+ * The keys are exactly what the format adapter produced when reading the catalog, in document
3718
+ * order, so two runs over an unchanged catalog write byte-identical bytes. Arguments come from the
3719
+ * placeholder tokens the adapter extracted, except for the ICU message formats (`next-intl-json`
3720
+ * and `arb`): there each message is analysed with the same ICU parser the adapter uses, so an
3721
+ * argument that only some `select` or `plural` branches use is still declared, as optional. Keys
3722
+ * are emitted as quoted string literals, so a key carrying a dot, a reserved word, a leading digit,
3723
+ * a quote, or nothing at all is declared verbatim rather than dropped or re-split.
3724
+ *
3725
+ * What it will not claim is as important as what it will. A message whose placeholders name their
3726
+ * arguments gets an object shape; one whose placeholders are numbered or anonymous gets a readonly
3727
+ * tuple; one that takes nothing gets a shape that makes passing an argument a type error. A
3728
+ * message whose syntax the adapter reported as invalid, one that mixes named, numbered and
3729
+ * anonymous arguments, and one that numbers an argument past the 64th are declared with
3730
+ * {@link GenerateTypesResult.unresolved} recording why, rather than being silently declared as
3731
+ * taking nothing. Argument types come only from what the catalog actually records: `number` for a
3732
+ * numeric annotation, printf conversion or ICU `plural`, `string` for a text conversion or ICU
3733
+ * `select`, `Date | number` for a date or time argument, and a `string | number` alias everywhere
3734
+ * else, never a permissive `any`. A name used with several types is declared as the union of what
3735
+ * each use accepts.
3736
+ *
3737
+ * With `check` set, nothing is written: the run reports whether the committed file still matches
3738
+ * what a fresh generation would produce, which is the shape a CI gate wants.
3739
+ *
3740
+ * @param input - The config, the output path, and whether to check rather than write.
3741
+ * @param deps - Optional adapter registry and file-system overrides.
3742
+ * @returns What was declared, and whether the file on disk matched.
3743
+ *
3744
+ * @example
3745
+ * ```ts
3746
+ * const result = await generateTypes({ config });
3747
+ * console.log(`${result.keys} keys declared in ${result.path}`);
3748
+ * ```
3749
+ *
3750
+ * @throws {@link SdkError} `TYPES_OUTPUT_CONFLICT`: the output path is refused (see
3751
+ * {@link GenerateTypesInput.out} for the full set), or a generating run found a file there that
3752
+ * does not begin with the header verbatra writes.
3753
+ * @throws {@link SdkError} `TYPES_UNWRITABLE`: the declaration file could not be written.
3754
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
3755
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
3756
+ * cannot be combined.
3757
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
3758
+ * path.
3759
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
3760
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
3761
+ */
3762
+ declare function generateTypes(input: GenerateTypesInput, deps?: GenerateTypesDeps): Promise<GenerateTypesResult>;
1659
3763
 
1660
- /** One key's placeholder and ICU verdict in a {@link LocaleKeyIntegrity} report. */
3764
+ /** One key's placeholder, ICU, and inline-markup verdict in a {@link LocaleKeyIntegrity} report. */
1661
3765
  interface KeyIntegrityEntry {
1662
3766
  /** The key this verdict describes. */
1663
3767
  readonly key: string;
@@ -1671,6 +3775,20 @@ interface KeyIntegrityEntry {
1671
3775
  readonly extra: readonly string[];
1672
3776
  /** True when the translation parses as a valid ICU message under the configured format. */
1673
3777
  readonly icuValid: boolean;
3778
+ /**
3779
+ * True when the translation carries the source's inline HTML or XML tags, well formed. Compared
3780
+ * exactly as the write-time gate compares them, so a value already on disk is judged by the same
3781
+ * rule as one about to be written.
3782
+ */
3783
+ readonly markupMatches: boolean;
3784
+ /**
3785
+ * The tags behind a `markupMatches: false` verdict, each prefixed with `-` for one the source has
3786
+ * and the translation lacks or `+` for one the translation added, or `+more than N inline tags`
3787
+ * for a translation carrying more than twice its source's inline tags and constructs (N is at
3788
+ * least 256). Empty when the markup matches, and empty when no single tag is at fault because
3789
+ * the translation's markup is structurally broken.
3790
+ */
3791
+ readonly markupDetails: readonly string[];
1674
3792
  }
1675
3793
  /** One locale's per-key integrity verdicts. */
1676
3794
  interface LocaleKeyIntegrity {
@@ -1687,7 +3805,11 @@ interface KeyIntegrityInput {
1687
3805
  readonly cwd?: string;
1688
3806
  /** Restrict the report to these target locales. Defaults to every configured target locale. */
1689
3807
  readonly locales?: readonly string[];
1690
- /** Restrict the report to these keys. Defaults to every key the diff reports as changed. */
3808
+ /**
3809
+ * Restrict the report to these keys. Only keys the diff reports as changed are ever judged, so a
3810
+ * requested key that is missing, orphaned, or up to date is left out rather than reported.
3811
+ * Defaults to every changed key.
3812
+ */
1691
3813
  readonly keys?: readonly string[];
1692
3814
  }
1693
3815
  /** Injectable dependencies for {@link keyIntegrity}. Every field has a working default. */
@@ -1699,16 +3821,22 @@ interface KeyIntegrityDeps {
1699
3821
  }
1700
3822
  /**
1701
3823
  * Reports, per changed key, whether the existing translation still carries the source's
1702
- * placeholders and still parses as valid ICU. It writes nothing and calls no provider.
3824
+ * placeholders and inline markup and still parses as valid ICU. It writes nothing and calls no
3825
+ * provider.
1703
3826
  *
1704
3827
  * The scope is deliberately the changed keys rather than every key: a key whose source text has not
1705
3828
  * moved was already gated when it was written, so re-reporting it would bury the keys that a source
1706
3829
  * edit may have just invalidated. Only keys present in both the source and the target are judged,
1707
3830
  * since a missing translation has no placeholders to compare.
1708
3831
  *
1709
- * This is the read-only counterpart to the gate that {@link editEntry} and
1710
- * {@link retranslateEntry} enforce at write time, and the data behind a review dashboard's
1711
- * per-key integrity indicator.
3832
+ * This is the read-only counterpart to the placeholder, ICU and inline-markup checks of the
3833
+ * write-time integrity gate that {@link translate}, {@link editEntry}, {@link retranslateEntry},
3834
+ * and the workbook and TMX imports enforce, and the data behind a
3835
+ * review dashboard's per-key integrity indicator. Reporting markup here is what makes drift that
3836
+ * predates the gate visible at all: a translation written before the check existed, edited outside
3837
+ * verbatra, or produced by a path that never crossed the gate is judged here by the same rule.
3838
+ * Emptiness and degeneracy are deliberately not reported: both describe a candidate that was
3839
+ * refused rather than a state a stored translation can usefully be in.
1712
3840
  *
1713
3841
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1714
3842
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
@@ -2022,313 +4150,233 @@ interface LockStateDeps {
2022
4150
  */
2023
4151
  declare function lockState(input: LockStateInput, deps?: LockStateDeps): Promise<LockStateResult>;
2024
4152
 
2025
- /**
2026
- * Builds a {@link TranslationProvider} from the config's `provider` block. Passed as
2027
- * `deps.createProvider` to {@link translate}, {@link watch}, and {@link retranslateEntry}, it is the
2028
- * seam for injecting a stub in a test or a provider the SDK does not ship.
2029
- *
2030
- * The default implementation dispatches on the provider ID and reads the API key from the
2031
- * environment. A factory that throws is reported as `PROVIDER_CONSTRUCTION_FAILED`.
2032
- */
2033
- type CreateProvider = (config: ProviderConfig) => TranslationProvider;
2034
-
2035
- /** Input for {@link retranslateEntry}. */
2036
- interface RetranslateEntryInput {
4153
+ /** Input for {@link pseudolocalize}. */
4154
+ interface PseudolocalizeInput {
2037
4155
  /** The resolved project config, normally from {@link loadConfig}. */
2038
4156
  readonly config: VerbatraConfig;
2039
4157
  /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
2040
4158
  readonly cwd?: string;
2041
- /** The target locale to retranslate into. Must be a configured target locale. */
2042
- readonly locale: string;
2043
- /** The key to retranslate. Must exist in the source resource. */
2044
- readonly key: string;
4159
+ /**
4160
+ * The pseudolocale's BCP-47 code. Defaults to `en-XA`. It must not be the source locale or any
4161
+ * configured target locale, compared case-insensitively, so a pseudolocale can never stand in for
4162
+ * a real translation.
4163
+ */
4164
+ readonly locale?: string;
4165
+ /**
4166
+ * Directory the pseudolocale file is written under, relative to `cwd`. Defaults to
4167
+ * `.verbatra-local/pseudo`, which `verbatra init` already adds to `.gitignore`. The configured
4168
+ * `files.pattern` is expanded inside it, so the file keeps the layout the application expects.
4169
+ *
4170
+ * It must name a directory inside `cwd`: an absolute path, one that climbs out with `..`, and
4171
+ * `cwd` itself are all refused, as is a directory under which the expanded pattern would place
4172
+ * the pseudolocale file beside a configured locale file, so a generated pseudolocale never lands
4173
+ * outside the project or beside the real translations where nothing ignores it.
4174
+ */
4175
+ readonly out?: string;
2045
4176
  }
2046
- /** Injectable dependencies for {@link retranslateEntry}. Every field has a working default. */
2047
- interface RetranslateEntryDeps {
4177
+ /** Injectable dependencies for {@link pseudolocalize}. Every field has a working default. */
4178
+ interface PseudolocalizeDeps {
2048
4179
  /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
2049
4180
  readonly adapterRegistry?: AdapterRegistry;
2050
- /** Provider factory. Defaults to constructing the provider named in the config. */
2051
- readonly createProvider?: CreateProvider;
2052
4181
  /** File-system port. Defaults to the real file system. */
2053
4182
  readonly fs?: SdkFs;
2054
4183
  }
2055
- /**
2056
- * The outcome of {@link retranslateEntry}. As with {@link EditEntryResult}, a value refused by the
2057
- * integrity gate is reported as data rather than thrown, because it is an expected outcome of
2058
- * asking a provider for a fresh translation.
2059
- */
2060
- type RetranslateEntryResult = {
2061
- /** The provider's value passed the integrity gate and was written. */
2062
- readonly accepted: true;
2063
- /** The newly translated value now stored for the key. */
2064
- readonly value: string;
4184
+ /** The result of {@link pseudolocalize}: what was generated and where it landed. */
4185
+ interface PseudolocalizeResult {
4186
+ /** The pseudolocale that was generated. */
4187
+ readonly locale: string;
4188
+ /** Absolute path of the file that was written, or would have been written on a changed run. */
4189
+ readonly path: string;
4190
+ /** How many source entries were processed. */
4191
+ readonly entries: number;
4192
+ /** How many entries carry a pseudolocalized value. */
4193
+ readonly transformed: number;
2065
4194
  /**
2066
- * Quality signals the provider layer raised for this value, such as a length-ratio outlier or
2067
- * a value identical to the source. Empty when nothing was flagged. The value is written
2068
- * either way; these are advisory.
4195
+ * Keys whose source value was copied verbatim because a pseudolocalized value would not have
4196
+ * passed the same integrity gate a translation must pass. Never a failure: the entry is still
4197
+ * present and still renders.
2069
4198
  */
2070
- readonly reviewReasons: readonly ReviewReasonCode[];
2071
- } | {
2072
- /** The value was refused; nothing was written and the previous translation is intact. */
2073
- readonly accepted: false;
2074
- /** Which integrity rule the provider's value broke. */
2075
- readonly reason: IntegrityGateReason;
2076
- /** The rejected value, echoed back so a UI can show what was refused. */
2077
- readonly value: string;
2078
- };
4199
+ readonly copied: readonly string[];
4200
+ /** False when the file on disk already matched, so nothing was rewritten. */
4201
+ readonly written: boolean;
4202
+ }
2079
4203
  /**
2080
- * Re-runs the configured provider for a single key and saves the result. This is the paid
2081
- * counterpart to {@link editEntry}: it calls the provider and therefore spends tokens, which is why
2082
- * a UI should gate it behind an explicit user action.
2083
- *
2084
- * The returned value goes through the same integrity gate as a full run, so a translation that
2085
- * loses a placeholder or breaks ICU syntax is refused and nothing is written. Provider quality
2086
- * signals are surfaced on an accepted result as `reviewReasons` rather than blocking the write.
2087
- *
2088
- * The locale's write lock is taken before the provider is called and held across it, covering the
2089
- * whole read-translate-write cycle, so a slow provider keeps the lock for the length of its call
2090
- * and a concurrent {@link translate} run on that locale waits. The lock is held for a refused
2091
- * translation too, since the gate runs inside it. An accepted value then updates the lock-file
2092
- * baseline and feeds the translation memory, so a later {@link translate} run sees the key as up
2093
- * to date.
2094
- *
2095
- * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
2096
- * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
2097
- * A malformed target file fails the read with a message naming the offending locale and the
2098
- * resolved path. The write raises the adapter's error too, when the entries cannot be represented
2099
- * in the configured format or the existing destination file cannot be read back to be updated in
2100
- * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
2101
- * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
2102
- * error from a target file on either path.
4204
+ * Generates a pseudolocale from the source strings alone: every value is accented, expanded by
4205
+ * roughly a third of its translatable length, and wrapped in `[` and `]` boundary markers, so a
4206
+ * truncated or concatenated string is obvious on screen and an untranslated hardcoded string stands
4207
+ * out for having escaped the transform.
4208
+ *
4209
+ * It constructs no provider, reads no API key and makes no network request, so it runs on a fresh
4210
+ * checkout before any key exists or any budget is approved.
4211
+ *
4212
+ * Placeholders survive untouched in every syntax the shipped adapters recognise, and every
4213
+ * generated value is held to the same integrity gate a provider's translation must pass. A value
4214
+ * that would not pass it is copied from the source verbatim and reported in
4215
+ * {@link PseudolocalizeResult.copied} rather than written in a broken form.
4216
+ *
4217
+ * The output is deliberately kept out of the project's real locale files: it is written under
4218
+ * `.verbatra-local/pseudo` by default, which `verbatra init` already adds to `.gitignore`, and the
4219
+ * pseudolocale is refused when it names a configured locale, when its file would land in the same
4220
+ * directory as a configured locale file, and when it is directed at the project root or outside the
4221
+ * working directory. Because it lives outside `files.pattern`, {@link translate} never spends on it and
4222
+ * {@link check} and {@link diff} never report it as drifted.
4223
+ *
4224
+ * The transform is deterministic, so a second run over unchanged source rewrites nothing and
4225
+ * reports {@link PseudolocalizeResult.written} as false. For `xliff` and `apple-xcstrings`, whose
4226
+ * writers only patch units already present in the destination, the output is re-copied from the
4227
+ * source on every write, so a key added to the source after the first run still reaches the
4228
+ * pseudolocale; a copied XLIFF has its target-language attribute rewritten to the pseudolocale, so
4229
+ * the file never misdescribes what it holds.
4230
+ *
4231
+ * @param input - The config, the pseudolocale code, and the output directory.
4232
+ * @param deps - Optional adapter registry and file-system overrides.
4233
+ * @returns Where the pseudolocale landed, how many entries it carries, and whether it changed.
2103
4234
  *
2104
- * @param input - The config, locale, and key to retranslate.
2105
- * @param deps - Optional adapter registry, provider factory, and file-system overrides.
2106
- * @returns Whether the new value was accepted, with review reasons or the rejection reason.
4235
+ * @example
4236
+ * ```ts
4237
+ * const result = await pseudolocalize({ config });
4238
+ * console.log(`${result.transformed} of ${result.entries} entries in ${result.path}`);
4239
+ * ```
2107
4240
  *
4241
+ * @throws {@link SdkError} `PSEUDO_OUTPUT_CONFLICT`: the pseudolocale names a configured locale, its
4242
+ * file would land in the same directory as a configured locale file, or the output directory is not
4243
+ * a relative path naming a directory inside `cwd`.
2108
4244
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
2109
- * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
2110
4245
  * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
2111
- * cannot be combined, or the locale has no valid path spelling under that style.
4246
+ * cannot be combined, or the pseudolocale has no valid path spelling under that style.
2112
4247
  * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
2113
4248
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
2114
- * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
2115
- * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
2116
- * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
2117
- * most often because its API key environment variable is unset.
2118
- * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
2119
- * the timeout elapsed.
2120
- * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
2121
- * of a file-system failure. The message names the target file and the file-system code, never the
2122
- * internal temporary file.
2123
- * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
2124
- * unsupported version.
2125
- * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
2126
- * is malformed or on the write because the entries cannot be represented in the configured format.
2127
- * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
2128
- * @throws `ProviderError` `INVALID_RESPONSE`: the provider returned no value for the key. Provider
2129
- * transport and rate-limit failures propagate as `ProviderError` too, since a single-key call has
2130
- * no per-locale summary to record them on.
4249
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed, or, for a
4250
+ * format whose writer only patches an existing document (`xliff` and `apple-xcstrings`), the
4251
+ * source file could not be copied to seed the output.
4252
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the pseudolocale file could not be written.
2131
4253
  */
2132
- declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
4254
+ declare function pseudolocalize(input: PseudolocalizeInput, deps?: PseudolocalizeDeps): Promise<PseudolocalizeResult>;
2133
4255
 
2134
4256
  /**
2135
- * Conditions the SDK itself reports on a locale, as opposed to the {@link ProviderNotice} codes a
2136
- * provider raises. Each marks a run that completed but did something a caller may want to know
2137
- * about.
2138
- *
2139
- * - `PLURAL_CATEGORIES_INCOMPLETE`: plural generation could not produce every category the target
2140
- * language requires, so the entry is written with the categories that were produced.
2141
- * - `SUB_BATCH_FAILED`: one sub-batch of a locale failed while others succeeded. The locale is
2142
- * reported as `partial` rather than failed.
2143
- * - `BLANK_ROW_BASELINE_RETAINED`: an imported handoff row was blank, so the existing translation
2144
- * and its lock-file baseline were kept rather than being erased.
2145
- * - `BUDGET_TOKENS_EXCEEDED`: the configured token budget was passed. Under `warn` the run
2146
- * continues; under `stop` the remaining keys are withheld.
2147
- * - `CACHE_VERSION_UNRECOGNIZED`: the translation memory is at a version this release does not
2148
- * understand, so it was ignored rather than trusted.
2149
- */
2150
- type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
2151
- /**
2152
- * Token usage as reported by the provider. Absent when the provider does not report usage, which is
2153
- * the case for machine-translation APIs such as DeepL that do not bill in tokens.
2154
- */
2155
- interface UsageSummary {
2156
- /** Tokens consumed by the prompts sent to the provider. */
2157
- readonly inputTokens: number;
2158
- /** Tokens consumed by the provider's responses. */
2159
- readonly outputTokens: number;
2160
- }
2161
- /**
2162
- * What a run does when it passes its token budget: `warn` finishes the work and reports the
2163
- * overrun, `stop` withholds the remaining keys.
2164
- */
2165
- type BudgetBehavior = "warn" | "stop";
2166
- /** The token budget in force for a run, and how much of it was actually consumed. */
2167
- interface RunBudget {
2168
- /** The configured ceiling, in tokens. */
2169
- readonly maxTokens: number;
2170
- /** Whether passing the ceiling warns or stops the run. */
2171
- readonly behavior: BudgetBehavior;
2172
- /**
2173
- * Whether the configured provider reports usage at all. When false the budget cannot be enforced,
2174
- * because there is nothing to count.
2175
- */
2176
- readonly supported: boolean;
2177
- /** Tokens consumed across the whole run so far. */
2178
- readonly tokensUsed: number;
2179
- /** True once `tokensUsed` passed `maxTokens`. */
2180
- readonly exceeded: boolean;
2181
- }
2182
- /** A condition the SDK reported on a locale. See {@link SdkNoticeCode}. */
2183
- interface SdkNotice {
2184
- /** The stable notice code. Branch on this, not on the message. */
2185
- readonly code: SdkNoticeCode;
2186
- /** A human-readable description of the condition. */
2187
- readonly message: string;
2188
- }
2189
- /**
2190
- * Any notice attached to a locale: either one the provider raised, such as a downgraded formality
2191
- * or an ignored glossary, or one the SDK raised. Discriminate on `code`.
2192
- */
2193
- type LocaleNotice = ProviderNotice | SdkNotice;
2194
- /**
2195
- * A translated key the provider layer flagged as worth a human look. The translation was still
2196
- * written; these are advisory quality signals, not rejections.
2197
- */
2198
- interface NeedsReviewEntry {
2199
- /** The key that was flagged. */
2200
- readonly key: string;
2201
- /** Why it was flagged. A key can carry more than one reason. */
2202
- readonly reasons: readonly ReviewReasonCode[];
2203
- }
2204
- /** A row of an imported handoff that could not be read. Reported rather than aborting the import. */
2205
- interface MalformedRowReport {
2206
- /** The row's 1-based index within its sheet or file. */
2207
- readonly row: number;
2208
- /**
2209
- * The 1-based physical line in the source file, for delimited formats where a quoted value can
2210
- * span several lines. Absent for `.xlsx`, which has rows but no lines.
2211
- */
2212
- readonly line?: number;
2213
- /** The column whose value was missing or unusable. */
2214
- readonly column: string;
2215
- }
2216
- /**
2217
- * A key that appeared more than once in an imported handoff. The first occurrence wins and the rest
2218
- * are reported here, so a translator who duplicated a row learns which value was actually used.
2219
- */
2220
- interface DuplicateKeyReport {
2221
- /** The key that appeared more than once. */
2222
- readonly key: string;
2223
- /** The 1-based row index of the ignored occurrence. */
2224
- readonly row: number;
2225
- /** The 1-based physical line of the ignored occurrence, for delimited formats. Absent for `.xlsx`. */
2226
- readonly line?: number;
2227
- }
2228
- /**
2229
- * Everything that happened for one locale during a run. The key lists are disjoint accounts of what
2230
- * became of each key, so a caller can reconstruct the whole run without re-reading any file.
4257
+ * Builds a {@link TranslationProvider} from the config's `provider` block. Passed as
4258
+ * `deps.createProvider` to {@link translate}, {@link watch}, and {@link retranslateEntry}, it is the
4259
+ * seam for injecting a stub in a test or a provider the SDK does not ship.
2231
4260
  *
2232
- * A locale that failed outright still appears here with `status: "failed"` and an `error`, rather
2233
- * than the whole run throwing. That is the central contract of {@link translate}: one unreachable
2234
- * provider or one unwritable file does not discard the locales that succeeded.
2235
- */
2236
- interface LocaleSummary {
2237
- /** The target locale this summary describes. */
2238
- readonly locale: string;
2239
- /**
2240
- * `succeeded` when everything asked for was done, `partial` when some keys were translated and
2241
- * others were not, and `failed` when the locale produced no usable result.
2242
- */
2243
- readonly status: "succeeded" | "partial" | "failed";
2244
- /** Keys newly translated by the provider in this run. */
2245
- readonly translated: readonly string[];
2246
- /** Keys already up to date against the lock-file baseline, so no provider call was made. */
2247
- readonly unchanged: readonly string[];
2248
- /** Keys present in this locale but no longer in the source. Reported, and removed only when pruning. */
2249
- readonly orphaned: readonly string[];
2250
- /** Orphaned keys that were actually removed, which happens only when the run was asked to prune. */
2251
- readonly pruned: readonly string[];
2252
- /**
2253
- * Keys whose source text is not valid ICU. They are skipped rather than sent to the provider,
2254
- * because a broken source message cannot yield a sound translation.
2255
- */
2256
- readonly invalidIcuSource: readonly string[];
2257
- /** Keys served from the translation memory instead of the provider, and so not paid for. */
2258
- readonly cacheHits: readonly string[];
2259
- /**
2260
- * Keys whose translation was refused by the integrity gate, for instance because it dropped a
2261
- * placeholder. The previous translation, if any, is left untouched.
2262
- */
2263
- readonly integrityMismatches: readonly string[];
2264
- /** Keys the provider failed to translate, for instance because their sub-batch errored. */
2265
- readonly providerFailures: readonly string[];
2266
- /** Keys whose plural categories were generated for the target language rather than translated one by one. */
2267
- readonly generated: readonly string[];
2268
- /** Keys not translated because the token budget was exhausted under `stop` behavior. */
2269
- readonly budgetWithheld: readonly string[];
2270
- /** Token usage for this locale. Absent when the provider does not report usage. */
2271
- readonly usage?: UsageSummary;
2272
- /** Provider and SDK notices raised while running this locale. Always present, possibly empty. */
2273
- readonly notices: readonly LocaleNotice[];
2274
- /** Translated keys flagged as worth a human look. The translations were still written. */
2275
- readonly needsReview: readonly NeedsReviewEntry[];
2276
- /** Keys left with no translation after the run, whatever the cause. */
2277
- readonly unfilled: readonly string[];
2278
- /** Unreadable rows from an imported handoff. Always empty for a {@link translate} run. */
2279
- readonly malformedRows: readonly MalformedRowReport[];
2280
- /** Repeated keys from an imported handoff. Always empty for a {@link translate} run. */
2281
- readonly duplicateKeys: readonly DuplicateKeyReport[];
2282
- /**
2283
- * Why this locale failed, when the failure came from a thrown error. The `code` is the failure's
2284
- * own code where it has one, and `LOCALE_FAILED` otherwise. Never carries a secret.
2285
- *
2286
- * Absent unless `status` is `failed`, but a `failed` locale does not always carry it: a locale
2287
- * whose every key was withheld by the integrity gate, a provider failure, or the token budget is
2288
- * `failed` with nothing thrown, so this stays undefined and the withheld keys are the account of
2289
- * what went wrong. Treat it as an optional detail on a failure, never as the failure test.
2290
- */
2291
- readonly error?: {
2292
- /** The failure's own code where it had one, and `LOCALE_FAILED` otherwise. */
2293
- readonly code: string;
2294
- /** A human-readable description of the failure. Never contains a secret. */
2295
- readonly message: string;
2296
- };
4261
+ * The default implementation dispatches on the provider ID and reads the API key from the
4262
+ * environment. A factory that throws is reported as `PROVIDER_CONSTRUCTION_FAILED`.
4263
+ */
4264
+ type CreateProvider = (config: ProviderConfig) => TranslationProvider;
4265
+
4266
+ /** Input for {@link retranslateEntry}. */
4267
+ interface RetranslateEntryInput {
4268
+ /** The resolved project config, normally from {@link loadConfig}. */
4269
+ readonly config: VerbatraConfig;
4270
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
4271
+ readonly cwd?: string;
4272
+ /** The target locale to retranslate into. Must be a configured target locale. */
4273
+ readonly locale: string;
4274
+ /** The key to retranslate. Must exist in the source resource. */
4275
+ readonly key: string;
4276
+ }
4277
+ /** Injectable dependencies for {@link retranslateEntry}. Every field has a working default. */
4278
+ interface RetranslateEntryDeps {
4279
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
4280
+ readonly adapterRegistry?: AdapterRegistry;
4281
+ /** Provider factory. Defaults to constructing the provider named in the config. */
4282
+ readonly createProvider?: CreateProvider;
4283
+ /** File-system port. Defaults to the real file system. */
4284
+ readonly fs?: SdkFs;
2297
4285
  }
2298
4286
  /**
2299
- * The result of a whole run, returned by {@link translate}, {@link watch}, and
2300
- * {@link importWorkbook}.
2301
- *
2302
- * Per-locale outcomes are data, not exceptions: inspect `failed` and `partial` to decide an exit
2303
- * code rather than relying on the call to throw. Only whole-run failures, such as an invalid config
2304
- * or an unreadable source file, throw an {@link SdkError}.
4287
+ * The outcome of {@link retranslateEntry}. As with {@link EditEntryResult}, a value refused by the
4288
+ * integrity gate is reported as data rather than thrown, because it is an expected outcome of
4289
+ * asking a provider for a fresh translation.
2305
4290
  */
2306
- interface RunSummary {
2307
- /** True when the run computed everything but wrote nothing and called no provider. */
2308
- readonly dryRun: boolean;
4291
+ type RetranslateEntryResult = {
4292
+ /** The provider's value passed the integrity gate and was written. */
4293
+ readonly accepted: true;
4294
+ /** The newly translated value now stored for the key. */
4295
+ readonly value: string;
2309
4296
  /**
2310
- * The full per-locale account. A {@link translate} or {@link watch} run reports them in
2311
- * configured target order, whatever order a `locales` subset was given in and whatever order
2312
- * concurrent locales finished in. An {@link importWorkbook} run does not: it reports them in the
2313
- * order the handoff yielded them, then appends the configured locales the handoff had nothing
2314
- * for. Match an entry on its `locale` rather than on its position.
4297
+ * Quality signals the provider layer raised for this value, such as a length-ratio outlier,
4298
+ * a value identical to the source, or `MAX_LENGTH_EXCEEDED` for a value over the key's
4299
+ * configured `maxLength` budget. Never `FUZZY_CACHE_REUSE`, since this path always calls the
4300
+ * provider and never consults the translation memory. Empty when nothing was flagged. The
4301
+ * value is written either way; these are advisory.
2315
4302
  */
2316
- readonly locales: readonly LocaleSummary[];
2317
- /** Names of the locales whose status is `succeeded`. */
2318
- readonly succeeded: readonly string[];
4303
+ readonly reviewReasons: readonly ReviewReasonCode[];
4304
+ } | {
4305
+ /** The value was refused; nothing was written and the previous translation is intact. */
4306
+ readonly accepted: false;
4307
+ /** Which integrity rule the provider's value broke. */
4308
+ readonly reason: IntegrityGateReason;
2319
4309
  /**
2320
- * Names of the locales whose status is `partial`: written to disk, but with keys still missing.
2321
- * Check this alongside `failed` before treating a run as clean. The CLI exits `1` on a partial
2322
- * locale for that reason.
4310
+ * The specific tags behind a `markup` refusal, each prefixed with `-` for one the source had
4311
+ * and the candidate dropped or `+` for one the candidate invented. A candidate carrying more
4312
+ * than twice its source's inline tags and constructs is named as `+more than N inline tags`
4313
+ * instead, where N is at least 256. Absent when no single tag is at fault, such as markup
4314
+ * that came back mis-nested, and absent for every other reason.
2323
4315
  */
2324
- readonly partial: readonly string[];
2325
- /** Names of the locales whose status is `failed`. Check this before treating a run as clean. */
2326
- readonly failed: readonly string[];
2327
- /** Token usage summed across every locale. Absent when the provider does not report usage. */
2328
- readonly usage?: UsageSummary;
2329
- /** The token budget in force, present only when the config set one. */
2330
- readonly budget?: RunBudget;
2331
- }
4316
+ readonly details?: readonly string[];
4317
+ /** The rejected value, echoed back so a UI can show what was refused. */
4318
+ readonly value: string;
4319
+ };
4320
+ /**
4321
+ * Re-runs the configured provider for a single key and saves the result. This is the paid
4322
+ * counterpart to {@link editEntry}: it calls the provider and therefore spends tokens, which is why
4323
+ * a UI should gate it behind an explicit user action.
4324
+ *
4325
+ * The returned value goes through the same integrity gate as a full run, so a translation that
4326
+ * loses a placeholder, breaks the source's inline markup, breaks ICU syntax, degenerates into
4327
+ * runaway output, or comes back blank is refused and nothing is written. Provider quality
4328
+ * signals are surfaced on an accepted result as `reviewReasons` rather than blocking the write.
4329
+ *
4330
+ * The locale's write lock is taken before the provider is called and held across it, covering the
4331
+ * whole read-translate-write cycle, so a slow provider keeps the lock for the length of its call
4332
+ * and a concurrent {@link translate} run on that locale waits. The lock is held for a refused
4333
+ * translation too, since the gate runs inside it. An accepted value then updates the lock-file
4334
+ * baseline and feeds the translation memory, so a later {@link translate} run sees the key as up
4335
+ * to date.
4336
+ *
4337
+ * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
4338
+ * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
4339
+ * A malformed target file fails the read with a message naming the offending locale and the
4340
+ * resolved path. The write raises the adapter's error too, when the entries cannot be represented
4341
+ * in the configured format or the existing destination file cannot be read back to be updated in
4342
+ * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
4343
+ * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
4344
+ * error from a target file on either path.
4345
+ *
4346
+ * It spends outside the token budget. `maxTokens` and `budgetBehavior` bound a {@link translate}
4347
+ * run, and this is its own single-key path with its own provider, so a configured ceiling neither
4348
+ * withholds this call nor counts it. A caller that exposes retranslation to users, as the Studio
4349
+ * dashboard and the agent tools do, has to bound that spend itself.
4350
+ *
4351
+ * @param input - The config, locale, and key to retranslate.
4352
+ * @param deps - Optional adapter registry, provider factory, and file-system overrides.
4353
+ * @returns Whether the new value was accepted, with review reasons or the rejection reason.
4354
+ *
4355
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
4356
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
4357
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
4358
+ * cannot be combined, or the locale has no valid path spelling under that style.
4359
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
4360
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
4361
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
4362
+ * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
4363
+ * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
4364
+ * most often because its API key environment variable is unset.
4365
+ * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
4366
+ * the timeout elapsed.
4367
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
4368
+ * of a file-system failure. The message names the target file and the file-system code, never the
4369
+ * internal temporary file.
4370
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
4371
+ * unsupported version.
4372
+ * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
4373
+ * is malformed or on the write because the entries cannot be represented in the configured format.
4374
+ * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
4375
+ * @throws `ProviderError` `INVALID_RESPONSE`: the provider returned no value for the key. Provider
4376
+ * transport and rate-limit failures propagate as `ProviderError` too, since a single-key call has
4377
+ * no per-locale summary to record them on.
4378
+ */
4379
+ declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
2332
4380
 
2333
4381
  /**
2334
4382
  * One locale's entry in a persisted {@link RunStatusFile}. It is a deliberately narrow projection
@@ -2342,6 +4390,14 @@ interface RunStatusLocale {
2342
4390
  readonly status: "succeeded" | "partial" | "failed";
2343
4391
  /** Keys the run flagged as worth a human look. */
2344
4392
  readonly needsReview: readonly NeedsReviewEntry[];
4393
+ /**
4394
+ * Translations reused for a source string that had changed, with the score and the earlier
4395
+ * source behind each one. Every key listed here also carries `FUZZY_CACHE_REUSE` in
4396
+ * {@link needsReview}; this is the evidence a reviewer needs to judge the reuse without
4397
+ * re-running. Absent for a locale that reused nothing, and for a file written before the field
4398
+ * existed.
4399
+ */
4400
+ readonly fuzzyHits?: readonly FuzzyCacheHit[];
2345
4401
  /** Token usage for this locale. Absent when the provider does not report usage. */
2346
4402
  readonly usage?: UsageSummary;
2347
4403
  }
@@ -2360,7 +4416,11 @@ interface RunStatusFile {
2360
4416
  readonly generatedAt: string;
2361
4417
  /** Token usage summed across every locale. Absent when the provider does not report usage. */
2362
4418
  readonly usage?: UsageSummary;
2363
- /** The token budget in force during the run, present only when one was configured. */
4419
+ /**
4420
+ * The token budget in force during the run, present only when one was configured. A snapshot
4421
+ * written before the budget was enforced, when `supported: false` meant nothing was counted at
4422
+ * all, has such a budget left out rather than read as a count of zero.
4423
+ */
2364
4424
  readonly budget?: RunBudget;
2365
4425
  /** Per-locale outcomes from the recorded run. */
2366
4426
  readonly locales: readonly RunStatusLocale[];
@@ -2407,6 +4467,319 @@ type RunStatusResult = {
2407
4467
  */
2408
4468
  declare function runStatus(input?: RunStatusInput, deps?: RunStatusDeps): Promise<RunStatusResult>;
2409
4469
 
4470
+ /** Default output path for a TMX export, used when {@link ExportTmxInput.out} is omitted. */
4471
+ declare const DEFAULT_TMX_PATH = "verbatra-memory.tmx";
4472
+ /** How many translation units one locale contributed to the exported file. */
4473
+ interface ExportTmxLocaleCount {
4474
+ /** The configured target locale. */
4475
+ readonly locale: string;
4476
+ /** How many of its memory entries were written. */
4477
+ readonly units: number;
4478
+ }
4479
+ /** What {@link exportTmx} wrote. */
4480
+ interface ExportTmxResult {
4481
+ /** The resolved path the file was written to. */
4482
+ readonly path: string;
4483
+ /** How many `tu` elements the file holds, one per distinct source string. */
4484
+ readonly units: number;
4485
+ /** Per-locale contribution to that total. */
4486
+ readonly locales: readonly ExportTmxLocaleCount[];
4487
+ /**
4488
+ * Translations left out because the memory holds no source text for their content hash, counted
4489
+ * once per locale and hash rather than once per distinct source string. A cache carried forward
4490
+ * from an older schema stores translations without their source, and TMX cannot represent a unit
4491
+ * with no source segment. Those entries fill in as later runs touch the same strings.
4492
+ */
4493
+ readonly withoutSource: number;
4494
+ /**
4495
+ * Characters removed from segment text because XML 1.0 cannot represent them at all, such as a
4496
+ * control character, a lone surrogate or a noncharacter, counted across every written segment.
4497
+ * Writing them would produce a file no conformant parser accepts, so they are left out and counted
4498
+ * rather than dropped silently.
4499
+ */
4500
+ readonly illegalCharactersRemoved: number;
4501
+ }
4502
+ /** Input for {@link exportTmx}. */
4503
+ interface ExportTmxInput {
4504
+ /** The resolved project config, normally from {@link loadConfig}. */
4505
+ readonly config: VerbatraConfig;
4506
+ /** Where to write the file. Defaults to {@link DEFAULT_TMX_PATH}, resolved against `cwd`. */
4507
+ readonly out?: string;
4508
+ /** Directory the output path and the memory are resolved against. Defaults to the process working directory. */
4509
+ readonly cwd?: string;
4510
+ /** Subset of configured target locales to export. Defaults to all of them. */
4511
+ readonly locales?: readonly string[];
4512
+ /** Value for the TMX header's `creationtoolversion`. Defaults to `unknown`. */
4513
+ readonly toolVersion?: string;
4514
+ }
4515
+ /** Injectable dependencies for {@link exportTmx}. Every field has a working default. */
4516
+ interface ExportTmxDeps {
4517
+ /** File-system port. Defaults to the real file system. */
4518
+ readonly fs?: SdkFs;
4519
+ }
4520
+ /**
4521
+ * Writes the project's translation memory out as a TMX 1.4b file, the format every mainstream
4522
+ * translation platform can read. It calls no provider and needs no API key: every value comes from
4523
+ * the memory already on disk.
4524
+ *
4525
+ * Only entries stored under the project's current configuration fingerprint are written, which is
4526
+ * the same set a run would reuse, so the file describes the memory as it is actually being used
4527
+ * rather than every translation the project has ever produced. One `tu` element is written per
4528
+ * distinct source string, carrying the source segment and one target segment per exported locale.
4529
+ * An empty memory, including a missing, unparsable or newer-version cache file, produces a valid,
4530
+ * empty TMX file rather than an error. An existing file at the output path is replaced.
4531
+ *
4532
+ * It reads the memory and writes a file; it never changes the memory, which is why the CLI refuses
4533
+ * `--dry-run` and `--overwrite` on this direction rather than accepting and ignoring them.
4534
+ *
4535
+ * @param input - The config, the output path, the locale subset, and the tool version to stamp.
4536
+ * @param deps - Optional file-system override.
4537
+ * @returns The path written, the unit count, and what was left out.
4538
+ *
4539
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
4540
+ * @throws {@link SdkError} `CONFIG_INVALID`: the source locale and one of the target locales are the
4541
+ * same language tag once case and separators are normalized. Writing that file would produce two
4542
+ * language attributes {@link importTmx} could not tell apart, so the file this project exported
4543
+ * would be one it refuses to read back.
4544
+ * @throws The underlying file-system error, unwrapped, when the output file could not be written.
4545
+ *
4546
+ * @example
4547
+ * ```ts
4548
+ * const result = await exportTmx({ config, out: "memory.tmx" });
4549
+ * console.log(`${result.units} units written to ${result.path}`);
4550
+ * ```
4551
+ */
4552
+ declare function exportTmx(input: ExportTmxInput, deps?: ExportTmxDeps): Promise<ExportTmxResult>;
4553
+
4554
+ /**
4555
+ * The two delimiter-separated interchange formats. They are one implementation with one parameter, the
4556
+ * field delimiter: `tsv` is not a second format, it is `csv` with a tab. The member is also the file
4557
+ * extension an export writes and an import reads back.
4558
+ */
4559
+ type DelimitedFormat = "csv" | "tsv";
4560
+ /** Where in an interchange file a refusal happened. */
4561
+ interface ExchangeErrorLocation {
4562
+ /** The 1-based line. */
4563
+ readonly line: number;
4564
+ /** The 1-based column on that line. */
4565
+ readonly column: number;
4566
+ /** The 1-based ordinal of the translation unit the problem sits in, when it sits in one. */
4567
+ readonly unit?: number;
4568
+ }
4569
+
4570
+ /**
4571
+ * Why one translation unit in an imported TMX file was refused. The first five are the shared
4572
+ * integrity gate's own reasons, so an imported unit is held to exactly the standard a provider's
4573
+ * output and a filled translator handoff already face. `sourceBlank` is the one reason particular
4574
+ * to interchange: a unit whose source segment is blank identifies no string, so it could never be
4575
+ * matched and is never stored.
4576
+ */
4577
+ type TmxRejectionReason = IntegrityGateReason | "sourceBlank";
4578
+ /**
4579
+ * Where in a TMX file a `SOURCE_INVALID` refusal from {@link importTmx} happened: the 1-based `line`
4580
+ * and `column`, and the 1-based `unit` ordinal when the problem sits inside a translation unit. Read
4581
+ * it from a caught error with {@link tmxErrorLocation}; the same place is named in the message.
4582
+ */
4583
+ type TmxErrorLocation = ExchangeErrorLocation;
4584
+ /**
4585
+ * Reads where in a TMX file a refusal from {@link importTmx} happened, without casting the error or
4586
+ * its `cause`.
4587
+ *
4588
+ * @param error - Whatever was caught from `importTmx`.
4589
+ * @returns The {@link TmxErrorLocation} when `error` is an {@link SdkError} wrapping a TMX parse
4590
+ * failure that has a place in the file; `undefined` for any other error, including a refusal with
4591
+ * no place in the file such as a missing or oversized file.
4592
+ *
4593
+ * @example
4594
+ * ```ts
4595
+ * try {
4596
+ * await importTmx({ config, file: "legacy.tmx" });
4597
+ * } catch (error) {
4598
+ * const at = tmxErrorLocation(error);
4599
+ * if (at !== undefined) console.error(`line ${at.line}, column ${at.column}`);
4600
+ * }
4601
+ * ```
4602
+ */
4603
+ declare function tmxErrorLocation(error: unknown): TmxErrorLocation | undefined;
4604
+ /** How many units were refused, by reason. See {@link TmxRejectionReason}. */
4605
+ type TmxRejectionCounts = Readonly<Record<TmxRejectionReason, number>>;
4606
+ /** What an import did to the memory for one target locale. */
4607
+ interface ImportTmxLocaleResult {
4608
+ /** The configured target locale, spelled as the config spells it. */
4609
+ readonly locale: string;
4610
+ /** Units stored for the first time. */
4611
+ readonly added: number;
4612
+ /** Units the memory already held with the same translation, so nothing changed. */
4613
+ readonly unchanged: number;
4614
+ /** Units that replaced a different existing translation, which needs `overwrite`. */
4615
+ readonly overwritten: number;
4616
+ /** Units refused because the memory already held a different translation and `overwrite` was off. */
4617
+ readonly kept: number;
4618
+ /** Later units in the same file repeating a source the file had already translated. */
4619
+ readonly duplicates: number;
4620
+ /**
4621
+ * Units carrying two segments of equal standing for this locale with different values, such as
4622
+ * `de-CH` and `de-AT` in a project configured for `de`. Which one is right cannot be known, so
4623
+ * neither is stored. An exact tag always outranks one that only reaches the locale by subtag
4624
+ * prefix, and two segments carrying the same value are not a conflict.
4625
+ */
4626
+ readonly conflicting: number;
4627
+ /** Units refused before they could be stored, by reason. */
4628
+ readonly rejected: TmxRejectionCounts;
4629
+ }
4630
+ /** A language tag in the file that no configured locale could be resolved to. */
4631
+ interface TmxLanguageReport {
4632
+ /** The tag exactly as the file spelled it. */
4633
+ readonly language: string;
4634
+ /** How many units carried a segment in it. */
4635
+ readonly units: number;
4636
+ }
4637
+ /** What {@link importTmx} read and what it wrote. */
4638
+ interface ImportTmxResult {
4639
+ /** Whether the run was a dry run, in which case the memory file was not written. */
4640
+ readonly dryRun: boolean;
4641
+ /** The resolved path the file was read from. */
4642
+ readonly file: string;
4643
+ /** The `srclang` the file's header declared, or undefined if it declared none. */
4644
+ readonly sourceLanguage: string | undefined;
4645
+ /** How many translation units the file held. */
4646
+ readonly units: number;
4647
+ /** Per-target-locale account of what was stored. */
4648
+ readonly locales: readonly ImportTmxLocaleResult[];
4649
+ /** Units the reader could not use at all, such as a unit with no segment. */
4650
+ readonly skippedUnits: number;
4651
+ /** `tu` elements outside the file's first `body`, which the reader never walked. */
4652
+ readonly unreachableUnits: number;
4653
+ /**
4654
+ * The header's `srclang` when it does not resolve to the configured source locale, so a file
4655
+ * exported from a project with a different source language is visible rather than silently
4656
+ * yielding nothing. Undefined when it matches, when the header declares none, or when it is the
4657
+ * TMX `*all*` wildcard, which declares that the source varies per unit.
4658
+ */
4659
+ readonly sourceLanguageMismatch: string | undefined;
4660
+ /** Units carrying no segment in the configured source locale, so nothing could be keyed. */
4661
+ readonly unmatchedSourceUnits: number;
4662
+ /**
4663
+ * Units carrying two segments of equal standing that resolve to the configured source locale with
4664
+ * different values, such as an `en-US` "Color" and an `en-GB` "Colour" segment in a project
4665
+ * configured for `en`. An exact tag outranks a prefix match and identical values agree, so neither
4666
+ * counts here. Which value the translations belong to cannot be known, so the unit is refused
4667
+ * rather than attributed to whichever came last.
4668
+ */
4669
+ readonly conflictingSourceUnits: number;
4670
+ /** Units whose segments carried inline markup, which is flattened to its text. */
4671
+ readonly markupStrippedUnits: number;
4672
+ /**
4673
+ * Units whose inline markup carried a `sub` element, a sub-flow such as a tooltip or alternative
4674
+ * text embedded in the markup. Its text is not part of the segment's own string, so it is left out
4675
+ * of the segment rather than flattened into it.
4676
+ */
4677
+ readonly subflowDroppedUnits: number;
4678
+ /** Language tags that resolved to no configured locale, with how many units carried each. */
4679
+ readonly unmatchedLanguages: readonly TmxLanguageReport[];
4680
+ /** Language tags that two or more configured locales could claim, so none was chosen. */
4681
+ readonly ambiguousLanguages: readonly TmxLanguageReport[];
4682
+ /**
4683
+ * Segments that resolved cleanly to a configured target locale this run left out, because
4684
+ * {@link ImportTmxInput.locales} narrowed it. Nothing is wrong with them; they are reported so a
4685
+ * narrowed run does not look like a file that held less than it did.
4686
+ */
4687
+ readonly notImported: readonly TmxLanguageReport[];
4688
+ /**
4689
+ * Whether the memory file could be written. False when the project's cache was written by a newer
4690
+ * build, which is left untouched rather than downgraded.
4691
+ */
4692
+ readonly memoryWritable: boolean;
4693
+ }
4694
+ /** Input for {@link importTmx}. */
4695
+ interface ImportTmxInput {
4696
+ /** The resolved project config, normally from {@link loadConfig}. */
4697
+ readonly config: VerbatraConfig;
4698
+ /** Path to the TMX file, resolved against `cwd`. */
4699
+ readonly file: string;
4700
+ /** Directory the file and the memory are resolved against. Defaults to the process working directory. */
4701
+ readonly cwd?: string;
4702
+ /** Read and validate the file but write nothing. Defaults to false. */
4703
+ readonly dryRun?: boolean;
4704
+ /**
4705
+ * Let an imported unit replace a translation the memory already holds for the same source and
4706
+ * locale. Off by default, so the project's own memory wins a collision.
4707
+ */
4708
+ readonly overwrite?: boolean;
4709
+ /** Subset of configured target locales to import. Defaults to all of them. */
4710
+ readonly locales?: readonly string[];
4711
+ }
4712
+ /** Injectable dependencies for {@link importTmx}. Every field has a working default. */
4713
+ interface ImportTmxDeps {
4714
+ /** Format-adapter registry, used for placeholder extraction and ICU validation. Defaults to the built-in registry. */
4715
+ readonly adapterRegistry?: AdapterRegistry;
4716
+ /** File-system port. Defaults to the real file system. */
4717
+ readonly fs?: SdkFs;
4718
+ }
4719
+ /**
4720
+ * Reads a TMX translation memory into the project's own memory, so a team arriving with years of
4721
+ * accumulated translations does not start cold. It calls no provider and needs no API key: every
4722
+ * value comes from the file.
4723
+ *
4724
+ * Nothing from the file is trusted. The XML is parsed under the interchange package's hardening
4725
+ * (entity declarations refused, size and unit bounds enforced), a language tag is resolved to a
4726
+ * configured locale by an explicit rule rather than guessed at, and every candidate translation
4727
+ * must pass the same placeholder, inline markup, ICU, degeneracy and emptiness gate that a
4728
+ * provider's output and a filled translator handoff already face before it is stored. A unit that
4729
+ * fails is counted, never written, so it can never later be served as a cache hit that was never
4730
+ * validated.
4731
+ *
4732
+ * Units are stored under the project's current configuration fingerprint, the same key a real run
4733
+ * writes. That is deliberate: an imported memory is reused exactly when the configuration that
4734
+ * would consume it matches, and stops matching when the provider, model, tone or glossary changes,
4735
+ * which is the protection the fingerprint layer exists to give. Re-import after such a change.
4736
+ *
4737
+ * A collision is decided in favour of what the project already has: if the memory already holds a
4738
+ * different translation for the same source and locale, the imported one is refused and counted as
4739
+ * `kept` unless {@link ImportTmxInput.overwrite} is set. Importing the same file twice therefore
4740
+ * changes nothing the second time, and writes no file at all.
4741
+ *
4742
+ * One consequence is worth stating plainly, because no import-time check can police it. An accepted
4743
+ * unit's source text is written into the memory's source index, which is the same index fuzzy reuse
4744
+ * scores a changed string against. An imported source that DIFFERS from a string in the project can
4745
+ * therefore be served for that string by fuzzy reuse, which is what resemblance means.
4746
+ *
4747
+ * An imported source that is identical to a project string but hashes differently is not reachable
4748
+ * at all. Fuzzy reuse discards any candidate whose normalized source equals the query, so a project
4749
+ * entry that carries a description, a meaning, or a plural flag the unit cannot carry falls through
4750
+ * to the provider rather than being served: the hashes differ, and the identical text disqualifies
4751
+ * the fuzzy candidate.
4752
+ *
4753
+ * A fuzzy reuse is still held to the integrity gate against the real entry and is reported as a
4754
+ * `FUZZY_CACHE_REUSE` review flag on the run summary, so it is visible rather than silent. A project
4755
+ * that does not want an imported memory reachable that way should leave `fuzzyCache` out of its
4756
+ * config.
4757
+ *
4758
+ * @param input - The config, the file path, and the dry-run, overwrite and locale-subset switches.
4759
+ * @param deps - Optional adapter registry and file-system overrides.
4760
+ * @returns What was read, what was stored, and everything that was skipped or refused.
4761
+ *
4762
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
4763
+ * @throws {@link SdkError} `CONFIG_INVALID`: the source locale and one of the target locales are the
4764
+ * same language tag once case and separators are normalized, so a segment could not be attributed
4765
+ * to either.
4766
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
4767
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: no file exists at the given path.
4768
+ * @throws {@link SdkError} `SOURCE_INVALID`: the file is oversized, malformed, not a TMX document,
4769
+ * or declares an XML entity. When the problem has a place in the file, the message names its line,
4770
+ * column and, inside a translation unit, the unit's 1-based ordinal, and `cause` is the interchange
4771
+ * reader's error carrying the same as a structured `location`.
4772
+ *
4773
+ * @example
4774
+ * ```ts
4775
+ * const result = await importTmx({ config, file: "legacy-memory.tmx" });
4776
+ * for (const locale of result.locales) {
4777
+ * console.log(`${locale.locale}: ${locale.added} added, ${locale.kept} kept`);
4778
+ * }
4779
+ * ```
4780
+ */
4781
+ declare function importTmx(input: ImportTmxInput, deps?: ImportTmxDeps): Promise<ImportTmxResult>;
4782
+
2410
4783
  /**
2411
4784
  * Whatever could be read about the process currently holding a write lock. Both fields are optional
2412
4785
  * because a lock file left behind by a killed process may be truncated or unreadable, and reporting
@@ -2465,6 +4838,10 @@ interface LocaleStartedEvent {
2465
4838
  * has already stopped the run still emits, and no provider call follows it. That is deliberate, so
2466
4839
  * that `batchIndex` always advances to `totalBatches` and a progress bar reaches its end instead of
2467
4840
  * stalling at the point the budget tripped.
4841
+ *
4842
+ * Only the translation batches emit it. A dry run sends nothing and emits none, and neither do the
4843
+ * plural-generation requests, so `totalBatches` counts translation batches alone. Keys served from
4844
+ * the translation memory are not batched, so a fully cached locale emits none either.
2468
4845
  */
2469
4846
  interface SubBatchProgressEvent {
2470
4847
  /** Discriminant for {@link ProgressEvent}. */
@@ -2482,14 +4859,17 @@ interface LocaleFinishedEvent {
2482
4859
  readonly type: "locale-finished";
2483
4860
  /** The locale that finished. */
2484
4861
  readonly locale: string;
2485
- /** How many keys were newly translated for this locale. */
4862
+ /** The length of the locale's {@link LocaleSummary.translated} list. */
2486
4863
  readonly translated: number;
2487
4864
  /** This locale's 0-based position among the run's locales. */
2488
4865
  readonly localeIndex: number;
2489
4866
  /** How many locales the run covers in total. */
2490
4867
  readonly totalLocales: number;
2491
4868
  }
2492
- /** Emitted once, after every locale has finished. */
4869
+ /**
4870
+ * Emitted once, after every locale has finished. Not emitted when the run rejects part way, as it
4871
+ * does on a corrupt lock-file.
4872
+ */
2493
4873
  interface RunFinishedEvent {
2494
4874
  /** Discriminant for {@link ProgressEvent}. */
2495
4875
  readonly type: "run-finished";
@@ -2506,7 +4886,10 @@ type ProgressListener = (event: ProgressEvent) => void;
2506
4886
  interface TranslateInput {
2507
4887
  /** The resolved project config, normally from {@link loadConfig}. */
2508
4888
  readonly config: VerbatraConfig;
2509
- /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
4889
+ /**
4890
+ * Directory the `files.pattern` is resolved against, and where the lock-file, the translation
4891
+ * memory, and the run-status file live. Defaults to the process working directory.
4892
+ */
2510
4893
  readonly cwd?: string;
2511
4894
  /**
2512
4895
  * Restrict the run to a subset of the configured target locales, which is how you translate one
@@ -2526,6 +4909,20 @@ interface TranslateInput {
2526
4909
  * preview work without spending anything. Defaults to false.
2527
4910
  */
2528
4911
  readonly dryRun?: boolean;
4912
+ /**
4913
+ * Compute a pre-run cost estimate and return it on {@link RunSummary.estimate}. Implies
4914
+ * `dryRun`: an estimate constructs no provider, reads no API key, makes no network call, and
4915
+ * writes no file, so it is safe to run anywhere. Defaults to false.
4916
+ *
4917
+ * The figure bounds the plan rather than the invoice: every provider call a live run schedules is
4918
+ * counted, plural generation included, and the prompt is measured from the request payload that
4919
+ * would be sent. Provider-side retries, a repair round, and a language that expands past the
4920
+ * allowance can each push a live run above it; {@link EstimateCaveatCode} names them all. It
4921
+ * carries a currency amount only when the config supplies a `rates` block covering the configured
4922
+ * provider and model; otherwise it reports the quantity and says why the money is missing. See
4923
+ * {@link RunEstimate}.
4924
+ */
4925
+ readonly estimate?: boolean;
2529
4926
  /**
2530
4927
  * Remove keys that no longer exist in the source. Defaults to the config's `prune`, then to
2531
4928
  * false, so orphaned keys are reported but kept unless removal is asked for explicitly.
@@ -2533,7 +4930,8 @@ interface TranslateInput {
2533
4930
  readonly prune?: boolean;
2534
4931
  /**
2535
4932
  * Generate the plural categories a target language requires rather than translating each
2536
- * category separately. Defaults to the config's `generatePlurals`, then to false.
4933
+ * category separately. Defaults to the config's `generatePlurals`, then to false. Takes effect
4934
+ * only with an LLM provider; a machine-translation provider generates nothing.
2537
4935
  */
2538
4936
  readonly generatePlurals?: boolean;
2539
4937
  /**
@@ -2543,17 +4941,22 @@ interface TranslateInput {
2543
4941
  readonly onLockWait?: LockWaitListener;
2544
4942
  /** Called as locales and sub-batches start and finish, for progress reporting. */
2545
4943
  readonly onProgress?: ProgressListener;
2546
- /** How long to wait for a locale's write lock before failing with `LOCK_CONTENDED`. */
4944
+ /**
4945
+ * How long, in milliseconds, to wait for a locale's write lock before that locale fails with
4946
+ * `LOCK_CONTENDED`. Defaults to ten minutes. Not used on a dry run, which takes no lock.
4947
+ */
2547
4948
  readonly lockAcquireTimeoutMs?: number;
2548
4949
  /**
2549
4950
  * How many locales to run at once. Must be an integer of at least 1; defaults to 1. On a live
2550
- * run it cannot be combined with a configured token budget, because concurrent locales would
2551
- * overshoot the budget nondeterministically.
4951
+ * run it cannot be combined with a configured token budget: the ceiling would still hold, but
4952
+ * which locale loses its remaining work would depend on the order the locales interleave, so the
4953
+ * run would not be reproducible.
2552
4954
  */
2553
4955
  readonly concurrency?: number;
2554
4956
  /**
2555
- * Consult and update the translation memory. Defaults to true. Turning it off forces every key
2556
- * through the provider, which is what to do when you want to re-pay for a fresh translation.
4957
+ * Consult and update the translation memory, fuzzy reuse included. Defaults to true. Turning it
4958
+ * off forces every key through the provider, which is what to do when you want to re-pay for a
4959
+ * fresh translation. A dry run never reads the memory, whatever this says.
2557
4960
  */
2558
4961
  readonly cache?: boolean;
2559
4962
  }
@@ -2566,11 +4969,38 @@ interface TranslateDeps {
2566
4969
  /** File-system port. Defaults to the real file system. */
2567
4970
  readonly fs?: SdkFs;
2568
4971
  }
4972
+ /**
4973
+ * Whether a set of run options resolves to a dry run. `estimate` implies `dryRun`, and this is the
4974
+ * one place that implication is decided: {@link translate} calls it, and so should any caller that
4975
+ * has to know before the run starts whether anything will be written or spent, rather than
4976
+ * re-deriving the rule and drifting from it.
4977
+ *
4978
+ * @param input - The `dryRun` and `estimate` options as the caller received them.
4979
+ * @returns True when the run will construct no provider, write no file, and spend nothing.
4980
+ *
4981
+ * @example
4982
+ * ```ts
4983
+ * import { resolveDryRun, translate } from "@verbatra/sdk";
4984
+ *
4985
+ * if (!resolveDryRun(options)) {
4986
+ * await prepareWorkspaceForWrites();
4987
+ * }
4988
+ * const summary = await translate({ config, ...options });
4989
+ * ```
4990
+ */
4991
+ declare function resolveDryRun(input: {
4992
+ /** The caller's {@link TranslateInput.dryRun}. */
4993
+ readonly dryRun?: boolean | undefined;
4994
+ /** The caller's {@link TranslateInput.estimate}, which implies a dry run. */
4995
+ readonly estimate?: boolean | undefined;
4996
+ }): boolean;
2569
4997
  /**
2570
4998
  * Runs the one-shot translation flow over every configured target locale, or over the subset named
2571
4999
  * by `locales`: read the source, diff
2572
5000
  * each locale against the lock-file baseline, translate what is missing or stale, verify placeholder
2573
- * and ICU integrity, write the locale files, and update the lock-file and translation memory.
5001
+ * and ICU integrity, write the locale files, and update the lock-file and translation memory. A
5002
+ * live run also records its summary in the run-status file that {@link runStatus} reads; a failure
5003
+ * to write that file or the translation memory is swallowed rather than failing the run.
2574
5004
  *
2575
5005
  * Failure handling is the contract worth understanding. Whole-run problems, such as an unreadable
2576
5006
  * source file or a provider that cannot be constructed, throw an {@link SdkError} before any locale
@@ -2640,13 +5070,6 @@ interface TranslateDeps {
2640
5070
  */
2641
5071
  declare function translate(input: TranslateInput, deps?: TranslateDeps): Promise<RunSummary>;
2642
5072
 
2643
- /**
2644
- * The two delimiter-separated interchange formats. They are one implementation with one parameter, the
2645
- * field delimiter: `tsv` is not a second format, it is `csv` with a tab. The member is also the file
2646
- * extension an export writes and an import reads back.
2647
- */
2648
- type DelimitedFormat = "csv" | "tsv";
2649
-
2650
5073
  /**
2651
5074
  * The file shape of a translator handoff: a single styled `.xlsx` workbook with one sheet per
2652
5075
  * locale, or one plain `.csv` or `.tsv` file per locale.
@@ -2707,7 +5130,10 @@ interface ExportWorkbookDeps {
2707
5130
  }
2708
5131
  /** What {@link exportWorkbook} wrote. */
2709
5132
  interface ExportWorkbookResult {
2710
- /** The absolute path written, or the shared base path when one file per locale was written. */
5133
+ /**
5134
+ * The absolute path written: the workbook file for `xlsx`, or the directory the per-locale files
5135
+ * were written into for a delimited format.
5136
+ */
2711
5137
  readonly path: string;
2712
5138
  /** Row counts per exported locale. */
2713
5139
  readonly locales: readonly {
@@ -2719,7 +5145,10 @@ interface ExportWorkbookResult {
2719
5145
  }
2720
5146
  /**
2721
5147
  * Writes the strings awaiting translation to a handoff a human translator can work in: a styled
2722
- * `.xlsx` workbook with one sheet per locale, or one `.csv` or `.tsv` file per locale.
5148
+ * `.xlsx` workbook with one sheet per locale, or one `.csv` or `.tsv` file per locale. A delimited
5149
+ * export also writes a `.verbatra-export-<format>.json` manifest into the output directory naming
5150
+ * the locales it exported, which {@link importWorkbook} uses to tell a leftover file from an earlier
5151
+ * export apart from a current one.
2723
5152
  *
2724
5153
  * By default only missing and stale keys are exported, which is what makes the handoff a work list
2725
5154
  * rather than a dump of the whole project. Each row carries the source text alongside any existing
@@ -2795,9 +5224,11 @@ interface ImportWorkbookDeps {
2795
5224
  * handoff.
2796
5225
  *
2797
5226
  * Imported values are held to the same integrity gate as provider output, so a translator who drops
2798
- * a placeholder or breaks ICU syntax has that row refused rather than written. Each locale takes
2799
- * its write lock, and the lock-file and translation memory are updated exactly as in a
2800
- * {@link translate} run, so an imported translation counts as up to date afterwards.
5227
+ * a placeholder, breaks inline markup or breaks ICU syntax has that row refused rather than
5228
+ * written, and a row whose source text changed since the export is refused the same way. A row
5229
+ * holding exactly `[[CLEAR]]` empties that key's translation. Each locale takes its write lock, and
5230
+ * the lock-file and translation memory are updated exactly as in a {@link translate} run, so an
5231
+ * imported translation counts as up to date afterwards.
2801
5232
  *
2802
5233
  * Damage is contained rather than fatal: a blank row keeps the existing translation and its
2803
5234
  * baseline, an unreadable row is reported as a {@link MalformedRowReport}, and a repeated key is
@@ -2805,9 +5236,12 @@ interface ImportWorkbookDeps {
2805
5236
  * the returned {@link RunSummary} rather than aborting the import. A sheet or file naming a locale
2806
5237
  * that is not configured is contained the same way: that locale fails with `CONFIG_INVALID` on its
2807
5238
  * own {@link LocaleSummary}, so nothing is written to an unmanaged path and the configured locales
2808
- * still import. Once the handoff has been read, every per-locale failure is isolated this way, so
2809
- * callers should inspect {@link RunSummary.failed} and {@link RunSummary.partial} rather than
2810
- * relying on a thrown error. A corrupt lock-file is the one exception, because it is a single
5239
+ * still import. A configured target locale the handoff carries no sheet or file for fails with
5240
+ * `WORKBOOK_SHEET_MISSING`, a delimited file the directory's export manifest does not list fails
5241
+ * with `HANDOFF_FILE_STALE` without being applied, and a row naming a key neither the source nor
5242
+ * the target holds fails its locale. Once the handoff has been read, every per-locale failure is
5243
+ * isolated this way, so callers should inspect {@link RunSummary.failed} and
5244
+ * {@link RunSummary.partial} rather than relying on a thrown error. A corrupt lock-file is the one exception, because it is a single
2811
5245
  * shared file rather than a per-locale one: it aborts the whole run even when it is discovered
2812
5246
  * after a locale has been applied, so the locales still to come are not written at all.
2813
5247
  *
@@ -2860,9 +5294,10 @@ interface LocalePathResolverConfig {
2860
5294
  /**
2861
5295
  * The configured format. Every format maps each locale to a distinct path except a
2862
5296
  * shared-catalogue format (one holding every locale in a single file, such as `apple-xcstrings`),
2863
- * which maps every locale to the same path.
5297
+ * which maps every locale to the same path. A `custom:` format is always treated as one file per
5298
+ * locale.
2864
5299
  */
2865
- readonly format: SupportedFormat$1;
5300
+ readonly format: FormatId$1;
2866
5301
  /** The file layout. */
2867
5302
  readonly files: {
2868
5303
  /** The path pattern, which must contain the `{locale}` token. */
@@ -2876,12 +5311,15 @@ interface LocalePathResolver {
2876
5311
  /**
2877
5312
  * The absolute path of a locale's file. Accepts any locale string, including ones outside the
2878
5313
  * configured set, so it can be used for exploratory reads.
5314
+ *
5315
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the locale is outside the configured set and
5316
+ * has no valid single-segment spelling under the configured style.
2879
5317
  */
2880
5318
  pathFor(locale: string): string;
2881
5319
  /**
2882
5320
  * The configured locale owning an absolute path, or `undefined` when the path belongs to no
2883
- * configured locale. The path is normalized before lookup, so a relative or non-canonical path
2884
- * still resolves.
5321
+ * configured locale. The path is resolved against the resolver's `cwd` and normalized before
5322
+ * lookup, so a relative or non-canonical path still resolves.
2885
5323
  *
2886
5324
  * Always `undefined` for a shared-catalogue format: every configured locale resolves to the same
2887
5325
  * path there, so no single locale owns it.
@@ -3041,8 +5479,11 @@ declare const scaffoldingMetadata: {
3041
5479
  readonly openai: "maxOutputTokens";
3042
5480
  readonly gemini: "maxOutputTokens";
3043
5481
  };
3044
- /** Every i18n file format the SDK can read and write. */
3045
- readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb", "properties", "apple-strings", "apple-xcstrings", "android-xml", "gettext-po"];
5482
+ /**
5483
+ * Every built-in i18n file format the SDK can read and write. A `custom:` format is never listed,
5484
+ * since its adapter ships outside verbatra.
5485
+ */
5486
+ readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb", "properties", "apple-strings", "apple-xcstrings", "android-xml", "gettext-po", "ini", "resx"];
3046
5487
  };
3047
5488
 
3048
5489
  /**
@@ -3084,7 +5525,10 @@ type WatchRunResult = {
3084
5525
  interface WatchInput {
3085
5526
  /** The resolved project config, normally from {@link loadConfig}. */
3086
5527
  readonly config: VerbatraConfig;
3087
- /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
5528
+ /**
5529
+ * Directory the `files.pattern` is resolved against, and where each run's lock-file, translation
5530
+ * memory, and run-status file live. Defaults to the process working directory.
5531
+ */
3088
5532
  readonly cwd?: string;
3089
5533
  /**
3090
5534
  * Restrict every run of the session to a subset of the configured target locales. Validated once
@@ -3103,11 +5547,17 @@ interface WatchInput {
3103
5547
  readonly onLockWait?: LockWaitListener;
3104
5548
  /** Called as locales and sub-batches start and finish, for progress reporting. */
3105
5549
  readonly onProgress?: ProgressListener;
3106
- /** How long to wait for a locale's write lock before that run fails. */
5550
+ /**
5551
+ * How long, in milliseconds, to wait for a locale's write lock before that locale fails with
5552
+ * `LOCK_CONTENDED` on the run's summary. Defaults to ten minutes.
5553
+ */
3107
5554
  readonly lockAcquireTimeoutMs?: number;
3108
- /** How many locales to run at once. Validated once when watching starts, and applied to every run. */
5555
+ /**
5556
+ * How many locales to run at once. Defaults to 1. Validated once when watching starts, and
5557
+ * applied to every run.
5558
+ */
3109
5559
  readonly concurrency?: number;
3110
- /** Consult and update the translation memory. Defaults to true. */
5560
+ /** Consult and update the translation memory, fuzzy reuse included. Defaults to true. */
3111
5561
  readonly cache?: boolean;
3112
5562
  }
3113
5563
  /** Injectable dependencies for {@link watch}. Every field has a working default. */
@@ -3187,4 +5637,4 @@ interface WatchController {
3187
5637
  */
3188
5638
  declare function watch(input: WatchInput, deps?: WatchDeps): Promise<WatchController>;
3189
5639
 
3190
- export { type BudgetBehavior, CACHE_FILE_NAME, type CheckDeps, type CheckInput, type CheckSummary, type ConfigSource, type CreateProvider, type CreateWatcher, DEFAULT_DELIMITED_PATH, DEFAULT_EXCHANGE_FORMAT, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type DoctorCheck, type DoctorCheckId, type DoctorCheckStatus, type DoctorDeps, type DoctorInput, type DoctorResult, type DuplicateKeyReport, EXCHANGE_FORMATS, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type ExchangeFormat, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type GlossaryFileDeps, type GlossaryFileInput, type GlossaryProvenance, type ImportWorkbookDeps, type ImportWorkbookInput, type IntegrityGateReason, type KeyIntegrityDeps, type KeyIntegrityEntry, type KeyIntegrityInput, type KeyValueDeps, type KeyValueInput, type KeyValuePair, type KeyValueResult, LOCK_FILE_NAME, type LoadConfigOptions, type LoadLockFileDeps, type LoadLockFileInput, type LoadedConfig, type LocaleCheckSummary, type LocaleDiff, type LocaleFileSnapshot, type LocaleFinishedEvent, type LocaleKeyIntegrity, type LocaleNotice, type LocalePathResolver, type LocalePathResolverConfig, type LocaleSnapshotDelta, type LocaleStartedEvent, type LocaleStyle, type LocaleSummary, type LocaleValues, type LocaleValuesDeps, type LocaleValuesInput, type LockFile, type LockHolder, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type LockWaitEvent, type LockWaitListener, type MalformedRowReport, type NeedsReviewEntry, type ProgressEvent, type ProgressListener, type ProviderConfig, type ProviderId, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunFinishedEvent, type RunStatusDeps, type RunStatusFile, type RunStatusInput, type RunStatusLocale, type RunStatusResult, type RunSummary, type RunTranslate, type ScaffoldableProviderId, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type SubBatchProgressEvent, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type TranslationMemory, type UpdateGlossaryTermInput, type UsageSummary, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, createLocalePathResolver, defineConfig, diff, diffLocaleSnapshots, doctor, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, localeValues, lockState, readGlossaryFile, readLocaleFileSnapshot, redact, retranslateEntry, runStatus, scaffoldingMetadata, translate, updateGlossaryTerm, verbatraConfigSchema, watch };
5640
+ export { AdapterError, type AdapterErrorCode, type AdapterFs, AdapterRegistry, type AdapterResolution, type AddedKey, type BillingUnit, type BoundedReadOutcome, type BudgetBehavior, type BudgetStanding, type BuildWriteTree, CACHE_FILE_NAME, type CharacterRate, type CharacterRunQuantity, type CheckDeps, type CheckInput, type CheckSummary, type ComparePlaceholders, type ComputeInvalidIcuKeys, type ConfigSource, type CreateProvider, type CreateWatcher, type CustomFormatId$1 as CustomFormatId, DEFAULT_DELIMITED_PATH, DEFAULT_EXCHANGE_FORMAT, DEFAULT_TMX_PATH, DEFAULT_TYPES_PATH, DEFAULT_WORKBOOK_PATH, type DeriveDescriptions, type DeriveEntry, type DiffDeps, type DiffInput, type DiffSummary, type DirectoryEntry, type DoctorCheck, type DoctorCheckId, type DoctorCheckStatus, type DoctorDeps, type DoctorInput, type DoctorResult, type DuplicateKeyReport, EXCHANGE_FORMATS, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type EstimateCaveatCode, type EstimateIdentity, type EstimatePricing, type ExchangeFormat, type ExportTmxDeps, type ExportTmxInput, type ExportTmxLocaleCount, type ExportTmxResult, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type ExtractDeps, type ExtractInput, type ExtractPlaceholders, type ExtractResult, type ExtractionConfig, type FlatFileAdapterOptions, type FlatParseOutcome, type FlatParseResult, type FormatAdapter, type FormatId$1 as FormatId, type FuzzyCacheHit, type GenerateTypesDeps, type GenerateTypesInput, type GenerateTypesResult, type GlossaryFileDeps, type GlossaryFileInput, type GlossaryProvenance, INTEGRITY_GATE_REASONS, type ImportTmxDeps, type ImportTmxInput, type ImportTmxLocaleResult, type ImportTmxResult, type ImportWorkbookDeps, type ImportWorkbookInput, type InconsistencyGroup, type InconsistentTranslation, type IntegrityGateReason, type JsonLeaf, type JsonRecord, type JsonTree, type KeyConflict, type KeyIntegrityDeps, type KeyIntegrityEntry, type KeyIntegrityInput, type KeyMode, type KeyValueDeps, type KeyValueInput, type KeyValuePair, type KeyValueResult, LOCK_FILE_NAME, type LiteralFinding, type LiteralScan, type LiteralSuppressionReason, type LoadConfigOptions, type LoadLockFileDeps, type LoadLockFileInput, type LoadedConfig, type LocaleCheckSummary, type LocaleDiff, type LocaleEstimate, type LocaleEstimateQuantity, type LocaleFileSnapshot, type LocaleFinishedEvent, type LocaleKeyIntegrity, type LocaleNotice, type LocalePathResolver, type LocalePathResolverConfig, type LocaleResource$1 as LocaleResource, type LocaleSnapshotDelta, type LocaleStartedEvent, type LocaleStyle, type LocaleSummary, type LocaleValues, type LocaleValuesDeps, type LocaleValuesInput, type LockFile, type LockHolder, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type LockWaitEvent, type LockWaitListener, type MalformedRowReport, type ModelRate, type NeedsReviewEntry, type OrderedRecord, type OrderedValue, type PlaceholderIntegrityResult$1 as PlaceholderIntegrityResult, type PossiblyDynamicKey, type PricedLocaleEstimate, type PricedRunEstimate, type ProgressEvent, type ProgressListener, type ProviderBilling, type ProviderConfig, type ProviderId, type PseudolocalizeDeps, type PseudolocalizeInput, type PseudolocalizeResult, REVIEW_REASON_CODES, type RateCard, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type ReadResult, type ResolveOptions, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunEstimate, type RunEstimateQuantity, type RunFinishedEvent, type RunStatusDeps, type RunStatusFile, type RunStatusInput, type RunStatusLocale, type RunStatusResult, type RunSummary, type RunTranslate, type ScaffoldableProviderId, type ScanDiagnostic, type ScanDiagnosticReason, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type Sniff, type SourceFramework, type SourceLocation, type SubBatchProgressEvent, type SupportedFormat$1 as SupportedFormat, type SuppressedLiteral, type TmxErrorLocation, type TmxLanguageReport, type TmxRejectionCounts, type TmxRejectionReason, type TokenRate, type TokenRunQuantity, type TranslateDeps, type TranslateInput, type TranslationEntry$1 as TranslationEntry, type TranslationMemory, type TreeFileAdapterOptions, type UnpricedLocaleEstimate, type UnpricedRunEstimate, type UnresolvedArgumentReason, type UnresolvedMessage, type UnusedKey, type UnusedKeysNotRun, type UnusedKeysNotRunReason, type UnusedKeysPrefixSite, type UnusedKeysReport, type UnusedKeysScan, type UnusedKeysSite, type UnusedKeysUnreliability, type UnusedKeysUnreliableReason, type UpdateGlossaryTermInput, type UsageSummary, type ValidateMessage, type ValidateTree, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, budgetStanding, check, createDefaultRegistry, createFlatFileAdapter, createLocalePathResolver, createTreeFileAdapter, defineConfig, diff, diffLocaleSnapshots, doctor, editEntry, exportTmx, exportWorkbook, extract, generateTypes, importTmx, importWorkbook, isCustomFormatId, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, localeValues, lockState, nodeAdapterFs, pseudolocalize, readGlossaryFile, readLocaleFileSnapshot, redact, resolveDryRun, retranslateEntry, runStatus, scaffoldingMetadata, tmxErrorLocation, translate, updateGlossaryTerm, verbatraConfigSchema, watch };