@verbatra/sdk 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,9 @@ const config = await loadConfig();
44
44
  // The provider reads its API key from the environment (e.g. ANTHROPIC_API_KEY). No key is passed.
45
45
  const summary = await translate({ config });
46
46
 
47
- console.log(`${summary.succeeded.length} locale(s) translated, ${summary.failed.length} failed`);
47
+ console.log(
48
+ `${summary.succeeded.length} locale(s) done, ${summary.partial.length} partial, ${summary.failed.length} failed`,
49
+ );
48
50
  ```
49
51
 
50
52
  ## Defining config
@@ -71,21 +73,29 @@ export default defineConfig({
71
73
  });
72
74
  ```
73
75
 
74
- `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, `arb`, and `properties`. JSON-family, YAML, and ARB files round-trip in exact document key order: integer-like keys keep their position, new keys append in source-document order, and a YAML composite key (a map or sequence used as a mapping key) fails with a structured error. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` additionally takes a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
76
+ `files.pattern` must contain the `{locale}` token, `targetLocales` must not include `sourceLocale`, and `targetLocales` must not list the same locale twice, compared case-insensitively (two such entries would collide as one Excel worksheet on export); all three are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, `arb`, and `properties`. JSON-family, YAML, and ARB files round-trip in exact document key order: integer-like keys keep their position, new keys append in source-document order, and a YAML composite key (a map or sequence used as a mapping key) fails with a structured error; a `.properties` write preserves the destination file's existing line endings. The optional `glossary` (a term map, inline or a path to a JSON file of the same shape) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. The optional `maxBatchSize` (a positive integer, 50 when absent) caps how many entries go into a single provider request, so a large locale is split into sequential sub-batches and one oversized request cannot sink the whole locale. The optional `maxTokens` sets a whole-run ceiling on input plus output tokens across every provider call, and `budgetBehavior` decides what happens once it is reached: `"warn"` (the default) flags it and lets the run continue, `"stop"` withholds every not-yet-attempted key for the rest of the run so it retries next time. Both are config-only, have no CLI flag, and never change an exit code; against a token-less provider such as DeepL, which reports no usage, the budget stays inert rather than tripping falsely.
77
+
78
+ Anthropic takes `{ model, maxTokens }`; OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` takes the same pair plus a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). Every provider additionally accepts an optional `requestTimeoutMs`, a positive-integer per-request timeout in milliseconds that bounds each outbound call. API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
75
79
 
76
80
  ## API reference
77
81
 
78
82
  ### `defineConfig(config)`
79
83
 
80
- Returns the config unchanged. It exists purely for type inference and editor autocomplete when authoring a code-defined config. The `model` field is restricted to the selected provider's known model IDs (sourced from that provider's own SDK), so a model from another provider is a type error at authoring time; the runtime still validates `model` only as a non-empty string, so an unlisted model runs even though the editor flags it.
84
+ Returns the config unchanged. It exists purely for type inference and editor autocomplete when authoring a code-defined config. For Anthropic, OpenAI, and Gemini the `model` field is restricted to that provider's known model IDs (sourced from its own SDK), so a model from another provider is a type error at authoring time; the runtime still validates `model` only as a non-empty string, so an unlisted model runs even though the editor flags it. DeepL has no `model` field, and `openai-compatible`'s model is whatever the local server exposes, so neither is restricted.
81
85
 
82
86
  ### `loadConfig(options?): Promise<VerbatraConfig>`
83
87
 
84
- Discovers and validates the configuration. With no arguments it searches upward from the current working directory; `options` accepts `cwd`, an explicit `configPath`, or an in-memory `configOverride`. Resolves to the validated `VerbatraConfig`, and throws an `SdkError` if no config is found or it fails validation.
88
+ Discovers and validates the configuration. With no arguments it searches upward from the current working directory; `options` accepts `cwd`, an explicit `configPath`, an in-memory `configOverride`, and an `fs` seam. Precedence is `configOverride`, then `configPath`, then the search. Resolves to the validated `VerbatraConfig`, and throws an `SdkError` if no config is found (`CONFIG_NOT_FOUND`) or it fails validation (`CONFIG_INVALID`). A `glossary` given as a file path is read and validated here, so every downstream call receives a plain term map.
85
89
 
86
90
  ### `translate(input): Promise<RunSummary>`
87
91
 
88
- Runs the one-shot read, diff, translate, write flow over every target locale. `input` is `{ config, cwd?, dryRun?, prune?, generatePlurals? }`. With `dryRun: true` it reads, diffs, and reports without calling the provider or writing anything. `prune` and `generatePlurals` each override the matching config option for this run. Resolves to a `RunSummary` (`dryRun`, `locales`, `succeeded`, and `failed`); each locale summary lists `translated`, `generated`, `pruned`, `integrityMismatches`, `providerFailures`, and `notices`. `integrityMismatches` is a translation that came back and failed the placeholder-integrity check; `providerFailures` is a key withheld because the provider call itself failed (nothing was translated), with the secret-free failure code and message reported in `notices`.
92
+ Runs the one-shot read, diff, translate, write flow over every target locale. `input` is a `TranslateInput`: `{ config, cwd?, dryRun?, prune?, generatePlurals?, cache?, concurrency?, lockAcquireTimeoutMs?, onProgress?, onLockWait? }`. With `dryRun: true` it reads, diffs, and reports without calling the provider or writing anything. `prune` and `generatePlurals` each override the matching config option for this run.
93
+
94
+ `cache` (on by default, and ignored on a dry run) toggles the local content-addressed translation-memory cache in `verbatra.cache.json`: a key whose source content is unchanged, including under a renamed key or shared with another key, is served from the cache instead of being re-sent to the provider. A cached value is re-checked against that key's own current source first, and one that fails the gate falls through to the provider rather than being written. `concurrency` (defaults to 1, strictly serial) is how many target locales may run at once; it must be an integer of at least 1, and on a live run a value above 1 is refused when the config sets `maxTokens`, because concurrent locales would overshoot the budget nondeterministically. `lockAcquireTimeoutMs` overrides how long a locale's write lock keeps retrying before it fails. `onProgress` receives a structured event once per locale before and after it runs, once per provider sub-batch, and once when the locale loop ends; `onLockWait` fires while a locale's write lock is blocked on another process holding it. The SDK writes to no stream, so these callbacks are the only progress signal.
95
+
96
+ Resolves to a `RunSummary`: `dryRun`, `locales`, `succeeded`, `partial`, `failed`, plus `usage` when any provider call reported tokens and `budget` when `maxTokens` is configured. A locale's `status` is `"succeeded"` when nothing was withheld (a no-op with no candidate keys included), `"partial"` when it accepted at least one key and withheld at least one, and `"failed"` when it withheld keys and accepted none, or threw. Each `LocaleSummary` carries `locale`, `status`, `translated`, `unchanged`, `orphaned`, `pruned`, `invalidIcuSource`, `cacheHits`, `integrityMismatches`, `providerFailures`, `budgetWithheld`, `generated`, `notices`, `needsReview`, `unfilled`, `malformedRows`, `duplicateKeys`, an optional `usage`, and an optional `error` on a locale that threw. `cacheHits` are keys served from the translation-memory cache rather than the provider. `integrityMismatches` is a translation that came back and was withheld by verbatra's integrity gate, whose rejection reasons are the exported `IntegrityGateReason` union. `providerFailures` is a key withheld because nothing was translated for it (the provider call failed, or the response was still missing that key), with any secret-free failure code and message reported in `notices`. `budgetWithheld` is a candidate never sent because a `maxTokens` budget in `"stop"` mode had already tripped. `needsReview` flags accepted keys the review heuristics want a human to look at, and never withholds anything. `unfilled`, `malformedRows`, and `duplicateKeys` are populated only by `importWorkbook`, which returns this same shape. Every withheld key keeps its prior lock hash and is retried next run.
97
+
98
+ Whole-run failures throw an `SdkError`: an unknown format, provider construction (including a missing API key), an unreadable or invalid source file, a corrupt lock file, an invalid `concurrency`, or the concurrency-and-budget conflict above. A per-locale failure never throws; it is isolated on that locale's summary. On a non-dry run the flow also writes `.verbatra-local/run-status.json` (best-effort, read back through `runStatus`).
89
99
 
90
100
  ```ts
