@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.js CHANGED
@@ -5,11 +5,15 @@ import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
5
  import { z } from 'zod';
6
6
  import Anthropic from '@anthropic-ai/sdk';
7
7
  import * as deepl from 'deepl-node';
8
+ import { createRequire } from 'module';
8
9
  import log from 'loglevel';
9
10
  import { GoogleGenAI } from '@google/genai';
10
11
  import OpenAI from 'openai';
12
+ import { randomUUID } from 'crypto';
11
13
  import { access, writeFile, rename, rm, open } from 'fs/promises';
12
- import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
14
+ import { parse as parse$1, TYPE } from '@formatjs/icu-messageformat-parser';
15
+ import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
+ import { stringify, parse } from 'yaml';
13
17
  import ExcelJS from 'exceljs';
14
18
  import JSZip from 'jszip';
15
19
  import { watch as watch$1 } from 'chokidar';
@@ -44,13 +48,16 @@ function fnv1a64(input) {
44
48
  }
45
49
  return hash.toString(16).padStart(16, "0");
46
50
  }
51
+ function normalizeText(text) {
52
+ return text.normalize("NFC").replace(/\r\n?/g, "\n");
53
+ }
47
54
  function canonicalize(entry) {
48
55
  return JSON.stringify([
49
- entry.value,
50
- entry.description ?? null,
51
- entry.meaning ?? null,
56
+ normalizeText(entry.value),
57
+ entry.description == null ? null : normalizeText(entry.description),
58
+ entry.meaning == null ? null : normalizeText(entry.meaning),
52
59
  entry.isPlural,
53
- [...entry.placeholders].sort()
60
+ [...entry.placeholders].map(normalizeText).sort()
54
61
  ]);
55
62
  }
