@verbatra/sdk 0.2.2 → 0.4.0

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.cjs CHANGED
@@ -7,15 +7,20 @@ var cosmiconfigTypescriptLoader = require('cosmiconfig-typescript-loader');
7
7
  var zod = require('zod');
8
8
  var Anthropic = require('@anthropic-ai/sdk');
9
9
  var deepl = require('deepl-node');
10
+ var module$1 = require('module');
10
11
  var log = require('loglevel');
11
12
  var genai = require('@google/genai');
12
13
  var OpenAI = require('openai');
14
+ var crypto = require('crypto');
13
15
  var promises = require('fs/promises');
14
16
  var icuMessageformatParser = require('@formatjs/icu-messageformat-parser');
17
+ var xmldom = require('@xmldom/xmldom');
18
+ var yaml = require('yaml');
15
19
  var ExcelJS = require('exceljs');
16
20
  var JSZip = require('jszip');
17
21
  var chokidar = require('chokidar');
18
22
 
23
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
19
24
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
20
25
 
21
26
  function _interopNamespace(e) {
@@ -73,13 +78,16 @@ function fnv1a64(input) {
73
78
  }
74
79
  return hash.toString(16).padStart(16, "0");
75
80
  }
81
+ function normalizeText(text) {
82
+ return text.normalize("NFC").replace(/\r\n?/g, "\n");
83
+ }
76
84
  function canonicalize(entry) {
77
85
  return JSON.stringify([
78
- entry.value,
79
- entry.description ?? null,
80
- entry.meaning ?? null,
86
+ normalizeText(entry.value),
87
+ entry.description == null ? null : normalizeText(entry.description),
88
+ entry.meaning == null ? null : normalizeText(entry.meaning),
81
89
  entry.isPlural,
82
- [...entry.placeholders].sort()
90
+ [...entry.placeholders].map(normalizeText).sort()
83
91
  ]);
84
92
  }