91
101
  const preview = await translate({ config, dryRun: true });
@@ -93,7 +103,9 @@ const preview = await translate({ config, dryRun: true });
93
103
 
94
104
  ### `watch(input): Promise<WatchController>`
95
105
 
96
- Watches the source file and re-runs the translate flow on each debounced change. `input` is `{ config, cwd?, debounceMs?, onRun }`, where `onRun` receives a `WatchRunResult` per run. Resolves to a `WatchController` whose `stop()` closes the watcher and awaits the in-flight run.
106
+ Watches the source file and re-runs the translate flow on each debounced change. `input` is `{ config, cwd?, debounceMs?, onRun, cache?, concurrency?, lockAcquireTimeoutMs?, onLockWait?, onProgress? }`; `debounceMs` defaults to 300, and the last five are passed straight through to every run. One run starts immediately at startup, before any change arrives. Runs are serialized, so changes during a run collapse into a single follow-up.
107
+
108
+ `onRun` receives a `WatchRunResult` per run: `{ status: "succeeded", summary }` or `{ status: "failed", error }` with a secret-free `{ code, message }`, so a failing run is reported and watching continues. `watch` itself throws only at startup: `CONCURRENCY_INVALID` or `CONCURRENCY_BUDGET_CONFLICT` for a `concurrency` no cycle could honor (resolved once, before the watcher exists, rather than failing every cycle), and `SOURCE_UNREADABLE` when the source locale file is absent. Resolves to a `WatchController` whose `stop()` closes the watcher and awaits the in-flight run.
97
109
 