56
63
  function contentHash(entry) {
@@ -96,7 +103,10 @@ var SUPPORTED_FORMATS = [
96
103
  "i18next-json",
97
104
  "vue-i18n-json",
98
105
  "next-intl-json",
99
- "ngx-translate-json"
106
+ "ngx-translate-json",
107
+ "xliff",
108
+ "yaml",
109
+ "arb"
100
110
  ];
101
111
  var supportedFormatSchema = z.enum(SUPPORTED_FORMATS);
102
112
  var translationEntrySchema = z.object({
@@ -114,17 +124,31 @@ z.object({
114
124
  format: supportedFormatSchema,
115
125
  entries: z.map(z.string(), translationEntrySchema)
116
126
  });
117
- function difference(a, b) {
118
- return [...new Set(a.filter((item) => !b.has(item)))].sort();
127
+ function counts(items) {
128
+ const map = /* @__PURE__ */ new Map();
129
+ for (const item of items) {
130
+ map.set(item, (map.get(item) ?? 0) + 1);
131
+ }
132
+ return map;
133
+ }
134
+ function multisetExcess(a, b) {
135
+ const excess = [];
136
+ for (const [token, count] of a) {
137
+ const surplus = count - (b.get(token) ?? 0);
138
+ for (let i = 0; i < surplus; i += 1) {
139
+ excess.push(token);
140
+ }
141
+ }
142
+ return excess.sort();
119
143
  }
120
144
  function sameOrder(a, b) {
121
145
  return a.length === b.length && a.every((item, index) => item === b[index]);
122
146
  }
123
147
  function checkPlaceholders(source, translated) {
124
- const sourceSet = new Set(source);
125
- const translatedSet = new Set(translated);
126
- const missing = difference(source, translatedSet);
127
- const extra = difference(translated, sourceSet);
148
+ const sourceCounts = counts(source);
149
+ const translatedCounts = counts(translated);
150
+ const missing = multisetExcess(sourceCounts, translatedCounts);
151
+ const extra = multisetExcess(translatedCounts, sourceCounts);
128
152
  const reordered = missing.length === 0 && extra.length === 0 && !sameOrder(source, translated);
129
153
  return {
130
154
  matches: missing.length === 0 && extra.length === 0 && !reordered,
@@ -137,6 +161,26 @@ var LOCALE_TOKEN = "{locale}";
137
161
  function localeFilePath(cwd, pattern, locale) {
138
162
  return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
139
163
  }
164
+ var REDACTED = "[REDACTED]";
165
+ var KEY_PATTERNS = [
166
+ // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
167
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
168
+ /AIza[0-9A-Za-z_-]{35}/g,
169
+ /[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
170
+ ];
171
+ function escapeForRegExp(value) {
172
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
173
+ }
174
+ function redact(text, secret = process.env.ANTHROPIC_API_KEY) {
175
+ let out = text;
176
+ for (const pattern of KEY_PATTERNS) {
177
+ out = out.replace(pattern, REDACTED);
178
+ }
179
+ if (secret !== void 0 && secret.length > 0) {
180
+ out = out.replace(new RegExp(escapeForRegExp(secret), "g"), REDACTED);
181
+ }
182
+ return out;
183
+ }
140
184
  var ProviderError = class extends Error {
141
185
  /** The stable {@link ProviderErrorCode} for this failure; branch on this, not the message. */
142
186
  code;
@@ -145,7 +189,7 @@ var ProviderError = class extends Error {
145
189
  * @param message - A fixed, safe message; callers must never pass key, SDK, or request-derived text.
146
190
  */
147
191
  constructor(code, message) {
148
- super(message);
192
+ super(redact(message, ""));
149
193
  this.name = "ProviderError";
150
194
  this.code = code;
151
195
  }
@@ -266,6 +310,18 @@ async function runLlmTranslation(request, mechanism) {
266
310
  );
267
311
  return completion.usage === void 0 ? { values, integrity } : { values, integrity, usage: completion.usage };
268
312
  }
313
+ 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.";
314
+ function assertNotTruncated(truncated) {
315
+ if (truncated) {
316
+ throw new ProviderError("OUTPUT_TRUNCATED", OUTPUT_TRUNCATED_MESSAGE);
317
+ }
318
+ }
319
+ var PROVIDER_ENV = {
320
+ anthropic: "ANTHROPIC_API_KEY",
321
+ openai: "OPENAI_API_KEY",
322
+ gemini: "GEMINI_API_KEY",
323
+ deepl: "DEEPL_API_KEY"
324
+ };
269
325
  function readRequiredEnv(name) {
270
326
  const value = process.env[name];
271
327
  if (value === void 0 || value.length === 0) {
@@ -274,16 +330,16 @@ function readRequiredEnv(name) {
274
330
  return value;
275
331
  }
276
332
  function requireAnthropicKey() {
277
- return readRequiredEnv("ANTHROPIC_API_KEY");
333
+ return readRequiredEnv(PROVIDER_ENV.anthropic);
278
334
  }
279
335
  function requireOpenAiKey() {
280
- return readRequiredEnv("OPENAI_API_KEY");
336
+ return readRequiredEnv(PROVIDER_ENV.openai);
281
337
  }
282
338
  function requireGeminiKey() {
283
- return readRequiredEnv("GEMINI_API_KEY");
339
+ return readRequiredEnv(PROVIDER_ENV.gemini);
284
340
  }
285
341
  function requireDeepLKey() {
286
- return readRequiredEnv("DEEPL_API_KEY");
342
+ return readRequiredEnv(PROVIDER_ENV.deepl);
287
343
  }
288
344
  function createDefaultClient() {
289
345
  const sdk = new Anthropic({ apiKey: requireAnthropicKey(), logLevel: "off" });
@@ -361,6 +417,7 @@ function createMechanism(client, config) {
361
417
  translate: async ({ payloadJson }) => {
362
418
  const body = buildRequest(config, payloadJson);
363
419
  const message = await callClient(client, body);
420
+ assertNotTruncated(message.stop_reason === "max_tokens");
364
421
  const raw = requireToolInput(message.content);
365
422
  const usage = toUsage(message.usage);
366
423
  return usage === void 0 ? { raw } : { raw, usage };
@@ -383,8 +440,22 @@ function toUsage(usage) {
383
440
  var deepLConfigSchema = z.object({
384
441
  glossaryId: z.string().min(1).optional()
385
442
  });
443
+ var DEEPL_LOGGER = "deepl";
444
+ function resolveDeeplLoglevel(requireFn = createRequire(import.meta.url)) {
445
+ try {
446
+ const entry = requireFn.resolve("deepl-node");
447
+ return createRequire(entry)("loglevel");
448
+ } catch {
449
+ return void 0;
450
+ }
451
+ }
452
+ function silenceDeeplLogger(instances) {
453
+ for (const instance of instances) {
454
+ instance?.getLogger(DEEPL_LOGGER).setLevel("silent");
455
+ }
456
+ }
386
457
  function silenceSdkLogging() {
387
- log.getLogger("deepl").setLevel("silent");
458
+ silenceDeeplLogger([log, resolveDeeplLoglevel()]);
388
459
  }
389
460
  function createDefaultClient2() {
390
461
  silenceSdkLogging();
@@ -608,6 +679,7 @@ function extractGeminiResult(response) {
608
679
  if (candidate.finishReason !== void 0 && BLOCKED_FINISH_REASONS.has(candidate.finishReason)) {
609
680
  throw new ProviderError("PROVIDER_BLOCKED", "The provider filtered the translation response.");
610
681
  }
682
+ assertNotTruncated(candidate.finishReason === "MAX_TOKENS");
611
683
  const text = response.text;
612
684
  if (text === void 0 || text === "") {
613
685
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no translation content.");
@@ -704,10 +776,12 @@ function toUsage3(usage) {
704
776
  return { inputTokens: prompt_tokens, outputTokens: completion_tokens };
705
777
  }
706
778
  function extractOpenAiResult(completion) {
707
- const message = completion.choices[0]?.message;
708
- if (message === void 0) {
779
+ const choice = completion.choices[0];
780
+ if (choice === void 0) {
709
781
  throw new ProviderError("INVALID_RESPONSE", "The provider returned no message.");
710
782
  }
783
+ assertNotTruncated(choice.finish_reason === "length");
784
+ const message = choice.message;
711
785
  if (message.refusal !== void 0 && message.refusal !== null && message.refusal !== "") {
712
786
  throw new ProviderError("PROVIDER_REFUSED", "The provider refused the translation request.");
713
787
  }
@@ -742,6 +816,11 @@ function createMechanism3(client, config) {
742
816
  function callClient4(client, body) {
743
817
  return guardProviderCall(() => client.chat.completions.create(body));
744
818
  }
819
+ var SCAFFOLD_MODELS = {
820
+ anthropic: "claude-sonnet-4-6",
821
+ openai: "gpt-5.4-mini",
822
+ gemini: "gemini-2.5-flash"
823
+ };
745
824
  var providerConfigSchema = z.discriminatedUnion("id", [
746
825
  z.object({ id: z.literal("anthropic"), options: anthropicConfigSchema.strict() }),
747
826
  z.object({ id: z.literal("openai"), options: openAiConfigSchema.strict() }),
@@ -760,6 +839,7 @@ function buildProvider(config) {
760
839
  }
761
840
 
762
841
  // src/config/schema.ts
842
+ var DEFAULT_MAX_BATCH_SIZE = 50;
763
843
  var verbatraConfigSchema = z.strictObject({
764
844
  sourceLocale: z.string().min(1),
765
845
  targetLocales: z.array(z.string().min(1)).min(1),
@@ -769,7 +849,29 @@ var verbatraConfigSchema = z.strictObject({
769
849
  }),
770
850
  provider: providerConfigSchema,
771
851
  glossary: z.record(z.string(), z.string()).optional(),
772
- tone: z.enum(["formal", "informal", "neutral"]).optional()
852
+ tone: z.enum(["formal", "informal", "neutral"]).optional(),
853
+ /**
854
+ * Opt-in orphan pruning, off by default. When true, keys present in a target file but absent from
855
+ * the source are removed from the written file and the lock. The per-run `prune` option on
856
+ * `translate` (the CLI `--prune` flag) overrides this.
857
+ */
858
+ prune: z.boolean().optional(),
859
+ /**
860
+ * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
861
+ * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
862
+ * requires but the source does not supply (for example Polish few/many). The per-run
863
+ * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
864
+ * unknown language) fall back to the per-locale plural warning.
865
+ */
866
+ generatePlurals: z.boolean().optional(),
867
+ /**
868
+ * Optional maximum number of entries sent in a single provider request. A locale's missing and
869
+ * changed entries are split into sequential sub-batches no larger than this, so one oversized request
870
+ * cannot sink the whole locale; a failed sub-batch is withheld while the others make progress. Must
871
+ * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
872
+ * {@link DEFAULT_MAX_BATCH_SIZE} applies.
873
+ */
874
+ maxBatchSize: z.number().int().positive().optional()
773
875
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
774
876
  message: "targetLocales must not include the source locale",
775
877
  path: ["targetLocales"]
@@ -914,8 +1016,11 @@ async function readBoundedBytes(path, maxBytes) {
914
1016
  await handle.close();
915
1017
  }
916
1018
  }
1019
+ function tempFileName(path) {
1020
+ return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
1021
+ }
917
1022
  async function atomicWrite(path, data) {
918
- const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}`);
1023
+ const tmp = tempFileName(path);
919
1024
  await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
920
1025
  try {
921
1026
  await rename(tmp, path);
@@ -996,6 +1101,85 @@ async function writeLockFile(path, lock, fs) {
996
1101
  await fs.writeFile(path, `${JSON.stringify(ordered, null, 2)}
997
1102
  `);
998
1103
  }
1104
+ var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1105
+ var INVALID = { placeholders: [], isPlural: false, valid: false };
1106
+ function tokenOf(element) {
1107
+ switch (element.type) {
1108
+ case TYPE.argument:
1109
+ case TYPE.number:
1110
+ case TYPE.date:
1111
+ case TYPE.time:
1112
+ case TYPE.select:
1113
+ case TYPE.plural:
1114
+ return `{${element.value}}`;
1115
+ case TYPE.tag:
1116
+ return `<${element.value}>`;
1117
+ default:
1118
+ return void 0;
1119
+ }
1120
+ }
1121
+ function childMessages(element) {
1122
+ if (element.type === TYPE.plural || element.type === TYPE.select) {
1123
+ return Object.values(element.options).map((option) => option.value);
1124
+ }
1125
+ if (element.type === TYPE.tag) {
1126
+ return [element.children];
1127
+ }
1128
+ return [];
1129
+ }
1130
+ function collect(elements, add, state) {
1131
+ for (const element of elements) {
1132
+ const token = tokenOf(element);
1133
+ if (token !== void 0) {
1134
+ add(token);
1135
+ }
1136
+ if (element.type === TYPE.plural) {
1137
+ state.isPlural = true;
1138
+ }
1139
+ for (const child of childMessages(element)) {
1140
+ collect(child, add, state);
1141
+ }
1142
+ }
1143
+ }
1144
+ function analyzeIcuValue(value) {
1145
+ if (!value.includes("{") && !value.includes("<")) {
1146
+ return VALID_EMPTY;
1147
+ }
1148
+ try {
1149
+ const ast = parse$1(value);
1150
+ const placeholders = [];
1151
+ const state = { isPlural: false };
1152
+ collect(
1153
+ ast,
1154
+ (token) => {
1155
+ placeholders.push(token);
1156
+ },
1157
+ state
1158
+ );
1159
+ return { placeholders, isPlural: state.isPlural, valid: true };
1160
+ } catch {
1161
+ return INVALID;
1162
+ }
1163
+ }
1164
+ function icuPlaceholders(value) {
1165
+ return analyzeIcuValue(value).placeholders;
1166
+ }
1167
+ function icuIsValid(value) {
1168
+ return analyzeIcuValue(value).valid;
1169
+ }
1170
+ function icuDeriveEntry(_key, value) {
1171
+ const analysis = analyzeIcuValue(value);
1172
+ return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
1173
+ }
1174
+ function icuInvalidKeys(entries) {
1175
+ const invalid = [];
1176
+ for (const [key, entry] of entries) {
1177
+ if (!icuIsValid(entry.value)) {
1178
+ invalid.push(key);
1179
+ }
1180
+ }
1181
+ return invalid;
1182
+ }
999
1183
  var AdapterError = class extends Error {
1000
1184
  code;
1001
1185
  constructor(code, message) {
@@ -1004,6 +1188,82 @@ var AdapterError = class extends Error {
1004
1188
  this.code = code;
1005
1189
  }
1006
1190
  };
1191
+ var MAX_DEPTH = 100;
1192
+ var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1193
+ var jsonTreeSchema = z.lazy(
1194
+ () => z.union([z.string(), z.record(z.string(), jsonTreeSchema)])
1195
+ );
1196
+ var rootSchema = z.record(z.string(), jsonTreeSchema);
1197
+ function assertWithinDepth(value, max) {
1198
+ const stack = [{ node: value, depth: 1 }];
1199
+ while (stack.length > 0) {
1200
+ const top = stack.pop();
1201
+ if (top === void 0) {
1202
+ break;
1203
+ }
1204
+ const { node, depth } = top;
1205
+ if (typeof node !== "object" || node === null) {
1206
+ continue;
1207
+ }
1208
+ if (depth > max) {
1209
+ throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1210
+ }
1211
+ for (const child of Object.values(node)) {
1212
+ stack.push({ node: child, depth: depth + 1 });
1213
+ }
1214
+ }
1215
+ }
1216
+ function assertJsonRecord(value) {
1217
+ assertWithinDepth(value, MAX_DEPTH);
1218
+ const result = rootSchema.safeParse(value);
1219
+ if (!result.success) {
1220
+ throw new AdapterError(
1221
+ "INVALID_STRUCTURE",
1222
+ "The file is not a valid object (expected nested objects of string values)."
1223
+ );
1224
+ }
1225
+ return result.data;
1226
+ }
1227
+ function parseJsonObject(content) {
1228
+ let parsed;
1229
+ try {
1230
+ parsed = JSON.parse(content);
1231
+ } catch {
1232
+ throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1233
+ }
1234
+ return assertJsonRecord(parsed);
1235
+ }
1236
+ function serializeJsonTree(tree) {
1237
+ return `${JSON.stringify(tree, null, 2)}
1238
+ `;
1239
+ }
1240
+ function namespaceOf(filePath) {
1241
+ return basename(filePath, extname(filePath));
1242
+ }
1243
+ function rethrowStructured(error, message) {
1244
+ if (error instanceof AdapterError) {
1245
+ throw error;
1246
+ }
1247
+ throw new AdapterError("INVALID_STRUCTURE", message);
1248
+ }
1249
+ function computeIcu(entries, compute) {
1250
+ if (!compute) {
1251
+ return [];
1252
+ }
1253
+ try {
1254
+ return compute(entries);
1255
+ } catch (error) {
1256
+ rethrowStructured(error, "The file could not be analyzed for message validity.");
1257
+ }
1258
+ }
1259
+ function buildCanHandle(extensions, sniff) {
1260
+ return (filePath, sample) => {
1261
+ if (!extensions.includes(extname(filePath).toLowerCase())) {
1262
+ return false;
1263
+ }
1264
+ return sample === void 0 || sniff === void 0 || sniff(sample);
1265
+ };
1266
+ }
1007
1267
  var nodeOps = {
1008
1268
  writeFile: (path, data) => writeFile(path, data, "utf8"),
1009
1269
  rename: (from, to) => rename(from, to),
@@ -1015,8 +1275,11 @@ async function cleanup(ops, tmp) {
1015
1275
  } catch {
1016
1276
  }
1017
1277
  }
1278
+ function tempFileName2(path) {
1279
+ return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
1280
+ }
1018
1281
  async function atomicWriteFile(path, data, ops = nodeOps) {
1019
- const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}`);
1282
+ const tmp = tempFileName2(path);
1020
1283
  try {
1021
1284
  await ops.writeFile(tmp, data);
1022
1285
  await ops.rename(tmp, path);
@@ -1025,8 +1288,6 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1025
1288
  throw error;
1026
1289
  }
1027
1290
  }
1028
- var MAX_DEPTH = 100;
1029
- var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1030
1291
  async function readBoundedUtf82(handle, size) {
1031
1292
  const buffer = Buffer.allocUnsafe(size);
1032
1293
  let offset = 0;
@@ -1054,61 +1315,128 @@ async function readBounded2(filePath) {
1054
1315
  await handle.close();
1055
1316
  }
1056
1317
  }
1057
- function addEntries(node, prefix, namespace, derive, out) {
1058
- for (const [key, value] of Object.entries(node)) {
1059
- const path = prefix === "" ? key : `${prefix}.${key}`;
1060
- if (typeof value === "string") {
1061
- const { placeholders, isPlural } = derive(key, value);
1062
- out.set(path, { key: path, namespace, value, placeholders, isPlural });
1318
+ async function readFileContent(filePath) {
1319
+ const outcome = await readBounded2(filePath);
1320
+ if (outcome.kind === "not-a-file") {
1321
+ throw new AdapterError("INVALID_STRUCTURE", "The path is not a regular file.");
1322
+ }
1323
+ if (outcome.kind === "too-large") {
1324
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1325
+ }
1326
+ return outcome.content;
1327
+ }
1328
+ var BACKSLASH = "\\";
1329
+ var DOT = ".";
1330
+ var ESCAPED_BACKSLASH = "\\\\";
1331
+ var ESCAPED_DOT = "\\.";
1332
+ function needsEncoding(segment) {
1333
+ return segment.includes(BACKSLASH) || segment.includes(DOT);
1334
+ }
1335
+ function encodeSegment(segment) {
1336
+ if (!needsEncoding(segment)) {
1337
+ return segment;
1338
+ }
1339
+ let out = "";
1340
+ for (const char of segment) {
1341
+ if (char === BACKSLASH) {
1342
+ out += ESCAPED_BACKSLASH;
1343
+ } else if (char === DOT) {
1344
+ out += ESCAPED_DOT;
1063
1345
  } else {
1064
- addEntries(value, path, namespace, derive, out);
1346
+ out += char;
1065
1347
  }
1066
1348
  }
1067
- }
1068
- function flattenTree(tree, namespace, derive) {
1069
- const out = /* @__PURE__ */ new Map();
1070
- addEntries(tree, "", namespace, derive, out);
1071
1349
  return out;
1072
1350
  }
1073
- var jsonTreeSchema = z.lazy(
1074
- () => z.union([z.string(), z.record(z.string(), jsonTreeSchema)])
1075
- );
1076
- var rootSchema = z.record(z.string(), jsonTreeSchema);
1077
- function assertWithinDepth(value, max) {
1078
- const stack = [{ node: value, depth: 1 }];
1079
- while (stack.length > 0) {
1080
- const top = stack.pop();
1081
- if (top === void 0) {
1082
- break;
1083
- }
1084
- const { node, depth } = top;
1085
- if (typeof node !== "object" || node === null) {
1086
- continue;
1087
- }
1088
- if (depth > max) {
1089
- throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1090
- }
1091
- for (const child of Object.values(node)) {
1092
- stack.push({ node: child, depth: depth + 1 });
1351
+ function decodeSegment(segment) {
1352
+ if (!segment.includes(BACKSLASH)) {
1353
+ return segment;
1354
+ }
1355
+ let out = "";
1356
+ let escaping = false;
1357
+ for (const char of segment) {
1358
+ if (escaping) {
1359
+ out += char;
1360
+ escaping = false;
1361
+ } else if (char === BACKSLASH) {
1362
+ escaping = true;
1363
+ } else {
1364
+ out += char;
1093
1365
  }
1094
1366
  }
1367
+ return out;
1095
1368
  }
1096
- function parseJsonObject(content) {
1097
- let parsed;
1098
- try {
1099
- parsed = JSON.parse(content);
1100
- } catch {
1101
- throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1369
+ function joinEncodedSegments(segments) {
1370
+ return segments.join(DOT);
1371
+ }
1372
+ function decodeKeyToSegments(key) {
1373
+ if (!key.includes(BACKSLASH)) {
1374
+ return key.split(DOT);
1375
+ }
1376
+ const segments = [];
1377
+ let current = "";
1378
+ let escaping = false;
1379
+ for (const char of key) {
1380
+ if (escaping) {
1381
+ current += BACKSLASH + char;
1382
+ escaping = false;
1383
+ } else if (char === BACKSLASH) {
1384
+ escaping = true;
1385
+ } else if (char === DOT) {
1386
+ segments.push(current);
1387
+ current = "";
1388
+ } else {
1389
+ current += char;
1390
+ }
1102
1391
  }
1103
- assertWithinDepth(parsed, MAX_DEPTH);
1104
- const result = rootSchema.safeParse(parsed);
1105
- if (!result.success) {
1392
+ if (escaping) {
1393
+ current += BACKSLASH;
1394
+ }
1395
+ segments.push(current);
1396
+ return segments.map(decodeSegment);
1397
+ }
1398
+ function addLeaf(ctx, segments, key, value) {
1399
+ const effectivePath = segments.join(".");
1400
+ const mapKey = joinEncodedSegments(segments.map(encodeSegment));
1401
+ if (ctx.claimed.has(effectivePath) && ctx.claimed.get(effectivePath) !== mapKey) {
1106
1402
  throw new AdapterError(
1107
1403
  "INVALID_STRUCTURE",
1108
- "The file is not a valid JSON object (expected nested objects of string values)."
1404
+ "A literal dotted leaf key and a nested key path resolve to the same path."
1109
1405
  );
1110
1406
  }
1111
- return result.data;
1407
+ ctx.claimed.set(effectivePath, mapKey);
1408
+ const { placeholders, isPlural } = ctx.derive(key, value);
1409
+ ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
1410
+ }
1411
+ function addEntries(ctx, prefix, node) {
1412
+ for (const [key, value] of Object.entries(node)) {
1413
+ const segments = [...prefix, key];
1414
+ if (typeof value === "string") {
1415
+ addLeaf(ctx, segments, key, value);
1416
+ } else {
1417
+ addEntries(ctx, segments, value);
1418
+ }
1419
+ }
1420
+ }
1421
+ function addPathEntries(node, prefix, namespace, derive, out) {
1422
+ for (const [key, value] of Object.entries(node)) {
1423
+ const path = prefix === "" ? key : `${prefix}.${key}`;
1424
+ if (typeof value === "string") {
1425
+ const { placeholders, isPlural } = derive(key, value);
1426
+ out.set(path, { key: path, namespace, value, placeholders, isPlural });
1427
+ } else {
1428
+ addPathEntries(value, path, namespace, derive, out);
1429
+ }
1430
+ }
1431
+ }
1432
+ function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
1433
+ const out = /* @__PURE__ */ new Map();
1434
+ if (keyMode === "path-notation") {
1435
+ addPathEntries(tree, "", namespace, derive, out);
1436
+ return out;
1437
+ }
1438
+ addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
1439
+ return out;
1112
1440
  }
1113
1441
  function emptyNode() {
1114
1442
  return /* @__PURE__ */ Object.create(null);
@@ -1134,103 +1462,194 @@ function setPath(root, segments, value) {
1134
1462
  for (const segment of segments.slice(0, -1)) {
1135
1463
  node = descend(node, segment);
1136
1464
  }
1465
+ if (typeof node[leaf] === "object") {
1466
+ throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
1467
+ }
1137
1468
  node[leaf] = value;
1138
1469
  }
1139
1470
  function unflattenEntries(entries) {
1140
1471
  const root = emptyNode();
1141
1472
  for (const [key, entry] of entries) {
1142
- setPath(root, key.split("."), entry.value);
1473
+ setPath(root, decodeKeyToSegments(key), entry.value);
1143
1474
  }
1144
1475
  return root;
1145
1476
  }
1146
- function namespaceOf(filePath) {
1147
- return basename(filePath, extname(filePath));
1148
- }
1149
- function canHandle(filePath, sample) {
1150
- if (extname(filePath).toLowerCase() !== ".json") {
1151
- return false;
1152
- }
1153
- return sample === void 0 || sample.trimStart().startsWith("{");
1154
- }
1155
- function rethrowStructured(error, message) {
1156
- if (error instanceof AdapterError) {
1157
- throw error;
1158
- }
1159
- throw new AdapterError("INVALID_STRUCTURE", message);
1160
- }
1161
- function toEntries(content, namespace, deriveEntry, validateTree) {
1477
+ function toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree) {
1162
1478
  try {
1163
- const tree = parseJsonObject(content);
1479
+ const tree = parse2(content);
1164
1480
  validateTree?.(tree);
1165
- return flattenTree(tree, namespace, deriveEntry);
1166
- } catch (error) {
1167
- rethrowStructured(error, "The file could not be read as JSON.");
1168
- }
1169
- }
1170
- function computeIcu(entries, compute) {
1171
- if (!compute) {
1172
- return [];
1173
- }
1174
- try {
1175
- return compute(entries);
1481
+ return flattenTree(tree, namespace, deriveEntry, keyMode);
1176
1482
  } catch (error) {
1177
- rethrowStructured(error, "The file could not be analyzed for message validity.");
1483
+ rethrowStructured(error, "The file could not be parsed.");
1178
1484
  }
1179
1485
  }
1180
- function createJsonFileAdapter(options) {
1486
+ function createTreeFileAdapter(options) {
1181
1487
  const {
1182
1488
  format,
1489
+ extensions,
1490
+ sniff,
1491
+ parse: parse2,
1492
+ serialize,
1183
1493
  deriveEntry,
1184
- extractPlaceholders: extractPlaceholders2,
1185
- computeInvalidIcuKeys: computeInvalidIcuKeys2,
1186
- validateMessage: validateMessage2,
1494
+ extractPlaceholders,
1495
+ computeInvalidIcuKeys,
1496
+ validateMessage,
1187
1497
  validateTree,
1188
- buildWriteTree
1498
+ buildWriteTree,
1499
+ keyMode = "literal-leaf"
1189
1500
  } = options;
1190
1501
  return {
1191
1502
  format,
1192
- canHandle,
1193
- extractPlaceholders: extractPlaceholders2,
1194
- // Non-ICU formats supply no validator: every value is valid for their syntax.
1195
- validateMessage: validateMessage2 ?? (() => true),
1503
+ canHandle: buildCanHandle(extensions, sniff),
1504
+ extractPlaceholders,
1505
+ validateMessage: validateMessage ?? (() => true),
1196
1506
  async read(filePath, locale) {
1197
- const outcome = await readBounded2(filePath);
1198
- if (outcome.kind === "not-a-file") {
1199
- throw new AdapterError("INVALID_STRUCTURE", "The path is not a regular file.");
1200
- }
1201
- if (outcome.kind === "too-large") {
1202
- throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1203
- }
1507
+ const content = await readFileContent(filePath);
1204
1508
  const namespace = namespaceOf(filePath);
1205
- const entries = toEntries(outcome.content, namespace, deriveEntry, validateTree);
1509
+ const entries = toEntries(content, namespace, parse2, deriveEntry, keyMode, validateTree);
1206
1510
  const resource = { locale, namespace, format, entries };
1207
- const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys2);
1511
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1208
1512
  return { resource, invalidIcuKeys };
1209
1513
  },
1210
1514
  async write(resource, filePath) {
1211
1515
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
1212
- await atomicWriteFile(filePath, `${JSON.stringify(tree, null, 2)}
1213
- `);
1516
+ await atomicWriteFile(filePath, serialize(tree));
1214
1517
  }
1215
1518
  };
1216
1519
  }
1217
- var PLACEHOLDER_PATTERN = /\{\{[^{}]*\}\}/g;
1218
- function extractI18nextPlaceholders(value) {
1219
- const seen = /* @__PURE__ */ new Set();
1520
+ function isMetadataKey(key) {
1521
+ return key.startsWith("@");
1522
+ }
1523
+ function parseArbObject(content) {
1524
+ let parsed;
1525
+ try {
1526
+ parsed = JSON.parse(content);
1527
+ } catch {
1528
+ throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1529
+ }
1530
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1531
+ throw new AdapterError(
1532
+ "INVALID_STRUCTURE",
1533
+ "The file is not a valid object (expected nested objects of string values)."
1534
+ );
1535
+ }
1536
+ return parsed;
1537
+ }
1538
+ function stripArbMetadata(tree) {
1539
+ const out = /* @__PURE__ */ Object.create(null);
1540
+ for (const [key, value] of Object.entries(tree)) {
1541
+ if (!isMetadataKey(key)) {
1542
+ out[key] = value;
1543
+ }
1544
+ }
1545
+ return out;
1546
+ }
1547
+ function originalKey(encoded) {
1548
+ return decodeKeyToSegments(encoded).join(".");
1549
+ }
1550
+ function messagesFromEntries(entries) {
1551
+ const out = /* @__PURE__ */ new Map();
1552
+ for (const [key, entry] of entries) {
1553
+ out.set(originalKey(key), entry.value);
1554
+ }
1555
+ return out;
1556
+ }
1557
+ async function readDestinationPairs(filePath) {
1558
+ let parsed;
1559
+ try {
1560
+ const outcome = await readBounded2(filePath);
1561
+ if (outcome.kind !== "ok") {
1562
+ return null;
1563
+ }
1564
+ parsed = JSON.parse(outcome.content);
1565
+ } catch {
1566
+ return null;
1567
+ }
1568
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1569
+ return null;
1570
+ }
1571
+ return Object.entries(parsed);
1572
+ }
1573
+ async function buildArbWriteTree(entries, filePath) {
1574
+ const messages = messagesFromEntries(entries);
1575
+ const pairs = await readDestinationPairs(filePath);
1576
+ const out = /* @__PURE__ */ Object.create(null);
1577
+ const consumed = /* @__PURE__ */ new Set();
1578
+ for (const [key, value] of pairs ?? []) {
1579
+ const translated = isMetadataKey(key) ? void 0 : messages.get(key);
1580
+ if (translated !== void 0) {
1581
+ consumed.add(key);
1582
+ }
1583
+ out[key] = translated ?? value;
1584
+ }
1585
+ for (const [key, value] of messages) {
1586
+ if (!consumed.has(key)) {
1587
+ out[key] = value;
1588
+ }
1589
+ }
1590
+ return out;
1591
+ }
1592
+ function parseArb(content) {
1593
+ return assertJsonRecord(stripArbMetadata(parseArbObject(content)));
1594
+ }
1595
+ function createArbAdapter() {
1596
+ return createTreeFileAdapter({
1597
+ format: "arb",
1598
+ extensions: [".arb"],
1599
+ sniff: (sample) => sample.trimStart().startsWith("{"),
1600
+ parse: parseArb,
1601
+ serialize: serializeJsonTree,
1602
+ extractPlaceholders: icuPlaceholders,
1603
+ deriveEntry: icuDeriveEntry,
1604
+ computeInvalidIcuKeys: icuInvalidKeys,
1605
+ validateMessage: icuIsValid,
1606
+ buildWriteTree: buildArbWriteTree
1607
+ });
1608
+ }
1609
+ function createJsonFileAdapter(options) {
1610
+ return createTreeFileAdapter({
1611
+ ...options,
1612
+ extensions: [".json"],
1613
+ sniff: (sample) => sample.trimStart().startsWith("{"),
1614
+ parse: parseJsonObject,
1615
+ serialize: serializeJsonTree
1616
+ });
1617
+ }
1618
+ var DOUBLE_BRACE_PATTERN = /\{\{[^{}]*\}\}/g;
1619
+ var I18NEXT_PATTERN = /\{\{[^{}]*\}\}|\$t\([^()]*\)/g;
1620
+ function scanTokens(value, pattern) {
1220
1621
  const result = [];
1221
- for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
1622
+ for (const match of value.matchAll(pattern)) {
1222
1623
  const token = match[0];
1223
- if (token !== void 0 && !seen.has(token)) {
1224
- seen.add(token);
1624
+ if (token !== void 0) {
1225
1625
  result.push(token);
1226
1626
  }
1227
1627
  }
1228
1628
  return result;
1229
1629
  }
1630
+ function extractDoubleBracePlaceholders(value) {
1631
+ return scanTokens(value, DOUBLE_BRACE_PATTERN);
1632
+ }
1633
+ function extractI18nextPlaceholders(value) {
1634
+ return scanTokens(value, I18NEXT_PATTERN);
1635
+ }
1230
1636
  var PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
1231
1637
  function isPluralKey(key) {
1232
1638
  return PLURAL_SUFFIX.test(key);
1233
1639
  }
1640
+ function pluralCategoryOf(key) {
1641
+ const match = PLURAL_SUFFIX.exec(key);
1642
+ return match?.[1];
1643
+ }
1644
+ function pluralBaseKey(key) {
1645
+ if (!isPluralKey(key)) {
1646
+ return void 0;
1647
+ }
1648
+ return key.replace(PLURAL_SUFFIX, "");
1649
+ }
1650
+ function makePluralKey(baseKey, category) {
1651
+ return `${baseKey}_${category}`;
1652
+ }
1234
1653
  function createI18nextJsonAdapter() {
1235
1654
  return createJsonFileAdapter({
1236
1655
  format: "i18next-json",
@@ -1241,95 +1660,13 @@ function createI18nextJsonAdapter() {
1241
1660
  })
1242
1661
  });
1243
1662
  }
1244
- var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1245
- var INVALID = { placeholders: [], isPlural: false, valid: false };
1246
- function tokenOf(element) {
1247
- switch (element.type) {
1248
- case TYPE.argument:
1249
- case TYPE.number:
1250
- case TYPE.date:
1251
- case TYPE.time:
1252
- case TYPE.select:
1253
- case TYPE.plural:
1254
- return `{${element.value}}`;
1255
- case TYPE.tag:
1256
- return `<${element.value}>`;
1257
- default:
1258
- return void 0;
1259
- }
1260
- }
1261
- function childMessages(element) {
1262
- if (element.type === TYPE.plural || element.type === TYPE.select) {
1263
- return Object.values(element.options).map((option) => option.value);
1264
- }
1265
- if (element.type === TYPE.tag) {
1266
- return [element.children];
1267
- }
1268
- return [];
1269
- }
1270
- function collect(elements, add, state) {
1271
- for (const element of elements) {
1272
- const token = tokenOf(element);
1273
- if (token !== void 0) {
1274
- add(token);
1275
- }
1276
- if (element.type === TYPE.plural) {
1277
- state.isPlural = true;
1278
- }
1279
- for (const child of childMessages(element)) {
1280
- collect(child, add, state);
1281
- }
1282
- }
1283
- }
1284
- function analyzeIcuValue(value) {
1285
- if (!value.includes("{") && !value.includes("<")) {
1286
- return VALID_EMPTY;
1287
- }
1288
- try {
1289
- const ast = parse(value);
1290
- const seen = /* @__PURE__ */ new Set();
1291
- const placeholders = [];
1292
- const state = { isPlural: false };
1293
- collect(
1294
- ast,
1295
- (token) => {
1296
- if (!seen.has(token)) {
1297
- seen.add(token);
1298
- placeholders.push(token);
1299
- }
1300
- },
1301
- state
1302
- );
1303
- return { placeholders, isPlural: state.isPlural, valid: true };
1304
- } catch {
1305
- return INVALID;
1306
- }
1307
- }
1308
- function extractPlaceholders(value) {
1309
- return analyzeIcuValue(value).placeholders;
1310
- }
1311
- function validateMessage(value) {
1312
- return analyzeIcuValue(value).valid;
1313
- }
1314
- function computeInvalidIcuKeys(entries) {
1315
- const invalid = [];
1316
- for (const [key, entry] of entries) {
1317
- if (!validateMessage(entry.value)) {
1318
- invalid.push(key);
1319
- }
1320
- }
1321
- return invalid;
1322
- }
1323
1663
  function createNextIntlJsonAdapter() {
1324
1664
  return createJsonFileAdapter({
1325
1665
  format: "next-intl-json",
1326
- extractPlaceholders,
1327
- deriveEntry: (_key, value) => {
1328
- const analysis = analyzeIcuValue(value);
1329
- return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
1330
- },
1331
- computeInvalidIcuKeys,
1332
- validateMessage
1666
+ extractPlaceholders: icuPlaceholders,
1667
+ deriveEntry: icuDeriveEntry,
1668
+ computeInvalidIcuKeys: icuInvalidKeys,
1669
+ validateMessage: icuIsValid
1333
1670
  });
1334
1671
  }
1335
1672
  function assertNotMixed(tree) {
@@ -1384,13 +1721,15 @@ async function buildNgxWriteTree(entries, filePath) {
1384
1721
  function createNgxTranslateJsonAdapter() {
1385
1722
  return createJsonFileAdapter({
1386
1723
  format: "ngx-translate-json",
1387
- extractPlaceholders: extractI18nextPlaceholders,
1724
+ extractPlaceholders: extractDoubleBracePlaceholders,
1388
1725
  deriveEntry: (_key, value) => ({
1389
- placeholders: extractI18nextPlaceholders(value),
1726
+ placeholders: extractDoubleBracePlaceholders(value),
1390
1727
  isPlural: false
1391
1728
  }),
1392
1729
  validateTree: assertNotMixed,
1393
- buildWriteTree: buildNgxWriteTree
1730
+ buildWriteTree: buildNgxWriteTree,
1731
+ // ngx-translate flat style uses dotted keys as path notation, not literal leaves.
1732
+ keyMode: "path-notation"
1394
1733
  });
1395
1734
  }
1396
1735
  var AdapterRegistry = class {
@@ -1441,15 +1780,13 @@ var AdapterRegistry = class {
1441
1780
  return this.resolveByDetection(filePath, options.sample);
1442
1781
  }
1443
1782
  };
1444
- var PLACEHOLDER_PATTERN2 = /\{[^{}]*\}/g;
1783
+ var PLACEHOLDER_PATTERN = /(?<!\{)\{\s*([A-Za-z_][\w$-]*|\d+)\s*\}(?!\})/g;
1445
1784
  function extractVueI18nPlaceholders(value) {
1446
- const seen = /* @__PURE__ */ new Set();
1447
1785
  const result = [];
1448
- for (const match of value.matchAll(PLACEHOLDER_PATTERN2)) {
1449
- const token = match[0];
1450
- if (token !== void 0 && !seen.has(token)) {
1451
- seen.add(token);
1452
- result.push(token);
1786
+ for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
1787
+ const key = match[1];
1788
+ if (key !== void 0) {
1789
+ result.push(`{${key}}`);
1453
1790
  }
1454
1791
  }
1455
1792
  return result;
@@ -1467,8 +1804,245 @@ function createVueI18nJsonAdapter() {
1467
1804
  })
1468
1805
  });
1469
1806
  }
1807
+ function toEntries2(content, namespace, parseEntries) {
1808
+ try {
1809
+ return parseEntries(content, namespace);
1810
+ } catch (error) {
1811
+ rethrowStructured(error, "The file could not be parsed.");
1812
+ }
1813
+ }
1814
+ function createFlatFileAdapter(options) {
1815
+ const {
1816
+ format,
1817
+ extensions,
1818
+ sniff,
1819
+ parseEntries,
1820
+ serializeEntries,
1821
+ extractPlaceholders,
1822
+ validateMessage,
1823
+ computeInvalidIcuKeys
1824
+ } = options;
1825
+ return {
1826
+ format,
1827
+ canHandle: buildCanHandle(extensions, sniff),
1828
+ extractPlaceholders,
1829
+ validateMessage: validateMessage ?? (() => true),
1830
+ async read(filePath, locale) {
1831
+ const content = await readFileContent(filePath);
1832
+ const namespace = namespaceOf(filePath);
1833
+ const entries = toEntries2(content, namespace, parseEntries);
1834
+ const resource = { locale, namespace, format, entries };
1835
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
1836
+ return { resource, invalidIcuKeys };
1837
+ },
1838
+ async write(resource, filePath) {
1839
+ const data = await serializeEntries(resource.entries, filePath);
1840
+ await atomicWriteFile(filePath, data);
1841
+ }
1842
+ };
1843
+ }
1844
+ var XLIFF_PATTERN = /<(?:x|g|bx|ex|ph|it|mrk)\b[^>]*>|\{[^{}]+\}/g;
1845
+ function extractXliffPlaceholders(value) {
1846
+ const result = [];
1847
+ for (const match of value.matchAll(XLIFF_PATTERN)) {
1848
+ const token = match[0];
1849
+ if (token !== void 0) {
1850
+ result.push(token);
1851
+ }
1852
+ }
1853
+ return result;
1854
+ }
1855
+ var ELEMENT_NODE = 1;
1856
+ function isElement(node) {
1857
+ return node.nodeType === ELEMENT_NODE;
1858
+ }
1859
+ function elementChildren(parent) {
1860
+ return Array.from(parent.childNodes).filter(isElement);
1861
+ }
1862
+ function childByName(parent, name) {
1863
+ return elementChildren(parent).find((el) => el.localName === name) ?? null;
1864
+ }
1865
+ function collectByTag(root, name) {
1866
+ return Array.from(root.getElementsByTagName(name));
1867
+ }
1868
+ function unitKey(element, index) {
1869
+ return element.getAttribute("id") ?? element.getAttribute("resname") ?? `unit-${index}`;
1870
+ }
1871
+ function onFatal(level) {
1872
+ if (level === "fatalError") {
1873
+ throw new Error("malformed XML");
1874
+ }
1875
+ }
1876
+ function assertNoDoctype(content) {
1877
+ if (/<!DOCTYPE/i.test(content) || /<!ENTITY/i.test(content)) {
1878
+ throw new AdapterError("INVALID_XML", "XLIFF with a DTD or entity declaration is rejected.");
1879
+ }
1880
+ }
1881
+ function parseXml(content) {
1882
+ assertNoDoctype(content);
1883
+ let doc;
1884
+ try {
1885
+ doc = new DOMParser({ onError: onFatal }).parseFromString(content, "text/xml");
1886
+ } catch {
1887
+ throw new AdapterError("INVALID_XML", "The file is not valid XML.");
1888
+ }
1889
+ const root = doc.documentElement;
1890
+ if (root === null || root.localName !== "xliff") {
1891
+ throw new AdapterError("INVALID_STRUCTURE", "The file is not an XLIFF document.");
1892
+ }
1893
+ return { doc, root };
1894
+ }
1895
+ function walkXliff12(root) {
1896
+ const units = [];
1897
+ collectByTag(root, "trans-unit").forEach((tu, index) => {
1898
+ const source = childByName(tu, "source");
1899
+ if (source !== null) {
1900
+ units.push({
1901
+ key: unitKey(tu, index),
1902
+ source,
1903
+ target: childByName(tu, "target"),
1904
+ container: tu
1905
+ });
1906
+ }
1907
+ });
1908
+ return units;
1909
+ }
1910
+ function walkXliff20(root) {
1911
+ const units = [];
1912
+ collectByTag(root, "unit").forEach((unit, index) => {
1913
+ const baseKey = unitKey(unit, index);
1914
+ const segments = elementChildren(unit).filter((el) => el.localName === "segment");
1915
+ segments.forEach((segment, segIndex) => {
1916
+ const source = childByName(segment, "source");
1917
+ if (source !== null) {
1918
+ const key = segments.length > 1 ? `${baseKey}#${segIndex}` : baseKey;
1919
+ units.push({ key, source, target: childByName(segment, "target"), container: segment });
1920
+ }
1921
+ });
1922
+ });
1923
+ return units;
1924
+ }
1925
+ function walkUnits(root) {
1926
+ const version = root.getAttribute("version") ?? "1.2";
1927
+ return version.startsWith("2") ? walkXliff20(root) : walkXliff12(root);
1928
+ }
1929
+ function innerXml(serializer, element) {
1930
+ return Array.from(element.childNodes).map((node) => serializer.serializeToString(node)).join("");
1931
+ }
1932
+ function unitValue(serializer, unit) {
1933
+ if (unit.target !== null) {
1934
+ const targetXml = innerXml(serializer, unit.target);
1935
+ if (targetXml.trim() !== "") {
1936
+ return targetXml;
1937
+ }
1938
+ }
1939
+ return innerXml(serializer, unit.source);
1940
+ }
1941
+ function parseXliffEntries(content, namespace) {
1942
+ const { root } = parseXml(content);
1943
+ const serializer = new XMLSerializer();
1944
+ const out = /* @__PURE__ */ new Map();
1945
+ for (const unit of walkUnits(root)) {
1946
+ const value = unitValue(serializer, unit);
1947
+ out.set(unit.key, {
1948
+ key: unit.key,
1949
+ namespace,
1950
+ value,
1951
+ placeholders: extractXliffPlaceholders(value),
1952
+ isPlural: false
1953
+ });
1954
+ }
1955
+ return out;
1956
+ }
1957
+ async function readDestination(filePath) {
1958
+ let outcome;
1959
+ try {
1960
+ outcome = await readBounded2(filePath);
1961
+ } catch {
1962
+ throw new AdapterError("INVALID_STRUCTURE", "The destination XLIFF file does not exist.");
1963
+ }
1964
+ if (outcome.kind === "not-a-file") {
1965
+ throw new AdapterError("INVALID_STRUCTURE", "The destination path is not a regular file.");
1966
+ }
1967
+ if (outcome.kind === "too-large") {
1968
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
1969
+ }
1970
+ return outcome.content;
1971
+ }
1972
+ function fragmentNodes(parser, value) {
1973
+ try {
1974
+ const root = parser.parseFromString(`<wrapper>${value}</wrapper>`, "text/xml").documentElement;
1975
+ return root === null ? null : Array.from(root.childNodes);
1976
+ } catch {
1977
+ return null;
1978
+ }
1979
+ }
1980
+ function setTargetValue(doc, parser, element, value) {
1981
+ while (element.firstChild !== null) {
1982
+ element.removeChild(element.firstChild);
1983
+ }
1984
+ const nodes = fragmentNodes(parser, value);
1985
+ if (nodes === null) {
1986
+ element.textContent = value;
1987
+ return;
1988
+ }
1989
+ for (const node of nodes) {
1990
+ element.appendChild(doc.importNode(node, true));
1991
+ }
1992
+ }
1993
+ async function serializeXliffEntries(entries, filePath) {
1994
+ const { doc, root } = parseXml(await readDestination(filePath));
1995
+ const parser = new DOMParser({ onError: onFatal });
1996
+ for (const unit of walkUnits(root)) {
1997
+ const entry = entries.get(unit.key);
1998
+ if (entry !== void 0) {
1999
+ const target = unit.target ?? doc.createElement("target");
2000
+ if (unit.target === null) {
2001
+ unit.container.appendChild(target);
2002
+ }
2003
+ setTargetValue(doc, parser, target, entry.value);
2004
+ }
2005
+ }
2006
+ return new XMLSerializer().serializeToString(doc);
2007
+ }
2008
+ function sniffXliff(sample) {
2009
+ const head = sample.trimStart();
2010
+ return head.startsWith("<xliff") || head.startsWith("<?xml");
2011
+ }
2012
+ function createXliffAdapter() {
2013
+ return createFlatFileAdapter({
2014
+ format: "xliff",
2015
+ extensions: [".xlf", ".xliff"],
2016
+ sniff: sniffXliff,
2017
+ parseEntries: parseXliffEntries,
2018
+ serializeEntries: serializeXliffEntries,
2019
+ extractPlaceholders: extractXliffPlaceholders
2020
+ });
2021
+ }
2022
+ function parseYamlObject(content) {
2023
+ let parsed;
2024
+ try {
2025
+ parsed = parse(content, { maxAliasCount: 100 });
2026
+ } catch {
2027
+ throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2028
+ }
2029
+ return assertJsonRecord(parsed);
2030
+ }
2031
+ function createYamlAdapter() {
2032
+ return createTreeFileAdapter({
2033
+ format: "yaml",
2034
+ extensions: [".yml", ".yaml"],
2035
+ parse: parseYamlObject,
2036
+ serialize: (tree) => stringify(tree),
2037
+ extractPlaceholders: extractDoubleBracePlaceholders,
2038
+ deriveEntry: (_key, value) => ({
2039
+ placeholders: extractDoubleBracePlaceholders(value),
2040
+ isPlural: false
2041
+ })
2042
+ });
2043
+ }
1470
2044
  function createDefaultRegistry() {
1471
- return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter());
2045
+ return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter());
1472
2046
  }
1473
2047
 
1474
2048
  // src/selection/select-adapter.ts
@@ -1483,6 +2057,89 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
1483
2057
  );
1484
2058
  }
1485
2059
 
2060
+ // src/flow/source.ts
2061
+ async function readSource(config, cwd, fs, adapter) {
2062
+ const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
2063
+ if (!await fs.fileExists(sourcePath)) {
2064
+ throw new SdkError(
2065
+ "SOURCE_UNREADABLE",
2066
+ `The source locale file was not found at ${sourcePath}.`
2067
+ );
2068
+ }
2069
+ try {
2070
+ return await adapter.read(sourcePath, config.sourceLocale);
2071
+ } catch (error) {
2072
+ const detail = error instanceof Error ? error.message : String(error);
2073
+ throw new SdkError(
2074
+ "SOURCE_INVALID",
2075
+ `The source locale file at ${sourcePath} could not be read: ${detail}`
2076
+ );
2077
+ }
2078
+ }
2079
+
2080
+ // src/flow/diff-locales.ts
2081
+ async function readTarget(cwd, config, adapter, fs, locale) {
2082
+ const path = localeFilePath(cwd, config.files.pattern, locale);
2083
+ if (!await fs.fileExists(path)) {
2084
+ return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
2085
+ }
2086
+ return (await adapter.read(path, locale)).resource;
2087
+ }
2088
+ function selectedLocales(config, requested) {
2089
+ if (requested === void 0) {
2090
+ return config.targetLocales;
2091
+ }
2092
+ const wanted = new Set(requested);
2093
+ return config.targetLocales.filter((locale) => wanted.has(locale));
2094
+ }
2095
+ async function diffLocales(input, deps = {}) {
2096
+ const config = input.config;
2097
+ const cwd = input.cwd ?? process.cwd();
2098
+ const fs = deps.fs ?? defaultFs;
2099
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2100
+ const source = await readSource(config, cwd, fs, adapter);
2101
+ const lock = await readLockFile(lockFilePath(cwd), fs);
2102
+ return Promise.all(
2103
+ selectedLocales(config, input.locales).map(async (locale) => {
2104
+ const target = await readTarget(cwd, config, adapter, fs, locale);
2105
+ const diff2 = diffResources(source.resource, target, { baseline: baselineFor(lock, locale) });
2106
+ return { locale, diff: diff2 };
2107
+ })
2108
+ );
2109
+ }
2110
+
2111
+ // src/flow/check.ts
2112
+ function toCheckSummary(locale, diff2) {
2113
+ return {
2114
+ locale,
2115
+ missing: diff2.missing.length,
2116
+ stale: diff2.changed.length,
2117
+ upToDate: diff2.unchanged.length,
2118
+ inSync: diff2.missing.length === 0 && diff2.changed.length === 0
2119
+ };
2120
+ }
2121
+ async function check(input, deps = {}) {
2122
+ const results = await diffLocales(input, deps);
2123
+ const locales = results.map(({ locale, diff: diff2 }) => toCheckSummary(locale, diff2));
2124
+ return { inSync: locales.every((entry) => entry.inSync), locales };
2125
+ }
2126
+
2127
+ // src/flow/diff.ts
2128
+ function toLocaleDiff(locale, diff2) {
2129
+ return {
2130
+ locale,
2131
+ missing: diff2.missing,
2132
+ changed: diff2.changed,
2133
+ orphaned: diff2.orphaned,
2134
+ hasPendingChanges: diff2.missing.length > 0 || diff2.changed.length > 0
2135
+ };
2136
+ }
2137
+ async function diff(input, deps = {}) {
2138
+ const results = await diffLocales(input, deps);
2139
+ const locales = results.map(({ locale, diff: result }) => toLocaleDiff(locale, result));
2140
+ return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
2141
+ }
2142
+
1486
2143
  // src/selection/select-provider.ts
1487
2144
  function selectProvider(config, createProvider = buildProvider) {
1488
2145
  try {
@@ -1511,8 +2168,10 @@ function failureSummary(locale, error) {
1511
2168
  translated: [],
1512
2169
  unchanged: [],
1513
2170
  orphaned: [],
2171
+ pruned: [],
1514
2172
  invalidIcuSource: [],
1515
2173
  integrityMismatches: [],
2174
+ generated: [],
1516
2175
  notices: [],
1517
2176
  error: describeError(error)
1518
2177
  };
@@ -1535,18 +2194,210 @@ function readNotices(result) {
1535
2194
  return candidate.filter(isNotice);
1536
2195
  }
1537
2196
 
2197
+ // src/flow/plural-categories.ts
2198
+ var LANGUAGE_CATEGORIES = {
2199
+ ar: ["zero", "one", "two", "few", "many", "other"],
2200
+ cy: ["zero", "one", "two", "few", "many", "other"],
2201
+ ga: ["one", "two", "few", "many", "other"],
2202
+ pl: ["one", "few", "many", "other"],
2203
+ ru: ["one", "few", "many", "other"],
2204
+ uk: ["one", "few", "many", "other"],
2205
+ be: ["one", "few", "many", "other"],
2206
+ lt: ["one", "few", "many", "other"],
2207
+ sl: ["one", "two", "few", "other"]
2208
+ };
2209
+ function isKnownRicherLanguage(locale) {
2210
+ const subtag = locale.toLowerCase().split(/[-_]/)[0] ?? "";
2211
+ return LANGUAGE_CATEGORIES[subtag] !== void 0;
2212
+ }
2213
+ function requiredCategories(locale) {
2214
+ const subtag = locale.toLowerCase().split(/[-_]/)[0] ?? "";
2215
+ return LANGUAGE_CATEGORIES[subtag] ?? ["one", "other"];
2216
+ }
2217
+ function groupPluralSources(source) {
2218
+ const groups = /* @__PURE__ */ new Map();
2219
+ for (const [key, entry] of source.entries) {
2220
+ const baseKey = pluralBaseKey(key);
2221
+ const category = pluralCategoryOf(key);
2222
+ if (baseKey === void 0 || category === void 0) {
2223
+ continue;
2224
+ }
2225
+ const group = groups.get(baseKey) ?? /* @__PURE__ */ new Map();
2226
+ group.set(category, entry);
2227
+ groups.set(baseKey, group);
2228
+ }
2229
+ return groups;
2230
+ }
2231
+ function suppliedCategories(groups) {
2232
+ const supplied = /* @__PURE__ */ new Set();
2233
+ for (const group of groups.values()) {
2234
+ for (const category of group.keys()) {
2235
+ supplied.add(category);
2236
+ }
2237
+ }
2238
+ return supplied;
2239
+ }
2240
+ function detectMissingPluralCategories(source, targetLocale, format) {
2241
+ if (format !== "i18next-json") {
2242
+ return void 0;
2243
+ }
2244
+ const groups = groupPluralSources(source);
2245
+ const supplied = suppliedCategories(groups);
2246
+ if (supplied.size === 0) {
2247
+ return void 0;
2248
+ }
2249
+ const missing = requiredCategories(targetLocale).filter((category) => !supplied.has(category));
2250
+ if (missing.length === 0) {
2251
+ return void 0;
2252
+ }
2253
+ return {
2254
+ code: "PLURAL_CATEGORIES_INCOMPLETE",
2255
+ 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.`
2256
+ };
2257
+ }
2258
+ function targetPluralSetIncomplete(targetKeys, targetLocale) {
2259
+ const required = requiredCategories(targetLocale);
2260
+ const present = /* @__PURE__ */ new Map();
2261
+ for (const key of targetKeys) {
2262
+ const baseKey = pluralBaseKey(key);
2263
+ const category = pluralCategoryOf(key);
2264
+ if (baseKey === void 0 || category === void 0) {
2265
+ continue;
2266
+ }
2267
+ const set = present.get(baseKey) ?? /* @__PURE__ */ new Set();
2268
+ set.add(category);
2269
+ present.set(baseKey, set);
2270
+ }
2271
+ for (const categories of present.values()) {
2272
+ if (required.some((category) => !categories.has(category))) {
2273
+ return true;
2274
+ }
2275
+ }
2276
+ return false;
2277
+ }
2278
+ function sourcePluralBaseKeys(source) {
2279
+ const bases = /* @__PURE__ */ new Set();
2280
+ for (const key of source.entries.keys()) {
2281
+ const baseKey = pluralBaseKey(key);
2282
+ if (baseKey !== void 0) {
2283
+ bases.add(baseKey);
2284
+ }
2285
+ }
2286
+ return bases;
2287
+ }
2288
+ function isGeneratedPluralKey(key, sourceBaseKeys) {
2289
+ const baseKey = pluralBaseKey(key);
2290
+ return baseKey !== void 0 && sourceBaseKeys.has(baseKey);
2291
+ }
2292
+ function pluralIncompleteNotice(targetLocale) {
2293
+ return {
2294
+ code: "PLURAL_CATEGORIES_INCOMPLETE",
2295
+ 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.`
2296
+ };
2297
+ }
2298
+ function representativeEntry(group) {
2299
+ return group.get("other") ?? group.get("one") ?? [...group.values()][0];
2300
+ }
2301
+ function planPluralGeneration(source, targetLocale, format) {
2302
+ if (format !== "i18next-json" || !isKnownRicherLanguage(targetLocale)) {
2303
+ return { items: [] };
2304
+ }
2305
+ const required = requiredCategories(targetLocale);
2306
+ const groups = groupPluralSources(source);
2307
+ const items = [];
2308
+ for (const [baseKey, group] of groups) {
2309
+ const representative = representativeEntry(group);
2310
+ if (representative === void 0) {
2311
+ continue;
2312
+ }
2313
+ const governingEntries = [...group.values()];
2314
+ for (const category of required) {
2315
+ if (group.has(category)) {
2316
+ continue;
2317
+ }
2318
+ items.push({
2319
+ targetKey: makePluralKey(baseKey, category),
2320
+ category,
2321
+ sourceEntry: representative,
2322
+ governingEntries
2323
+ });
2324
+ }
2325
+ }
2326
+ return { items };
2327
+ }
2328
+
2329
+ // src/flow/plural-generation.ts
2330
+ function generatedLockHash(governingEntries, category) {
2331
+ const governingHashes = governingEntries.map(contentHash).sort();
2332
+ return contentHash({
2333
+ value: `${category}:${governingHashes.join("|")}`,
2334
+ placeholders: [],
2335
+ isPlural: true
2336
+ });
2337
+ }
2338
+ function syntheticEntry(item) {
2339
+ return {
2340
+ ...item.sourceEntry,
2341
+ key: item.targetKey,
2342
+ isPlural: true,
2343
+ // The CLDR category travels as data context (the meaning field), never the instruction channel.
2344
+ meaning: `CLDR plural category "${item.category}"`
2345
+ };
2346
+ }
2347
+ function staleItems(items, baseline) {
2348
+ return items.filter((item) => {
2349
+ const hash = generatedLockHash(item.governingEntries, item.category);
2350
+ return baseline.get(item.targetKey) !== hash;
2351
+ });
2352
+ }
2353
+ function buildRequest2(context, entries) {
2354
+ return {
2355
+ sourceLocale: context.sourceLocale,
2356
+ targetLocale: context.targetLocale,
2357
+ entries,
2358
+ extractPlaceholders: context.adapter.extractPlaceholders,
2359
+ ...context.glossary !== void 0 ? { glossary: context.glossary } : {},
2360
+ ...context.tone !== void 0 ? { tone: context.tone } : {}
2361
+ };
2362
+ }
2363
+ async function generatePluralForms(context) {
2364
+ const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
2365
+ const stale = staleItems(plan.items, context.baseline);
2366
+ if (stale.length === 0) {
2367
+ return { accepted: [], withheld: [] };
2368
+ }
2369
+ const entries = stale.map(syntheticEntry);
2370
+ const result = await context.provider.translateBatch(buildRequest2(context, entries));
2371
+ const accepted = [];
2372
+ const withheld = [];
2373
+ for (const item of stale) {
2374
+ const value = result.values.get(item.targetKey);
2375
+ const integrity = result.integrity.get(item.targetKey);
2376
+ if (value !== void 0 && integrity?.matches === true) {
2377
+ accepted.push({
2378
+ targetKey: item.targetKey,
2379
+ entry: { ...syntheticEntry(item), value },
2380
+ lockHash: generatedLockHash(item.governingEntries, item.category)
2381
+ });
2382
+ } else {
2383
+ withheld.push(item.targetKey);
2384
+ }
2385
+ }
2386
+ return { accepted, withheld };
2387
+ }
2388
+
1538
2389
  // src/flow/locale-run.ts
1539
2390
  function emptyResource(locale, format) {
1540
2391
  return { locale, namespace: "", format, entries: /* @__PURE__ */ new Map() };
1541
2392
  }
1542
- async function readTarget(params) {
2393
+ async function readTarget2(params) {
1543
2394
  const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
1544
2395
  if (!await params.fs.fileExists(path)) {
1545
2396
  return emptyResource(params.targetLocale, params.format);
1546
2397
  }
1547
2398
  return (await params.adapter.read(path, params.targetLocale)).resource;
1548
2399
  }
1549
- function buildRequest2(params, entries) {
2400
+ function buildRequest3(params, entries) {
1550
2401
  return {
1551
2402
  sourceLocale: params.sourceLocale,
1552
2403
  targetLocale: params.targetLocale,
@@ -1557,27 +2408,58 @@ function buildRequest2(params, entries) {
1557
2408
  };
1558
2409
  }
1559
2410
  async function runLocale(params) {
1560
- const target = await readTarget(params);
1561
- const diff = diffResources(params.source, target, { baseline: params.baseline });
2411
+ const target = await readTarget2(params);
2412
+ const diff2 = diffResources(params.source, target, { baseline: params.baseline });
2413
+ const orphaned = params.generatePlurals ? diff2.orphaned.filter((key) => !isGeneratedPluralKey(key, sourcePluralBaseKeys(params.source))) : diff2.orphaned;
2414
+ const pruned = params.prune ? orphaned : [];
1562
2415
  const invalidIcu = new Set(params.sourceInvalidIcuKeys);
1563
- const candidates = [...diff.missing, ...diff.changed];
2416
+ const candidates = [...diff2.missing, ...diff2.changed];
1564
2417
  const toTranslate = candidates.filter((key) => !invalidIcu.has(key));
1565
2418
  const invalidIcuSource = candidates.filter((key) => invalidIcu.has(key));
2419
+ const pluralNotice = detectMissingPluralCategories(
2420
+ params.source,
2421
+ params.targetLocale,
2422
+ params.format
2423
+ );
2424
+ const sdkNotices = pluralNotice ? [pluralNotice] : [];
1566
2425
  const provider = params.provider;
1567
2426
  if (provider === void 0) {
1568
2427
  return {
1569
- summary: baseSummary(params.targetLocale, diff, invalidIcuSource, toTranslate, [], []),
2428
+ summary: baseSummary({
2429
+ locale: params.targetLocale,
2430
+ unchanged: diff2.unchanged,
2431
+ orphaned,
2432
+ invalidIcuSource,
2433
+ translated: toTranslate,
2434
+ generated: [],
2435
+ integrityMismatches: [],
2436
+ pruned,
2437
+ notices: sdkNotices
2438
+ }),
1570
2439
  lockEntries: {}
1571
2440
  };
1572
2441
  }
1573
2442
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
1574
2443
  const accepted = /* @__PURE__ */ new Map();
1575
2444
  const integrityMismatches = [];
1576
- const notices = await translateAndCheck(provider, params, entries, accepted, integrityMismatches);
2445
+ const subBatchNotices = await translateAndCheck(
2446
+ provider,
2447
+ params,
2448
+ entries,
2449
+ accepted,
2450
+ integrityMismatches
2451
+ );
1577
2452
  const merged = new Map(target.entries);
2453
+ for (const key of pruned) {
2454
+ merged.delete(key);
2455
+ }
1578
2456
  for (const [key, { value, source }] of accepted) {
1579
2457
  merged.set(key, { ...source, value, namespace: target.namespace });
1580
2458
  }
2459
+ const generation = await runGeneration(params, provider);
2460
+ for (const form of generation.accepted) {
2461
+ merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
2462
+ }
1581
2463
  const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
1582
2464
  await params.adapter.write(
1583
2465
  {
@@ -1588,37 +2470,83 @@ async function runLocale(params) {
1588
2470
  },
1589
2471
  path
1590
2472
  );
1591
- const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource]);
2473
+ const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
2474
+ const notices = [...pluralNotices, ...subBatchNotices];
2475
+ const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource, ...generation.withheld]);
1592
2476
  return {
1593
- summary: baseSummary(
1594
- params.targetLocale,
1595
- diff,
2477
+ summary: baseSummary({
2478
+ locale: params.targetLocale,
2479
+ unchanged: diff2.unchanged,
2480
+ orphaned,
1596
2481
  invalidIcuSource,
1597
- [...accepted.keys()],
1598
- integrityMismatches,
2482
+ translated: [...accepted.keys()],
2483
+ generated: generation.accepted.map((form) => form.targetKey).sort(),
2484
+ // Withheld generated forms surface alongside withheld translations: both failed integrity.
2485
+ integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
2486
+ pruned,
1599
2487
  notices
1600
- ),
1601
- lockEntries: computeLockEntries(params, merged, withheld)
2488
+ }),
2489
+ lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
1602
2490
  };
1603
2491
  }
1604
- function baseSummary(locale, diff, invalidIcuSource, translated, integrityMismatches, notices) {
2492
+ async function runGeneration(params, provider) {
2493
+ if (!params.generatePlurals || provider.kind !== "llm") {
2494
+ return { accepted: [], withheld: [] };
2495
+ }
2496
+ return generatePluralForms({
2497
+ source: params.source,
2498
+ sourceLocale: params.sourceLocale,
2499
+ targetLocale: params.targetLocale,
2500
+ format: params.format,
2501
+ adapter: params.adapter,
2502
+ provider,
2503
+ glossary: params.glossary,
2504
+ tone: params.tone,
2505
+ baseline: params.baseline
2506
+ });
2507
+ }
2508
+ function pluralNoticeFor(params, merged) {
2509
+ if (params.format !== "i18next-json") {
2510
+ return [];
2511
+ }
2512
+ if (!targetPluralSetIncomplete(merged.keys(), params.targetLocale)) {
2513
+ return [];
2514
+ }
2515
+ return [pluralIncompleteNotice(params.targetLocale)];
2516
+ }
2517
+ function baseSummary(parts) {
1605
2518
  return {
1606
- locale,
2519
+ locale: parts.locale,
1607
2520
  status: "succeeded",
1608
- translated,
1609
- unchanged: diff.unchanged,
1610
- orphaned: diff.orphaned,
1611
- invalidIcuSource,
1612
- integrityMismatches,
1613
- notices
2521
+ translated: parts.translated,
2522
+ unchanged: parts.unchanged,
2523
+ orphaned: parts.orphaned,
2524
+ pruned: parts.pruned,
2525
+ invalidIcuSource: parts.invalidIcuSource,
2526
+ integrityMismatches: parts.integrityMismatches,
2527
+ generated: parts.generated,
2528
+ notices: parts.notices
1614
2529
  };
1615
2530
  }
1616
2531
  async function translateAndCheck(provider, params, entries, accepted, integrityMismatches) {
1617
- if (entries.length === 0) {
1618
- return [];
2532
+ const notices = [];
2533
+ for (const batch of chunk(entries, params.maxBatchSize)) {
2534
+ const subNotices = await runSubBatch(provider, params, batch, accepted, integrityMismatches);
2535
+ notices.push(...subNotices);
1619
2536
  }
1620
- const result = await provider.translateBatch(buildRequest2(params, entries));
1621
- for (const entry of entries) {
2537
+ return notices;
2538
+ }
2539
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches) {
2540
+ let result;
2541
+ try {
2542
+ result = await provider.translateBatch(buildRequest3(params, batch));
2543
+ } catch {
2544
+ for (const entry of batch) {
2545
+ integrityMismatches.push(entry.key);
2546
+ }
2547
+ return [subBatchFailedNotice(batch.length)];
2548
+ }
2549
+ for (const entry of batch) {
1622
2550
  const value = result.values.get(entry.key);
1623
2551
  const integrity = result.integrity.get(entry.key);
1624
2552
  if (value !== void 0 && integrity?.matches === true) {
@@ -1629,11 +2557,28 @@ async function translateAndCheck(provider, params, entries, accepted, integrityM
1629
2557
  }
1630
2558
  return readNotices(result);
1631
2559
  }
1632
- function computeLockEntries(params, merged, withheld) {
2560
+ function subBatchFailedNotice(count) {
2561
+ return {
2562
+ code: "SUB_BATCH_FAILED",
2563
+ message: `A sub-batch of ${count} entries failed and was withheld; it will be retried next run.`
2564
+ };
2565
+ }
2566
+ function chunk(items, size) {
2567
+ const chunks = [];
2568
+ for (let index = 0; index < items.length; index += size) {
2569
+ chunks.push(items.slice(index, index + size));
2570
+ }
2571
+ return chunks;
2572
+ }
2573
+ function computeLockEntries(params, merged, withheld, generated) {
1633
2574
  const lockEntries = {};
2575
+ const sourceBaseKeys = sourcePluralBaseKeys(params.source);
1634
2576
  for (const key of merged.keys()) {
1635
2577
  const sourceEntry = params.source.entries.get(key);
1636
2578
  if (sourceEntry === void 0) {
2579
+ if (params.generatePlurals) {
2580
+ carryGeneratedLock(lockEntries, params.baseline, key, sourceBaseKeys);
2581
+ }
1637
2582
  continue;
1638
2583
  }
1639
2584
  if (withheld.has(key)) {
@@ -1645,26 +2590,18 @@ function computeLockEntries(params, merged, withheld) {
1645
2590
  }
1646
2591
  lockEntries[key] = contentHash(sourceEntry);
1647
2592
  }
2593
+ for (const form of generated) {
2594
+ lockEntries[form.targetKey] = form.lockHash;
2595
+ }
1648
2596
  return lockEntries;
1649
2597
  }
1650
-
1651
- // src/flow/source.ts
1652
- async function readSource(config, cwd, fs, adapter) {
1653
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
1654
- if (!await fs.fileExists(sourcePath)) {
1655
- throw new SdkError(
1656
- "SOURCE_UNREADABLE",
1657
- `The source locale file was not found at ${sourcePath}.`
1658
- );
2598
+ function carryGeneratedLock(lockEntries, baseline, key, sourceBaseKeys) {
2599
+ if (!isGeneratedPluralKey(key, sourceBaseKeys)) {
2600
+ return;
1659
2601
  }
1660
- try {
1661
- return await adapter.read(sourcePath, config.sourceLocale);
1662
- } catch (error) {
1663
- const detail = error instanceof Error ? error.message : String(error);
1664
- throw new SdkError(
1665
- "SOURCE_INVALID",
1666
- `The source locale file at ${sourcePath} could not be read: ${detail}`
1667
- );
2602
+ const prior = baseline.get(key);
2603
+ if (prior !== void 0) {
2604
+ lockEntries[key] = prior;
1668
2605
  }
1669
2606
  }
1670
2607
 
@@ -1673,6 +2610,9 @@ async function translate2(input, deps = {}) {
1673
2610
  const config = input.config;
1674
2611
  const cwd = input.cwd ?? process.cwd();
1675
2612
  const dryRun = input.dryRun ?? false;
2613
+ const prune = input.prune ?? config.prune ?? false;
2614
+ const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
2615
+ const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
1676
2616
  const fs = deps.fs ?? defaultFs;
1677
2617
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
1678
2618
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
@@ -1695,6 +2635,9 @@ async function translate2(input, deps = {}) {
1695
2635
  format: config.format,
1696
2636
  glossary: config.glossary,
1697
2637
  tone: config.tone,
2638
+ prune,
2639
+ generatePlurals,
2640
+ maxBatchSize,
1698
2641
  fs
1699
2642
  };
1700
2643
  const { summary, lockEntries } = await runLocale(params);
@@ -1870,7 +2813,7 @@ var DEFAULT_WORKBOOK_LIMITS = {
1870
2813
  maxRowsPerSheet: 1e5,
1871
2814
  maxCellsPerRow: 64
1872
2815
  };
1873
- function assertNoDoctype(name, xml) {
2816
+ function assertNoDoctype2(name, xml) {
1874
2817
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
1875
2818
  throw new ExchangeError(
1876
2819
  "WORKBOOK_INVALID",
@@ -1925,7 +2868,7 @@ async function guardWorkbookBytes(bytes, limits) {
1925
2868
  `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
1926
2869
  );
1927
2870
  }
1928
- assertNoDoctype(file.name, content);
2871
+ assertNoDoctype2(file.name, content);
1929
2872
  }
1930
2873
  }
1931
2874
  var rowSchema = z.object({
@@ -2034,7 +2977,7 @@ async function readWorkbook(bytes, options = {}) {
2034
2977
 
2035
2978
  // src/flow/workbook/export-workbook.ts
2036
2979
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
2037
- async function readTarget2(cwd, config, adapter, fs, locale) {
2980
+ async function readTarget3(cwd, config, adapter, fs, locale) {
2038
2981
  const path = localeFilePath(cwd, config.files.pattern, locale);
2039
2982
  if (!await fs.fileExists(path)) {
2040
2983
  return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
@@ -2042,7 +2985,7 @@ async function readTarget2(cwd, config, adapter, fs, locale) {
2042
2985
  return (await adapter.read(path, locale)).resource;
2043
2986
  }
2044
2987
  function buildRows(source, target, baseline, includeUnchanged) {
2045
- const diff = diffResources(source, target, { baseline });
2988
+ const diff2 = diffResources(source, target, { baseline });
2046
2989
  const rows = [];
2047
2990
  const add = (keys, status) => {
2048
2991
  for (const key of keys) {
@@ -2060,14 +3003,14 @@ function buildRows(source, target, baseline, includeUnchanged) {
2060
3003
  });
2061
3004
  }
2062
3005
  };
2063
- add(diff.missing, "new");
2064
- add(diff.changed, "changed");
3006
+ add(diff2.missing, "new");
3007
+ add(diff2.changed, "changed");
2065
3008
  if (includeUnchanged) {
2066
- add(diff.unchanged, "changed");
3009
+ add(diff2.unchanged, "changed");
2067
3010
  }
2068
3011
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
2069
3012
  }
2070
- function selectedLocales(config, requested) {
3013
+ function selectedLocales2(config, requested) {
2071
3014
  if (requested === void 0) {
2072
3015
  return config.targetLocales;
2073
3016
  }
@@ -2081,10 +3024,10 @@ async function exportWorkbook(input, deps = {}) {
2081
3024
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
2082
3025
  const source = await readSource(config, cwd, fs, adapter);
2083
3026
  const lock = await readLockFile(lockFilePath(cwd), fs);
2084
- const locales = selectedLocales(config, input.locales);
3027
+ const locales = selectedLocales2(config, input.locales);
2085
3028
  const sheets = await Promise.all(
2086
3029
  locales.map(async (locale) => {
2087
- const target = await readTarget2(cwd, config, adapter, fs, locale);
3030
+ const target = await readTarget3(cwd, config, adapter, fs, locale);
2088
3031
  const rows = buildRows(
2089
3032
  source.resource,
2090
3033
  target,
@@ -2154,7 +3097,7 @@ function classifyRows(params, buckets) {
2154
3097
  }
2155
3098
  }
2156
3099
  function importLocale(params) {
2157
- const diff = diffResources(params.source, params.target, { baseline: params.baseline });
3100
+ const diff2 = diffResources(params.source, params.target, { baseline: params.baseline });
2158
3101
  const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
2159
3102
  classifyRows(params, buckets);
2160
3103
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
@@ -2163,10 +3106,14 @@ function importLocale(params) {
2163
3106
  locale: params.sheet.locale,
2164
3107
  status: "succeeded",
2165
3108
  translated: [...buckets.accepted.keys()].sort(),
2166
- unchanged: diff.unchanged,
2167
- orphaned: diff.orphaned,
3109
+ unchanged: diff2.unchanged,
3110
+ orphaned: diff2.orphaned,
3111
+ // Import never prunes: orphans are reported but never removed here (pruning is a translate-flow concern).
3112
+ pruned: [],
2168
3113
  invalidIcuSource,
2169
3114
  integrityMismatches: [...buckets.mismatches].sort(),
3115
+ // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
3116
+ generated: [],
2170
3117
  notices: []
2171
3118
  };
2172
3119
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
@@ -2187,7 +3134,7 @@ async function readWorkbookBytes(path, fs) {
2187
3134
  }
2188
3135
  return read.bytes;
2189
3136
  }
2190
- async function readTarget3(cwd, config, adapter, fs, locale) {
3137
+ async function readTarget4(cwd, config, adapter, fs, locale) {
2191
3138
  const path = localeFilePath(cwd, config.files.pattern, locale);
2192
3139
  if (!await fs.fileExists(path)) {
2193
3140
  return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
@@ -2226,7 +3173,7 @@ async function runSheet(ctx, sheet, lock) {
2226
3173
  `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
2227
3174
  );
2228
3175
  }
2229
- const target = await readTarget3(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
3176
+ const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
2230
3177
  const baseline = baselineFor(lock, sheet.locale);
2231
3178
  const { summary, accepted, withheld } = importLocale({
2232
3179
  sheet,
@@ -2296,6 +3243,16 @@ async function importWorkbook(input, deps = {}) {
2296
3243
  const { succeeded, failed } = partition(summaries);
2297
3244
  return { dryRun, locales: summaries, succeeded, failed };
2298
3245
  }
3246
+
3247
+ // src/scaffolding.ts
3248
+ var scaffoldingMetadata = {
3249
+ /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
3250
+ providerEnv: PROVIDER_ENV,
3251
+ /** LLM provider id -> a cosmetic default scaffold model. Owned by ai-providers. DeepL has none. */
3252
+ scaffoldModels: SCAFFOLD_MODELS,
3253
+ /** The closed set of source format ids. Owned by core. */
3254
+ supportedFormats: SUPPORTED_FORMATS
3255
+ };
2299
3256
  var defaultCreateWatcher = (paths) => {
2300
3257
  const fsWatcher = watch$1([...paths], { persistent: true, ignoreInitial: true });
2301
3258
  return {
@@ -2402,6 +3359,6 @@ async function watch(input, deps = {}) {
2402
3359
  return { stop };
2403
3360
  }
2404
3361
 
2405
- export { DEFAULT_WORKBOOK_PATH, SdkError, defineConfig, exportWorkbook, importWorkbook, loadConfig, translate2 as translate, verbatraConfigSchema, watch };
3362
+ export { DEFAULT_WORKBOOK_PATH, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
2406
3363
  //# sourceMappingURL=index.js.map
2407
3364
  //# sourceMappingURL=index.js.map