@verbatra/sdk 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync } from 'fs';
2
1
  import { dirname, resolve, join, basename, extname } from 'path';
2
+ import { z } from 'zod';
3
+ import { existsSync } from 'fs';
3
4
  import { cosmiconfig } from 'cosmiconfig';
4
5
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
6
  import { randomUUID } from 'crypto';
6
7
  import { rm, access, mkdir, open, writeFile, rename } from 'fs/promises';
7
- import { z } from 'zod';
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
9
  import * as deepl from 'deepl-node';
10
10
  import { createRequire } from 'module';
@@ -15,10 +15,100 @@ import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
15
15
  import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
16
  import { stringify, parse as parse$1 } from 'yaml';
17
17
  import ExcelJS from 'exceljs';
18
- import { Readable } from 'stream';
19
18
  import JSZip from 'jszip';
19
+ import { Readable } from 'stream';
20
20
  import { watch as watch$1 } from 'chokidar';
21
21
 
22
+ // src/cache/translation-memory.ts
23
+ var CACHE_FILE_NAME = "verbatra.cache.json";
24
+ var CURRENT_VERSION = 1;
25
+ var EMPTY_MEMORY = { version: CURRENT_VERSION, entries: {} };
26
+ var MAX_CACHE_FILE_BYTES = 64 * 1024 * 1024;
27
+ var translationMemorySchema = z.object({
28
+ version: z.number().int().positive(),
29
+ entries: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.string())))
30
+ });
31
+ function cacheFilePath(cwd) {
32
+ return resolve(cwd, CACHE_FILE_NAME);
33
+ }
34
+ async function readTranslationMemory(path, fs) {
35
+ let read;
36
+ try {
37
+ read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
38
+ } catch {
39
+ return EMPTY_MEMORY;
40
+ }
41
+ if (read.kind !== "ok") {
42
+ return EMPTY_MEMORY;
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(read.content);
47
+ } catch {
48
+ return EMPTY_MEMORY;
49
+ }
50
+ const result = translationMemorySchema.safeParse(parsed);
51
+ if (!result.success || result.data.version !== CURRENT_VERSION) {
52
+ return EMPTY_MEMORY;
53
+ }
54
+ return result.data;
55
+ }
56
+ function lookupMemory(memory, fingerprint, locale, contentHash2) {
57
+ return memory.entries[fingerprint]?.[locale]?.[contentHash2];
58
+ }
59
+ function applyAdditions(base, fingerprint, additionsByLocale) {
60
+ if (additionsByLocale.size === 0) {
61
+ return base;
62
+ }
63
+ const fingerprintEntries = {};
64
+ for (const [locale, hashes] of Object.entries(base.entries[fingerprint] ?? {})) {
65
+ fingerprintEntries[locale] = { ...hashes };
66
+ }
67
+ for (const [locale, hashes] of additionsByLocale) {
68
+ fingerprintEntries[locale] = { ...fingerprintEntries[locale], ...hashes };
69
+ }
70
+ return {
71
+ version: base.version,
72
+ entries: { ...base.entries, [fingerprint]: fingerprintEntries }
73
+ };
74
+ }
75
+ function additionsToRecord(additions) {
76
+ const record = {};
77
+ for (const addition of additions) {
78
+ record[addition.contentHash] = addition.value;
79
+ }
80
+ return record;
81
+ }
82
+ function sortEntries(record) {
83
+ return Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1);
84
+ }
85
+ function serialize(memory) {
86
+ const entries = {};
87
+ for (const [fingerprint, locales] of sortEntries(memory.entries)) {
88
+ const localeMap = {};
89
+ for (const [locale, hashes] of sortEntries(locales)) {
90
+ localeMap[locale] = Object.fromEntries(sortEntries(hashes));
91
+ }
92
+ entries[fingerprint] = localeMap;
93
+ }
94
+ return `${JSON.stringify({ version: memory.version, entries }, null, 2)}
95
+ `;
96
+ }
97
+ async function writeTranslationMemory(path, memory, fs) {
98
+ await fs.writeFile(path, serialize(memory));
99
+ }
100
+ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
101
+ if (additionsByLocale.size === 0) {
102
+ return;
103
+ }
104
+ try {
105
+ const path = cacheFilePath(cwd);
106
+ const memory = await readTranslationMemory(path, fs);
107
+ await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
108
+ } catch {
109
+ }
110
+ }
111
+
22
112
  // src/config/define-config.ts