98
110
  ```ts
99
111
  import { loadConfig, watch } from "@verbatra/sdk";
@@ -125,7 +137,7 @@ if (!summary.inSync) {
125
137
 
126
138
  ### `diff(input): Promise<DiffSummary>`
127
139
 
128
- Lists the keys a run would touch, without writing anything. `input` is the same `{ config, cwd?, locales? }` shape as `check`. Resolves to a `DiffSummary` whose `locales` lists one `LocaleDiff` each, with the key arrays `missing` (would be added), `changed` (would be re-translated), and `orphaned` (present in the target but absent from the source).
140
+ Lists the keys a run would touch, without writing anything. `input` is the same `{ config, cwd?, locales? }` shape as `check`. Resolves to a `DiffSummary` whose `locales` lists one `LocaleDiff` each, with the key arrays `missing` (would be added), `changed` (would be re-translated), and `orphaned` (present in the target but absent from the source), plus a per-locale `hasPendingChanges` driven by `missing` and `changed` only, since a default run does not prune. The top-level `hasPendingChanges` is true when any checked locale has some.
129
141
 
130
142
  ```ts
131
143
  import { diff, loadConfig } from "@verbatra/sdk";
@@ -136,11 +148,11 @@ const summary = await diff({ config });
136
148
 
137
149
  ### `exportWorkbook(input): Promise<ExportWorkbookResult>`
138
150
 
139
- Exports the strings that need translating into a styled Excel workbook for a human translator. `input` is `{ config, cwd?, out?, locales?, includeUnchanged? }`. By default it writes the missing and changed strings for every target locale to `verbatra-translations.xlsx`; `out` overrides the path, `locales` narrows which target locales are exported, and `includeUnchanged: true` also exports already up-to-date strings. Resolves to an `ExportWorkbookResult` with the written `path` and a per-locale row count.
151
+ Exports the strings that need translating into a styled Excel workbook for a human translator. `input` is `{ config, cwd?, out?, locales?, includeUnchanged? }`. By default it writes the missing and changed strings for every target locale to `verbatra-translations.xlsx`; `out` overrides the path, `locales` narrows which target locales are exported, and `includeUnchanged: true` also exports already up-to-date strings. No provider is called and no lock file is written. Resolves to an `ExportWorkbookResult` with the absolute `path` written and a per-locale row count.
140
152
 
141
153
  ### `importWorkbook(input): Promise<RunSummary>`
142
154
 
143
- Imports a filled workbook back into the locale files, running the same placeholder and ICU checks as `translate`. `input` is `{ config, workbook, cwd?, dryRun? }`. With `dryRun: true` it validates and reports without writing locale files or updating the lock. Resolves to a `RunSummary`, the same shape `translate` returns.
155
+ Imports a filled workbook back into the locale files, gating every row through the same integrity gate as `translate` on top of a fresh source-drift check. `input` is `{ config, workbook, cwd?, dryRun? }`. With `dryRun: true` it validates and reports without writing locale files or updating the lock. Resolves to a `RunSummary`, the same shape `translate` returns: a row the translator left blank whose key still needs a translation is reported in that locale's `unfilled` (nothing is written and the prior lock baseline is kept), an unreadable row in `malformedRows`, a repeated key in `duplicateKeys` (the first occurrence wins), and a configured target locale whose sheet is missing from the workbook is reported as that locale's failed summary rather than silently dropped. `unfilled`, `malformedRows`, and `duplicateKeys` do not feed a locale's `status`, so a partly filled sheet still imports the rows it has. A key is cleared by filling its Translation cell with the `[[CLEAR]]` sentinel; an ordinary blank never clears a value.
144
156
 