85
93
  function contentHash(entry) {
@@ -125,7 +133,10 @@ var SUPPORTED_FORMATS = [
125
133
  "i18next-json",
126
134
  "vue-i18n-json",
127
135
  "next-intl-json",
128
- "ngx-translate-json"
136
+ "ngx-translate-json",
137
+ "xliff",
138
+ "yaml",
139
+ "arb"
129
140
  ];
130
141
  var supportedFormatSchema = zod.z.enum(SUPPORTED_FORMATS);
131
142
  var translationEntrySchema = zod.z.object({
@@ -143,17 +154,31 @@ zod.z.object({
143
154
  format: supportedFormatSchema,
144
155
  entries: zod.z.map(zod.z.string(), translationEntrySchema)
145
156
  });
146
- function difference(a, b) {
147
- return [...new Set(a.filter((item) => !b.has(item)))].sort();
157
+ function counts(items) {
158
+ const map = /* @__PURE__ */ new Map();
159
+ for (const item of items) {
160
+ map.set(item, (map.get(item) ?? 0) + 1);
161
+ }
162
+ return map;
163
+ }
164
+ function multisetExcess(a, b) {
165
+ const excess = [];
166
+ for (const [token, count] of a) {
167
+ const surplus = count - (b.get(token) ?? 0);
168
+ for (let i = 0; i < surplus; i += 1) {
169
+ excess.push(token);
170
+ }
171
+ }
172
+ return excess.sort();
148
173
  }
149
174
  function sameOrder(a, b) {
150
175
  return a.length === b.length && a.every((item, index) => item === b[index]);
151
176
  }
152
177
  function checkPlaceholders(source, translated) {
153
- const sourceSet = new Set(source);
154
- const translatedSet = new Set(translated);
155
- const missing = difference(source, translatedSet);
156
- const extra = difference(translated, sourceSet);
178
+ const sourceCounts = counts(source);
179
+ const translatedCounts = counts(translated);
180
+ const missing = multisetExcess(sourceCounts, translatedCounts);
181
+ const extra = multisetExcess(translatedCounts, sourceCounts);
157
182
  const reordered = missing.length === 0 && extra.length === 0 && !sameOrder(source, translated);
158
183
  return {
159
184
  matches: missing.length === 0 && extra.length === 0 && !reordered,
@@ -166,6 +191,26 @@ var LOCALE_TOKEN = "{locale}";
166
191
  function localeFilePath(cwd, pattern, locale) {
167
192
  return path.resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
168
193
  }
194
+ var REDACTED = "[REDACTED]";
195
+ var KEY_PATTERNS = [
196
+ // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
197
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
198
+ /AIza[0-9A-Za-z_-]{35}/g,
199
+ /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?::fx)?/g
200
+ ];
201
+ function escapeForRegExp(value) {
202
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ }
204
+ function redact(text, secret = process.env.ANTHROPIC_API_KEY) {
205
+ let out = text;
206
+ for (const pattern of KEY_PATTERNS) {
207
+ out = out.replace(pattern, REDACTED);
208
+ }
209
+ if (secret !== void 0 && secret.length > 0) {
210
+ out = out.replace(new RegExp(escapeForRegExp(secret), "g"), REDACTED);
211
+ }
212
+ return out;
213
+ }
169
214
  var ProviderError = class extends Error {
170
215
  /** The stable {@link ProviderErrorCode} for this failure; branch on this, not the message. */
171
216
  code;
@@ -174,7 +219,7 @@ var ProviderError = class extends Error {
174
219
  * @param message - A fixed, safe message; callers must never pass key, SDK, or request-derived text.
175
220
  */
176
221
  constructor(code, message) {
177
- super(message);
222
+ super(redact(message, ""));
178
223
  this.name = "ProviderError";
179
224
  this.code = code;
180
225
  }
@@ -295,6 +340,18 @@ async function runLlmTranslation(request, mechanism) {
295
340
  );
296
341
  return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
297
342
  }
343
+ var OUTPUT_TRUNCATED_MESSAGE = "The provider stopped because the output-token limit was reached. Reduce the batch size or raise the configured max output tokens.";
344
+ function assertNotTruncated(truncated) {
345
+ if (truncated) {
346
+ throw new ProviderError("OUTPUT_TRUNCATED", OUTPUT_TRUNCATED_MESSAGE);
347
+ }
348
+ }
349
+ var PROVIDER_ENV = {
350
+ anthropic: "ANTHROPIC_API_KEY",
351
+ openai: "OPENAI_API_KEY",
352
+ gemini: "GEMINI_API_KEY",
353
+ deepl: "DEEPL_API_KEY"
354
+ };
298
355
  function readRequiredEnv(name) {
299
356
  const value = process.env[name];
300
357
  if (value === void 0 || value.length === 0) {
@@ -303,16 +360,16 @@ function readRequiredEnv(name) {
303
360
  return value;
304
361
  }
305
362
  function requireAnthropicKey() {
306
- return readRequiredEnv("ANTHROPIC_API_KEY");
363
+ return readRequiredEnv(PROVIDER_ENV.anthropic);
307
364
  }
308
365
  function requireOpenAiKey() {
309
- return readRequiredEnv("OPENAI_API_KEY");
366
+ return readRequiredEnv(PROVIDER_ENV.openai);
310
367
  }
311
368
  function requireGeminiKey() {
312
- return readRequiredEnv("GEMINI_API_KEY");
369
+ return readRequiredEnv(PROVIDER_ENV.gemini);
313
370
  }
314
371
  function requireDeepLKey() {
315
- return readRequiredEnv("DEEPL_API_KEY");
372
+ return readRequiredEnv(PROVIDER_ENV.deepl);
316
373
  }
317
374
  function createDefaultClient() {
318
375
  const sdk = new Anthropic__default.default({ apiKey: requireAnthropicKey(), logLevel: "off" });
@@ -390,6 +447,7 @@ function createMechanism(client, config) {
390
447
  translate: async ({ payloadJson }) => {
391
448
  const body = buildRequest(config, payloadJson);
392
449
  const message = await callClient(client, body);
450
+ assertNotTruncated(message.stop_reason === "max_tokens");
393
451
  const raw = requireToolInput(message.content);
394
452
  const usage = toUsage(message.usage);
395
453
  return usage === void 0 ? { raw } : { raw, usage };
@@ -412,8 +470,22 @@ function toUsage(usage) {
412
470
  var deepLConfigSchema = zod.z.object({
413
471
  glossaryId: zod.z.string().min(1).optional()
414
472
  });
473
+ var DEEPL_LOGGER = "deepl";
474
+ function resolveDeeplLoglevel(requireFn = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)))) {
475
+ try {
476
+ const entry = requireFn.resolve("deepl-node");
477
+ return module$1.createRequire(entry)("loglevel");
478
+ } catch {
479
+ return void 0;
480
+ }
481
+ }
482
+ function silenceDeeplLogger(instances) {
483
+ for (const instance of instances) {
484
+ instance?.getLogger(DEEPL_LOGGER).setLevel("silent");
485
+ }
486
+ }
415
487
  function silenceSdkLogging() {
416
- log__default.default.getLogger("deepl").setLevel("silent");
488
+ silenceDeeplLogger([log__default.default, resolveDeeplLoglevel()]);
417
489
  }
418
490
  function createDefaultClient2() {
419
491
  silenceSdkLogging();
@@ -637,6 +709,7 @@ function extractGeminiResult(response) {
637
709
  if (candidate.finishReason !== void 0 && BLOCKED_FINISH_REASONS.has(candidate.finishReason)) {
638
710
  throw new ProviderError("PROVIDER_BLOCKED", "The provider filtered the translation response.");
639
711
  }
712
+ assertNotTruncated(candidate.finishReason === "MAX_TOKENS");
640
713
  const text = response.text;
641
714
  if (text === void 0 || text === "") {
642
715
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no translation content.");
@@ -733,10 +806,12 @@ function toUsage3(usage) {
733
806
  return { inputTokens: prompt_tokens, outputTokens: completion_tokens };
734
807
  }
735
808
  function extractOpenAiResult(completion) {
736
- const message = completion.choices[0]?.message;
737
- if (message === void 0) {
809
+ const choice = completion.choices[0];
810
+ if (choice === void 0) {
738
811
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no message.");
739
812
  }
813
+ assertNotTruncated(choice.finish_reason === "length");
814
+ const message = choice.message;
740
815
  if (message.refusal !== void 0 && message.refusal !== null && message.refusal !== "") {
741
816
  throw new ProviderError("PROVIDER_REFUSED", "The provider refused the translation request.");
742
817
  }
@@ -771,6 +846,11 @@ function createMechanism3(client, config) {
771
846
  function callClient4(client, body) {
772
847
  return guardProviderCall(() => client.chat.completions.create(body));
773
848
  }
849
+ var SCAFFOLD_MODELS = {
850
+ anthropic: "claude-sonnet-4-6",
851
+ openai: "gpt-5.4-mini",
852
+ gemini: "gemini-2.5-flash"
853
+ };
774
854
  var providerConfigSchema = zod.z.discriminatedUnion("id", [
775
855
  zod.z.object({ id: zod.z.literal("anthropic"), options: anthropicConfigSchema.strict() }),
776
856
  zod.z.object({ id: zod.z.literal("openai"), options: openAiConfigSchema.strict() }),
@@ -789,6 +869,7 @@ function buildProvider(config) {
789
869
  }
790
870
 
791
871
  // src/config/schema.ts
872
+ var DEFAULT_MAX_BATCH_SIZE = 50;
792
873
  var verbatraConfigSchema = zod.z.strictObject({
793
874
  sourceLocale: zod.z.string().min(1),
794
875
  targetLocales: zod.z.array(zod.z.string().min(1)).min(1),
@@ -798,7 +879,29 @@ var verbatraConfigSchema = zod.z.strictObject({
798
879
  }),
799
880
  provider: providerConfigSchema,
800
881
  glossary: zod.z.record(zod.z.string(), zod.z.string()).optional(),
801
- tone: zod.z.enum(["formal", "informal", "neutral"]).optional()
882
+ tone: zod.z.enum(["formal", "informal", "neutral"]).optional(),
883
+ /**
884
+ * Opt-in orphan pruning, off by default. When true, keys present in a target file but absent from
885
+ * the source are removed from the written file and the lock. The per-run `prune` option on
886
+ * `translate` (the CLI `--prune` flag) overrides this.
887
+ */
888
+ prune: zod.z.boolean().optional(),
889
+ /**
890
+ * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
891
+ * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
892
+ * requires but the source does not supply (for example Polish few/many). The per-run
893
+ * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
894
+ * unknown language) fall back to the per-locale plural warning.
895
+ */
896
+ generatePlurals: zod.z.boolean().optional(),
897
+ /**
898
+ * Optional maximum number of entries sent in a single provider request. A locale's missing and
899
+ * changed entries are split into sequential sub-batches no larger than this, so one oversized request
900
+ * cannot sink the whole locale; a failed sub-batch is withheld while the others make progress. Must
901
+ * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
902
+ * {@link DEFAULT_MAX_BATCH_SIZE} applies.
903
+ */
904
+ maxBatchSize: zod.z.number().int().positive().optional()
802
905
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
803
906
  message: "targetLocales must not include the source locale",
804
907
  path: ["targetLocales"]
@@ -943,11 +1046,14 @@ async function readBoundedBytes(path, maxBytes) {
943
1046
  await handle.close();
944
1047
  }
945
1048
  }
946
- async function atomicWrite(path$1, data) {
947
- const tmp = path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}`);
1049
+ function tempFileName(path$1) {
1050
+ return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
1051
+ }
1052
+ async function atomicWrite(path, data) {
1053
+ const tmp = tempFileName(path);
948
1054
  await (typeof data === "string" ? promises.writeFile(tmp, data, "utf8") : promises.writeFile(tmp, data));
949
1055
  try {
950
- await promises.rename(tmp, path$1);
1056
+ await promises.rename(tmp, path);
951
1057
  } catch (error) {
952
1058
  await promises.rm(tmp, { force: true });
953
1059
  throw error;
@@ -1025,6 +1131,85 @@ async function writeLockFile(path, lock, fs) {
1025
1131
  await fs.writeFile(path, `${JSON.stringify(ordered, null, 2)}
1026
1132
  `);
1027
1133
  }
1134
+ var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1135
+ var INVALID = { placeholders: [], isPlural: false, valid: false };
1136
+ function tokenOf(element) {
1137
+ switch (element.type) {
1138
+ case icuMessageformatParser.TYPE.argument:
1139
+ case icuMessageformatParser.TYPE.number:
1140
+ case icuMessageformatParser.TYPE.date:
1141
+ case icuMessageformatParser.TYPE.time:
1142
+ case icuMessageformatParser.TYPE.select:
1143
+ case icuMessageformatParser.TYPE.plural:
1144
+ return `{${element.value}}`;
1145
+ case icuMessageformatParser.TYPE.tag:
1146
+ return `<${element.value}>`;
1147
+ default:
1148
+ return void 0;
1149
+ }
1150
+ }
1151
+ function childMessages(element) {
1152
+ if (element.type === icuMessageformatParser.TYPE.plural || element.type === icuMessageformatParser.TYPE.select) {
1153
+ return Object.values(element.options).map((option) => option.value);
1154
+ }
1155
+ if (element.type === icuMessageformatParser.TYPE.tag) {
1156
+ return [element.children];
1157
+ }
1158
+ return [];
1159
+ }
1160
+ function collect(elements, add, state) {
1161
+ for (const element of elements) {
1162
+ const token = tokenOf(element);
1163
+ if (token !== void 0) {
1164
+ add(token);
1165
+ }
1166
+ if (element.type === icuMessageformatParser.TYPE.plural) {
1167
+ state.isPlural = true;
1168
+ }
1169
+ for (const child of childMessages(element)) {
1170
+ collect(child, add, state);
1171
+ }
1172
+ }
1173
+ }
1174
+ function analyzeIcuValue(value) {
1175
+ if (!value.includes("{") && !value.includes("<")) {
1176
+ return VALID_EMPTY;
1177
+ }
1178
+ try {
1179
+ const ast = icuMessageformatParser.parse(value);
1180
+ const placeholders = [];
1181
+ const state = { isPlural: false };
1182
+ collect(
1183
+ ast,
1184
+ (token) => {
1185
+ placeholders.push(token);
1186
+ },
1187
+ state
1188
+ );
1189
+ return { placeholders, isPlural: state.isPlural, valid: true };
1190
+ } catch {
1191
+ return INVALID;
1192
+ }
1193
+ }
1194
+ function icuPlaceholders(value) {
1195
+ return analyzeIcuValue(value).placeholders;
1196
+ }
1197
+ function icuIsValid(value) {
1198
+ return analyzeIcuValue(value).valid;
1199
+ }
1200
+ function icuDeriveEntry(_key, value) {
1201
+ const analysis = analyzeIcuValue(value);
1202
+ return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
1203
+ }
1204
+ function icuInvalidKeys(entries) {
1205
+ const invalid = [];
1206
+ for (const [key, entry] of entries) {
1207
+ if (!icuIsValid(entry.value)) {
1208
+ invalid.push(key);
1209
+ }
1210
+ }
1211
+ return invalid;
1212
+ }
1028
1213
  var AdapterError = class extends Error {
1029
1214
  code;
1030
1215
  constructor(code, message) {
@@ -1033,6 +1218,82 @@ var AdapterError = class extends Error {
1033
1218
  this.code = code;
1034
1219
  }
1035
1220
  };
1221
+ var MAX_DEPTH = 100;
1222
+ var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1223
+ var jsonTreeSchema = zod.z.lazy(
1224
+ () => zod.z.union([zod.z.string(), zod.z.record(zod.z.string(), jsonTreeSchema)])
1225
+ );
1226
+ var rootSchema = zod.z.record(zod.z.string(), jsonTreeSchema);
1227
+ function assertWithinDepth(value, max) {
1228
+ const stack = [{ node: value, depth: 1 }];
1229
+ while (stack.length > 0) {
1230
+ const top = stack.pop();
1231
+ if (top === void 0) {
1232
+ break;
1233
+ }
1234
+ const { node, depth } = top;
1235
+ if (typeof node !== "object" || node === null) {
1236
+ continue;
1237
+ }
1238
+ if (depth > max) {
1239
+ throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1240
+ }
1241
+ for (const child of Object.values(node)) {
1242
+ stack.push({ node: child, depth: depth + 1 });
1243
+ }
1244
+ }
1245
+ }
1246
+ function assertJsonRecord(value) {
1247
+ assertWithinDepth(value, MAX_DEPTH);
1248
+ const result = rootSchema.safeParse(value);
1249
+ if (!result.success) {
1250
+ throw new AdapterError(
1251
+ "INVALID_STRUCTURE",
1252
+ "The file is not a valid object (expected nested objects of string values)."
1253
+ );
1254
+ }
1255
+ return result.data;
1256
+ }
1257
+ function parseJsonObject(content) {
1258
+ let parsed;
1259
+ try {
1260
+ parsed = JSON.parse(content);
1261
+ } catch {
1262
+ throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1263
+ }
1264
+ return assertJsonRecord(parsed);
1265
+ }
1266
+ function serializeJsonTree(tree) {
1267
+ return `${JSON.stringify(tree, null, 2)}
1268
+ `;
1269
+ }
1270
+ function namespaceOf(filePath) {
1271
+ return path.basename(filePath, path.extname(filePath));
1272
+ }
1273
+ function rethrowStructured(error, message) {
1274
+ if (error instanceof AdapterError) {
1275
+ throw error;
1276
+ }
1277
+ throw new AdapterError("INVALID_STRUCTURE", message);
1278
+ }
1279
+ function computeIcu(entries, compute) {
1280
+ if (!compute) {
1281
+ return [];
1282
+ }
1283
+ try {
1284
+ return compute(entries);
1285
+ } catch (error) {
1286
+ rethrowStructured(error, "The file could not be analyzed for message validity.");
1287
+ }
1288
+ }
1289
+ function buildCanHandle(extensions, sniff) {
1290
+ return (filePath, sample) => {
1291
+ if (!extensions.includes(path.extname(filePath).toLowerCase())) {
1292
+ return false;
1293
+ }
1294
+ return sample === void 0 || sniff === void 0 || sniff(sample);
1295
+ };
1296
+ }
1036
1297
  var nodeOps = {
1037
1298
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
1038
1299
  rename: (from, to) => promises.rename(from, to),
@@ -1044,18 +1305,19 @@ async function cleanup(ops, tmp) {
1044
1305
  } catch {
1045
1306
  }
1046
1307
  }
1047
- async function atomicWriteFile(path$1, data, ops = nodeOps) {
1048
- const tmp = path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}`);
1308
+ function tempFileName2(path$1) {
1309
+ return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
1310
+ }
1311
+ async function atomicWriteFile(path, data, ops = nodeOps) {
1312
+ const tmp = tempFileName2(path);
1049
1313
  try {
1050
1314
  await ops.writeFile(tmp, data);
1051
- await ops.rename(tmp, path$1);
1315
+ await ops.rename(tmp, path);
1052
1316
  } catch (error) {
1053
1317
  await cleanup(ops, tmp);
1054
1318
  throw error;
1055
1319
  }
1056
1320
  }
1057
- var MAX_DEPTH = 100;
1058
- var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1059
1321
  async function readBoundedUtf82(handle, size) {
1060
1322
  const buffer = Buffer.allocUnsafe(size);
1061
1323
  let offset = 0;
@@ -1083,61 +1345,128 @@ async function readBounded2(filePath) {
1083
1345
  await handle.close();
1084
1346
  }
1085
1347
  }
1086
- function addEntries(node, prefix, namespace, derive, out) {
1087
- for (const [key, value] of Object.entries(node)) {
1088
- const path = prefix === "" ? key : `${prefix}.${key}`;
1089
- if (typeof value === "string") {
1090
- const { placeholders, isPlural } = derive(key, value);
1091
- out.set(path, { key: path, namespace, value, placeholders, isPlural });
1348
+ async function readFileContent(filePath) {
1349
+ const outcome = await readBounded2(filePath);
1350
+ if (outcome.kind === "not-a-file") {
1351
+ throw new AdapterError("INVALID_STRUCTURE", "The path is not a regular file.");
1352
+ }
1353
+ if (outcome.kind === "too-large") {
1354
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1355
+ }
1356
+ return outcome.content;
1357
+ }
1358
+ var BACKSLASH = "\\";
1359
+ var DOT = ".";
1360
+ var ESCAPED_BACKSLASH = "\\\\";
1361
+ var ESCAPED_DOT = "\\.";
1362
+ function needsEncoding(segment) {
1363
+ return segment.includes(BACKSLASH) || segment.includes(DOT);
1364
+ }
1365
+ function encodeSegment(segment) {
1366
+ if (!needsEncoding(segment)) {
1367
+ return segment;
1368
+ }
1369
+ let out = "";
1370
+ for (const char of segment) {
1371
+ if (char === BACKSLASH) {
1372
+ out += ESCAPED_BACKSLASH;
1373
+ } else if (char === DOT) {
1374
+ out += ESCAPED_DOT;
1092
1375
  } else {
1093
- addEntries(value, path, namespace, derive, out);
1376
+ out += char;
1094
1377
  }
1095
1378
  }
1096
- }
1097
- function flattenTree(tree, namespace, derive) {
1098
- const out = /* @__PURE__ */ new Map();
1099
- addEntries(tree, "", namespace, derive, out);
1100
1379
  return out;
1101
1380
  }
1102
- var jsonTreeSchema = zod.z.lazy(
1103
- () => zod.z.union([zod.z.string(), zod.z.record(zod.z.string(), jsonTreeSchema)])
1104
- );
1105
- var rootSchema = zod.z.record(zod.z.string(), jsonTreeSchema);
1106
- function assertWithinDepth(value, max) {
1107
- const stack = [{ node: value, depth: 1 }];
1108
- while (stack.length > 0) {
1109
- const top = stack.pop();
1110
- if (top === void 0) {
1111
- break;
1112
- }
1113
- const { node, depth } = top;
1114
- if (typeof node !== "object" || node === null) {
1115
- continue;
1116
- }
1117
- if (depth > max) {
1118
- throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1119
- }
1120
- for (const child of Object.values(node)) {
1121
- stack.push({ node: child, depth: depth + 1 });
1381
+ function decodeSegment(segment) {
1382
+ if (!segment.includes(BACKSLASH)) {
1383
+ return segment;
1384
+ }
1385
+ let out = "";
1386
+ let escaping = false;
1387
+ for (const char of segment) {
1388
+ if (escaping) {
1389
+ out += char;
1390
+ escaping = false;
1391
+ } else if (char === BACKSLASH) {
1392
+ escaping = true;
1393
+ } else {
1394
+ out += char;
1122
1395
  }
1123
1396
  }
1397
+ return out;
1124
1398
  }
1125
- function parseJsonObject(content) {
1126
- let parsed;
1127
- try {
1128
- parsed = JSON.parse(content);
1129
- } catch {
1130
- throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1399
+ function joinEncodedSegments(segments) {
1400
+ return segments.join(DOT);
1401
+ }
1402
+ function decodeKeyToSegments(key) {
1403
+ if (!key.includes(BACKSLASH)) {
1404
+ return key.split(DOT);
1405
+ }
1406
+ const segments = [];
1407
+ let current = "";
1408
+ let escaping = false;
1409
+ for (const char of key) {
1410
+ if (escaping) {
1411
+ current += BACKSLASH + char;
1412
+ escaping = false;
1413
+ } else if (char === BACKSLASH) {
1414
+ escaping = true;
1415
+ } else if (char === DOT) {
1416
+ segments.push(current);
1417
+ current = "";
1418
+ } else {
1419
+ current += char;
1420
+ }
1131
1421
  }
1132
- assertWithinDepth(parsed, MAX_DEPTH);
1133
- const result = rootSchema.safeParse(parsed);
1134
- if (!result.success) {
1422
+ if (escaping) {
1423
+ current += BACKSLASH;
1424
+ }
1425
+ segments.push(current);
1426
+ return segments.map(decodeSegment);
1427
+ }
1428
+ function addLeaf(ctx, segments, key, value) {
1429
+ const effectivePath = segments.join(".");
1430
+ const mapKey = joinEncodedSegments(segments.map(encodeSegment));
1431
+ if (ctx.claimed.has(effectivePath) && ctx.claimed.get(effectivePath) !== mapKey) {
1135
1432
  throw new AdapterError(
1136
1433
  "INVALID_STRUCTURE",
1137
- "The file is not a valid JSON object (expected nested objects of string values)."
1434
+ "A literal dotted leaf key and a nested key path resolve to the same path."
1138
1435
  );
1139
1436
  }
1140
- return result.data;
1437
+ ctx.claimed.set(effectivePath, mapKey);
1438
+ const { placeholders, isPlural } = ctx.derive(key, value);
1439
+ ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
1440
+ }
1441
+ function addEntries(ctx, prefix, node) {
1442
+ for (const [key, value] of Object.entries(node)) {
1443
+ const segments = [...prefix, key];
1444
+ if (typeof value === "string") {
1445
+ addLeaf(ctx, segments, key, value);
1446
+ } else {
1447
+ addEntries(ctx, segments, value);
1448
+ }
1449
+ }
1450
+ }
1451
+ function addPathEntries(node, prefix, namespace, derive, out) {
1452
+ for (const [key, value] of Object.entries(node)) {
1453
+ const path = prefix === "" ? key : `${prefix}.${key}`;
1454
+ if (typeof value === "string") {
1455
+ const { placeholders, isPlural } = derive(key, value);
1456
+ out.set(path, { key: path, namespace, value, placeholders, isPlural });
1457
+ } else {
1458
+ addPathEntries(value, path, namespace, derive, out);
1459
+ }
1460
+ }
1461
+ }
1462
+ function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
1463
+ const out = /* @__PURE__ */ new Map();
1464
+ if (keyMode === "path-notation") {
1465
+ addPathEntries(tree, "", namespace, derive, out);
1466
+ return out;
1467
+ }
1468
+ addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
1469
+ return out;
1141
1470
  }
1142
1471
  function emptyNode() {
1143
1472
  return /* @__PURE__ */ Object.create(null);
@@ -1163,103 +1492,194 @@ function setPath(root, segments, value) {
1163
1492
  for (const segment of segments.slice(0, -1)) {
1164
1493
  node = descend(node, segment);
1165
1494
  }
1495
+ if (typeof node[leaf] === "object") {
1496
+ throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
1497
+ }
1166
1498
  node[leaf] = value;
1167
1499
  }
1168
1500
  function unflattenEntries(entries) {
1169
1501
  const root = emptyNode();
1170
1502
  for (const [key, entry] of entries) {
1171
- setPath(root, key.split("."), entry.value);
1503
+ setPath(root, decodeKeyToSegments(key), entry.value);
1172
1504
  }
1173
1505
  return root;
1174
1506
  }
1175
- function namespaceOf(filePath) {
1176
- return path.basename(filePath, path.extname(filePath));
1177
- }
1178
- function canHandle(filePath, sample) {
1179
- if (path.extname(filePath).toLowerCase() !== ".json") {
1180
- return false;
1181
- }
1182
- return sample === void 0 || sample.trimStart().startsWith("{");
1183
- }
1184
- function rethrowStructured(error, message) {
1185
- if (error instanceof AdapterError) {
1186
- throw error;
1187
- }
1188
- throw new AdapterError("INVALID_STRUCTURE", message);
1189
- }
1190
- function toEntries(content, namespace, deriveEntry, validateTree) {
1507
+ function toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree) {
1191
1508
  try {
1192
- const tree = parseJsonObject(content);
1509
+ const tree = parse2(content);
1193
1510
  validateTree?.(tree);
1194
- return flattenTree(tree, namespace, deriveEntry);
1511
+ return flattenTree(tree, namespace, deriveEntry, keyMode);
1195
1512
  } catch (error) {
1196
- rethrowStructured(error, "The file could not be read as JSON.");
1513
+ rethrowStructured(error, "The file could not be parsed.");
1197
1514
  }
1198
1515
  }
1199
- function computeIcu(entries, compute) {
1200
- if (!compute) {
1201
- return [];
1202
- }
1203
- try {
1204
- return compute(entries);
1205
- } catch (error) {
1206
- rethrowStructured(error, "The file could not be analyzed for message validity.");
1207
- }
1208
- }
1209
- function createJsonFileAdapter(options) {
1516
+ function createTreeFileAdapter(options) {
1210
1517
  const {
1211
1518
  format,
1519
+ extensions,
1520
+ sniff,
1521
+ parse: parse2,
1522
+ serialize,
1212
1523
  deriveEntry,
1213
- extractPlaceholders: extractPlaceholders2,
1214
- computeInvalidIcuKeys: computeInvalidIcuKeys2,
1215
- validateMessage: validateMessage2,
1524
+ extractPlaceholders,
1525
+ computeInvalidIcuKeys,
1526
+ validateMessage,
1216
1527
  validateTree,
1217
- buildWriteTree
1528
+ buildWriteTree,
1529
+ keyMode = "literal-leaf"
1218
1530
  } = options;
1219
1531
  return {
1220
1532
  format,
1221
- canHandle,
1222
- extractPlaceholders: extractPlaceholders2,
1223
- // Non-ICU formats supply no validator: every value is valid for their syntax.
1224
- validateMessage: validateMessage2 ?? (() => true),
1533
+ canHandle: buildCanHandle(extensions, sniff),
1534
+ extractPlaceholders,
1535
+ validateMessage: validateMessage ?? (() => true),
1225
1536
  async read(filePath, locale) {
1226
- const outcome = await readBounded2(filePath);
1227
- if (outcome.kind === "not-a-file") {
1228
- throw new AdapterError("INVALID_STRUCTURE", "The path is not a regular file.");
1229
- }
1230
- if (outcome.kind === "too-large") {
1231
- throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1232
- }
1537
+ const content = await readFileContent(filePath);
1233
1538
  const namespace = namespaceOf(filePath);
1234
- const entries = toEntries(outcome.content, namespace, deriveEntry, validateTree);
1539
+ const entries = toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree);
1235
1540
  const resource = { locale, namespace, format, entries };
1236
- const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys2);
1541
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1237
1542
  return { resource, invalidIcuKeys };
1238
1543
  },
1239
1544
  async write(resource, filePath) {
1240
1545
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
1241
- await atomicWriteFile(filePath, `${JSON.stringify(tree, null, 2)}
1242
- `);
1546
+ await atomicWriteFile(filePath, serialize(tree));
1243
1547
  }
1244
1548
  };
1245
1549
  }
1246
- var PLACEHOLDER_PATTERN = /\{\{[^{}]*\}\}/g;
1247
- function extractI18nextPlaceholders(value) {
1248
- const seen = /* @__PURE__ */ new Set();
1550
+ function isMetadataKey(key) {
1551
+ return key.startsWith("@");
1552
+ }
1553
+ function parseArbObject(content) {
1554
+ let parsed;
1555
+ try {
1556
+ parsed = JSON.parse(content);
1557
+ } catch {
1558
+ throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1559
+ }
1560
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1561
+ throw new AdapterError(
1562
+ "INVALID_STRUCTURE",
1563
+ "The file is not a valid object (expected nested objects of string values)."
1564
+ );
1565
+ }
1566
+ return parsed;
1567
+ }
1568
+ function stripArbMetadata(tree) {
1569
+ const out = /* @__PURE__ */ Object.create(null);
1570
+ for (const [key, value] of Object.entries(tree)) {
1571
+ if (!isMetadataKey(key)) {
1572
+ out[key] = value;
1573
+ }
1574
+ }
1575
+ return out;
1576
+ }
1577
+ function originalKey(encoded) {
1578
+ return decodeKeyToSegments(encoded).join(".");
1579
+ }
1580
+ function messagesFromEntries(entries) {
1581
+ const out = /* @__PURE__ */ new Map();
1582
+ for (const [key, entry] of entries) {
1583
+ out.set(originalKey(key), entry.value);
1584
+ }
1585
+ return out;
1586
+ }
1587
+ async function readDestinationPairs(filePath) {
1588
+ let parsed;
1589
+ try {
1590
+ const outcome = await readBounded2(filePath);
1591
+ if (outcome.kind !== "ok") {
1592
+ return null;
1593
+ }
1594
+ parsed = JSON.parse(outcome.content);
1595
+ } catch {
1596
+ return null;
1597
+ }
1598
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1599
+ return null;
1600
+ }
1601
+ return Object.entries(parsed);
1602
+ }
1603
+ async function buildArbWriteTree(entries, filePath) {
1604
+ const messages = messagesFromEntries(entries);
1605
+ const pairs = await readDestinationPairs(filePath);
1606
+ const out = /* @__PURE__ */ Object.create(null);
1607
+ const consumed = /* @__PURE__ */ new Set();
1608
+ for (const [key, value] of pairs ?? []) {
1609
+ const translated = isMetadataKey(key) ? void 0 : messages.get(key);
1610
+ if (translated !== void 0) {
1611
+ consumed.add(key);
1612
+ }
1613
+ out[key] = translated ?? value;
1614
+ }
1615
+ for (const [key, value] of messages) {
1616
+ if (!consumed.has(key)) {
1617
+ out[key] = value;
1618
+ }
1619
+ }
1620
+ return out;
1621
+ }
1622
+ function parseArb(content) {
1623
+ return assertJsonRecord(stripArbMetadata(parseArbObject(content)));
1624
+ }
1625
+ function createArbAdapter() {
1626
+ return createTreeFileAdapter({
1627
+ format: "arb",
1628
+ extensions: [".arb"],
1629
+ sniff: (sample) => sample.trimStart().startsWith("{"),
1630
+ parse: parseArb,
1631
+ serialize: serializeJsonTree,
1632
+ extractPlaceholders: icuPlaceholders,
1633
+ deriveEntry: icuDeriveEntry,
1634
+ computeInvalidIcuKeys: icuInvalidKeys,
1635
+ validateMessage: icuIsValid,
1636
+ buildWriteTree: buildArbWriteTree
1637
+ });
1638
+ }
1639
+ function createJsonFileAdapter(options) {
1640
+ return createTreeFileAdapter({
1641
+ ...options,
1642
+ extensions: [".json"],
1643
+ sniff: (sample) => sample.trimStart().startsWith("{"),
1644
+ parse: parseJsonObject,
1645
+ serialize: serializeJsonTree
1646
+ });
1647
+ }
1648
+ var DOUBLE_BRACE_PATTERN = /\{\{[^{}]*\}\}/g;
1649
+ var I18NEXT_PATTERN = /\{\{[^{}]*\}\}|\$t\([^()]*\)/g;
1650
+ function scanTokens(value, pattern) {
1249
1651
  const result = [];
1250
- for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
1652
+ for (const match of value.matchAll(pattern)) {
1251
1653
  const token = match[0];
1252
- if (token !== void 0 && !seen.has(token)) {
1253
- seen.add(token);
1654
+ if (token !== void 0) {
1254
1655
  result.push(token);
1255
1656
  }
1256
1657
  }
1257
1658
  return result;
1258
1659
  }
1660
+ function extractDoubleBracePlaceholders(value) {
1661
+ return scanTokens(value, DOUBLE_BRACE_PATTERN);
1662
+ }
1663
+ function extractI18nextPlaceholders(value) {
1664
+ return scanTokens(value, I18NEXT_PATTERN);
1665
+ }
1259
1666
  var PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
1260
1667
  function isPluralKey(key) {
1261
1668
  return PLURAL_SUFFIX.test(key);
1262
1669
  }
1670
+ function pluralCategoryOf(key) {
1671
+ const match = PLURAL_SUFFIX.exec(key);
1672
+ return match?.[1];
1673
+ }
1674
+ function pluralBaseKey(key) {
1675
+ if (!isPluralKey(key)) {
1676
+ return void 0;
1677
+ }
1678
+ return key.replace(PLURAL_SUFFIX, "");
1679
+ }
1680
+ function makePluralKey(baseKey, category) {
1681
+ return `${baseKey}_${category}`;
1682
+ }
1263
1683
  function createI18nextJsonAdapter() {
1264
1684
  return createJsonFileAdapter({
1265
1685
  format: "i18next-json",
@@ -1270,95 +1690,13 @@ function createI18nextJsonAdapter() {
1270
1690
  })
1271
1691
  });
1272
1692
  }
1273
- var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1274
- var INVALID = { placeholders: [], isPlural: false, valid: false };
1275
- function tokenOf(element) {
1276
- switch (element.type) {
1277
- case icuMessageformatParser.TYPE.argument:
1278
- case icuMessageformatParser.TYPE.number:
1279
- case icuMessageformatParser.TYPE.date:
1280
- case icuMessageformatParser.TYPE.time:
1281
- case icuMessageformatParser.TYPE.select:
1282
- case icuMessageformatParser.TYPE.plural:
1283
- return `{${element.value}}`;
1284
- case icuMessageformatParser.TYPE.tag:
1285
- return `<${element.value}>`;
1286
- default:
1287
- return void 0;
1288
- }
1289
- }
1290
- function childMessages(element) {
1291
- if (element.type === icuMessageformatParser.TYPE.plural || element.type === icuMessageformatParser.TYPE.select) {
1292
- return Object.values(element.options).map((option) => option.value);
1293
- }
1294
- if (element.type === icuMessageformatParser.TYPE.tag) {
1295
- return [element.children];
1296
- }
1297
- return [];
1298
- }
1299
- function collect(elements, add, state) {
1300
- for (const element of elements) {
1301
- const token = tokenOf(element);
1302
- if (token !== void 0) {
1303
- add(token);
1304
- }
1305
- if (element.type === icuMessageformatParser.TYPE.plural) {
1306
- state.isPlural = true;
1307
- }
1308
- for (const child of childMessages(element)) {
1309
- collect(child, add, state);
1310
- }
1311
- }
1312
- }
1313
- function analyzeIcuValue(value) {
1314
- if (!value.includes("{") && !value.includes("<")) {
1315
- return VALID_EMPTY;
1316
- }
1317
- try {
1318
- const ast = icuMessageformatParser.parse(value);
1319
- const seen = /* @__PURE__ */ new Set();
1320
- const placeholders = [];
1321
- const state = { isPlural: false };
1322
- collect(
1323
- ast,
1324
- (token) => {
1325
- if (!seen.has(token)) {
1326
- seen.add(token);
1327
- placeholders.push(token);
1328
- }
1329
- },
1330
- state
1331
- );
1332
- return { placeholders, isPlural: state.isPlural, valid: true };
1333
- } catch {
1334
- return INVALID;
1335
- }
1336
- }
1337
- function extractPlaceholders(value) {
1338
- return analyzeIcuValue(value).placeholders;
1339
- }
1340
- function validateMessage(value) {
1341
- return analyzeIcuValue(value).valid;
1342
- }
1343
- function computeInvalidIcuKeys(entries) {
1344
- const invalid = [];
1345
- for (const [key, entry] of entries) {
1346
- if (!validateMessage(entry.value)) {
1347
- invalid.push(key);
1348
- }
1349
- }
1350
- return invalid;
1351
- }
1352
1693
  function createNextIntlJsonAdapter() {
1353
1694
  return createJsonFileAdapter({
1354
1695
  format: "next-intl-json",
1355
- extractPlaceholders,
1356
- deriveEntry: (_key, value) => {
1357
- const analysis = analyzeIcuValue(value);
1358
- return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
1359
- },
1360
- computeInvalidIcuKeys,
1361
- validateMessage
1696
+ extractPlaceholders: icuPlaceholders,
1697
+ deriveEntry: icuDeriveEntry,
1698
+ computeInvalidIcuKeys: icuInvalidKeys,
1699
+ validateMessage: icuIsValid
1362
1700
  });
1363
1701
  }
1364
1702
  function assertNotMixed(tree) {
@@ -1413,13 +1751,15 @@ async function buildNgxWriteTree(entries, filePath) {
1413
1751
  function createNgxTranslateJsonAdapter() {
1414
1752
  return createJsonFileAdapter({
1415
1753
  format: "ngx-translate-json",
1416
- extractPlaceholders: extractI18nextPlaceholders,
1754
+ extractPlaceholders: extractDoubleBracePlaceholders,
1417
1755
  deriveEntry: (_key, value) => ({
1418
- placeholders: extractI18nextPlaceholders(value),
1756
+ placeholders: extractDoubleBracePlaceholders(value),
1419
1757
  isPlural: false
1420
1758
  }),
1421
1759
  validateTree: assertNotMixed,
1422
- buildWriteTree: buildNgxWriteTree
1760
+ buildWriteTree: buildNgxWriteTree,
1761
+ // ngx-translate flat style uses dotted keys as path notation, not literal leaves.
1762
+ keyMode: "path-notation"
1423
1763
  });
1424
1764
  }
1425
1765
  var AdapterRegistry = class {
@@ -1470,15 +1810,13 @@ var AdapterRegistry = class {
1470
1810
  return this.resolveByDetection(filePath, options.sample);
1471
1811
  }
1472
1812
  };
1473
- var PLACEHOLDER_PATTERN2 = /\{[^{}]*\}/g;
1813
+ var PLACEHOLDER_PATTERN = /(?<!\{)\{\s*([A-Za-z_][\w$-]*|\d+)\s*\}(?!\})/g;
1474
1814
  function extractVueI18nPlaceholders(value) {
1475
- const seen = /* @__PURE__ */ new Set();
1476
1815
  const result = [];
1477
- for (const match of value.matchAll(PLACEHOLDER_PATTERN2)) {
1478
- const token = match[0];
1479
- if (token !== void 0 && !seen.has(token)) {
1480
- seen.add(token);
1481
- result.push(token);
1816
+ for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
1817
+ const key = match[1];
1818
+ if (key !== void 0) {
1819
+ result.push(`{${key}}`);
1482
1820
  }
1483
1821
  }
1484
1822
  return result;
@@ -1496,8 +1834,245 @@ function createVueI18nJsonAdapter() {
1496
1834
  })
1497
1835
  });
1498
1836
  }
1837
+ function toEntries2(content, namespace, parseEntries) {
1838
+ try {
1839
+ return parseEntries(content, namespace);
1840
+ } catch (error) {
1841
+ rethrowStructured(error, "The file could not be parsed.");
1842
+ }
1843
+ }
1844
+ function createFlatFileAdapter(options) {
1845
+ const {
1846
+ format,
1847
+ extensions,
1848
+ sniff,
1849
+ parseEntries,
1850
+ serializeEntries,
1851
+ extractPlaceholders,
1852
+ validateMessage,
1853
+ computeInvalidIcuKeys
1854
+ } = options;
1855
+ return {
1856
+ format,
1857
+ canHandle: buildCanHandle(extensions, sniff),
1858
+ extractPlaceholders,
1859
+ validateMessage: validateMessage ?? (() => true),
1860
+ async read(filePath, locale) {
1861
+ const content = await readFileContent(filePath);
1862
+ const namespace = namespaceOf(filePath);
1863
+ const entries = toEntries2(content, namespace, parseEntries);
1864
+ const resource = { locale, namespace, format, entries };
1865
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1866
+ return { resource, invalidIcuKeys };
1867
+ },
1868
+ async write(resource, filePath) {
1869
+ const data = await serializeEntries(resource.entries, filePath);
1870
+ await atomicWriteFile(filePath, data);
1871
+ }
1872
+ };
1873
+ }
1874
+ var XLIFF_PATTERN = /<(?:x|g|bx|ex|ph|it|mrk)\b[^>]*>|\{[^{}]+\}/g;
1875
+ function extractXliffPlaceholders(value) {
1876
+ const result = [];
1877
+ for (const match of value.matchAll(XLIFF_PATTERN)) {
1878
+ const token = match[0];
1879
+ if (token !== void 0) {
1880
+ result.push(token);
1881
+ }
1882
+ }
1883
+ return result;
1884
+ }
1885
+ var ELEMENT_NODE = 1;
1886
+ function isElement(node) {
1887
+ return node.nodeType === ELEMENT_NODE;
1888
+ }
1889
+ function elementChildren(parent) {
1890
+ return Array.from(parent.childNodes).filter(isElement);
1891
+ }
1892
+ function childByName(parent, name) {
1893
+ return elementChildren(parent).find((el) => el.localName === name) ?? null;
1894
+ }
1895
+ function collectByTag(root, name) {
1896
+ return Array.from(root.getElementsByTagName(name));
1897
+ }
1898
+ function unitKey(element, index) {
1899
+ return element.getAttribute("id") ?? element.getAttribute("resname") ?? `unit-${index}`;
1900
+ }
1901
+ function onFatal(level) {
1902
+ if (level === "fatalError") {
1903
+ throw new Error("malformed XML");
1904
+ }
1905
+ }
1906
+ function assertNoDoctype(content) {
1907
+ if (/<!DOCTYPE/i.test(content) || /<!ENTITY/i.test(content)) {
1908
+ throw new AdapterError("INVALID_XML", "XLIFF with a DTD or entity declaration is rejected.");
1909
+ }
1910
+ }
1911
+ function parseXml(content) {
1912
+ assertNoDoctype(content);
1913
+ let doc;
1914
+ try {
1915
+ doc = new xmldom.DOMParser({ onError: onFatal }).parseFromString(content, "text/xml");
1916
+ } catch {
1917
+ throw new AdapterError("INVALID_XML", "The file is not valid XML.");
1918
+ }
1919
+ const root = doc.documentElement;
1920
+ if (root === null || root.localName !== "xliff") {
1921
+ throw new AdapterError("INVALID_STRUCTURE", "The file is not an XLIFF document.");
1922
+ }
1923
+ return { doc, root };
1924
+ }
1925
+ function walkXliff12(root) {
1926
+ const units = [];
1927
+ collectByTag(root, "trans-unit").forEach((tu, index) => {
1928
+ const source = childByName(tu, "source");
1929
+ if (source !== null) {
1930
+ units.push({
1931
+ key: unitKey(tu, index),
1932
+ source,
1933
+ target: childByName(tu, "target"),
1934
+ container: tu
1935
+ });
1936
+ }
1937
+ });
1938
+ return units;
1939
+ }
1940
+ function walkXliff20(root) {
1941
+ const units = [];
1942
+ collectByTag(root, "unit").forEach((unit, index) => {
1943
+ const baseKey = unitKey(unit, index);
1944
+ const segments = elementChildren(unit).filter((el) => el.localName === "segment");
1945
+ segments.forEach((segment, segIndex) => {
1946
+ const source = childByName(segment, "source");
1947
+ if (source !== null) {
1948
+ const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
1949
+ units.push({ key, source, target: childByName(segment, "target"), container: segment });
1950
+ }
1951
+ });
1952
+ });
1953
+ return units;
1954
+ }
1955
+ function walkUnits(root) {
1956
+ const version = root.getAttribute("version") ?? "1.2";
1957
+ return version.startsWith("2") ? walkXliff20(root) : walkXliff12(root);
1958
+ }
1959
+ function innerXml(serializer, element) {
1960
+ return Array.from(element.childNodes).map((node) => serializer.serializeToString(node)).join("");
1961
+ }
1962
+ function unitValue(serializer, unit) {
1963
+ if (unit.target !== null) {
1964
+ const targetXml = innerXml(serializer, unit.target);
1965
+ if (targetXml.trim() !== "") {
1966
+ return targetXml;
1967
+ }
1968
+ }
1969
+ return innerXml(serializer, unit.source);
1970
+ }
1971
+ function parseXliffEntries(content, namespace) {
1972
+ const { root } = parseXml(content);
1973
+ const serializer = new xmldom.XMLSerializer();
1974
+ const out = /* @__PURE__ */ new Map();
1975
+ for (const unit of walkUnits(root)) {
1976
+ const value = unitValue(serializer, unit);
1977
+ out.set(unit.key, {
1978
+ key: unit.key,
1979
+ namespace,
1980
+ value,
1981
+ placeholders: extractXliffPlaceholders(value),
1982
+ isPlural: false
1983
+ });
1984
+ }
1985
+ return out;
1986
+ }
1987
+ async function readDestination(filePath) {
1988
+ let outcome;
1989
+ try {
1990
+ outcome = await readBounded2(filePath);
1991
+ } catch {
1992
+ throw new AdapterError("INVALID_STRUCTURE", "The destination XLIFF file does not exist.");
1993
+ }
1994
+ if (outcome.kind === "not-a-file") {
1995
+ throw new AdapterError("INVALID_STRUCTURE", "The destination path is not a regular file.");
1996
+ }
1997
+ if (outcome.kind === "too-large") {
1998
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1999
+ }
2000
+ return outcome.content;
2001
+ }
2002
+ function fragmentNodes(parser, value) {
2003
+ try {
2004
+ const root = parser.parseFromString(`<wrapper>${value}</wrapper>`, "text/xml").documentElement;
2005
+ return root === null ? null : Array.from(root.childNodes);
2006
+ } catch {
2007
+ return null;
2008
+ }
2009
+ }
2010
+ function setTargetValue(doc, parser, element, value) {
2011
+ while (element.firstChild !== null) {
2012
+ element.removeChild(element.firstChild);
2013
+ }
2014
+ const nodes = fragmentNodes(parser, value);
2015
+ if (nodes === null) {
2016
+ element.textContent = value;
2017
+ return;
2018
+ }
2019
+ for (const node of nodes) {
2020
+ element.appendChild(doc.importNode(node, true));
2021
+ }
2022
+ }
2023
+ async function serializeXliffEntries(entries, filePath) {
2024
+ const { doc, root } = parseXml(await readDestination(filePath));
2025
+ const parser = new xmldom.DOMParser({ onError: onFatal });
2026
+ for (const unit of walkUnits(root)) {
2027
+ const entry = entries.get(unit.key);
2028
+ if (entry !== void 0) {
2029
+ const target = unit.target ?? doc.createElement("target");
2030
+ if (unit.target === null) {
2031
+ unit.container.appendChild(target);
2032
+ }
2033
+ setTargetValue(doc, parser, target, entry.value);
2034
+ }
2035
+ }
2036
+ return new xmldom.XMLSerializer().serializeToString(doc);
2037
+ }
2038
+ function sniffXliff(sample) {
2039
+ const head = sample.trimStart();
2040
+ return head.startsWith("<xliff") || head.startsWith("<?xml");
2041
+ }
2042
+ function createXliffAdapter() {
2043
+ return createFlatFileAdapter({
2044
+ format: "xliff",
2045
+ extensions: [".xlf", ".xliff"],
2046
+ sniff: sniffXliff,
2047
+ parseEntries: parseXliffEntries,
2048
+ serializeEntries: serializeXliffEntries,
2049
+ extractPlaceholders: extractXliffPlaceholders
2050
+ });
2051
+ }
2052
+ function parseYamlObject(content) {
2053
+ let parsed;
2054
+ try {
2055
+ parsed = yaml.parse(content, { maxAliasCount: 100 });
2056
+ } catch {
2057
+ throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2058
+ }
2059
+ return assertJsonRecord(parsed);
2060
+ }
2061
+ function createYamlAdapter() {
2062
+ return createTreeFileAdapter({
2063
+ format: "yaml",
2064
+ extensions: [".yml", ".yaml"],
2065
+ parse: parseYamlObject,
2066
+ serialize: (tree) => yaml.stringify(tree),
2067
+ extractPlaceholders: extractDoubleBracePlaceholders,
2068
+ deriveEntry: (_key, value) => ({
2069
+ placeholders: extractDoubleBracePlaceholders(value),
2070
+ isPlural: false
2071
+ })
2072
+ });
2073
+ }
1499
2074
  function createDefaultRegistry() {
1500
- return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter());
2075
+ return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter());
1501
2076
  }
1502
2077
 
1503
2078
  // src/selection/select-adapter.ts
@@ -1512,6 +2087,89 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
1512
2087
  );
1513
2088
  }
1514
2089
 
2090
+ // src/flow/source.ts
2091
+ async function readSource(config, cwd, fs, adapter) {
2092
+ const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
2093
+ if (!await fs.fileExists(sourcePath)) {
2094
+ throw new SdkError(
2095
+ "SOURCE_UNREADABLE",
2096
+ `The source locale file was not found at ${sourcePath}.`
2097
+ );
2098
+ }
2099
+ try {
2100
+ return await adapter.read(sourcePath, config.sourceLocale);
2101
+ } catch (error) {
2102
+ const detail = error instanceof Error ? error.message : String(error);
2103
+ throw new SdkError(
2104
+ "SOURCE_INVALID",
2105
+ `The source locale file at ${sourcePath} could not be read: ${detail}`
2106
+ );
2107
+ }
2108
+ }
2109
+
2110
+ // src/flow/diff-locales.ts
2111
+ async function readTarget(cwd, config, adapter, fs, locale) {
2112
+ const path = localeFilePath(cwd, config.files.pattern, locale);
2113
+ if (!await fs.fileExists(path)) {
2114
+ return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
2115
+ }
2116
+ return (await adapter.read(path, locale)).resource;
2117
+ }
2118
+ function selectedLocales(config, requested) {
2119
+ if (requested === void 0) {
2120
+ return config.targetLocales;
2121
+ }
2122
+ const wanted = new Set(requested);
2123
+ return config.targetLocales.filter((locale) => wanted.has(locale));
2124
+ }
2125
+ async function diffLocales(input, deps = {}) {
2126
+ const config = input.config;
2127
+ const cwd = input.cwd ?? process.cwd();
2128
+ const fs = deps.fs ?? defaultFs;
2129
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2130
+ const source = await readSource(config, cwd, fs, adapter);
2131
+ const lock = await readLockFile(lockFilePath(cwd), fs);
2132
+ return Promise.all(
2133
+ selectedLocales(config, input.locales).map(async (locale) => {
2134
+ const target = await readTarget(cwd, config, adapter, fs, locale);
2135
+ const diff2 = diffResources(source.resource, target, { baseline: baselineFor(lock, locale) });
2136
+ return { locale, diff: diff2 };
2137
+ })
2138
+ );
2139
+ }
2140
+
2141
+ // src/flow/check.ts
2142
+ function toCheckSummary(locale, diff2) {
2143
+ return {
2144
+ locale,
2145
+ missing: diff2.missing.length,
2146
+ stale: diff2.changed.length,
2147
+ upToDate: diff2.unchanged.length,
2148
+ inSync: diff2.missing.length === 0 && diff2.changed.length === 0
2149
+ };
2150
+ }
2151
+ async function check(input, deps = {}) {
2152
+ const results = await diffLocales(input, deps);
2153
+ const locales = results.map(({ locale, diff: diff2 }) => toCheckSummary(locale, diff2));
2154
+ return { inSync: locales.every((entry) => entry.inSync), locales };
2155
+ }
2156
+
2157
+ // src/flow/diff.ts
2158
+ function toLocaleDiff(locale, diff2) {
2159
+ return {
2160
+ locale,
2161
+ missing: diff2.missing,
2162
+ changed: diff2.changed,
2163
+ orphaned: diff2.orphaned,
2164
+ hasPendingChanges: diff2.missing.length > 0 || diff2.changed.length > 0
2165
+ };
2166
+ }
2167
+ async function diff(input, deps = {}) {
2168
+ const results = await diffLocales(input, deps);
2169
+ const locales = results.map(({ locale, diff: result }) => toLocaleDiff(locale, result));
2170
+ return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
2171
+ }
2172
+
1515
2173
  // src/selection/select-provider.ts
1516
2174
  function selectProvider(config, createProvider = buildProvider) {
1517
2175
  try {
@@ -1540,8 +2198,10 @@ function failureSummary(locale, error) {
1540
2198
  translated: [],
1541
2199
  unchanged: [],
1542
2200
  orphaned: [],
2201
+ pruned: [],
1543
2202
  invalidIcuSource: [],
1544
2203
  integrityMismatches: [],
2204
+ generated: [],
1545
2205
  notices: [],
1546
2206
  error: describeError(error)
1547
2207
  };
@@ -1564,18 +2224,210 @@ function readNotices(result) {
1564
2224
  return candidate.filter(isNotice);
1565
2225
  }
1566
2226
 
2227
+ // src/flow/plural-categories.ts
2228
+ var LANGUAGE_CATEGORIES = {
2229
+ ar: ["zero", "one", "two", "few", "many", "other"],
2230
+ cy: ["zero", "one", "two", "few", "many", "other"],
2231
+ ga: ["one", "two", "few", "many", "other"],
2232
+ pl: ["one", "few", "many", "other"],
2233
+ ru: ["one", "few", "many", "other"],
2234
+ uk: ["one", "few", "many", "other"],
2235
+ be: ["one", "few", "many", "other"],
2236
+ lt: ["one", "few", "many", "other"],
2237
+ sl: ["one", "two", "few", "other"]
2238
+ };
2239
+ function isKnownRicherLanguage(locale) {
2240
+ const subtag = locale.toLowerCase().split(/[-_]/)[0] ?? "";
2241
+ return LANGUAGE_CATEGORIES[subtag] !== void 0;
2242
+ }
2243
+ function requiredCategories(locale) {
2244
+ const subtag = locale.toLowerCase().split(/[-_]/)[0] ?? "";
2245
+ return LANGUAGE_CATEGORIES[subtag] ?? ["one", "other"];
2246
+ }
2247
+ function groupPluralSources(source) {
2248
+ const groups = /* @__PURE__ */ new Map();
2249
+ for (const [key, entry] of source.entries) {
2250
+ const baseKey = pluralBaseKey(key);
2251
+ const category = pluralCategoryOf(key);
2252
+ if (baseKey === void 0 || category === void 0) {
2253
+ continue;
2254
+ }
2255
+ const group = groups.get(baseKey) ?? /* @__PURE__ */ new Map();
2256
+ group.set(category, entry);
2257
+ groups.set(baseKey, group);
2258
+ }
2259
+ return groups;
2260
+ }
2261
+ function suppliedCategories(groups) {
2262
+ const supplied = /* @__PURE__ */ new Set();
2263
+ for (const group of groups.values()) {
2264
+ for (const category of group.keys()) {
2265
+ supplied.add(category);
2266
+ }
2267
+ }
2268
+ return supplied;
2269
+ }
2270
+ function detectMissingPluralCategories(source, targetLocale, format) {
2271
+ if (format !== "i18next-json") {
2272
+ return void 0;
2273
+ }
2274
+ const groups = groupPluralSources(source);
2275
+ const supplied = suppliedCategories(groups);
2276
+ if (supplied.size === 0) {
2277
+ return void 0;
2278
+ }
2279
+ const missing = requiredCategories(targetLocale).filter((category) => !supplied.has(category));
2280
+ if (missing.length === 0) {
2281
+ return void 0;
2282
+ }
2283
+ return {
2284
+ code: "PLURAL_CATEGORIES_INCOMPLETE",
2285
+ message: `The source does not supply all CLDR plural categories the target language "${targetLocale}" requires (missing: ${missing.join(", ")}); verbatra translates only the source's plural forms and does not synthesize the others. Add the missing forms manually.`
2286
+ };
2287
+ }
2288
+ function targetPluralSetIncomplete(targetKeys, targetLocale) {
2289
+ const required = requiredCategories(targetLocale);
2290
+ const present = /* @__PURE__ */ new Map();
2291
+ for (const key of targetKeys) {
2292
+ const baseKey = pluralBaseKey(key);
2293
+ const category = pluralCategoryOf(key);
2294
+ if (baseKey === void 0 || category === void 0) {
2295
+ continue;
2296
+ }
2297
+ const set = present.get(baseKey) ?? /* @__PURE__ */ new Set();
2298
+ set.add(category);
2299
+ present.set(baseKey, set);
2300
+ }
2301
+ for (const categories of present.values()) {
2302
+ if (required.some((category) => !categories.has(category))) {
2303
+ return true;
2304
+ }
2305
+ }
2306
+ return false;
2307
+ }
2308
+ function sourcePluralBaseKeys(source) {
2309
+ const bases = /* @__PURE__ */ new Set();
2310
+ for (const key of source.entries.keys()) {
2311
+ const baseKey = pluralBaseKey(key);
2312
+ if (baseKey !== void 0) {
2313
+ bases.add(baseKey);
2314
+ }
2315
+ }
2316
+ return bases;
2317
+ }
2318
+ function isGeneratedPluralKey(key, sourceBaseKeys) {
2319
+ const baseKey = pluralBaseKey(key);
2320
+ return baseKey !== void 0 && sourceBaseKeys.has(baseKey);
2321
+ }
2322
+ function pluralIncompleteNotice(targetLocale) {
2323
+ return {
2324
+ code: "PLURAL_CATEGORIES_INCOMPLETE",
2325
+ message: `The plural set for the target language "${targetLocale}" is still incomplete: verbatra could not generate every required CLDR plural form (an unsupported case, or a generated form was withheld for a placeholder mismatch). Add the remaining forms manually.`
2326
+ };
2327
+ }
2328
+ function representativeEntry(group) {
2329
+ return group.get("other") ?? group.get("one") ?? [...group.values()][0];
2330
+ }
2331
+ function planPluralGeneration(source, targetLocale, format) {
2332
+ if (format !== "i18next-json" || !isKnownRicherLanguage(targetLocale)) {
2333
+ return { items: [] };
2334
+ }
2335
+ const required = requiredCategories(targetLocale);
2336
+ const groups = groupPluralSources(source);
2337
+ const items = [];
2338
+ for (const [baseKey, group] of groups) {
2339
+ const representative = representativeEntry(group);
2340
+ if (representative === void 0) {
2341
+ continue;
2342
+ }
2343
+ const governingEntries = [...group.values()];
2344
+ for (const category of required) {
2345
+ if (group.has(category)) {
2346
+ continue;
2347
+ }
2348
+ items.push({
2349
+ targetKey: makePluralKey(baseKey, category),
2350
+ category,
2351
+ sourceEntry: representative,
2352
+ governingEntries
2353
+ });
2354
+ }
2355
+ }
2356
+ return { items };
2357
+ }
2358
+
2359
+ // src/flow/plural-generation.ts
2360
+ function generatedLockHash(governingEntries, category) {
2361
+ const governingHashes = governingEntries.map(contentHash).sort();
2362
+ return contentHash({
2363
+ value: `${category}:${governingHashes.join("|")}`,
2364
+ placeholders: [],
2365
+ isPlural: true
2366
+ });
2367
+ }
2368
+ function syntheticEntry(item) {
2369
+ return {
2370
+ ...item.sourceEntry,
2371
+ key: item.targetKey,
2372
+ isPlural: true,
2373
+ // The CLDR category travels as data context (the meaning field), never the instruction channel.
2374
+ meaning: `CLDR plural category "${item.category}"`
2375
+ };
2376
+ }
2377
+ function staleItems(items, baseline) {
2378
+ return items.filter((item) => {
2379
+ const hash = generatedLockHash(item.governingEntries, item.category);
2380
+ return baseline.get(item.targetKey) !== hash;
2381
+ });
2382
+ }
2383
+ function buildRequest2(context, entries) {
2384
+ return {
2385
+ sourceLocale: context.sourceLocale,
2386
+ targetLocale: context.targetLocale,
2387
+ entries,
2388
+ extractPlaceholders: context.adapter.extractPlaceholders,
2389
+ ...context.glossary !== void 0 ? { glossary: context.glossary } : {},
2390
+ ...context.tone !== void 0 ? { tone: context.tone } : {}
2391
+ };
2392
+ }
2393
+ async function generatePluralForms(context) {
2394
+ const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
2395
+ const stale = staleItems(plan.items, context.baseline);
2396
+ if (stale.length === 0) {
2397
+ return { accepted: [], withheld: [] };
2398
+ }
2399
+ const entries = stale.map(syntheticEntry);
2400
+ const result = await context.provider.translateBatch(buildRequest2(context, entries));
2401
+ const accepted = [];
2402
+ const withheld = [];
2403
+ for (const item of stale) {
2404
+ const value = result.values.get(item.targetKey);
2405
+ const integrity = result.integrity.get(item.targetKey);
2406
+ if (value !== void 0 && integrity?.matches === true) {
2407
+ accepted.push({
2408
+ targetKey: item.targetKey,
2409
+ entry: { ...syntheticEntry(item), value },
2410
+ lockHash: generatedLockHash(item.governingEntries, item.category)
2411
+ });
2412
+ } else {
2413
+ withheld.push(item.targetKey);
2414
+ }
2415
+ }
2416
+ return { accepted, withheld };
2417
+ }
2418
+
1567
2419
  // src/flow/locale-run.ts
1568
2420
  function emptyResource(locale, format) {
1569
2421
  return { locale, namespace: "", format, entries: /* @__PURE__ */ new Map() };
1570
2422
  }
1571
- async function readTarget(params) {
2423
+ async function readTarget2(params) {
1572
2424
  const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
1573
2425
  if (!await params.fs.fileExists(path)) {
1574
2426
  return emptyResource(params.targetLocale, params.format);
1575
2427
  }
1576
2428
  return (await params.adapter.read(path, params.targetLocale)).resource;
1577
2429
  }
1578
- function buildRequest2(params, entries) {
2430
+ function buildRequest3(params, entries) {
1579
2431
  return {
1580
2432
  sourceLocale: params.sourceLocale,
1581
2433
  targetLocale: params.targetLocale,
@@ -1586,27 +2438,58 @@ function buildRequest2(params, entries) {
1586
2438
  };
1587
2439
  }
1588
2440
  async function runLocale(params) {
1589
- const target = await readTarget(params);
1590
- const diff = diffResources(params.source, target, { baseline: params.baseline });
2441
+ const target = await readTarget2(params);
2442
+ const diff2 = diffResources(params.source, target, { baseline: params.baseline });
2443
+ const orphaned = params.generatePlurals ? diff2.orphaned.filter((key) => !isGeneratedPluralKey(key, sourcePluralBaseKeys(params.source))) : diff2.orphaned;
2444
+ const pruned = params.prune ? orphaned : [];
1591
2445
  const invalidIcu = new Set(params.sourceInvalidIcuKeys);
1592
- const candidates = [...diff.missing, ...diff.changed];
2446
+ const candidates = [...diff2.missing, ...diff2.changed];
1593
2447
  const toTranslate = candidates.filter((key) => !invalidIcu.has(key));
1594
2448
  const invalidIcuSource = candidates.filter((key) => invalidIcu.has(key));
2449
+ const pluralNotice = detectMissingPluralCategories(
2450
+ params.source,
2451
+ params.targetLocale,
2452
+ params.format
2453
+ );
2454
+ const sdkNotices = pluralNotice ? [pluralNotice] : [];
1595
2455
  const provider = params.provider;
1596
2456
  if (provider === void 0) {
1597
2457
  return {
1598
- summary: baseSummary(params.targetLocale, diff, invalidIcuSource, toTranslate, [], []),
2458
+ summary: baseSummary({
2459
+ locale: params.targetLocale,
2460
+ unchanged: diff2.unchanged,
2461
+ orphaned,
2462
+ invalidIcuSource,
2463
+ translated: toTranslate,
2464
+ generated: [],
2465
+ integrityMismatches: [],
2466
+ pruned,
2467
+ notices: sdkNotices
2468
+ }),
1599
2469
  lockEntries: {}
1600
2470
  };
1601
2471
  }
1602
2472
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
1603
2473
  const accepted = /* @__PURE__ */ new Map();
1604
2474
  const integrityMismatches = [];
1605
- const notices = await translateAndCheck(provider, params, entries, accepted, integrityMismatches);
2475
+ const subBatchNotices = await translateAndCheck(
2476
+ provider,
2477
+ params,
2478
+ entries,
2479
+ accepted,
2480
+ integrityMismatches
2481
+ );
1606
2482
  const merged = new Map(target.entries);
2483
+ for (const key of pruned) {
2484
+ merged.delete(key);
2485
+ }
1607
2486
  for (const [key, { value, source }] of accepted) {
1608
2487
  merged.set(key, { ...source, value, namespace: target.namespace });
1609
2488
  }
2489
+ const generation = await runGeneration(params, provider);
2490
+ for (const form of generation.accepted) {
2491
+ merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
2492
+ }
1610
2493
  const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
1611
2494
  await params.adapter.write(
1612
2495
  {
@@ -1617,37 +2500,83 @@ async function runLocale(params) {
1617
2500
  },
1618
2501
  path
1619
2502
  );
1620
- const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource]);
2503
+ const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
2504
+ const notices = [...pluralNotices, ...subBatchNotices];
2505
+ const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource, ...generation.withheld]);
1621
2506
  return {
1622
- summary: baseSummary(
1623
- params.targetLocale,
1624
- diff,
2507
+ summary: baseSummary({
2508
+ locale: params.targetLocale,
2509
+ unchanged: diff2.unchanged,
2510
+ orphaned,
1625
2511
  invalidIcuSource,
1626
- [...accepted.keys()],
1627
- integrityMismatches,
2512
+ translated: [...accepted.keys()],
2513
+ generated: generation.accepted.map((form) => form.targetKey).sort(),
2514
+ // Withheld generated forms surface alongside withheld translations: both failed integrity.
2515
+ integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
2516
+ pruned,
1628
2517
  notices
1629
- ),
1630
- lockEntries: computeLockEntries(params, merged, withheld)
2518
+ }),
2519
+ lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
1631
2520
  };
1632
2521
  }
1633
- function baseSummary(locale, diff, invalidIcuSource, translated, integrityMismatches, notices) {
2522
+ async function runGeneration(params, provider) {
2523
+ if (!params.generatePlurals || provider.kind !== "llm") {
2524
+ return { accepted: [], withheld: [] };
2525
+ }
2526
+ return generatePluralForms({
2527
+ source: params.source,
2528
+ sourceLocale: params.sourceLocale,
2529
+ targetLocale: params.targetLocale,
2530
+ format: params.format,
2531
+ adapter: params.adapter,
2532
+ provider,
2533
+ glossary: params.glossary,
2534
+ tone: params.tone,
2535
+ baseline: params.baseline
2536
+ });
2537
+ }
2538
+ function pluralNoticeFor(params, merged) {
2539
+ if (params.format !== "i18next-json") {
2540
+ return [];
2541
+ }
2542
+ if (!targetPluralSetIncomplete(merged.keys(), params.targetLocale)) {
2543
+ return [];
2544
+ }
2545
+ return [pluralIncompleteNotice(params.targetLocale)];
2546
+ }
2547
+ function baseSummary(parts) {
1634
2548
  return {
1635
- locale,
2549
+ locale: parts.locale,
1636
2550
  status: "succeeded",
1637
- translated,
1638
- unchanged: diff.unchanged,
1639
- orphaned: diff.orphaned,
1640
- invalidIcuSource,
1641
- integrityMismatches,
1642
- notices
2551
+ translated: parts.translated,
2552
+ unchanged: parts.unchanged,
2553
+ orphaned: parts.orphaned,
2554
+ pruned: parts.pruned,
2555
+ invalidIcuSource: parts.invalidIcuSource,
2556
+ integrityMismatches: parts.integrityMismatches,
2557
+ generated: parts.generated,
2558
+ notices: parts.notices
1643
2559
  };
1644
2560
  }
1645
2561
  async function translateAndCheck(provider, params, entries, accepted, integrityMismatches) {
1646
- if (entries.length === 0) {
1647
- return [];
2562
+ const notices = [];
2563
+ for (const batch of chunk(entries, params.maxBatchSize)) {
2564
+ const subNotices = await runSubBatch(provider, params, batch, accepted, integrityMismatches);
2565
+ notices.push(...subNotices);
1648
2566
  }
1649
- const result = await provider.translateBatch(buildRequest2(params, entries));
1650
- for (const entry of entries) {
2567
+ return notices;
2568
+ }
2569
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches) {
2570
+ let result;
2571
+ try {
2572
+ result = await provider.translateBatch(buildRequest3(params, batch));
2573
+ } catch {
2574
+ for (const entry of batch) {
2575
+ integrityMismatches.push(entry.key);
2576
+ }
2577
+ return [subBatchFailedNotice(batch.length)];
2578
+ }
2579
+ for (const entry of batch) {
1651
2580
  const value = result.values.get(entry.key);
1652
2581
  const integrity = result.integrity.get(entry.key);
1653
2582
  if (value !== void 0 && integrity?.matches === true) {
@@ -1658,11 +2587,28 @@ async function translateAndCheck(provider, params, entries, accepted, integrityM
1658
2587
  }
1659
2588
  return readNotices(result);
1660
2589
  }
1661
- function computeLockEntries(params, merged, withheld) {
2590
+ function subBatchFailedNotice(count) {
2591
+ return {
2592
+ code: "SUB_BATCH_FAILED",
2593
+ message: `A sub-batch of ${count} entries failed and was withheld; it will be retried next run.`
2594
+ };
2595
+ }
2596
+ function chunk(items, size) {
2597
+ const chunks = [];
2598
+ for (let index = 0; index < items.length; index += size) {
2599
+ chunks.push(items.slice(index, index + size));
2600
+ }
2601
+ return chunks;
2602
+ }
2603
+ function computeLockEntries(params, merged, withheld, generated) {
1662
2604
  const lockEntries = {};
2605
+ const sourceBaseKeys = sourcePluralBaseKeys(params.source);
1663
2606
  for (const key of merged.keys()) {
1664
2607
  const sourceEntry = params.source.entries.get(key);
1665
2608
  if (sourceEntry === void 0) {
2609
+ if (params.generatePlurals) {
2610
+ carryGeneratedLock(lockEntries, params.baseline, key, sourceBaseKeys);
2611
+ }
1666
2612
  continue;
1667
2613
  }
1668
2614
  if (withheld.has(key)) {
@@ -1674,26 +2620,18 @@ function computeLockEntries(params, merged, withheld) {
1674
2620
  }
1675
2621
  lockEntries[key] = contentHash(sourceEntry);
1676
2622
  }
2623
+ for (const form of generated) {
2624
+ lockEntries[form.targetKey] = form.lockHash;
2625
+ }
1677
2626
  return lockEntries;
1678
2627
  }
1679
-
1680
- // src/flow/source.ts
1681
- async function readSource(config, cwd, fs, adapter) {
1682
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
1683
- if (!await fs.fileExists(sourcePath)) {
1684
- throw new SdkError(
1685
- "SOURCE_UNREADABLE",
1686
- `The source locale file was not found at ${sourcePath}.`
1687
- );
2628
+ function carryGeneratedLock(lockEntries, baseline, key, sourceBaseKeys) {
2629
+ if (!isGeneratedPluralKey(key, sourceBaseKeys)) {
2630
+ return;
1688
2631
  }
1689
- try {
1690
- return await adapter.read(sourcePath, config.sourceLocale);
1691
- } catch (error) {
1692
- const detail = error instanceof Error ? error.message : String(error);
1693
- throw new SdkError(
1694
- "SOURCE_INVALID",
1695
- `The source locale file at ${sourcePath} could not be read: ${detail}`
1696
- );
2632
+ const prior = baseline.get(key);
2633
+ if (prior !== void 0) {
2634
+ lockEntries[key] = prior;
1697
2635
  }
1698
2636
  }
1699
2637
 
@@ -1702,6 +2640,9 @@ async function translate2(input, deps = {}) {
1702
2640
  const config = input.config;
1703
2641
  const cwd = input.cwd ?? process.cwd();
1704
2642
  const dryRun = input.dryRun ?? false;
2643
+ const prune = input.prune ?? config.prune ?? false;
2644
+ const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
2645
+ const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
1705
2646
  const fs = deps.fs ?? defaultFs;
1706
2647
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
1707
2648
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
@@ -1724,6 +2665,9 @@ async function translate2(input, deps = {}) {
1724
2665
  format: config.format,
1725
2666
  glossary: config.glossary,
1726
2667
  tone: config.tone,
2668
+ prune,
2669
+ generatePlurals,
2670
+ maxBatchSize,
1727
2671
  fs
1728
2672
  };
1729
2673
  const { summary, lockEntries } = await runLocale(params);
@@ -1899,7 +2843,7 @@ var DEFAULT_WORKBOOK_LIMITS = {
1899
2843
  maxRowsPerSheet: 1e5,
1900
2844
  maxCellsPerRow: 64
1901
2845
  };
1902
- function assertNoDoctype(name, xml) {
2846
+ function assertNoDoctype2(name, xml) {
1903
2847
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
1904
2848
  throw new ExchangeError(
1905
2849
  "WORKBOOK_INVALID",
@@ -1954,7 +2898,7 @@ async function guardWorkbookBytes(bytes, limits) {
1954
2898
  `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
1955
2899
  );
1956
2900
  }
1957
- assertNoDoctype(file.name, content);
2901
+ assertNoDoctype2(file.name, content);
1958
2902
  }
1959
2903
  }
1960
2904
  var rowSchema = zod.z.object({
@@ -2063,7 +3007,7 @@ async function readWorkbook(bytes, options = {}) {
2063
3007
 
2064
3008
  // src/flow/workbook/export-workbook.ts
2065
3009
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
2066
- async function readTarget2(cwd, config, adapter, fs, locale) {
3010
+ async function readTarget3(cwd, config, adapter, fs, locale) {
2067
3011
  const path = localeFilePath(cwd, config.files.pattern, locale);
2068
3012
  if (!await fs.fileExists(path)) {
2069
3013
  return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
@@ -2071,7 +3015,7 @@ async function readTarget2(cwd, config, adapter, fs, locale) {
2071
3015
  return (await adapter.read(path, locale)).resource;
2072
3016
  }
2073
3017
  function buildRows(source, target, baseline, includeUnchanged) {
2074
- const diff = diffResources(source, target, { baseline });
3018
+ const diff2 = diffResources(source, target, { baseline });
2075
3019
  const rows = [];
2076
3020
  const add = (keys, status) => {
2077
3021
  for (const key of keys) {
@@ -2089,14 +3033,14 @@ function buildRows(source, target, baseline, includeUnchanged) {
2089
3033
  });
2090
3034
  }
2091
3035
  };
2092
- add(diff.missing, "new");
2093
- add(diff.changed, "changed");
3036
+ add(diff2.missing, "new");
3037
+ add(diff2.changed, "changed");
2094
3038
  if (includeUnchanged) {
2095
- add(diff.unchanged, "changed");
3039
+ add(diff2.unchanged, "changed");
2096
3040
  }
2097
3041
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
2098
3042
  }
2099
- function selectedLocales(config, requested) {
3043
+ function selectedLocales2(config, requested) {
2100
3044
  if (requested === void 0) {
2101
3045
  return config.targetLocales;
2102
3046
  }
@@ -2110,10 +3054,10 @@ async function exportWorkbook(input, deps = {}) {
2110
3054
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
2111
3055
  const source = await readSource(config, cwd, fs, adapter);
2112
3056
  const lock = await readLockFile(lockFilePath(cwd), fs);
2113
- const locales = selectedLocales(config, input.locales);
3057
+ const locales = selectedLocales2(config, input.locales);
2114
3058
  const sheets = await Promise.all(
2115
3059
  locales.map(async (locale) => {
2116
- const target = await readTarget2(cwd, config, adapter, fs, locale);
3060
+ const target = await readTarget3(cwd, config, adapter, fs, locale);
2117
3061
  const rows = buildRows(
2118
3062
  source.resource,
2119
3063
  target,
@@ -2183,7 +3127,7 @@ function classifyRows(params, buckets) {
2183
3127
  }
2184
3128
  }
2185
3129
  function importLocale(params) {
2186
- const diff = diffResources(params.source, params.target, { baseline: params.baseline });
3130
+ const diff2 = diffResources(params.source, params.target, { baseline: params.baseline });
2187
3131
  const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
2188
3132
  classifyRows(params, buckets);
2189
3133
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
@@ -2192,10 +3136,14 @@ function importLocale(params) {
2192
3136
  locale: params.sheet.locale,
2193
3137
  status: "succeeded",
2194
3138
  translated: [...buckets.accepted.keys()].sort(),
2195
- unchanged: diff.unchanged,
2196
- orphaned: diff.orphaned,
3139
+ unchanged: diff2.unchanged,
3140
+ orphaned: diff2.orphaned,
3141
+ // Import never prunes: orphans are reported but never removed here (pruning is a translate-flow concern).
3142
+ pruned: [],
2197
3143
  invalidIcuSource,
2198
3144
  integrityMismatches: [...buckets.mismatches].sort(),
3145
+ // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
3146
+ generated: [],
2199
3147
  notices: []
2200
3148
  };
2201
3149
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
@@ -2216,7 +3164,7 @@ async function readWorkbookBytes(path, fs) {
2216
3164
  }
2217
3165
  return read.bytes;
2218
3166
  }
2219
- async function readTarget3(cwd, config, adapter, fs, locale) {
3167
+ async function readTarget4(cwd, config, adapter, fs, locale) {
2220
3168
  const path = localeFilePath(cwd, config.files.pattern, locale);
2221
3169
  if (!await fs.fileExists(path)) {
2222
3170
  return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
@@ -2255,7 +3203,7 @@ async function runSheet(ctx, sheet, lock) {
2255
3203
  `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
2256
3204
  );
2257
3205
  }
2258
- const target = await readTarget3(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
3206
+ const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
2259
3207
  const baseline = baselineFor(lock, sheet.locale);
2260
3208
  const { summary, accepted, withheld } = importLocale({
2261
3209
  sheet,
@@ -2325,6 +3273,16 @@ async function importWorkbook(input, deps = {}) {
2325
3273
  const { succeeded, failed } = partition(summaries);
2326
3274
  return { dryRun, locales: summaries, succeeded, failed };
2327
3275
  }
3276
+
3277
+ // src/scaffolding.ts
3278
+ var scaffoldingMetadata = {
3279
+ /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
3280
+ providerEnv: PROVIDER_ENV,
3281
+ /** LLM provider id -> a cosmetic default scaffold model. Owned by ai-providers. DeepL has none. */
3282
+ scaffoldModels: SCAFFOLD_MODELS,
3283
+ /** The closed set of source format ids. Owned by core. */
3284
+ supportedFormats: SUPPORTED_FORMATS
3285
+ };
2328
3286
  var defaultCreateWatcher = (paths) => {
2329
3287
  const fsWatcher = chokidar.watch([...paths], { persistent: true, ignoreInitial: true });
2330
3288
  return {
@@ -2433,10 +3391,13 @@ async function watch(input, deps = {}) {
2433
3391
 
2434
3392
  exports.DEFAULT_WORKBOOK_PATH = DEFAULT_WORKBOOK_PATH;
2435
3393
  exports.SdkError = SdkError;
3394
+ exports.check = check;
2436
3395
  exports.defineConfig = defineConfig;
3396
+ exports.diff = diff;
2437
3397
  exports.exportWorkbook = exportWorkbook;
2438
3398
  exports.importWorkbook = importWorkbook;
2439
3399
  exports.loadConfig = loadConfig;
3400
+ exports.scaffoldingMetadata = scaffoldingMetadata;
2440
3401
  exports.translate = translate2;
2441
3402
  exports.verbatraConfigSchema = verbatraConfigSchema;
2442
3403
  exports.watch = watch;