@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 +27 -13
- package/dist/index.cjs +127 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -10
- package/dist/index.d.ts +64 -10
- package/dist/index.js +127 -64
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
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(
|
|
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,
|
|
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.
|
|
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`,
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
+
return UNUSABLE;
|
|
70
71
|
}
|
|
71
72
|
if (read.kind !== "ok") {
|
|
72
|
-
return
|
|
73
|
+
return UNUSABLE;
|
|
73
74
|
}
|
|
74
75
|
let parsed;
|
|
75
76
|
try {
|
|
76
77
|
parsed = JSON.parse(read.content);
|
|
77
78
|
} catch {
|
|
78
|
-
return
|
|
79
|
+
return UNUSABLE;
|
|
79
80
|
}
|
|
80
81
|
const result = translationMemorySchema.safeParse(parsed);
|
|
81
|
-
if (!result.success
|
|
82
|
-
return
|
|
82
|
+
if (!result.success) {
|
|
83
|
+
return UNUSABLE;
|
|
83
84
|
}
|
|
84
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
3587
|
+
return {
|
|
3588
|
+
items: parseItems(outcome.content),
|
|
3589
|
+
terminator: detectLineTerminator(outcome.content)
|
|
3590
|
+
};
|
|
3558
3591
|
}
|
|
3559
3592
|
async function serializePropertiesEntries(entries, filePath) {
|
|
3560
|
-
const
|
|
3593
|
+
const { items, terminator } = await readStructure(filePath);
|
|
3561
3594
|
const emitted = /* @__PURE__ */ new Set();
|
|
3562
3595
|
const lines = [];
|
|
3563
|
-
for (const item of
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
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
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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:
|
|
6523
|
+
input.onRun({ status: "failed", error: describeError(error, "WATCH_RUN_FAILED") });
|
|
6461
6524
|
}
|
|
6462
6525
|
}
|
|
6463
6526
|
function startRun() {
|