145
157
  ```ts
146
158
  import { exportWorkbook, importWorkbook, loadConfig } from "@verbatra/sdk";
@@ -160,17 +172,19 @@ See [Manual translation](https://verbatra.kreitz-webdev.de/docs/manual-translati
160
172
 
161
173
  Beyond the flows above, the SDK exports the building blocks Verbatra Studio and other tooling sit on, each a one-call read or a locked single-key write:
162
174
 
163
- - `keyIntegrity` reports, per changed key, whether its placeholders and ICU structure still match the source.
175
+ - `keyIntegrity` reports, per changed key, whether its placeholders still match the source (with the missing and extra tokens on a mismatch) and whether the current target value is still valid ICU.
164
176
  - `lockState` reports the lock file's existence, version, and per-locale drift; `loadLockFile` reads the lock file itself.
165
- - `runStatus` reads the persisted review-flag and token-usage snapshot the last `translate` or `watch` run left behind.
177
+ - `runStatus` reads the persisted review-flag and token-usage snapshot the last non-dry `translate` or `watch` run left behind; it never throws, and a missing, corrupt, or unrecognized file simply reports as unavailable.
166
178
  - `keyValue` reads one key's current source and target values.
167
- - `editEntry` saves a manually edited translation for one key, and `retranslateEntry` re-runs the provider for one key; both run the same placeholder and ICU integrity checks as a full run and hold the same per-locale write lock.
179
+ - `editEntry` saves a manually edited translation for one key, and `retranslateEntry` re-runs the provider for one key; both run the candidate through the same integrity gate as a full run (a rejection names an `IntegrityGateReason` and writes nothing) and hold the same per-locale write lock.
168
180
  - `readLocaleFileSnapshot` and `diffLocaleSnapshots` snapshot one locale file as per-key content hashes and compare two snapshots, the primitives behind live-refresh watching.
169
181
  - `loadConfigWithMeta` is `loadConfig` plus config-source and glossary provenance.
170
182
 
171
183
  ## Errors and results
172
184
 
173
- The SDK throws a single structured error type, `SdkError`, for whole-run failures such as a missing or invalid config or an unreadable source file. It carries a stable `code` and never contains an API key. Per-locale failures do not throw: they are recorded on the `RunSummary` so one failing locale never aborts the others.
185
+ `SdkError` is the SDK's own structured error type, thrown for whole-run failures such as a missing or invalid config or an unreadable source file. It carries a stable `code` from the exported `SdkErrorCode` union and never contains an API key. It is not the only error a caller can see: `retranslateEntry` propagates the provider's own `ProviderError` when the provider call fails or returns nothing for the key, and a target locale file that exists but is malformed rejects with the adapter's own error.
186
+
187
+ Per-locale failures do not throw: they are recorded on the `RunSummary` so one failing locale never aborts the others, and that includes a locale whose write lock could not be acquired. A locale's `error.code` is a preserved string from the underlying provider or adapter failure (`"LOCALE_FAILED"` is only the fallback), deliberately wider than `SdkErrorCode`, so do not treat it as a closed set.
174
188
 
175
189
  ## Documentation
176
190
 
package/dist/index.cjs CHANGED
@@ -61,27 +61,31 @@ var translationMemorySchema = zod.z.object({
61
61
  function cacheFilePath(cwd) {
62
62
  return path.resolve(cwd, CACHE_FILE_NAME);
63
63
  }
64
+ var UNUSABLE = { memory: EMPTY_MEMORY, writable: true };
64
65
  async function readTranslationMemory(path, fs) {
65
66
  let read;
66
67
  try {
67
68
  read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
68
69
  } catch {
69
- return EMPTY_MEMORY;
70
+ return UNUSABLE;
70
71
  }
71
72
  if (read.kind !== "ok") {
72
- return EMPTY_MEMORY;
73
+ return UNUSABLE;
73
74
  }
74
75
  let parsed;
75
76
  try {
76
77
  parsed = JSON.parse(read.content);
77
78
  } catch {
78
- return EMPTY_MEMORY;
79
+ return UNUSABLE;
79
80
  }
80
81
  const result = translationMemorySchema.safeParse(parsed);
81
- if (!result.success || result.data.version !== CURRENT_VERSION) {
82
- return EMPTY_MEMORY;
82
+ if (!result.success) {
83
+ return UNUSABLE;
83
84
  }
84
- return result.data;
85
+ if (result.data.version !== CURRENT_VERSION) {
86
+ return { memory: EMPTY_MEMORY, writable: false };
87
+ }
88
+ return { memory: result.data, writable: true };
85
89
  }
86
90
  function lookupMemory(memory, fingerprint, locale, contentHash2) {
87
91
  return memory.entries[fingerprint]?.[locale]?.[contentHash2];
@@ -133,7 +137,10 @@ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
133
137
  }
134
138
  try {
135
139
  const path = cacheFilePath(cwd);
136
- const memory = await readTranslationMemory(path, fs);
140
+ const { memory, writable } = await readTranslationMemory(path, fs);
141
+ if (!writable) {
142
+ return;
143
+ }
137
144
  await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
138
145
  } catch {
139
146
  }
@@ -145,6 +152,18 @@ function defineConfig(config) {
145
152
  }
146
153
 
147
154
  // src/errors.ts
155
+ function errorMessage(error) {
156
+ return error instanceof Error ? error.message : String(error);
157
+ }
158
+ function stringCode(error) {
159
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
160
+ return error.code;
161
+ }
162
+ return void 0;
163
+ }
164
+ function describeError(error, fallbackCode) {
165
+ return { code: stringCode(error) ?? fallbackCode, message: errorMessage(error) };
166
+ }
148
167
  var SdkError = class extends Error {
149
168
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
150
169
  code;
@@ -1942,7 +1961,7 @@ async function loadExplicitWithMeta(explorer, configPath, cwd, fs$1) {
1942
1961
  try {
1943
1962
  result = await explorer.load(resolved);
1944
1963
  } catch (error) {
1945
- const detail = error instanceof Error ? error.message : String(error);
1964
+ const detail = errorMessage(error);
1946
1965
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1947
1966
  }
1948
1967
  const parsed = parseConfig(result?.config);
@@ -1967,7 +1986,7 @@ async function loadConfigWithMeta(options = {}) {
1967
1986
  try {
1968
1987
  result = await explorer.search(options.cwd);
1969
1988
  } catch (error) {
1970
- const detail = error instanceof Error ? error.message : String(error);
1989
+ const detail = errorMessage(error);
1971
1990
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1972
1991
  }
1973
1992
  if (result === null || result.isEmpty === true) {
@@ -2052,15 +2071,15 @@ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
2052
2071
  if (await fs.createExclusive(path, lockPayload())) {
2053
2072
  return;
2054
2073
  }
2074
+ if (notify !== void 0) {
2075
+ await notify();
2076
+ }
2055
2077
  if (Date.now() >= deadline) {
2056
2078
  throw new SdkError(
2057
2079
  "LOCK_CONTENDED",
2058
2080
  `Could not acquire the write lock at ${path}: another process may be holding it. If no verbatra process is currently running, this lock file was likely left behind by one that was killed; delete it and retry.`
2059
2081
  );
2060
2082
  }
2061
- if (notify !== void 0) {
2062
- await notify();
2063
- }
2064
2083
  const jitter = Math.random() * pollIntervalMs;
2065
2084
  await sleep(pollIntervalMs + jitter);
2066
2085
  }
@@ -2650,6 +2669,9 @@ async function fsyncDirBestEffort(path) {
2650
2669
  }
2651
2670
  }
2652
2671
  var nodeOps = {
2672
+ mkdir: async (path) => {
2673
+ await promises.mkdir(path, { recursive: true });
2674
+ },
2653
2675
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
2654
2676
  fsyncFile: (path) => fsyncPath(path),
2655
2677
  rename: (from, to) => promises.rename(from, to),
@@ -2666,8 +2688,10 @@ function tempFileName2(path$1) {
2666
2688
  return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
2667
2689
  }
2668
2690
  async function atomicWriteFile(path$1, data, ops = nodeOps) {
2691
+ const directory = path.dirname(path$1);
2669
2692
  const tmp = tempFileName2(path$1);
2670
2693
  try {
2694
+ await ops.mkdir(directory);
2671
2695
  await ops.writeFile(tmp, data);
2672
2696
  await ops.fsyncFile(tmp);
2673
2697
  await ops.rename(tmp, path$1);
@@ -2676,7 +2700,7 @@ async function atomicWriteFile(path$1, data, ops = nodeOps) {
2676
2700
  throw error;
2677
2701
  }
2678
2702
  try {
2679
- await ops.fsyncDir(path.dirname(path$1));
2703
+ await ops.fsyncDir(directory);
2680
2704
  } catch {
2681
2705
  }
2682
2706
  }
@@ -3339,6 +3363,12 @@ function extractPropertiesPlaceholders(value) {
3339
3363
  var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3340
3364
  var LEADING_WHITESPACE = /^[ \t\f]+/;
3341
3365
  var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3366
+ function detectLineTerminator(content) {
3367
+ if (content.includes("\r\n")) {
3368
+ return "\r\n";
3369
+ }
3370
+ return content.includes("\r") ? "\r" : "\n";
3371
+ }
3342
3372
  function isPropertiesWhitespace(char) {
3343
3373
  return char === " " || char === " " || char === "\f";
3344
3374
  }
@@ -3544,7 +3574,7 @@ async function readStructure(filePath) {
3544
3574
  outcome = await readBounded2(filePath);
3545
3575
  } catch (error) {
3546
3576
  if (isFileNotFound(error)) {
3547
- return [];
3577
+ return { items: [], terminator: "\n" };
3548
3578
  }
3549
3579
  throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
3550
3580
  }
@@ -3554,13 +3584,16 @@ async function readStructure(filePath) {
3554
3584
  if (outcome.kind === "too-large") {
3555
3585
  throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
3556
3586
  }
3557
- return parseItems(outcome.content);
3587
+ return {
3588
+ items: parseItems(outcome.content),
3589
+ terminator: detectLineTerminator(outcome.content)
3590
+ };
3558
3591
  }
3559
3592
  async function serializePropertiesEntries(entries, filePath) {
3560
- const structure = await readStructure(filePath);
3593
+ const { items, terminator } = await readStructure(filePath);
3561
3594
  const emitted = /* @__PURE__ */ new Set();
3562
3595
  const lines = [];
3563
- for (const item of structure) {
3596
+ for (const item of items) {
3564
3597
  if (item.kind === "raw") {
3565
3598
  lines.push(item.text);
3566
3599
  continue;
@@ -3576,8 +3609,7 @@ async function serializePropertiesEntries(entries, filePath) {
3576
3609
  lines.push(formatEntry(key, entry.value));
3577
3610
  }
3578
3611
  }
3579
- return lines.length === 0 ? "" : `${lines.join("\n")}
3580
- `;
3612
+ return lines.length === 0 ? "" : `${lines.join(terminator)}${terminator}`;
3581
3613
  }
3582
3614
  function createPropertiesAdapter() {
3583
3615
  return createFlatFileAdapter({
@@ -4029,7 +4061,7 @@ async function readSource(config, cwd, fs, adapter) {
4029
4061
  try {
4030
4062
  return await adapter.read(sourcePath, config.sourceLocale);
4031
4063
  } catch (error) {
4032
- const detail = error instanceof Error ? error.message : String(error);
4064
+ const detail = errorMessage(error);
4033
4065
  throw new SdkError(
4034
4066
  "SOURCE_INVALID",
4035
4067
  `The source locale file at ${sourcePath} could not be read: ${detail}`
@@ -4130,6 +4162,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
4130
4162
  if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
4131
4163
  return { accepted: false, reason: "degenerate" };
4132
4164
  }
4165
+ if (sourceEntry.value.trim() !== "" && candidateValue.trim() === "") {
4166
+ return { accepted: false, reason: "empty" };
4167
+ }
4133
4168
  return { accepted: true };
4134
4169
  }
4135
4170
 
@@ -4326,7 +4361,7 @@ function selectProvider(config, createProvider = buildProvider) {
4326
4361
  try {
4327
4362
  return createProvider(config);
4328
4363
  } catch (error) {
4329
- const detail = error instanceof Error ? error.message : String(error);
4364
+ const detail = errorMessage(error);
4330
4365
  throw new SdkError(
4331
4366
  "PROVIDER_CONSTRUCTION_FAILED",
4332
4367
  `Failed to construct provider "${config.id}": ${detail}`
@@ -4549,13 +4584,6 @@ function budgetExceededNotice(tracker) {
4549
4584
  }
4550
4585
 
4551
4586
  // src/flow/locale-failure.ts
4552
- function describeError(error) {
4553
- if (error instanceof Error) {
4554
- const code = error.code;
4555
- return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
4556
- }
4557
- return { code: "LOCALE_FAILED", message: String(error) };
4558
- }
4559
4587
  function failureSummary(locale, error) {
4560
4588
  return {
4561
4589
  locale,
@@ -4575,7 +4603,7 @@ function failureSummary(locale, error) {
4575
4603
  unfilled: [],
4576
4604
  malformedRows: [],
4577
4605
  duplicateKeys: [],
4578
- error: describeError(error)
4606
+ error: describeError(error, "LOCALE_FAILED")
4579
4607
  };
4580
4608
  }
4581
4609
  function deriveLocaleStatus(parts) {
@@ -4979,6 +5007,10 @@ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4979
5007
  if (source === void 0) {
4980
5008
  continue;
4981
5009
  }
5010
+ if (!gateCandidateValue(source, acceptedRepresentative.value, params.adapter).accepted) {
5011
+ outcome.integrityMismatches.push(key);
5012
+ continue;
5013
+ }
4982
5014
  outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4983
5015
  if (flag !== void 0) {
4984
5016
  outcome.reviewFlags.set(key, flag);
@@ -5005,6 +5037,12 @@ function buildRequest3(params, entries) {
5005
5037
  ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
5006
5038
  };
5007
5039
  }
5040
+ async function shouldWriteTarget(params, path, changed) {
5041
+ if (changed.accepted > 0 || changed.pruned > 0 || changed.generated > 0) {
5042
+ return true;
5043
+ }
5044
+ return !await params.fs.fileExists(path);
5045
+ }
5008
5046
  async function runLocale(params) {
5009
5047
  const target = await readTargetResource({
5010
5048
  cwd: params.cwd,
@@ -5090,15 +5128,22 @@ async function runLocale(params) {
5090
5128
  merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
5091
5129
  }
5092
5130
  const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
5093
- await params.adapter.write(
5094
- {
5095
- locale: params.targetLocale,
5096
- namespace: target.namespace,
5097
- format: params.format,
5098
- entries: merged
5099
- },
5100
- path
5101
- );
5131
+ const writeNeeded = await shouldWriteTarget(params, path, {
5132
+ accepted: accepted.size,
5133
+ pruned: pruned.length,
5134
+ generated: generation.accepted.length
5135
+ });
5136
+ if (writeNeeded) {
5137
+ await params.adapter.write(
5138
+ {
5139
+ locale: params.targetLocale,
5140
+ namespace: target.namespace,
5141
+ format: params.format,
5142
+ entries: merged
5143
+ },
5144
+ path
5145
+ );
5146
+ }
5102
5147
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
5103
5148
  const notices = [
5104
5149
  ...pluralNotices,
@@ -5370,14 +5415,21 @@ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5370
5415
  if (dryRun || input.cache === false) {
5371
5416
  return void 0;
5372
5417
  }
5373
- return {
5374
- memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5375
- fingerprint: computeFingerprint(config),
5376
- additions: /* @__PURE__ */ new Map()
5418
+ const { memory, writable } = await readTranslationMemory(cacheFilePath(cwd), fs);
5419
+ return { memory, writable, fingerprint: computeFingerprint(config), additions: /* @__PURE__ */ new Map() };
5420
+ }
5421
+ function withCacheNotices(summaries, cache) {
5422
+ if (cache === void 0 || cache.writable) {
5423
+ return [...summaries];
5424
+ }
5425
+ const notice = {
5426
+ code: "CACHE_VERSION_UNRECOGNIZED",
5427
+ message: `${CACHE_FILE_NAME} carries a version this build does not recognize, so it was written by a newer verbatra. It was left untouched and this run used no cache. Upgrade verbatra, or delete the file to rebuild it in this build's format.`
5377
5428
  };