23
113
  function defineConfig(config) {
24
114
  return config;
@@ -212,7 +302,7 @@ async function resolveGlossary(glossary, baseDir, fs) {
212
302
  var FNV_OFFSET_BASIS = 14695981039346656037n;
213
303
  var FNV_PRIME = 1099511628211n;
214
304
  var U64_MASK = (1n << 64n) - 1n;
215
- function fnv1a64(input) {
305
+ function stableStringHash(input) {
216
306
  let hash = FNV_OFFSET_BASIS;
217
307
  for (let index = 0; index < input.length; index += 1) {
218
308
  hash ^= BigInt(input.charCodeAt(index));
@@ -233,7 +323,7 @@ function canonicalize(entry) {
233
323
  ]);
234
324
  }
235
325
  function contentHash(entry) {
236
- return fnv1a64(canonicalize(entry));
326
+ return stableStringHash(canonicalize(entry));
237
327
  }
238
328
  function sorted(keys) {
239
329
  return [...keys].sort();
@@ -278,7 +368,8 @@ var SUPPORTED_FORMATS = [
278
368
  "ngx-translate-json",
279
369
  "xliff",
280
370
  "yaml",
281
- "arb"
371
+ "arb",
372
+ "properties"
282
373
  ];
283
374
  var supportedFormatSchema = z.enum(SUPPORTED_FORMATS);
284
375
  var translationEntrySchema = z.object({
@@ -1220,14 +1311,14 @@ function throwAbort(signal) {
1220
1311
  throw new DOMException("This operation was aborted.", "AbortError");
1221
1312
  }
1222
1313
  function delay(ms, signal) {
1223
- return new Promise((resolve9) => {
1314
+ return new Promise((resolve10) => {
1224
1315
  const onAbort = () => {
1225
1316
  clearTimeout(timer);
1226
- resolve9();
1317
+ resolve10();
1227
1318
  };
1228
1319
  const timer = setTimeout(() => {
1229
1320
  signal?.removeEventListener("abort", onAbort);
1230
- resolve9();
1321
+ resolve10();
1231
1322
  }, ms);
1232
1323
  signal?.addEventListener("abort", onAbort, { once: true });
1233
1324
  });
@@ -1965,8 +2056,8 @@ async function withLockFileGuard(cwd, fs, fn, options = {}) {
1965
2056
 
1966
2057
  // src/lock/lock-file.ts
1967
2058
  var LOCK_FILE_NAME = "verbatra.lock.json";
1968
- var CURRENT_VERSION = 1;
1969
- var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
2059
+ var CURRENT_VERSION2 = 1;
2060
+ var EMPTY_LOCK = { version: CURRENT_VERSION2, locales: {} };
1970
2061
  var MAX_LOCK_FILE_BYTES = 16 * 1024 * 1024;
1971
2062
  var lockFileSchema = z.object({
1972
2063
  version: z.number().int().positive(),
@@ -1995,10 +2086,10 @@ function parseLockFileRead(read, path) {
1995
2086
  if (!result.success) {
1996
2087
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1997
2088
  }
1998
- if (result.data.version !== CURRENT_VERSION) {
2089
+ if (result.data.version !== CURRENT_VERSION2) {
1999
2090
  throw new SdkError(
2000
2091
  "LOCK_FILE_INVALID",
2001
- `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
2092
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION2}.`
2002
2093
  );
2003
2094
  }
2004
2095
  return result.data;
@@ -2788,7 +2879,7 @@ function createTreeFileAdapter(options) {
2788
2879
  extensions,
2789
2880
  sniff,
2790
2881
  parse: parse3,
2791
- serialize,
2882
+ serialize: serialize2,
2792
2883
  deriveEntry,
2793
2884
  extractPlaceholders,
2794
2885
  computeInvalidIcuKeys,
@@ -2823,7 +2914,7 @@ function createTreeFileAdapter(options) {
2823
2914
  },
2824
2915
  async write(resource, filePath) {
2825
2916
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
2826
- await atomicWriteFile(filePath, serialize(tree));
2917
+ await atomicWriteFile(filePath, serialize2(tree));
2827
2918
  }
2828
2919
  };
2829
2920
  }
@@ -3080,6 +3171,393 @@ function createNgxTranslateJsonAdapter() {
3080
3171
  keyMode: "path-notation"
3081
3172
  });
3082
3173
  }
3174
+ function toEntries2(content, namespace, parseEntries) {
3175
+ try {
3176
+ return parseEntries(content, namespace);
3177
+ } catch (error) {
3178
+ rethrowStructured(error, "The file could not be parsed.");
3179
+ }
3180
+ }
3181
+ function createFlatFileAdapter(options) {
3182
+ const {
3183
+ format,
3184
+ extensions,
3185
+ sniff,
3186
+ parseEntries,
3187
+ serializeEntries,
3188
+ extractPlaceholders,
3189
+ validateMessage,
3190
+ computeInvalidIcuKeys
3191
+ } = options;
3192
+ return {
3193
+ format,
3194
+ canHandle: buildCanHandle(extensions, sniff),
3195
+ extractPlaceholders,
3196
+ validateMessage: validateMessage ?? (() => true),
3197
+ async read(filePath, locale) {
3198
+ const content = await readFileContent(filePath);
3199
+ const namespace = namespaceOf(filePath);
3200
+ const entries = toEntries2(content, namespace, parseEntries);
3201
+ const resource = { locale, namespace, format, entries };
3202
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3203
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3204
+ },
3205
+ async write(resource, filePath) {
3206
+ const data = await serializeEntries(resource.entries, filePath);
3207
+ await atomicWriteFile(filePath, data);
3208
+ }
3209
+ };
3210
+ }
3211
+ var SUBMESSAGE_TYPES = /* @__PURE__ */ new Set(["plural", "select", "selectordinal", "choice"]);
3212
+ var ARGUMENT_NAME = /^(?:\d+|[A-Za-z_$][\w$-]*)$/;
3213
+ function matchingBraces(value) {
3214
+ const close = new Int32Array(value.length).fill(-1);
3215
+ const open3 = [];
3216
+ for (let i = 0; i < value.length; i += 1) {
3217
+ const char = value[i];
3218
+ if (char === "{") {
3219
+ open3.push(i);
3220
+ } else if (char === "}") {
3221
+ const start = open3.pop();
3222
+ if (start !== void 0) {
3223
+ close[start] = i;
3224
+ }
3225
+ }
3226
+ }
3227
+ return close;
3228
+ }
3229
+ function findNameEnd(value, from, limit) {
3230
+ for (let i = from; i < limit; i += 1) {
3231
+ const char = value[i];
3232
+ if (char === ",") {
3233
+ return { comma: i, hasBrace: false };
3234
+ }
3235
+ if (char === "{" || char === "}") {
3236
+ return { comma: -1, hasBrace: true };
3237
+ }
3238
+ }
3239
+ return { comma: -1, hasBrace: false };
3240
+ }
3241
+ function parseArgumentAt(value, open3, close) {
3242
+ const { comma, hasBrace } = findNameEnd(value, open3 + 1, close);
3243
+ if (hasBrace) {
3244
+ return null;
3245
+ }
3246
+ const nameEnd = comma === -1 ? close : comma;
3247
+ const name = value.slice(open3 + 1, nameEnd).trim();
3248
+ if (!ARGUMENT_NAME.test(name)) {
3249
+ return null;
3250
+ }
3251
+ if (comma === -1) {
3252
+ return { name, type: null, styleStart: -1 };
3253
+ }
3254
+ const secondComma = value.indexOf(",", comma + 1);
3255
+ if (secondComma === -1 || secondComma >= close) {
3256
+ return { name, type: value.slice(comma + 1, close).trim(), styleStart: -1 };
3257
+ }
3258
+ return { name, type: value.slice(comma + 1, secondComma).trim(), styleStart: secondComma + 1 };
3259
+ }
3260
+ function canonicalToken(value, arg, close) {
3261
+ if (arg.type === null) {
3262
+ return `{${arg.name}}`;
3263
+ }
3264
+ if (arg.styleStart === -1) {
3265
+ return `{${arg.name},${arg.type}}`;
3266
+ }
3267
+ return `{${arg.name},${arg.type},${value.slice(arg.styleStart, close).trim()}}`;
3268
+ }
3269
+ function emitArgument(value, close, arg, closeIndex, out) {
3270
+ if (arg.type !== null && SUBMESSAGE_TYPES.has(arg.type)) {
3271
+ out.push(`{${arg.name},${arg.type}}`);
3272
+ if (arg.styleStart !== -1) {
3273
+ scanRange(value, close, arg.styleStart, closeIndex, false, out);
3274
+ }
3275
+ return;
3276
+ }
3277
+ out.push(canonicalToken(value, arg, closeIndex));
3278
+ }
3279
+ function scanRange(value, close, start, end, topLevel, out) {
3280
+ let i = start;
3281
+ while (i < end) {
3282
+ if (value[i] !== "{") {
3283
+ i += 1;
3284
+ continue;
3285
+ }
3286
+ if (topLevel && value[i + 1] === "{") {
3287
+ i += 2;
3288
+ continue;
3289
+ }
3290
+ const closeIndex = close[i] ?? -1;
3291
+ if (closeIndex === -1 || closeIndex >= end) {
3292
+ i += 1;
3293
+ continue;
3294
+ }
3295
+ const arg = parseArgumentAt(value, i, closeIndex);
3296
+ if (arg === null) {
3297
+ i += 1;
3298
+ continue;
3299
+ }
3300
+ emitArgument(value, close, arg, closeIndex, out);
3301
+ i = closeIndex + 1;
3302
+ }
3303
+ }
3304
+ function extractPropertiesPlaceholders(value) {
3305
+ const out = [];
3306
+ scanRange(value, matchingBraces(value), 0, value.length, true, out);
3307
+ return out;
3308
+ }
3309
+ var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3310
+ var LEADING_WHITESPACE = /^[ \t\f]+/;
3311
+ var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3312
+ function isPropertiesWhitespace(char) {
3313
+ return char === " " || char === " " || char === "\f";
3314
+ }
3315
+ function splitPhysicalLines(content) {
3316
+ if (content === "") {
3317
+ return [];
3318
+ }
3319
+ const lines = content.split(/\r\n|\r|\n/);
3320
+ if (TRAILING_TERMINATOR.test(content)) {
3321
+ lines.pop();
3322
+ }
3323
+ return lines;
3324
+ }
3325
+ function countTrailingBackslashes(line) {
3326
+ let count = 0;
3327
+ for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i -= 1) {
3328
+ count += 1;
3329
+ }
3330
+ return count;
3331
+ }
3332
+ function isContinued(line) {
3333
+ return countTrailingBackslashes(line) % 2 === 1;
3334
+ }
3335
+ function isBlankLine(line) {
3336
+ return line.trim() === "";
3337
+ }
3338
+ function isCommentLine(line) {
3339
+ const trimmed = line.replace(LEADING_WHITESPACE, "");
3340
+ return trimmed.startsWith("#") || trimmed.startsWith("!");
3341
+ }
3342
+ function joinContinuation(lines, start) {
3343
+ const pieces = [];
3344
+ let index = start;
3345
+ let current = lines[start] ?? "";
3346
+ while (isContinued(current) && index + 1 < lines.length) {
3347
+ pieces.push(current.slice(0, -1));
3348
+ index += 1;
3349
+ current = (lines[index] ?? "").replace(LEADING_WHITESPACE, "");
3350
+ }
3351
+ if (isContinued(current)) {
3352
+ current = current.slice(0, -1);
3353
+ }
3354
+ pieces.push(current);
3355
+ return { logical: pieces.join(""), nextIndex: index + 1 };
3356
+ }
3357
+ function decodeSimpleEscape(char) {
3358
+ switch (char) {
3359
+ case "t":
3360
+ return " ";
3361
+ case "n":
3362
+ return "\n";
3363
+ case "r":
3364
+ return "\r";
3365
+ case "f":
3366
+ return "\f";
3367
+ default:
3368
+ return char;
3369
+ }
3370
+ }
3371
+ function decodeUnicodeEscape(raw, at) {
3372
+ const hex = raw.slice(at, at + 4);
3373
+ if (!UNICODE_ESCAPE.test(hex)) {
3374
+ throw new AdapterError("INVALID_STRUCTURE", "The file has a malformed unicode escape.");
3375
+ }
3376
+ return String.fromCharCode(Number.parseInt(hex, 16));
3377
+ }
3378
+ function decodeEscapes(raw) {
3379
+ let out = "";
3380
+ let i = 0;
3381
+ while (i < raw.length) {
3382
+ const char = raw[i];
3383
+ if (char !== "\\") {
3384
+ out += char;
3385
+ i += 1;
3386
+ continue;
3387
+ }
3388
+ const next = raw[i + 1];
3389
+ if (next === void 0) {
3390
+ break;
3391
+ }
3392
+ if (next === "u") {
3393
+ out += decodeUnicodeEscape(raw, i + 2);
3394
+ i += 6;
3395
+ continue;
3396
+ }
3397
+ out += decodeSimpleEscape(next);
3398
+ i += 2;
3399
+ }
3400
+ return out;
3401
+ }
3402
+ function keyEndIndex(logical, from) {
3403
+ let i = from;
3404
+ while (i < logical.length) {
3405
+ const char = logical[i];
3406
+ if (char === "\\") {
3407
+ i += 2;
3408
+ continue;
3409
+ }
3410
+ if (char === void 0 || isPropertiesWhitespace(char) || char === "=" || char === ":") {
3411
+ break;
3412
+ }
3413
+ i += 1;
3414
+ }
3415
+ return i;
3416
+ }
3417
+ function skipWhitespace(logical, from) {
3418
+ let i = from;
3419
+ while (i < logical.length) {
3420
+ const char = logical[i];
3421
+ if (char === void 0 || !isPropertiesWhitespace(char)) {
3422
+ break;
3423
+ }
3424
+ i += 1;
3425
+ }
3426
+ return i;
3427
+ }
3428
+ function parseEntryLine(logical) {
3429
+ const keyStart = skipWhitespace(logical, 0);
3430
+ const keyEnd = keyEndIndex(logical, keyStart);
3431
+ const afterKey = skipWhitespace(logical, keyEnd);
3432
+ const separator = logical[afterKey];
3433
+ const valueStart = separator === "=" || separator === ":" ? skipWhitespace(logical, afterKey + 1) : afterKey;
3434
+ return {
3435
+ key: decodeEscapes(logical.slice(keyStart, keyEnd)),
3436
+ value: decodeEscapes(logical.slice(valueStart))
3437
+ };
3438
+ }
3439
+ function parseItems(content) {
3440
+ const lines = splitPhysicalLines(content);
3441
+ const items = [];
3442
+ let i = 0;
3443
+ while (i < lines.length) {
3444
+ const line = lines[i] ?? "";
3445
+ if (isBlankLine(line) || isCommentLine(line)) {
3446
+ items.push({ kind: "raw", text: line });
3447
+ i += 1;
3448
+ continue;
3449
+ }
3450
+ const { logical, nextIndex } = joinContinuation(lines, i);
3451
+ items.push({ kind: "entry", ...parseEntryLine(logical) });
3452
+ i = nextIndex;
3453
+ }
3454
+ return items;
3455
+ }
3456
+ function parsePropertiesEntries(content, namespace) {
3457
+ const map = /* @__PURE__ */ new Map();
3458
+ for (const item of parseItems(content)) {
3459
+ if (item.kind === "entry") {
3460
+ map.set(item.key, {
3461
+ key: item.key,
3462
+ namespace,
3463
+ value: item.value,
3464
+ placeholders: extractPropertiesPlaceholders(item.value),
3465
+ isPlural: false
3466
+ });
3467
+ }
3468
+ }
3469
+ return map;
3470
+ }
3471
+ function unicodeEscape(code) {
3472
+ return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
3473
+ }
3474
+ function escapeChar(char, isFirst, isKey) {
3475
+ switch (char) {
3476
+ case "\\":
3477
+ return "\\\\";
3478
+ case " ":
3479
+ return "\\t";
3480
+ case "\n":
3481
+ return "\\n";
3482
+ case "\r":
3483
+ return "\\r";
3484
+ case "\f":
3485
+ return "\\f";
3486
+ case " ":
3487
+ return isKey || isFirst ? "\\ " : " ";
3488
+ }
3489
+ if (isKey && (char === "=" || char === ":" || char === "#" || char === "!")) {
3490
+ return `\\${char}`;
3491
+ }
3492
+ const code = char.charCodeAt(0);
3493
+ return code < 32 || code > 126 ? unicodeEscape(code) : char;
3494
+ }
3495
+ function escapeString(input, isKey) {
3496
+ let out = "";
3497
+ for (let i = 0; i < input.length; i += 1) {
3498
+ const char = input[i];
3499
+ if (char !== void 0) {
3500
+ out += escapeChar(char, i === 0, isKey);
3501
+ }
3502
+ }
3503
+ return out;
3504
+ }
3505
+ function formatEntry(key, value) {
3506
+ return `${escapeString(key, true)}=${escapeString(value, false)}`;
3507
+ }
3508
+ function isFileNotFound(error) {
3509
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
3510
+ }
3511
+ async function readStructure(filePath) {
3512
+ let outcome;
3513
+ try {
3514
+ outcome = await readBounded2(filePath);
3515
+ } catch (error) {
3516
+ if (isFileNotFound(error)) {
3517
+ return [];
3518
+ }
3519
+ throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
3520
+ }
3521
+ if (outcome.kind === "not-a-file") {
3522
+ throw new AdapterError("INVALID_STRUCTURE", "The destination path is not a regular file.");
3523
+ }
3524
+ if (outcome.kind === "too-large") {
3525
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
3526
+ }
3527
+ return parseItems(outcome.content);
3528
+ }
3529
+ async function serializePropertiesEntries(entries, filePath) {
3530
+ const structure = await readStructure(filePath);
3531
+ const emitted = /* @__PURE__ */ new Set();
3532
+ const lines = [];
3533
+ for (const item of structure) {
3534
+ if (item.kind === "raw") {
3535
+ lines.push(item.text);
3536
+ continue;
3537
+ }
3538
+ const entry = entries.get(item.key);
3539
+ if (entry !== void 0 && !emitted.has(item.key)) {
3540
+ lines.push(formatEntry(item.key, entry.value));
3541
+ emitted.add(item.key);
3542
+ }
3543
+ }
3544
+ for (const [key, entry] of entries) {
3545
+ if (!emitted.has(key)) {
3546
+ lines.push(formatEntry(key, entry.value));
3547
+ }
3548
+ }
3549
+ return lines.length === 0 ? "" : `${lines.join("\n")}
3550
+ `;
3551
+ }
3552
+ function createPropertiesAdapter() {
3553
+ return createFlatFileAdapter({
3554
+ format: "properties",
3555
+ extensions: [".properties"],
3556
+ parseEntries: parsePropertiesEntries,
3557
+ serializeEntries: serializePropertiesEntries,
3558
+ extractPlaceholders: extractPropertiesPlaceholders
3559
+ });
3560
+ }
3083
3561
  var AdapterRegistry = class {
3084
3562
  adapters = [];
3085
3563
  /**
@@ -3157,43 +3635,6 @@ function createVueI18nJsonAdapter() {
3157
3635
  })
3158
3636
  });
3159
3637
  }
3160
- function toEntries2(content, namespace, parseEntries) {
3161
- try {
3162
- return parseEntries(content, namespace);
3163
- } catch (error) {
3164
- rethrowStructured(error, "The file could not be parsed.");
3165
- }
3166
- }
3167
- function createFlatFileAdapter(options) {
3168
- const {
3169
- format,
3170
- extensions,
3171
- sniff,
3172
- parseEntries,
3173
- serializeEntries,
3174
- extractPlaceholders,
3175
- validateMessage,
3176
- computeInvalidIcuKeys
3177
- } = options;
3178
- return {
3179
- format,
3180
- canHandle: buildCanHandle(extensions, sniff),
3181
- extractPlaceholders,
3182
- validateMessage: validateMessage ?? (() => true),
3183
- async read(filePath, locale) {
3184
- const content = await readFileContent(filePath);
3185
- const namespace = namespaceOf(filePath);
3186
- const entries = toEntries2(content, namespace, parseEntries);
3187
- const resource = { locale, namespace, format, entries };
3188
- const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3189
- return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3190
- },
3191
- async write(resource, filePath) {
3192
- const data = await serializeEntries(resource.entries, filePath);
3193
- await atomicWriteFile(filePath, data);
3194
- }
3195
- };
3196
- }
3197
3638
  var XLIFF_PATTERN = /<(?:x|g|bx|ex|ph|it|mrk)\b[^>]*>|\{[^{}]+\}/g;
3198
3639
  function extractXliffPlaceholders(value) {
3199
3640
  const result = [];
@@ -3504,7 +3945,7 @@ function createYamlAdapter() {
3504
3945
  });
3505
3946
  }
3506
3947
  function createDefaultRegistry() {
3507
- return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter());
3948
+ return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter()).register(createPropertiesAdapter());
3508
3949
  }
3509
3950
 
3510
3951
  // src/selection/select-adapter.ts
@@ -3625,6 +4066,28 @@ async function diff(input, deps = {}) {
3625
4066
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
3626
4067
  }
3627
4068
 
4069
+ // src/cache/fingerprint.ts
4070
+ function fingerprintModel(provider) {
4071
+ const options = provider.options;
4072
+ const model = options.model;
4073
+ return typeof model === "string" ? model : null;
4074
+ }
4075
+ function sortGlossary(glossary) {
4076
+ if (glossary === void 0) {
4077
+ return {};
4078
+ }
4079
+ return Object.fromEntries(Object.entries(glossary).sort(([a], [b]) => a < b ? -1 : 1));
4080
+ }
4081
+ function computeFingerprint(config) {
4082
+ const canonical = JSON.stringify({
4083
+ provider: config.provider.id,
4084
+ model: fingerprintModel(config.provider),
4085
+ tone: config.tone ?? null,
4086
+ glossary: sortGlossary(config.glossary)
4087
+ });
4088
+ return stableStringHash(canonical);
4089
+ }
4090
+
3628
4091
  // src/flow/integrity-gate.ts
3629
4092
  function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3630
4093
  const placeholderResult = adapter.comparePlaceholders?.(sourceEntry.value, candidateValue) ?? checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(candidateValue));
@@ -3675,6 +4138,12 @@ async function editEntry(input, deps = {}) {
3675
4138
  mode: "merge",
3676
4139
  entries: { [input.key]: contentHash(sourceEntry) }
3677
4140
  });
4141
+ await feedTranslationMemory(
4142
+ cwd,
4143
+ fs,
4144
+ computeFingerprint(config),
4145
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: input.value }]])
4146
+ );
3678
4147
  return { accepted: true, value: input.value };
3679
4148
  });
3680
4149
  }
@@ -3892,13 +4361,19 @@ async function retranslateEntry(input, deps = {}) {
3892
4361
  mode: "merge",
3893
4362
  entries: { [input.key]: contentHash(sourceEntry) }
3894
4363
  });
4364
+ await feedTranslationMemory(
4365
+ cwd,
4366
+ fs,
4367
+ computeFingerprint(config),
4368
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: value }]])
4369
+ );
3895
4370
  const reviewReasons = result.reviewFlags?.get(input.key)?.reasons ?? [];
3896
4371
  return { accepted: true, value, reviewReasons };
3897
4372
  });
3898
4373
  }
3899
4374
  var RUN_STATUS_DIR_NAME = ".verbatra-local";
3900
4375
  var RUN_STATUS_FILE_NAME = "run-status.json";
3901
- var CURRENT_VERSION2 = 1;
4376
+ var CURRENT_VERSION3 = 1;
3902
4377
  var MAX_RUN_STATUS_FILE_BYTES = 16 * 1024 * 1024;
3903
4378
  var reviewReasonCodeSchema = z.enum([
3904
4379
  "LENGTH_RATIO_OUTLIER",
@@ -3948,7 +4423,7 @@ function toRunStatusLocale(locale) {
3948
4423
  }
3949
4424
  function buildRunStatusFile(summary, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3950
4425
  return {
3951
- version: CURRENT_VERSION2,
4426
+ version: CURRENT_VERSION3,
3952
4427
  generatedAt,
3953
4428
  ...summary.usage !== void 0 ? { usage: summary.usage } : {},
3954
4429
  ...summary.budget !== void 0 ? { budget: summary.budget } : {},
@@ -3981,7 +4456,7 @@ async function readRunStatusFile(path, fs) {
3981
4456
  return void 0;
3982
4457
  }
3983
4458
  const result = runStatusFileSchema.safeParse(parsed);
3984
- if (!result.success || result.data.version !== CURRENT_VERSION2) {
4459
+ if (!result.success || result.data.version !== CURRENT_VERSION3) {
3985
4460
  return void 0;
3986
4461
  }
3987
4462
  return fromParsed(result.data);
@@ -4060,12 +4535,16 @@ function failureSummary(locale, error) {
4060
4535
  orphaned: [],
4061
4536
  pruned: [],
4062
4537
  invalidIcuSource: [],
4538
+ cacheHits: [],
4063
4539
  integrityMismatches: [],
4064
4540
  providerFailures: [],
4065
4541
  budgetWithheld: [],
4066
4542
  generated: [],
4067
4543
  notices: [],
4068
4544
  needsReview: [],
4545
+ unfilled: [],
4546
+ malformedRows: [],
4547
+ duplicateKeys: [],
4069
4548
  error: describeError(error)
4070
4549
  };
4071
4550
  }
@@ -4074,7 +4553,7 @@ function deriveLocaleStatus(parts) {
4074
4553
  if (!withheld) {
4075
4554
  return "succeeded";
4076
4555
  }
4077
- const accepted = parts.translated.length > 0 || parts.generated.length > 0;
4556
+ const accepted = parts.translated.length > 0 || parts.cacheHits.length > 0 || parts.generated.length > 0;
4078
4557
  return accepted ? "partial" : "failed";
4079
4558
  }
4080
4559
  function partition(locales) {
@@ -4394,6 +4873,97 @@ function foldGenerationItem(item, result, adapter, accepted, withheld, providerF
4394
4873
  }
4395
4874
 
4396
4875
  // src/flow/locale-run.ts
4876
+ function partitionCacheHits(params, toTranslate) {
4877
+ const cache = params.cache;
4878
+ const hits = /* @__PURE__ */ new Map();
4879
+ if (cache === void 0) {
4880
+ return { hits, misses: toTranslate };
4881
+ }
4882
+ const misses = [];
4883
+ for (const key of toTranslate) {
4884
+ const source = params.source.entries.get(key);
4885
+ if (source === void 0) {
4886
+ continue;
4887
+ }
4888
+ const cached = lookupMemory(
4889
+ cache.snapshot,
4890
+ cache.fingerprint,
4891
+ params.targetLocale,
4892
+ contentHash(source)
4893
+ );
4894
+ if (cached !== void 0 && gateCandidateValue(source, cached, params.adapter).accepted) {
4895
+ hits.set(key, { value: cached, source });
4896
+ } else {
4897
+ misses.push(key);
4898
+ }
4899
+ }
4900
+ return { hits, misses };
4901
+ }
4902
+ function collectCacheAdditions(params, accepted, cacheHitKeys) {
4903
+ if (params.cache === void 0) {
4904
+ return [];
4905
+ }
4906
+ const additions = [];
4907
+ for (const [key, entry] of accepted) {
4908
+ if (!cacheHitKeys.has(key)) {
4909
+ additions.push({ contentHash: contentHash(entry.source), value: entry.value });
4910
+ }
4911
+ }
4912
+ return additions;
4913
+ }
4914
+ function groupMissesByContent(params, misses) {
4915
+ const byHash = /* @__PURE__ */ new Map();
4916
+ for (const key of misses) {
4917
+ const source = params.source.entries.get(key);
4918
+ if (source === void 0) {
4919
+ continue;
4920
+ }
4921
+ const existing = byHash.get(contentHash(source));
4922
+ if (existing === void 0) {
4923
+ byHash.set(contentHash(source), { representative: key, duplicates: [] });
4924
+ } else {
4925
+ existing.duplicates.push(key);
4926
+ }
4927
+ }
4928
+ return [...byHash.values()];
4929
+ }
4930
+ function fanOutContentDuplicates(params, groups, outcome) {
4931
+ for (const group of groups) {
4932
+ if (group.duplicates.length > 0) {
4933
+ applyGroupOutcome(params, group, outcome);
4934
+ }
4935
+ }
4936
+ }
4937
+ function applyGroupOutcome(params, group, outcome) {
4938
+ const acceptedRepresentative = outcome.accepted.get(group.representative);
4939
+ if (acceptedRepresentative !== void 0) {
4940
+ fanOutAccepted(params, group, acceptedRepresentative, outcome);
4941
+ return;
4942
+ }
4943
+ withheldBucketFor(group.representative, outcome).push(...group.duplicates);
4944
+ }
4945
+ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4946
+ const flag = outcome.reviewFlags.get(group.representative);
4947
+ for (const key of group.duplicates) {
4948
+ const source = params.source.entries.get(key);
4949
+ if (source === void 0) {
4950
+ continue;
4951
+ }
4952
+ outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4953
+ if (flag !== void 0) {
4954
+ outcome.reviewFlags.set(key, flag);
4955
+ }
4956
+ }
4957
+ }
4958
+ function withheldBucketFor(representative, outcome) {
4959
+ if (outcome.integrityMismatches.includes(representative)) {
4960
+ return outcome.integrityMismatches;
4961
+ }
4962
+ if (outcome.budgetWithheld.includes(representative)) {
4963
+ return outcome.budgetWithheld;
4964
+ }
4965
+ return outcome.providerFailures;
4966
+ }
4397
4967
  function buildRequest3(params, entries) {
4398
4968
  return {
4399
4969
  sourceLocale: params.sourceLocale,
@@ -4436,6 +5006,7 @@ async function runLocale(params) {
4436
5006
  orphaned,
4437
5007
  invalidIcuSource,
4438
5008
  translated: toTranslate,
5009
+ cacheHits: [],
4439
5010
  generated: [],
4440
5011
  integrityMismatches: [],
4441
5012
  providerFailures: [],
@@ -4443,12 +5014,16 @@ async function runLocale(params) {
4443
5014
  pruned,
4444
5015
  notices: sdkNotices
4445
5016
  }),
4446
- lockEntries: {}
5017
+ lockEntries: {},
5018
+ cacheAdditions: []
4447
5019
  };
4448
5020
  }
4449
- const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
5021
+ const partition2 = partitionCacheHits(params, toTranslate);
5022
+ const cacheHitKeys = new Set(partition2.hits.keys());
5023
+ const missGroups = groupMissesByContent(params, partition2.misses);
5024
+ const entries = missGroups.map((group) => params.source.entries.get(group.representative)).filter((entry) => entry !== void 0);
4450
5025
  const startedStopped = params.budget.stopped;
4451
- const accepted = /* @__PURE__ */ new Map();
5026
+ const accepted = new Map(partition2.hits);
4452
5027
  const integrityMismatches = [];
4453
5028
  const providerFailures = [];
4454
5029
  const budgetWithheld = [];
@@ -4463,6 +5038,13 @@ async function runLocale(params) {
4463
5038
  budgetWithheld,
4464
5039
  reviewFlags
4465
5040
  );
5041
+ fanOutContentDuplicates(params, missGroups, {
5042
+ accepted,
5043
+ integrityMismatches,
5044
+ providerFailures,
5045
+ budgetWithheld,
5046
+ reviewFlags
5047
+ });
4466
5048
  const merged = new Map(target.entries);
4467
5049
  for (const key of pruned) {
4468
5050
  merged.delete(key);
@@ -4509,7 +5091,8 @@ async function runLocale(params) {
4509
5091
  unchanged: diff2.unchanged,
4510
5092
  orphaned,
4511
5093
  invalidIcuSource,
4512
- translated: [...accepted.keys()],
5094
+ translated: [...accepted.keys()].filter((key) => !cacheHitKeys.has(key)),
5095
+ cacheHits: [...cacheHitKeys].sort(),
4513
5096
  generated: generation.accepted.map((form) => form.targetKey).sort(),
4514
5097
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
4515
5098
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
@@ -4519,7 +5102,8 @@ async function runLocale(params) {
4519
5102
  needsReview: needsReviewFor(accepted.keys(), reviewFlags),
4520
5103
  ...localeUsage !== void 0 ? { usage: localeUsage } : {}
4521
5104
  }),
4522
- lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
5105
+ lockEntries: computeLockEntries(params, merged, withheld, generation.accepted),
5106
+ cacheAdditions: collectCacheAdditions(params, accepted, cacheHitKeys)
4523
5107
  };
4524
5108
  }
4525
5109
  var NO_GENERATION_RESULT = {
@@ -4570,12 +5154,16 @@ function baseSummary(parts) {
4570
5154
  orphaned: parts.orphaned,
4571
5155
  pruned: parts.pruned,
4572
5156
  invalidIcuSource: parts.invalidIcuSource,
5157
+ cacheHits: parts.cacheHits,
4573
5158
  integrityMismatches: parts.integrityMismatches,
4574
5159
  providerFailures: parts.providerFailures,
4575
5160
  budgetWithheld: parts.budgetWithheld,
4576
5161
  generated: parts.generated,
4577
5162
  notices: parts.notices,
4578
5163
  needsReview: parts.needsReview ?? [],
5164
+ unfilled: [],
5165
+ malformedRows: [],
5166
+ duplicateKeys: [],
4579
5167
  ...parts.usage !== void 0 ? { usage: parts.usage } : {}
4580
5168
  };
4581
5169
  }
@@ -4593,7 +5181,16 @@ async function translateAndCheck(provider, params, entries, accepted, integrityM
4593
5181
  const notices = [];
4594
5182
  const usage = createUsageAccumulator();
4595
5183
  let tripped = false;
4596
- for (const batch of chunk(entries, params.maxBatchSize)) {
5184
+ const batches = chunk(entries, params.maxBatchSize);
5185
+ let batchIndex = 0;
5186
+ for (const batch of batches) {
5187
+ batchIndex += 1;
5188
+ params.onProgress?.({
5189
+ type: "sub-batch",
5190
+ locale: params.targetLocale,
5191
+ batchIndex,
5192
+ totalBatches: batches.length
5193
+ });
4597
5194
  if (params.budget.stopped) {
4598
5195
  for (const entry of batch) {
4599
5196
  budgetWithheld.push(entry.key);
@@ -4739,6 +5336,26 @@ async function recordRunStatus(cwd, dryRun, summary, fs) {
4739
5336
  } catch {
4740
5337
  }
4741
5338
  }
5339
+ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5340
+ if (dryRun || input.cache === false) {
5341
+ return void 0;
5342
+ }
5343
+ return {
5344
+ memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5345
+ fingerprint: computeFingerprint(config),
5346
+ additions: /* @__PURE__ */ new Map()
5347
+ };
5348
+ }
5349
+ async function recordCacheAdditions(cwd, cache, fs) {
5350
+ if (cache === void 0 || cache.additions.size === 0) {
5351
+ return;
5352
+ }
5353
+ try {
5354
+ const merged = applyAdditions(cache.memory, cache.fingerprint, cache.additions);
5355
+ await writeTranslationMemory(cacheFilePath(cwd), merged, fs);
5356
+ } catch {
5357
+ }
5358
+ }
4742
5359
  function buildLocaleRunParams(context, targetLocale, baseline) {
4743
5360
  return {
4744
5361
  source: context.source.resource,
@@ -4757,7 +5374,9 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
4757
5374
  generatePlurals: context.generatePlurals,
4758
5375
  maxBatchSize: context.maxBatchSize,
4759
5376
  fs: context.fs,
4760
- budget: context.budget
5377
+ budget: context.budget,
5378
+ ...context.cache !== void 0 ? { cache: { snapshot: context.cache.memory, fingerprint: context.cache.fingerprint } } : {},
5379
+ ...context.onProgress !== void 0 ? { onProgress: context.onProgress } : {}
4761
5380
  };
4762
5381
  }
4763
5382
  async function runDryLocale(context, targetLocale, lock) {
@@ -4781,6 +5400,9 @@ async function runLiveLocale(context, targetLocale) {
4781
5400
  mode: "replace",
4782
5401
  entries: result.lockEntries
4783
5402
  });
5403
+ if (context.cache !== void 0 && result.cacheAdditions.length > 0) {
5404
+ context.cache.additions.set(targetLocale, additionsToRecord(result.cacheAdditions));
5405
+ }
4784
5406
  return result.summary;
4785
5407
  },
4786
5408
  lockOptions
@@ -4796,27 +5418,88 @@ async function runOneLocale(targetLocale, run) {
4796
5418
  return failureSummary(targetLocale, error);
4797
5419
  }
4798
5420
  }
4799
- async function runAllLocalesDry(context, targetLocales) {
5421
+ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results) {
5422
+ const targetLocale = targetLocales[localeIndex];
5423
+ if (targetLocale === void 0) {
5424
+ return;
5425
+ }
5426
+ context.onProgress?.({
5427
+ type: "locale-started",
5428
+ locale: targetLocale,
5429
+ localeIndex,
5430
+ totalLocales: targetLocales.length
5431
+ });
5432
+ const summary = await runOneLocale(targetLocale, () => runOne(targetLocale));
5433
+ results[localeIndex] = summary;
5434
+ context.onProgress?.({
5435
+ type: "locale-finished",
5436
+ locale: targetLocale,
5437
+ translated: summary.translated.length
5438
+ });
5439
+ }
5440
+ async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5441
+ const totalLocales = targetLocales.length;
5442
+ const results = new Array(totalLocales);
5443
+ let nextIndex = 0;
5444
+ async function worker() {
5445
+ while (nextIndex < totalLocales) {
5446
+ const localeIndex = nextIndex;
5447
+ nextIndex += 1;
5448
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5449
+ }
5450
+ }
5451
+ const workerCount = Math.min(concurrency, totalLocales);
5452
+ const workers = [];
5453
+ for (let index = 0; index < workerCount; index += 1) {
5454
+ workers.push(worker());
5455
+ }
5456
+ await Promise.all(workers);
5457
+ return results.filter((summary) => summary !== void 0);
5458
+ }
5459
+ async function runAllLocalesDry(context, targetLocales, concurrency) {
4800
5460
  const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4801
- const summaries = [];
4802
- for (const targetLocale of targetLocales) {
4803
- summaries.push(
4804
- await runOneLocale(targetLocale, () => runDryLocale(context, targetLocale, lock))
5461
+ return runLocalesWithProgress(
5462
+ context,
5463
+ targetLocales,
5464
+ (targetLocale) => runDryLocale(context, targetLocale, lock),
5465
+ concurrency
5466
+ );
5467
+ }
5468
+ async function runAllLocalesLive(context, targetLocales, concurrency) {
5469
+ return runLocalesWithProgress(
5470
+ context,
5471
+ targetLocales,
5472
+ (targetLocale) => runLiveLocale(context, targetLocale),
5473
+ concurrency
5474
+ );
5475
+ }
5476
+ function resolveConcurrency(value) {
5477
+ if (value === void 0) {
5478
+ return 1;
5479
+ }
5480
+ if (!Number.isInteger(value) || value < 1) {
5481
+ throw new SdkError(
5482
+ "CONCURRENCY_INVALID",
5483
+ `The concurrency option must be an integer of at least 1, got ${value}.`
4805
5484
  );
4806
5485
  }
4807
- return summaries;
5486
+ return value;
4808
5487
  }
4809
- async function runAllLocalesLive(context, targetLocales) {
4810
- const summaries = [];
4811
- for (const targetLocale of targetLocales) {
4812
- summaries.push(await runOneLocale(targetLocale, () => runLiveLocale(context, targetLocale)));
5488
+ function resolveRunConcurrency(value, dryRun, config) {
5489
+ const concurrency = resolveConcurrency(value);
5490
+ if (!dryRun && concurrency > 1 && config.maxTokens !== void 0) {
5491
+ throw new SdkError(
5492
+ "CONCURRENCY_BUDGET_CONFLICT",
5493
+ "A token budget (maxTokens) and concurrency greater than 1 cannot be combined on a live run: concurrent locales would overshoot the budget nondeterministically. Set concurrency to 1, remove maxTokens, or use --dry-run."
5494
+ );
4813
5495
  }
4814
- return summaries;
5496
+ return concurrency;
4815
5497
  }
4816
5498
  async function translate2(input, deps = {}) {
4817
5499
  const config = input.config;
4818
5500
  const cwd = input.cwd ?? process.cwd();
4819
5501
  const dryRun = input.dryRun ?? false;
5502
+ const concurrency = resolveRunConcurrency(input.concurrency, dryRun, config);
4820
5503
  const prune = input.prune ?? config.prune ?? false;
4821
5504
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
4822
5505
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
@@ -4828,6 +5511,7 @@ async function translate2(input, deps = {}) {
4828
5511
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
4829
5512
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
4830
5513
  const source = await readSource(config, cwd, fs, adapter);
5514
+ const cache = await createRunCacheState(input, config, cwd, dryRun, fs);
4831
5515
  const context = {
4832
5516
  source,
4833
5517
  adapter,
@@ -4839,10 +5523,13 @@ async function translate2(input, deps = {}) {
4839
5523
  maxBatchSize,
4840
5524
  fs,
4841
5525
  budget,
5526
+ cache,
4842
5527
  ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
5528
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
4843
5529
  ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
4844
5530
  };
4845
- const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
5531
+ const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5532
+ input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
4846
5533
  const { succeeded, partial, failed } = partition(summaries);
4847
5534
  const usage = summaries.reduce(
4848
5535
  (total, summary2) => combineUsage(total, summary2.usage),
@@ -4858,6 +5545,7 @@ async function translate2(input, deps = {}) {
4858
5545
  ...usage !== void 0 ? { usage } : {},
4859
5546
  ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4860
5547
  };
5548
+ await recordCacheAdditions(cwd, cache, fs);
4861
5549
  await recordRunStatus(cwd, dryRun, summary, fs);
4862
5550
  return summary;
4863
5551
  }
@@ -4873,8 +5561,12 @@ var INSTRUCTIONS_LINES = [
4873
5561
  "How to use this workbook",
4874
5562
  "",
4875
5563
  "1. There is one sheet per language. Open the sheet named for the language you are translating.",
5564
+ " Do not rename, delete, or reorder the language tabs. verbatra matches each tab to a language by",
5565
+ " its exact name; a renamed or missing tab is reported and its translations are not imported.",
4876
5566
  "2. Fill ONLY the 'Translation' column. Leave every other column unchanged.",
4877
- "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty string.",
5567
+ "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty",
5568
+ " string. A cell that contains only spaces is treated the same as an empty cell.",
5569
+ " To deliberately clear an existing value (set it to empty), type exactly [[CLEAR]] in the cell.",
4878
5570
  "4. Keep placeholders exactly as they appear. A token such as {name} or {count} must stay verbatim,",
4879
5571
  " in your translation, with the same spelling. Do not translate or remove it.",
4880
5572
  "5. For ICU messages (for example {count, plural, one {# item} other {# items}}), keep the ICU",
@@ -5039,6 +5731,26 @@ function buildInstructionsSheet(workbook) {
5039
5731
  }
5040
5732
  sheet.getRow(1).font = { bold: true };
5041
5733
  }
5734
+ var WORKBOOK_PROTECTION_XML = '<workbookProtection lockStructure="1" lockWindows="0"/>';
5735
+ function spliceWorkbookProtection(xml) {
5736
+ const anchor = xml.includes("<bookViews") ? "<bookViews" : "<sheets";
5737
+ return xml.replace(anchor, `${WORKBOOK_PROTECTION_XML}${anchor}`);
5738
+ }
5739
+ async function protectWorkbookStructure(bytes) {
5740
+ let zip;
5741
+ try {
5742
+ zip = await JSZip.loadAsync(bytes);
5743
+ } catch {
5744
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
5745
+ }
5746
+ const workbookPart = zip.file("xl/workbook.xml");
5747
+ if (workbookPart === null) {
5748
+ return bytes;
5749
+ }
5750
+ const xml = await workbookPart.async("string");
5751
+ zip.file("xl/workbook.xml", spliceWorkbookProtection(xml));
5752
+ return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
5753
+ }
5042
5754
  async function buildWorkbook(model) {
5043
5755
  assertNoWorksheetNameCollisions(model.sheets);
5044
5756
  const workbook = new ExcelJS.Workbook();
@@ -5046,13 +5758,15 @@ async function buildWorkbook(model) {
5046
5758
  for (const sheet of model.sheets) {
5047
5759
  await buildDataSheet(workbook, sheet);
5048
5760
  }
5761
+ let serialized;
5049
5762
  try {
5050
5763
  const buffer = await workbook.xlsx.writeBuffer();
5051
- const view = buffer;
5052
- return Uint8Array.prototype.slice.call(view);
5764
+ serialized = buffer;
5053
5765
  } catch {
5054
5766
  throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
5055
5767
  }
5768
+ const protectedBytes = await protectWorkbookStructure(serialized);
5769
+ return Uint8Array.prototype.slice.call(protectedBytes);
5056
5770
  }
5057
5771
  var DEFAULT_WORKBOOK_LIMITS = {
5058
5772
  maxDecompressedBytes: 64 * 1024 * 1024,
@@ -5172,26 +5886,24 @@ function assertHeader(sheet) {
5172
5886
  );
5173
5887
  }
5174
5888
  }
5175
- function parseRow(sheet, row) {
5889
+ var MALFORMED_ROW_COLUMN = "Status";
5890
+ function parseRow(row) {
5176
5891
  const candidate = {
5177
5892
  key: cellString(row.getCell(COLUMN.key)),
5178
5893
  source: cellString(row.getCell(COLUMN.source)),
5179
5894
  currentTarget: cellString(row.getCell(COLUMN.current)),
5180
5895
  status: cellString(row.getCell(COLUMN.status)),
5181
5896
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
5182
- translation: cellString(row.getCell(COLUMN.translation)),
5897
+ translation: cellString(row.getCell(COLUMN.translation)).trim(),
5183
5898
  context: cellString(row.getCell(COLUMN.context)),
5184
5899
  reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
5185
5900
  reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
5186
5901
  };
5187
5902
  const result = rowSchema.safeParse(candidate);
5188
5903
  if (!result.success) {
5189
- throw new ExchangeError(
5190
- "WORKBOOK_INVALID",
5191
- `The sheet "${sheet.name}" has a row with a missing key or an unrecognized status.`
5192
- );
5904
+ return { ok: false, column: MALFORMED_ROW_COLUMN };
5193
5905
  }
5194
- return result.data;
5906
+ return { ok: true, row: result.data };
5195
5907
  }
5196
5908
  function readDataSheet(sheet, limits) {
5197
5909
  assertHeader(sheet);
@@ -5202,6 +5914,9 @@ function readDataSheet(sheet, limits) {
5202
5914
  );
5203
5915
  }
5204
5916
  const rows = [];
5917
+ const malformed = [];
5918
+ const duplicates = [];
5919
+ const seenKeys = /* @__PURE__ */ new Set();
5205
5920
  for (let rowNumber = HEADER_ROW + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
5206
5921
  const row = sheet.getRow(rowNumber);
5207
5922
  if (row.cellCount > limits.maxCellsPerRow) {
@@ -5213,9 +5928,19 @@ function readDataSheet(sheet, limits) {
5213
5928
  if (cellString(row.getCell(COLUMN.key)) === "") {
5214
5929
  continue;
5215
5930
  }
5216
- rows.push(parseRow(sheet, row));
5931
+ const outcome = parseRow(row);
5932
+ if (!outcome.ok) {
5933
+ malformed.push({ locale: sheet.name, row: rowNumber, column: outcome.column });
5934
+ continue;
5935
+ }
5936
+ if (seenKeys.has(outcome.row.key)) {
5937
+ duplicates.push({ locale: sheet.name, key: outcome.row.key, row: rowNumber });
5938
+ continue;
5939
+ }
5940
+ seenKeys.add(outcome.row.key);
5941
+ rows.push(outcome.row);
5217
5942
  }
5218
- return { locale: sheet.name, rows };
5943
+ return { sheet: { locale: sheet.name, rows }, malformed, duplicates };
5219
5944
  }
5220
5945
  async function loadWorkbook(bytes) {
5221
5946
  const workbook = new ExcelJS.Workbook();
@@ -5238,13 +5963,18 @@ async function readWorkbook(bytes, options = {}) {
5238
5963
  );
5239
5964
  }
5240
5965
  const sheets = [];
5966
+ const malformedRows = [];
5967
+ const duplicateKeys = [];
5241
5968
  for (const sheet of workbook.worksheets) {
5242
5969
  if (sheet.name === INSTRUCTIONS_SHEET_NAME) {
5243
5970
  continue;
5244
5971
  }
5245
- sheets.push(readDataSheet(sheet, limits));
5972
+ const read = readDataSheet(sheet, limits);
5973
+ sheets.push(read.sheet);
5974
+ malformedRows.push(...read.malformed);
5975
+ duplicateKeys.push(...read.duplicates);
5246
5976
  }
5247
- return { sheets };
5977
+ return { sheets, malformedRows, duplicateKeys };
5248
5978
  }
5249
5979
 
5250
5980
  // src/flow/workbook/export-workbook.ts
@@ -5345,6 +6075,7 @@ async function exportWorkbook(input, deps = {}) {
5345
6075
  }
5346
6076
 
5347
6077
  // src/flow/workbook/import-locale.ts
6078
+ var CLEAR_SENTINEL = "[[CLEAR]]";
5348
6079
  var UnknownKeyError = class extends Error {
5349
6080
  key;
5350
6081
  constructor(key) {
@@ -5373,9 +6104,20 @@ function trackBlankDrift(row, params, buckets) {
5373
6104
  buckets.blankDrifted.add(row.key);
5374
6105
  }
5375
6106
  }
6107
+ function classifyClear(row, sourceEntry, buckets) {
6108
+ if (contentHash(sourceEntry) !== row.sourceHash) {
6109
+ buckets.mismatches.push(row.key);
6110
+ buckets.withheld.add(row.key);
6111
+ return;
6112
+ }
6113
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6114
+ }
5376
6115
  function classifyRows(params, buckets) {
5377
6116
  for (const row of params.sheet.rows) {
5378
6117
  if (row.translation === "") {
6118
+ if (row.status === "changed") {
6119
+ buckets.unfilled.push(row.key);
6120
+ }
5379
6121
  trackBlankDrift(row, params, buckets);
5380
6122
  continue;
5381
6123
  }
@@ -5386,6 +6128,10 @@ function classifyRows(params, buckets) {
5386
6128
  if (sourceEntry === void 0) {
5387
6129
  continue;
5388
6130
  }
6131
+ if (row.translation === CLEAR_SENTINEL) {
6132
+ classifyClear(row, sourceEntry, buckets);
6133
+ continue;
6134
+ }
5389
6135
  const reason = judge(row, sourceEntry, params.adapter);
5390
6136
  if (reason === void 0) {
5391
6137
  buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
@@ -5407,7 +6153,8 @@ function importLocale(params) {
5407
6153
  accepted: /* @__PURE__ */ new Map(),
5408
6154
  mismatches: [],
5409
6155
  withheld: /* @__PURE__ */ new Set(),
5410
- blankDrifted: /* @__PURE__ */ new Set()
6156
+ blankDrifted: /* @__PURE__ */ new Set(),
6157
+ unfilled: []
5411
6158
  };
5412
6159
  classifyRows(params, buckets);
5413
6160
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
@@ -5418,6 +6165,7 @@ function importLocale(params) {
5418
6165
  locale: params.sheet.locale,
5419
6166
  status: deriveLocaleStatus({
5420
6167
  translated,
6168
+ cacheHits: [],
5421
6169
  generated: [],
5422
6170
  integrityMismatches,
5423
6171
  providerFailures: [],
@@ -5428,12 +6176,16 @@ function importLocale(params) {
5428
6176
  orphaned: diff2.orphaned,
5429
6177
  pruned: [],
5430
6178
  invalidIcuSource,
6179
+ cacheHits: [],
5431
6180
  integrityMismatches,
5432
6181
  providerFailures: [],
5433
6182
  budgetWithheld: [],
5434
6183
  generated: [],
5435
6184
  notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
5436
- needsReview: []
6185
+ needsReview: [],
6186
+ unfilled: [...new Set(buckets.unfilled)].sort(),
6187
+ malformedRows: params.malformedRows,
6188
+ duplicateKeys: params.duplicateKeys
5437
6189
  };
5438
6190
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
5439
6191
  }
@@ -5460,6 +6212,19 @@ function mergeAccepted(target, accepted) {
5460
6212
  }
5461
6213
  return merged;
5462
6214
  }
6215
+ function sheetCacheAdditions(accepted) {
6216
+ const record = {};
6217
+ for (const [, { value, source }] of accepted) {
6218
+ record[contentHash(source)] = value;
6219
+ }
6220
+ return record;
6221
+ }
6222
+ function collectSheetAdditions(byLocale, locale, additions) {
6223
+ if (Object.keys(additions).length === 0) {
6224
+ return;
6225
+ }
6226
+ byLocale.set(locale, { ...byLocale.get(locale), ...additions });
6227
+ }
5463
6228
  function computeLockEntries2(source, merged, baseline, accepted) {
5464
6229
  const entries = {};
5465
6230
  for (const key of merged.keys()) {
@@ -5476,11 +6241,20 @@ function computeLockEntries2(source, merged, baseline, accepted) {
5476
6241
  }
5477
6242
  return entries;
5478
6243
  }
6244
+ var MissingSheetError = class extends Error {
6245
+ code = "WORKBOOK_SHEET_MISSING";
6246
+ constructor(locale) {
6247
+ super(
6248
+ `The workbook has no sheet (tab) for the configured target locale "${locale}". The tab may have been renamed, deleted, or reordered out of the workbook.`
6249
+ );
6250
+ this.name = "MissingSheetError";
6251
+ }
6252
+ };
5479
6253
  async function runSheet(ctx, sheet, lock) {
5480
6254
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
5481
6255
  throw new SdkError(
5482
6256
  "CONFIG_INVALID",
5483
- `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
6257
+ `The workbook has a sheet named "${sheet.locale}", which is not a configured target locale. It may be a renamed, added, or reordered tab; leave every language tab named exactly as exported.`
5484
6258
  );
5485
6259
  }
5486
6260
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -5491,10 +6265,12 @@ async function runSheet(ctx, sheet, lock) {
5491
6265
  target,
5492
6266
  baseline,
5493
6267
  adapter: ctx.adapter,
5494
- sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys
6268
+ sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6269
+ malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6270
+ duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row }))
5495
6271
  });
5496
6272
  if (ctx.dryRun) {
5497
- return { summary, lockEntries: {} };
6273
+ return { summary, lockEntries: {}, cacheAdditions: {} };
5498
6274
  }
5499
6275
  const merged = mergeAccepted(target, accepted);
5500
6276
  if (accepted.size > 0) {
@@ -5509,7 +6285,11 @@ async function runSheet(ctx, sheet, lock) {
5509
6285
  path
5510
6286
  );
5511
6287
  }
5512
- return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted) };
6288
+ return {
6289
+ summary,
6290
+ lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted),
6291
+ cacheAdditions: sheetCacheAdditions(accepted)
6292
+ };
5513
6293
  }
5514
6294
  async function importWorkbook(input, deps = {}) {
5515
6295
  const config = input.config;
@@ -5534,9 +6314,12 @@ async function importWorkbook(input, deps = {}) {
5534
6314
  fs,
5535
6315
  source: source.resource,
5536
6316
  sourceInvalidIcuKeys: source.invalidIcuKeys,
5537
- dryRun
6317
+ dryRun,
6318
+ malformedRows: data.malformedRows,
6319
+ duplicateKeys: data.duplicateKeys
5538
6320
  };
5539
6321
  const summaries = [];
6322
+ const cacheAdditions = /* @__PURE__ */ new Map();
5540
6323
  for (const sheet of data.sheets) {
5541
6324
  try {
5542
6325
  let summary;
@@ -5549,6 +6332,7 @@ async function importWorkbook(input, deps = {}) {
5549
6332
  mode: "replace",
5550
6333
  entries: result.lockEntries
5551
6334
  });
6335
+ collectSheetAdditions(cacheAdditions, sheet.locale, result.cacheAdditions);
5552
6336
  return result.summary;
5553
6337
  });
5554
6338
  }
@@ -5557,6 +6341,15 @@ async function importWorkbook(input, deps = {}) {
5557
6341
  summaries.push(failureSummary(sheet.locale, error));
5558
6342
  }
5559
6343
  }
6344
+ const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6345
+ for (const locale of config.targetLocales) {
6346
+ if (!presentLocales.has(locale)) {
6347
+ summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6348
+ }
6349
+ }
6350
+ if (!dryRun) {
6351
+ await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6352
+ }
5560
6353
  const { succeeded, partial, failed } = partition(summaries);
5561
6354
  return { dryRun, locales: summaries, succeeded, partial, failed };
5562
6355
  }
@@ -5620,7 +6413,10 @@ async function watch(input, deps = {}) {
5620
6413
  config: input.config,
5621
6414
  cwd,
5622
6415
  ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
5623
- ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
6416
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
6417
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {},
6418
+ ...input.concurrency !== void 0 ? { concurrency: input.concurrency } : {},
6419
+ ...input.cache !== void 0 ? { cache: input.cache } : {}
5624
6420
  };
5625
6421
  let state = "idle";
5626
6422
  let pending = false;
@@ -5688,6 +6484,6 @@ async function watch(input, deps = {}) {
5688
6484
  return { stop };
5689
6485
  }
5690
6486
 
5691
- export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
6487
+ export { CACHE_FILE_NAME, DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
5692
6488
  //# sourceMappingURL=index.js.map
5693
6489
  //# sourceMappingURL=index.js.map