5429
+ return summaries.map((summary) => ({ ...summary, notices: [...summary.notices, notice] }));
5378
5430
  }
5379
5431
  async function recordCacheAdditions(cwd, cache, fs) {
5380
- if (cache === void 0 || cache.additions.size === 0) {
5432
+ if (cache === void 0 || cache.additions.size === 0 || !cache.writable) {
5381
5433
  return;
5382
5434
  }
5383
5435
  try {
@@ -5464,18 +5516,25 @@ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results)
5464
5516
  context.onProgress?.({
5465
5517
  type: "locale-finished",
5466
5518
  locale: targetLocale,
5467
- translated: summary.translated.length
5519
+ translated: summary.translated.length,
5520
+ localeIndex,
5521
+ totalLocales: targetLocales.length
5468
5522
  });
5469
5523
  }
5470
5524
  async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5471
5525
  const totalLocales = targetLocales.length;
5472
5526
  const results = new Array(totalLocales);
5473
5527
  let nextIndex = 0;
5528
+ let abort;
5474
5529
  async function worker() {
5475
- while (nextIndex < totalLocales) {
5530
+ while (abort === void 0 && nextIndex < totalLocales) {
5476
5531
  const localeIndex = nextIndex;
5477
5532
  nextIndex += 1;
5478
- await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5533
+ try {
5534
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5535
+ } catch (error) {
5536
+ abort ??= { reason: error };
5537
+ }
5479
5538
  }
5480
5539
  }
5481
5540
  const workerCount = Math.min(concurrency, totalLocales);
@@ -5484,6 +5543,9 @@ async function runLocalesWithProgress(context, targetLocales, runOne, concurrenc
5484
5543
  workers.push(worker());
5485
5544
  }
5486
5545
  await Promise.all(workers);
5546
+ if (abort !== void 0) {
5547
+ throw abort.reason;
5548
+ }
5487
5549
  return results.filter((summary) => summary !== void 0);
5488
5550
  }
5489
5551
  async function runAllLocalesDry(context, targetLocales, concurrency) {
@@ -5560,7 +5622,8 @@ async function translate2(input, deps = {}) {
5560
5622
  };
5561
5623
  const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5562
5624
  input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
5563
- const { succeeded, partial, failed } = partition(summaries);
5625
+ const locales = withCacheNotices(summaries, cache);
5626
+ const { succeeded, partial, failed } = partition(locales);
5564
5627
  const usage = summaries.reduce(
5565
5628
  (total, summary2) => combineUsage(total, summary2.usage),
5566
5629
  void 0
@@ -5568,7 +5631,7 @@ async function translate2(input, deps = {}) {
5568
5631
  const budgetSummary = toBudgetSummary(budget);
5569
5632
  const summary = {
5570
5633
  dryRun,
5571
- locales: summaries,
5634
+ locales,
5572
5635
  succeeded,
5573
5636
  partial,
5574
5637
  failed,
@@ -6140,12 +6203,12 @@ function classifyClear(row, sourceEntry, buckets) {
6140
6203
  buckets.withheld.add(row.key);
6141
6204
  return;
6142
6205
  }
6143
- buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6206
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry, cleared: true });
6144
6207
  }
6145
- function classifyRows(params, buckets) {
6208
+ function classifyRows(params, buckets, liveCandidates) {
6146
6209
  for (const row of params.sheet.rows) {
6147
6210
  if (row.translation === "") {
6148
- if (row.status === "changed") {
6211
+ if (liveCandidates.has(row.key)) {
6149
6212
  buckets.unfilled.push(row.key);
6150
6213
  }
6151
6214
  trackBlankDrift(row, params, buckets);
@@ -6164,7 +6227,11 @@ function classifyRows(params, buckets) {
6164
6227
  }
6165
6228
  const reason = judge(row, sourceEntry, params.adapter);
6166
6229
  if (reason === void 0) {
6167
- buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
6230
+ buckets.accepted.set(row.key, {
6231
+ value: row.translation,
6232
+ source: sourceEntry,
6233
+ cleared: false
6234
+ });
6168
6235
  } else {
6169
6236
  buckets.mismatches.push(row.key);
6170
6237
  buckets.withheld.add(row.key);
@@ -6186,7 +6253,7 @@ function importLocale(params) {
6186
6253
  blankDrifted: /* @__PURE__ */ new Set(),
6187
6254
  unfilled: []
6188
6255
  };
6189
- classifyRows(params, buckets);
6256
+ classifyRows(params, buckets, /* @__PURE__ */ new Set([...diff2.missing, ...diff2.changed]));
6190
6257
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
6191
6258
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
6192
6259
  const translated = [...buckets.accepted.keys()].sort();
@@ -6244,8 +6311,10 @@ function mergeAccepted(target, accepted) {
6244
6311
  }
6245
6312
  function sheetCacheAdditions(accepted) {
6246
6313
  const record = {};
6247
- for (const [, { value, source }] of accepted) {
6248
- record[contentHash(source)] = value;
6314
+ for (const [, { value, source, cleared }] of accepted) {
6315
+ if (!cleared) {
6316
+ record[contentHash(source)] = value;
6317
+ }
6249
6318
  }
6250
6319
  return record;
6251
6320
  }
@@ -6334,7 +6403,7 @@ async function importWorkbook(input, deps = {}) {
6334
6403
  try {
6335
6404
  data = await readWorkbook(bytes);
6336
6405
  } catch (error) {
6337
- throw new SdkError("SOURCE_INVALID", error.message);
6406
+ throw new SdkError("SOURCE_INVALID", errorMessage(error));
6338
6407
  }
6339
6408
  const lock = await readLockFile(lockFilePath(cwd), fs);
6340
6409
  const ctx = {
@@ -6420,17 +6489,11 @@ function defaultRunTranslate(deps) {
6420
6489
 
6421
6490
  // src/watch/watch.ts
6422
6491
  var DEFAULT_DEBOUNCE_MS = 300;
6423
- function describeError2(error) {
6424
- if (error instanceof Error) {
6425
- const code = error.code;
6426
- return { code: typeof code === "string" ? code : "WATCH_RUN_FAILED", message: error.message };
6427
- }
6428
- return { code: "WATCH_RUN_FAILED", message: String(error) };
6429
- }
6430
6492
  async function watch(input, deps = {}) {
6431
6493
  const cwd = input.cwd ?? process.cwd();
6432
6494
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6433
6495
  const fs = deps.fs ?? defaultFs;
6496
+ resolveRunConcurrency(input.concurrency, false, input.config);
6434
6497
  const sourcePath = localeFilePath(cwd, input.config.files.pattern, input.config.sourceLocale);
6435
6498
  if (!await fs.fileExists(sourcePath)) {
6436
6499
  throw new SdkError(
@@ -6457,7 +6520,7 @@ async function watch(input, deps = {}) {
6457
6520
  try {
6458
6521
  input.onRun({ status: "succeeded", summary: await runTranslate(runInput) });
6459
6522
  } catch (error) {
6460
- input.onRun({ status: "failed", error: describeError2(error) });
6523
+ input.onRun({ status: "failed", error: describeError(error, "WATCH_RUN_FAILED") });
6461
6524
  }
6462
6525
  }
6463
6526
  function startRun() {