@verbatra/sdk 0.6.3 → 0.7.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
@@ -152,6 +152,18 @@ function defineConfig(config) {
152
152
  }
153
153
 
154
154
  // src/errors.ts
155
+ function errorMessage(error) {
156
+ return error instanceof Error ? error.message : String(error);
157
+ }
158
+ function stringCode(error) {
159
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
160
+ return error.code;
161
+ }
162
+ return void 0;
163
+ }
164
+ function describeError(error, fallbackCode) {
165
+ return { code: stringCode(error) ?? fallbackCode, message: errorMessage(error) };
166
+ }
155
167
  var SdkError = class extends Error {
156
168
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
157
169
  code;
@@ -276,6 +288,9 @@ var defaultFs = {
276
288
  createExclusive: (path, data) => createExclusive(path, data),
277
289
  deleteFile: async (path) => {
278
290
  await promises.rm(path, { force: true });
291
+ },
292
+ mkdir: async (path) => {
293
+ await promises.mkdir(path, { recursive: true });
279
294
  }
280
295
  };
281
296
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
@@ -514,9 +529,109 @@ function assessValueDegeneracy(sourceValue, candidateValue) {
514
529
  }
515
530
  return { degenerate: hasRunawayRepetition(candidate) };
516
531
  }
532
+
533
+ // src/locale-path/pattern.ts
517
534
  var LOCALE_TOKEN = "{locale}";
518
- function localeFilePath(cwd, pattern, locale) {
519
- return path.resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
535
+ var SEPARATOR = /[/\\]/;
536
+ function tokenOccupiesWholeSegments(pattern) {
537
+ return pattern.split(SEPARATOR).every((segment) => segment === LOCALE_TOKEN || !segment.includes(LOCALE_TOKEN));
538
+ }
539
+ function expandPattern(pattern, spelling) {
540
+ return pattern.replaceAll(LOCALE_TOKEN, () => spelling);
541
+ }
542
+
543
+ // src/locale-path/tag.ts
544
+ var LANGUAGE = /^[a-z]{2,3}$/;
545
+ var SCRIPT = /^[a-z]{4}$/;
546
+ var REGION = /^([a-z]{2}|[0-9]{3})$/;
547
+ var NUMERIC_REGION = /^[0-9]{3}$/;
548
+ var VARIANT = /^([a-z0-9]{5,8}|[0-9][a-z0-9]{3})$/;
549
+ function subtagIf(subtags, index, pattern) {
550
+ const subtag = subtags[index];
551
+ return subtag !== void 0 && pattern.test(subtag) ? subtag : void 0;
552
+ }
553
+ function parseLocaleTag(locale) {
554
+ const subtags = locale.toLowerCase().split("-");
555
+ const language = subtags[0];
556
+ if (language === void 0 || !LANGUAGE.test(language)) {
557
+ return void 0;
558
+ }
559
+ let index = 1;
560
+ const script = subtagIf(subtags, index, SCRIPT);
561
+ index += script === void 0 ? 0 : 1;
562
+ const region = subtagIf(subtags, index, REGION);
563
+ index += region === void 0 ? 0 : 1;
564
+ const variants = subtags.slice(index);
565
+ if (!variants.every((variant) => VARIANT.test(variant))) {
566
+ return void 0;
567
+ }
568
+ return { language, script, region, variants };
569
+ }
570
+ function conventionalSubtags(tag) {
571
+ const subtags = [tag.language];
572
+ if (tag.script !== void 0) {
573
+ subtags.push(tag.script.charAt(0).toUpperCase() + tag.script.slice(1));
574
+ }
575
+ if (tag.region !== void 0) {
576
+ subtags.push(tag.region.toUpperCase());
577
+ }
578
+ subtags.push(...tag.variants);
579
+ return subtags;
580
+ }
581
+
582
+ // src/locale-path/android.ts
583
+ var SOURCE_SEGMENT = "values";
584
+ var MAX_BCP47_SUBTAGS = 4;
585
+ var UI_MODE_LANGUAGE_COLLISION = "car";
586
+ function needsBcp47(tag, subtagCount) {
587
+ return tag.script !== void 0 || tag.variants.length > 0 || tag.region !== void 0 && NUMERIC_REGION.test(tag.region) || tag.language === UI_MODE_LANGUAGE_COLLISION || subtagCount >= 3;
588
+ }
589
+ function legacySegment(tag) {
590
+ return tag.region === void 0 ? `${SOURCE_SEGMENT}-${tag.language}` : `${SOURCE_SEGMENT}-${tag.language}-r${tag.region.toUpperCase()}`;
591
+ }
592
+ function androidSegment(locale, isSourceLocale) {
593
+ if (isSourceLocale) {
594
+ return SOURCE_SEGMENT;
595
+ }
596
+ const tag = parseLocaleTag(locale);
597
+ if (tag === void 0) {
598
+ return void 0;
599
+ }
600
+ const subtags = conventionalSubtags(tag);
601
+ if (!needsBcp47(tag, subtags.length)) {
602
+ return legacySegment(tag);
603
+ }
604
+ if (subtags.length > MAX_BCP47_SUBTAGS) {
605
+ return void 0;
606
+ }
607
+ return `${SOURCE_SEGMENT}-b+${subtags.join("+")}`;
608
+ }
609
+
610
+ // src/locale-path/style.ts
611
+ var LOCALE_STYLES = ["literal", "posix", "android"];
612
+ var SEGMENT_STYLES = /* @__PURE__ */ new Set(["android"]);
613
+ var UNSAFE_IN_SEGMENT = /[/\\\0]/;
614
+ function isSegmentStyle(style) {
615
+ return SEGMENT_STYLES.has(style);
616
+ }
617
+ function posixSpelling(locale) {
618
+ const tag = parseLocaleTag(locale);
619
+ if (tag === void 0 || tag.script !== void 0 || tag.variants.length > 0) {
620
+ return void 0;
621
+ }
622
+ if (tag.region !== void 0 && NUMERIC_REGION.test(tag.region)) {
623
+ return void 0;
624
+ }
625
+ return locale.replaceAll("-", "_");
626
+ }
627
+ function spellLocale(locale, style, isSourceLocale) {
628
+ if (style === "android") {
629
+ return androidSegment(locale, isSourceLocale);
630
+ }
631
+ return style === "posix" ? posixSpelling(locale) : locale;
632
+ }
633
+ function isSafeSpelling(spelling) {
634
+ return spelling.length > 0 && !UNSAFE_IN_SEGMENT.test(spelling) && spelling !== "." && spelling !== "..";
520
635
  }
521
636
  function checkBatchIntegrity(inputs, extract, compare) {
522
637
  const integrity = /* @__PURE__ */ new Map();
@@ -689,7 +804,7 @@ function deriveJsonSchema(schema) {
689
804
  result[key] = value;
690
805
  }
691
806
  }
692
- return result;
807
+ return { ...result, type: "object" };
693
808
  }
694
809
  function partitionTranslations(translations, requested) {
695
810
  const firstSeen = /* @__PURE__ */ new Map();
@@ -809,6 +924,8 @@ function assertNotTruncated(truncated) {
809
924
  var RATE_LIMITED_STATUS = 429;
810
925
  var TIMEOUT_STATUS = 408;
811
926
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
927
+ var SERVER_OUTAGE_MIN_STATUS = 500;
928
+ var SERVER_OUTAGE_MAX_STATUS = 599;
812
929
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
813
930
  "RateLimitError",
814
931
  "TooManyRequestsError"
@@ -830,6 +947,9 @@ function getErrorStatus(error) {
830
947
  const status = error.status;
831
948
  return typeof status === "number" ? status : void 0;
832
949
  }
950
+ function isServerOutageStatus(status) {
951
+ return status !== void 0 && status >= SERVER_OUTAGE_MIN_STATUS && status <= SERVER_OUTAGE_MAX_STATUS;
952
+ }
833
953
  function readClassName(error) {
834
954
  if (typeof error !== "object" || error === null) {
835
955
  return void 0;
@@ -847,6 +967,9 @@ function classifyProviderError(error) {
847
967
  if (status === TIMEOUT_STATUS) {
848
968
  return "TIMEOUT";
849
969
  }
970
+ if (isServerOutageStatus(status)) {
971
+ return "PROVIDER_UNAVAILABLE";
972
+ }
850
973
  const className = readClassName(error);
851
974
  if (className !== void 0) {
852
975
  if (RATE_LIMITED_CLASS_NAMES.has(className)) {
@@ -878,10 +1001,12 @@ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
878
1001
  var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
879
1002
  var TIMEOUT_MESSAGE = "The translation provider request timed out.";
880
1003
  var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
1004
+ var PROVIDER_UNAVAILABLE_MESSAGE = "The translation provider is currently unavailable.";
881
1005
  var MESSAGE_BY_CODE = {
882
1006
  RATE_LIMITED: RATE_LIMITED_MESSAGE,
883
1007
  TIMEOUT: TIMEOUT_MESSAGE,
884
1008
  AUTH_FAILED: AUTH_FAILED_MESSAGE,
1009
+ PROVIDER_UNAVAILABLE: PROVIDER_UNAVAILABLE_MESSAGE,
885
1010
  PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
886
1011
  };
887
1012
  async function guardProviderCall(call, signal) {
@@ -962,14 +1087,14 @@ function resolveOpenAiCompatibleKey(customEnvVar) {
962
1087
  }
963
1088
  return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
964
1089
  }
1090
+ function toMutableRequest(request) {
1091
+ return request;
1092
+ }
965
1093
  function createDefaultClient() {
966
1094
  const sdk = new Anthropic__default.default({ apiKey: requireAnthropicKey(), logLevel: "off" });
967
1095
  return {
968
1096
  messages: {
969
- create: async (body, options) => await sdk.messages.create(
970
- body,
971
- options
972
- )
1097
+ create: async (body, options) => await sdk.messages.create(toMutableRequest(body), options)
973
1098
  }
974
1099
  };
975
1100
  }
@@ -1387,7 +1512,7 @@ function createDefaultClient3() {
1387
1512
  models: {
1388
1513
  generateContent: (request) => withGeminiRetry(
1389
1514
  async () => await ai.models.generateContent(
1390
- request
1515
+ toMutableRequest(request)
1391
1516
  ),
1392
1517
  request.config.abortSignal
1393
1518
  )
@@ -1551,7 +1676,7 @@ function createDefaultClient4() {
1551
1676
  chat: {
1552
1677
  completions: {
1553
1678
  create: async (body, options) => await sdk.chat.completions.create(
1554
- body,
1679
+ toMutableRequest(body),
1555
1680
  options
1556
1681
  )
1557
1682
  }
@@ -1752,7 +1877,7 @@ function createDefaultClient5(config) {
1752
1877
  chat: {
1753
1878
  completions: {
1754
1879
  create: async (body, options) => await sdk.chat.completions.create(
1755
- body,
1880
+ toMutableRequest(body),
1756
1881
  options
1757
1882
  )
1758
1883
  }
@@ -1834,7 +1959,13 @@ var verbatraConfigSchema = zod.z.strictObject({
1834
1959
  targetLocales: zod.z.array(zod.z.string().min(1)).min(1),
1835
1960
  format: supportedFormatSchema,
1836
1961
  files: zod.z.strictObject({
1837
- pattern: zod.z.string().min(1)
1962
+ pattern: zod.z.string().min(1),
1963
+ /**
1964
+ * How the `{locale}` token in `pattern` is spelled for each locale. Absent means `"literal"`,
1965
+ * the configured tag verbatim, which is what every layout except Android and the POSIX
1966
+ * underscore layouts needs. See `LocaleStyle` for what each style produces.
1967
+ */
1968
+ localeStyle: zod.z.enum(LOCALE_STYLES).optional()
1838
1969
  }),
1839
1970
  provider: providerConfigSchema,
1840
1971
  /**
@@ -1855,8 +1986,9 @@ var verbatraConfigSchema = zod.z.strictObject({
1855
1986
  * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
1856
1987
  * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
1857
1988
  * requires but the source does not supply (for example Polish few/many). The per-run
1858
- * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
1859
- * unknown language) fall back to the per-locale plural warning.
1989
+ * `generatePlurals` option on `translate` overrides this. A plural form the target file already holds
1990
+ * is adopted, never generated over. Unsupported cases (DeepL, non-i18next, an unknown language) fall
1991
+ * back to the per-locale plural warning.
1860
1992
  */
1861
1993
  generatePlurals: zod.z.boolean().optional(),
1862
1994
  /**
@@ -1949,7 +2081,7 @@ async function loadExplicitWithMeta(explorer, configPath, cwd, fs$1) {
1949
2081
  try {
1950
2082
  result = await explorer.load(resolved);
1951
2083
  } catch (error) {
1952
- const detail = error instanceof Error ? error.message : String(error);
2084
+ const detail = errorMessage(error);
1953
2085
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1954
2086
  }
1955
2087
  const parsed = parseConfig(result?.config);
@@ -1974,7 +2106,7 @@ async function loadConfigWithMeta(options = {}) {
1974
2106
  try {
1975
2107
  result = await explorer.search(options.cwd);
1976
2108
  } catch (error) {
1977
- const detail = error instanceof Error ? error.message : String(error);
2109
+ const detail = errorMessage(error);
1978
2110
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1979
2111
  }
1980
2112
  if (result === null || result.isEmpty === true) {
@@ -1991,6 +2123,63 @@ async function loadConfig(options = {}) {
1991
2123
  const { config } = await loadConfigWithMeta(options);
1992
2124
  return config;
1993
2125
  }
2126
+ var DEFAULT_LOCALE_STYLE = "literal";
2127
+ function validatePattern(pattern, style) {
2128
+ if (!pattern.includes(LOCALE_TOKEN)) {
2129
+ throw new SdkError(
2130
+ "LOCALE_LAYOUT_INVALID",
2131
+ `The files pattern "${pattern}" must contain the ${LOCALE_TOKEN} token.`
2132
+ );
2133
+ }
2134
+ if (isSegmentStyle(style) && !tokenOccupiesWholeSegments(pattern)) {
2135
+ throw new SdkError(
2136
+ "LOCALE_LAYOUT_INVALID",
2137
+ `The "${style}" locale style expands ${LOCALE_TOKEN} to a whole path segment, so the token must stand alone between separators, but the pattern "${pattern}" embeds it in a segment.`
2138
+ );
2139
+ }
2140
+ }
2141
+ function safeSpelling(locale, style, sourceLocale) {
2142
+ const spelling = spellLocale(locale, style, locale === sourceLocale);
2143
+ if (spelling === void 0) {
2144
+ throw new SdkError(
2145
+ "LOCALE_LAYOUT_INVALID",
2146
+ `The "${style}" locale style has no valid path spelling for the locale "${locale}".`
2147
+ );
2148
+ }
2149
+ if (!isSafeSpelling(spelling)) {
2150
+ throw new SdkError(
2151
+ "LOCALE_LAYOUT_INVALID",
2152
+ `The locale "${locale}" expands to "${spelling}" under the "${style}" locale style, which is not a single path segment.`
2153
+ );
2154
+ }
2155
+ return spelling;
2156
+ }
2157
+ function buildForwardMap(config, pathFor) {
2158
+ const forward = /* @__PURE__ */ new Map();
2159
+ for (const locale of [config.sourceLocale, ...config.targetLocales]) {
2160
+ const path = pathFor(locale);
2161
+ const claimed = forward.get(path);
2162
+ if (claimed !== void 0 && claimed !== locale) {
2163
+ throw new SdkError(
2164
+ "LOCALE_PATH_COLLISION",
2165
+ `The locales "${claimed}" and "${locale}" both resolve to ${path}.`
2166
+ );
2167
+ }
2168
+ forward.set(path, locale);
2169
+ }
2170
+ return forward;
2171
+ }
2172
+ function createLocalePathResolver(cwd, config) {
2173
+ const style = config.files.localeStyle ?? DEFAULT_LOCALE_STYLE;
2174
+ const pattern = config.files.pattern;
2175
+ validatePattern(pattern, style);
2176
+ const pathFor = (locale) => path.resolve(cwd, expandPattern(pattern, safeSpelling(locale, style, config.sourceLocale)));
2177
+ const forward = buildForwardMap(config, pathFor);
2178
+ return {
2179
+ pathFor,
2180
+ localeFor: (absolutePath) => forward.get(path.resolve(cwd, absolutePath))
2181
+ };
2182
+ }
1994
2183
  var LOCAL_DIR_NAME = ".verbatra-local";
1995
2184
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1996
2185
  var DEFAULT_POLL_INTERVAL_MS = 100;
@@ -2059,15 +2248,15 @@ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
2059
2248
  if (await fs.createExclusive(path, lockPayload())) {
2060
2249
  return;
2061
2250
  }
2251
+ if (notify !== void 0) {
2252
+ await notify();
2253
+ }
2062
2254
  if (Date.now() >= deadline) {
2063
2255
  throw new SdkError(
2064
2256
  "LOCK_CONTENDED",
2065
2257
  `Could not acquire the write lock at ${path}: another process may be holding it. If no verbatra process is currently running, this lock file was likely left behind by one that was killed; delete it and retry.`
2066
2258
  );
2067
2259
  }
2068
- if (notify !== void 0) {
2069
- await notify();
2070
- }
2071
2260
  const jitter = Math.random() * pollIntervalMs;
2072
2261
  await sleep(pollIntervalMs + jitter);
2073
2262
  }
@@ -2657,6 +2846,9 @@ async function fsyncDirBestEffort(path) {
2657
2846
  }
2658
2847
  }
2659
2848
  var nodeOps = {
2849
+ mkdir: async (path) => {
2850
+ await promises.mkdir(path, { recursive: true });
2851
+ },
2660
2852
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
2661
2853
  fsyncFile: (path) => fsyncPath(path),
2662
2854
  rename: (from, to) => promises.rename(from, to),
@@ -2673,8 +2865,10 @@ function tempFileName2(path$1) {
2673
2865
  return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
2674
2866
  }
2675
2867
  async function atomicWriteFile(path$1, data, ops = nodeOps) {
2868
+ const directory = path.dirname(path$1);
2676
2869
  const tmp = tempFileName2(path$1);
2677
2870
  try {
2871
+ await ops.mkdir(directory);
2678
2872
  await ops.writeFile(tmp, data);
2679
2873
  await ops.fsyncFile(tmp);
2680
2874
  await ops.rename(tmp, path$1);
@@ -2683,7 +2877,7 @@ async function atomicWriteFile(path$1, data, ops = nodeOps) {
2683
2877
  throw error;
2684
2878
  }
2685
2879
  try {
2686
- await ops.fsyncDir(path.dirname(path$1));
2880
+ await ops.fsyncDir(directory);
2687
2881
  } catch {
2688
2882
  }
2689
2883
  }
@@ -4007,7 +4201,7 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
4007
4201
 
4008
4202
  // src/flow/read-target.ts
4009
4203
  async function readTargetResource(input) {
4010
- const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
4204
+ const path = input.resolver.pathFor(input.locale);
4011
4205
  if (!await input.fs.fileExists(path)) {
4012
4206
  return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
4013
4207
  }
@@ -4034,7 +4228,7 @@ function selectLocales(config, requested) {
4034
4228
 
4035
4229
  // src/flow/source.ts
4036
4230
  async function readSource(config, cwd, fs, adapter) {
4037
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
4231
+ const sourcePath = createLocalePathResolver(cwd, config).pathFor(config.sourceLocale);
4038
4232
  if (!await fs.fileExists(sourcePath)) {
4039
4233
  throw new SdkError(
4040
4234
  "SOURCE_UNREADABLE",
@@ -4044,7 +4238,7 @@ async function readSource(config, cwd, fs, adapter) {
4044
4238
  try {
4045
4239
  return await adapter.read(sourcePath, config.sourceLocale);
4046
4240
  } catch (error) {
4047
- const detail = error instanceof Error ? error.message : String(error);
4241
+ const detail = errorMessage(error);
4048
4242
  throw new SdkError(
4049
4243
  "SOURCE_INVALID",
4050
4244
  `The source locale file at ${sourcePath} could not be read: ${detail}`
@@ -4055,8 +4249,7 @@ async function readSource(config, cwd, fs, adapter) {
4055
4249
  // src/flow/diff-locales.ts
4056
4250
  async function readTarget(cwd, config, adapter, fs, locale) {
4057
4251
  return readTargetResource({
4058
- cwd,
4059
- filesPattern: config.files.pattern,
4252
+ resolver: createLocalePathResolver(cwd, config),
4060
4253
  format: config.format,
4061
4254
  locale,
4062
4255
  adapter,
@@ -4177,7 +4370,7 @@ async function editEntry(input, deps = {}) {
4177
4370
  }
4178
4371
  const merged = new Map(target.entries);
4179
4372
  merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
4180
- const path = localeFilePath(cwd, config.files.pattern, locale);
4373
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4181
4374
  await adapter.write(
4182
4375
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4183
4376
  path
@@ -4344,7 +4537,7 @@ function selectProvider(config, createProvider = buildProvider) {
4344
4537
  try {
4345
4538
  return createProvider(config);
4346
4539
  } catch (error) {
4347
- const detail = error instanceof Error ? error.message : String(error);
4540
+ const detail = errorMessage(error);
4348
4541
  throw new SdkError(
4349
4542
  "PROVIDER_CONSTRUCTION_FAILED",
4350
4543
  `Failed to construct provider "${config.id}": ${detail}`
@@ -4400,7 +4593,7 @@ async function retranslateEntry(input, deps = {}) {
4400
4593
  }
4401
4594
  const merged = new Map(target.entries);
4402
4595
  merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
4403
- const path = localeFilePath(cwd, config.files.pattern, locale);
4596
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4404
4597
  await adapter.write(
4405
4598
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4406
4599
  path
@@ -4567,13 +4760,6 @@ function budgetExceededNotice(tracker) {
4567
4760
  }
4568
4761
 
4569
4762
  // src/flow/locale-failure.ts
4570
- function describeError(error) {
4571
- if (error instanceof Error) {
4572
- const code = error.code;
4573
- return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
4574
- }
4575
- return { code: "LOCALE_FAILED", message: String(error) };
4576
- }
4577
4763
  function failureSummary(locale, error) {
4578
4764
  return {
4579
4765
  locale,
@@ -4593,7 +4779,7 @@ function failureSummary(locale, error) {
4593
4779
  unfilled: [],
4594
4780
  malformedRows: [],
4595
4781
  duplicateKeys: [],
4596
- error: describeError(error)
4782
+ error: describeError(error, "LOCALE_FAILED")
4597
4783
  };
4598
4784
  }
4599
4785
  function deriveLocaleStatus(parts) {
@@ -4826,6 +5012,9 @@ function syntheticEntry(item) {
4826
5012
  meaning: `CLDR plural category "${item.category}"`
4827
5013
  };
4828
5014
  }
5015
+ function isAdopted(item, targetKeys, baseline) {
5016
+ return targetKeys.has(item.targetKey) && !baseline.has(item.targetKey);
5017
+ }
4829
5018
  function staleItems(items, baseline) {
4830
5019
  return items.filter((item) => {
4831
5020
  const hash = generatedLockHash(item.governingEntries, item.category);
@@ -4845,7 +5034,10 @@ function buildRequest2(context, entries) {
4845
5034
  }
4846
5035
  async function generatePluralForms(context) {
4847
5036
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
4848
- const stale = staleItems(plan.items, context.baseline);
5037
+ const candidates = plan.items.filter(
5038
+ (item) => !isAdopted(item, context.targetKeys, context.baseline)
5039
+ );
5040
+ const stale = staleItems(candidates, context.baseline);
4849
5041
  if (stale.length === 0) {
4850
5042
  return EMPTY_RESULT;
4851
5043
  }
@@ -5027,10 +5219,15 @@ function buildRequest3(params, entries) {
5027
5219
  ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
5028
5220
  };
5029
5221
  }
5222
+ async function shouldWriteTarget(params, path, changed) {
5223
+ if (changed.accepted > 0 || changed.pruned > 0 || changed.generated > 0) {
5224
+ return true;
5225
+ }
5226
+ return !await params.fs.fileExists(path);
5227
+ }
5030
5228
  async function runLocale(params) {
5031
5229
  const target = await readTargetResource({
5032
- cwd: params.cwd,
5033
- filesPattern: params.filesPattern,
5230
+ resolver: params.resolver,
5034
5231
  format: params.format,
5035
5232
  locale: params.targetLocale,
5036
5233
  adapter: params.adapter,
@@ -5107,20 +5304,27 @@ async function runLocale(params) {
5107
5304
  merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
5108
5305
  }
5109
5306
  }
5110
- const generation = await runGeneration(params, provider);
5307
+ const generation = await runGeneration(params, provider, new Set(target.entries.keys()));
5111
5308
  for (const form of generation.accepted) {
5112
5309
  merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
5113
5310
  }
5114
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
5115
- await params.adapter.write(
5116
- {
5117
- locale: params.targetLocale,
5118
- namespace: target.namespace,
5119
- format: params.format,
5120
- entries: merged
5121
- },
5122
- path
5123
- );
5311
+ const path = params.resolver.pathFor(params.targetLocale);
5312
+ const writeNeeded = await shouldWriteTarget(params, path, {
5313
+ accepted: accepted.size,
5314
+ pruned: pruned.length,
5315
+ generated: generation.accepted.length
5316
+ });
5317
+ if (writeNeeded) {
5318
+ await params.adapter.write(
5319
+ {
5320
+ locale: params.targetLocale,
5321
+ namespace: target.namespace,
5322
+ format: params.format,
5323
+ entries: merged
5324
+ },
5325
+ path
5326
+ );
5327
+ }
5124
5328
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
5125
5329
  const notices = [
5126
5330
  ...pluralNotices,
@@ -5167,7 +5371,7 @@ var NO_GENERATION_RESULT = {
5167
5371
  usage: void 0,
5168
5372
  tripped: false
5169
5373
  };
5170
- async function runGeneration(params, provider) {
5374
+ async function runGeneration(params, provider, targetKeys) {
5171
5375
  if (!params.generatePlurals || provider.kind !== "llm") {
5172
5376
  return NO_GENERATION_RESULT;
5173
5377
  }
@@ -5181,6 +5385,7 @@ async function runGeneration(params, provider) {
5181
5385
  glossary: params.glossary,
5182
5386
  tone: params.tone,
5183
5387
  baseline: params.baseline,
5388
+ targetKeys,
5184
5389
  maxBatchSize: params.maxBatchSize,
5185
5390
  budget: params.budget
5186
5391
  });
@@ -5423,7 +5628,7 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
5423
5628
  adapter: context.adapter,
5424
5629
  provider: context.provider,
5425
5630
  cwd: context.cwd,
5426
- filesPattern: context.config.files.pattern,
5631
+ resolver: context.resolver,
5427
5632
  sourceLocale: context.config.sourceLocale,
5428
5633
  targetLocale,
5429
5634
  format: context.config.format,
@@ -5577,6 +5782,7 @@ async function translate2(input, deps = {}) {
5577
5782
  config.maxTokens,
5578
5783
  config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
5579
5784
  );
5785
+ const resolver = createLocalePathResolver(cwd, config);
5580
5786
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
5581
5787
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
5582
5788
  const source = await readSource(config, cwd, fs, adapter);
@@ -5587,6 +5793,7 @@ async function translate2(input, deps = {}) {
5587
5793
  provider,
5588
5794
  cwd,
5589
5795
  config,
5796
+ resolver,
5590
5797
  prune,
5591
5798
  generatePlurals,
5592
5799
  maxBatchSize,
@@ -5627,6 +5834,123 @@ var ExchangeError = class extends Error {
5627
5834
  this.code = code;
5628
5835
  }
5629
5836
  };
5837
+ var DELIMITER = {
5838
+ csv: ",",
5839
+ tsv: " "
5840
+ };
5841
+ var FORBIDDEN_FILE_NAME_CHARS = /[\\/:*?"<>|\p{Cc}]/u;
5842
+ function assertPlainFileLocale(locale) {
5843
+ if (locale.length === 0 || locale === "." || locale === "..") {
5844
+ throw new ExchangeError(
5845
+ "WORKBOOK_INVALID",
5846
+ `The locale "${locale}" cannot be an interchange file name: it must name a file, not a directory.`
5847
+ );
5848
+ }
5849
+ if (FORBIDDEN_FILE_NAME_CHARS.test(locale)) {
5850
+ throw new ExchangeError(
5851
+ "WORKBOOK_INVALID",
5852
+ `The locale "${locale}" cannot be an interchange file name: it must not contain a path separator, a control character, or any of : * ? " < > | .`
5853
+ );
5854
+ }
5855
+ }
5856
+ function delimitedFileName(locale, format) {
5857
+ assertPlainFileLocale(locale);
5858
+ return `${locale}.${format}`;
5859
+ }
5860
+ var COLUMN = {
5861
+ key: 1,
5862
+ source: 2,
5863
+ current: 3,
5864
+ status: 4,
5865
+ translation: 5,
5866
+ sourceHash: 6,
5867
+ context: 7,
5868
+ reviewStatus: 8,
5869
+ reviewReasons: 9
5870
+ };
5871
+ var HEADERS = [
5872
+ "Key",
5873
+ "Source",
5874
+ "Current translation",
5875
+ "Status",
5876
+ "Translation",
5877
+ "Source hash",
5878
+ "Context",
5879
+ "Review status",
5880
+ "Review reasons"
5881
+ ];
5882
+ var HEADER_ROW = 1;
5883
+ var INSTRUCTIONS_SHEET_NAME = "Instructions";
5884
+ var rowSchema = zod.z.object({
5885
+ key: zod.z.string().min(1),
5886
+ source: zod.z.string(),
5887
+ currentTarget: zod.z.string(),
5888
+ status: zod.z.enum(["new", "changed", "unchanged"]),
5889
+ sourceHash: zod.z.string(),
5890
+ translation: zod.z.string(),
5891
+ context: zod.z.string(),
5892
+ reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
5893
+ reviewReasons: zod.z.string().catch("")
5894
+ });
5895
+ var MALFORMED_ROW_COLUMN = "Status";
5896
+ function cellAt(cells, column) {
5897
+ return cells[column - 1] ?? "";
5898
+ }
5899
+ function parseRowCells(cells) {
5900
+ const result = rowSchema.safeParse({
5901
+ key: cellAt(cells, COLUMN.key),
5902
+ source: cellAt(cells, COLUMN.source),
5903
+ currentTarget: cellAt(cells, COLUMN.current),
5904
+ status: cellAt(cells, COLUMN.status),
5905
+ sourceHash: cellAt(cells, COLUMN.sourceHash),
5906
+ translation: cellAt(cells, COLUMN.translation).trim(),
5907
+ context: cellAt(cells, COLUMN.context),
5908
+ reviewStatus: cellAt(cells, COLUMN.reviewStatus),
5909
+ reviewReasons: cellAt(cells, COLUMN.reviewReasons)
5910
+ });
5911
+ if (!result.success) {
5912
+ return { ok: false, column: MALFORMED_ROW_COLUMN };
5913
+ }
5914
+ return { ok: true, row: result.data };
5915
+ }
5916
+ function rowCells(row) {
5917
+ const cells = new Array(HEADERS.length).fill("");
5918
+ cells[COLUMN.key - 1] = row.key;
5919
+ cells[COLUMN.source - 1] = row.source;
5920
+ cells[COLUMN.current - 1] = row.currentTarget;
5921
+ cells[COLUMN.status - 1] = row.status;
5922
+ cells[COLUMN.translation - 1] = row.translation;
5923
+ cells[COLUMN.sourceHash - 1] = row.sourceHash;
5924
+ cells[COLUMN.context - 1] = row.context;
5925
+ cells[COLUMN.reviewStatus - 1] = row.reviewStatus;
5926
+ cells[COLUMN.reviewReasons - 1] = row.reviewReasons;
5927
+ return cells;
5928
+ }
5929
+ var QUOTE = '"';
5930
+ var ESCAPED_QUOTE = '""';
5931
+ var LINE_BREAK = "\n";
5932
+ var UTF8_BOM = "\uFEFF";
5933
+ function needsQuoting(value, delimiter) {
5934
+ return value.includes(delimiter) || value.includes(QUOTE) || value.includes("\n") || value.includes("\r") || value !== value.trim();
5935
+ }
5936
+ function encodeField(value, delimiter) {
5937
+ if (!needsQuoting(value, delimiter)) {
5938
+ return value;
5939
+ }
5940
+ return `${QUOTE}${value.replaceAll(QUOTE, ESCAPED_QUOTE)}${QUOTE}`;
5941
+ }
5942
+ function encodeRecord(fields, delimiter) {
5943
+ return fields.map((field) => encodeField(field, delimiter)).join(delimiter);
5944
+ }
5945
+ function buildDelimited(sheet, format) {
5946
+ const delimiter = DELIMITER[format];
5947
+ const records = [encodeRecord(HEADERS, delimiter)];
5948
+ for (const row of sheet.rows) {
5949
+ records.push(encodeRecord(rowCells(row), delimiter));
5950
+ }
5951
+ const text = `${records.join(LINE_BREAK)}${LINE_BREAK}`;
5952
+ return format === "csv" ? `${UTF8_BOM}${text}` : text;
5953
+ }
5630
5954
  var INSTRUCTIONS_LINES = [
5631
5955
  "How to use this workbook",
5632
5956
  "",
@@ -5655,30 +5979,6 @@ var INSTRUCTIONS_LINES = [
5655
5979
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
5656
5980
  "withheld (not written) and reported so you can fix and resubmit it."
5657
5981
  ];
5658
- var COLUMN = {
5659
- key: 1,
5660
- source: 2,
5661
- current: 3,
5662
- status: 4,
5663
- translation: 5,
5664
- sourceHash: 6,
5665
- context: 7,
5666
- reviewStatus: 8,
5667
- reviewReasons: 9
5668
- };
5669
- var HEADERS = [
5670
- "Key",
5671
- "Source",
5672
- "Current translation",
5673
- "Status",
5674
- "Translation",
5675
- "Source hash",
5676
- "Context",
5677
- "Review status",
5678
- "Review reasons"
5679
- ];
5680
- var HEADER_ROW = 1;
5681
- var INSTRUCTIONS_SHEET_NAME = "Instructions";
5682
5982
  var READ_ONLY_FILL = {
5683
5983
  type: "pattern",
5684
5984
  pattern: "solid",
@@ -5838,6 +6138,12 @@ async function buildWorkbook(model) {
5838
6138
  const protectedBytes = await protectWorkbookStructure(serialized);
5839
6139
  return Uint8Array.prototype.slice.call(protectedBytes);
5840
6140
  }
6141
+ var DEFAULT_DELIMITED_LIMITS = {
6142
+ maxInputBytes: 32 * 1024 * 1024,
6143
+ maxRowsPerFile: 1e5,
6144
+ maxFieldsPerRow: 64,
6145
+ maxFieldLength: 1024 * 1024
6146
+ };
5841
6147
  var DEFAULT_WORKBOOK_LIMITS = {
5842
6148
  maxDecompressedBytes: 64 * 1024 * 1024,
5843
6149
  maxEntryCount: 1024,
@@ -5845,6 +6151,205 @@ var DEFAULT_WORKBOOK_LIMITS = {
5845
6151
  maxRowsPerSheet: 1e5,
5846
6152
  maxCellsPerRow: 64
5847
6153
  };
6154
+ var QUOTE2 = '"';
6155
+ var HEADER_RECORD = 1;
6156
+ function scanQuotedField(text, start) {
6157
+ const parts = [];
6158
+ let cursor = start;
6159
+ for (; ; ) {
6160
+ const quote = text.indexOf(QUOTE2, cursor);
6161
+ if (quote === -1) {
6162
+ parts.push(text.slice(cursor));
6163
+ return { value: parts.join(""), next: text.length };
6164
+ }
6165
+ parts.push(text.slice(cursor, quote));
6166
+ if (text[quote + 1] !== QUOTE2) {
6167
+ return { value: parts.join(""), next: quote + 1 };
6168
+ }
6169
+ parts.push(QUOTE2);
6170
+ cursor = quote + 2;
6171
+ }
6172
+ }
6173
+ function scanPlainField(text, start, delimiter) {
6174
+ let cursor = start;
6175
+ while (cursor < text.length) {
6176
+ const char = text[cursor];
6177
+ if (char === delimiter || char === "\n" || char === "\r") {
6178
+ break;
6179
+ }
6180
+ cursor += 1;
6181
+ }
6182
+ return { value: text.slice(start, cursor), next: cursor };
6183
+ }
6184
+ function scanField(text, start, delimiter) {
6185
+ if (text[start] !== QUOTE2) {
6186
+ return scanPlainField(text, start, delimiter);
6187
+ }
6188
+ const quoted = scanQuotedField(text, start + 1);
6189
+ const trailing = scanPlainField(text, quoted.next, delimiter);
6190
+ return { value: `${quoted.value}${trailing.value}`, next: trailing.next };
6191
+ }
6192
+ function countLineBreaks(value) {
6193
+ let breaks = 0;
6194
+ for (let index = 0; index < value.length; index += 1) {
6195
+ const char = value[index];
6196
+ if (char === "\n") {
6197
+ breaks += 1;
6198
+ } else if (char === "\r") {
6199
+ breaks += 1;
6200
+ if (value[index + 1] === "\n") {
6201
+ index += 1;
6202
+ }
6203
+ }
6204
+ }
6205
+ return breaks;
6206
+ }
6207
+ function scanRecord(text, start, delimiter, limits) {
6208
+ const fields = [];
6209
+ let breaks = 0;
6210
+ let cursor = start;
6211
+ for (; ; ) {
6212
+ const field = scanField(text, cursor, delimiter);
6213
+ assertFieldLength(field.value, limits);
6214
+ fields.push(field.value);
6215
+ breaks += countLineBreaks(field.value);
6216
+ assertFieldCount(fields.length, limits);
6217
+ cursor = field.next;
6218
+ if (text[cursor] !== delimiter) {
6219
+ return { fields, breaks, next: cursor };
6220
+ }
6221
+ cursor += 1;
6222
+ }
6223
+ }
6224
+ function consumeLineBreak(text, cursor) {
6225
+ if (text[cursor] === "\r") {
6226
+ return text[cursor + 1] === "\n" ? cursor + 2 : cursor + 1;
6227
+ }
6228
+ return text[cursor] === "\n" ? cursor + 1 : cursor;
6229
+ }
6230
+ function scanRecords(text, delimiter, limits) {
6231
+ const records = [];
6232
+ let cursor = 0;
6233
+ let line = 1;
6234
+ while (cursor < text.length) {
6235
+ const record = scanRecord(text, cursor, delimiter, limits);
6236
+ records.push({ fields: record.fields, line });
6237
+ assertRecordCount(records.length, limits);
6238
+ const next = consumeLineBreak(text, record.next);
6239
+ if (next === record.next) {
6240
+ break;
6241
+ }
6242
+ line += record.breaks + 1;
6243
+ cursor = next;
6244
+ }
6245
+ return records;
6246
+ }
6247
+ function assertInputBytes(text, limits) {
6248
+ if (Buffer.byteLength(text, "utf8") > limits.maxInputBytes) {
6249
+ throw new ExchangeError(
6250
+ "WORKBOOK_INVALID",
6251
+ `The interchange file is larger than the maximum of ${limits.maxInputBytes} bytes.`
6252
+ );
6253
+ }
6254
+ }
6255
+ function assertFieldLength(value, limits) {
6256
+ if (value.length > limits.maxFieldLength) {
6257
+ throw new ExchangeError(
6258
+ "WORKBOOK_INVALID",
6259
+ `The interchange file has a field longer than the maximum of ${limits.maxFieldLength} characters.`
6260
+ );
6261
+ }
6262
+ }
6263
+ function assertFieldCount(count, limits) {
6264
+ if (count > limits.maxFieldsPerRow) {
6265
+ throw new ExchangeError(
6266
+ "WORKBOOK_INVALID",
6267
+ `The interchange file has a row with more than the maximum of ${limits.maxFieldsPerRow} fields.`
6268
+ );
6269
+ }
6270
+ }
6271
+ function assertRecordCount(count, limits) {
6272
+ if (count - HEADER_RECORD > limits.maxRowsPerFile) {
6273
+ throw new ExchangeError(
6274
+ "WORKBOOK_INVALID",
6275
+ `The interchange file has more than the maximum of ${limits.maxRowsPerFile} rows.`
6276
+ );
6277
+ }
6278
+ }
6279
+ function assertHeaderRecord(records) {
6280
+ const header = records[0]?.fields;
6281
+ if (header === void 0 || header.length !== HEADERS.length) {
6282
+ throw new ExchangeError(
6283
+ "WORKBOOK_INVALID",
6284
+ `The interchange file does not start with the expected header line of ${HEADERS.length} columns.`
6285
+ );
6286
+ }
6287
+ for (const [index, label] of HEADERS.entries()) {
6288
+ if (header[index] !== label) {
6289
+ throw new ExchangeError(
6290
+ "WORKBOOK_INVALID",
6291
+ `The interchange file's header line does not match the expected columns (column ${index + 1} should be "${label}").`
6292
+ );
6293
+ }
6294
+ }
6295
+ }
6296
+ function fieldCountColumn(fieldCount) {
6297
+ const label = HEADERS.at(Math.min(fieldCount, HEADERS.length - 1));
6298
+ if (label === void 0) {
6299
+ return MALFORMED_ROW_COLUMN;
6300
+ }
6301
+ return label;
6302
+ }
6303
+ function isBlankRecord(fields) {
6304
+ return fields.length === 1 && fields[0] === "";
6305
+ }
6306
+ function readRecord(fields, locale, at, into) {
6307
+ const { row, line } = at;
6308
+ if (isBlankRecord(fields)) {
6309
+ return;
6310
+ }
6311
+ if (fields.length !== HEADERS.length) {
6312
+ into.malformed.push({ locale, row, line, column: fieldCountColumn(fields.length) });
6313
+ return;
6314
+ }
6315
+ if (fields[COLUMN.key - 1] === "") {
6316
+ return;
6317
+ }
6318
+ const outcome = parseRowCells(fields);
6319
+ if (!outcome.ok) {
6320
+ into.malformed.push({ locale, row, line, column: outcome.column });
6321
+ return;
6322
+ }
6323
+ if (into.seenKeys.has(outcome.row.key)) {
6324
+ into.duplicates.push({ locale, key: outcome.row.key, row, line });
6325
+ return;
6326
+ }
6327
+ into.seenKeys.add(outcome.row.key);
6328
+ into.rows.push(outcome.row);
6329
+ }
6330
+ function readDelimited(input, options = {}) {
6331
+ const limits = options.limits ?? DEFAULT_DELIMITED_LIMITS;
6332
+ assertInputBytes(input.text, limits);
6333
+ const text = input.text.startsWith(UTF8_BOM) ? input.text.slice(UTF8_BOM.length) : input.text;
6334
+ const records = scanRecords(text, DELIMITER[input.format], limits);
6335
+ assertHeaderRecord(records);
6336
+ const into = {
6337
+ rows: [],
6338
+ malformed: [],
6339
+ duplicates: [],
6340
+ seenKeys: /* @__PURE__ */ new Set()
6341
+ };
6342
+ for (const [index, record] of records.entries()) {
6343
+ if (index >= HEADER_RECORD) {
6344
+ readRecord(record.fields, input.locale, { row: index + 1, line: record.line }, into);
6345
+ }
6346
+ }
6347
+ return {
6348
+ sheets: [{ locale: input.locale, rows: into.rows }],
6349
+ malformedRows: into.malformed,
6350
+ duplicateKeys: into.duplicates
6351
+ };
6352
+ }
5848
6353
  function assertNoDoctype2(name, xml) {
5849
6354
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
5850
6355
  throw new ExchangeError(
@@ -5921,17 +6426,6 @@ async function guardWorkbookBytes(bytes, limits) {
5921
6426
  assertNoDoctype2(file.name, content);
5922
6427
  }
5923
6428
  }
5924
- var rowSchema = zod.z.object({
5925
- key: zod.z.string().min(1),
5926
- source: zod.z.string(),
5927
- currentTarget: zod.z.string(),
5928
- status: zod.z.enum(["new", "changed", "unchanged"]),
5929
- sourceHash: zod.z.string(),
5930
- translation: zod.z.string(),
5931
- context: zod.z.string(),
5932
- reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
5933
- reviewReasons: zod.z.string().catch("")
5934
- });
5935
6429
  function cellString(cell) {
5936
6430
  const value = cell.value;
5937
6431
  if (value === null || value === void 0) {
@@ -5956,24 +6450,8 @@ function assertHeader(sheet) {
5956
6450
  );
5957
6451
  }
5958
6452
  }
5959
- var MALFORMED_ROW_COLUMN = "Status";
5960
6453
  function parseRow(row) {
5961
- const candidate = {
5962
- key: cellString(row.getCell(COLUMN.key)),
5963
- source: cellString(row.getCell(COLUMN.source)),
5964
- currentTarget: cellString(row.getCell(COLUMN.current)),
5965
- status: cellString(row.getCell(COLUMN.status)),
5966
- sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
5967
- translation: cellString(row.getCell(COLUMN.translation)).trim(),
5968
- context: cellString(row.getCell(COLUMN.context)),
5969
- reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
5970
- reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
5971
- };
5972
- const result = rowSchema.safeParse(candidate);
5973
- if (!result.success) {
5974
- return { ok: false, column: MALFORMED_ROW_COLUMN };
5975
- }
5976
- return { ok: true, row: result.data };
6454
+ return parseRowCells(HEADERS.map((_, index) => cellString(row.getCell(index + 1))));
5977
6455
  }
5978
6456
  function readDataSheet(sheet, limits) {
5979
6457
  assertHeader(sheet);
@@ -6047,8 +6525,52 @@ async function readWorkbook(bytes, options = {}) {
6047
6525
  return { sheets, malformedRows, duplicateKeys };
6048
6526
  }
6049
6527
 
6528
+ // src/flow/workbook/exchange-format.ts
6529
+ function isDelimitedFormat(format) {
6530
+ return format === "csv" || format === "tsv";
6531
+ }
6532
+ var MANIFEST_VERSION = 1;
6533
+ var MAX_MANIFEST_BYTES = 1024 * 1024;
6534
+ function exportManifestFileName(format) {
6535
+ return `.verbatra-export-${format}.json`;
6536
+ }
6537
+ var manifestSchema = zod.z.object({
6538
+ version: zod.z.number().int().positive(),
6539
+ format: zod.z.enum(["csv", "tsv"]),
6540
+ locales: zod.z.array(zod.z.string())
6541
+ });
6542
+ async function writeExportManifest(fs, directory, format, locales) {
6543
+ const manifest = { version: MANIFEST_VERSION, format, locales: [...locales] };
6544
+ await fs.writeFile(
6545
+ path.join(directory, exportManifestFileName(format)),
6546
+ `${JSON.stringify(manifest, null, 2)}
6547
+ `
6548
+ );
6549
+ }
6550
+ async function readExportedLocales(fs, directory, format) {
6551
+ const read = await fs.readFileBounded(
6552
+ path.join(directory, exportManifestFileName(format)),
6553
+ MAX_MANIFEST_BYTES
6554
+ );
6555
+ if (read.kind !== "ok") {
6556
+ return void 0;
6557
+ }
6558
+ let parsed;
6559
+ try {
6560
+ parsed = JSON.parse(read.content);
6561
+ } catch {
6562
+ return void 0;
6563
+ }
6564
+ const result = manifestSchema.safeParse(parsed);
6565
+ if (!result.success || result.data.version !== MANIFEST_VERSION || result.data.format !== format) {
6566
+ return void 0;
6567
+ }
6568
+ return new Set(result.data.locales);
6569
+ }
6570
+
6050
6571
  // src/flow/workbook/export-workbook.ts
6051
6572
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
6573
+ var DEFAULT_DELIMITED_PATH = "verbatra-translations";
6052
6574
  function reasonLabel(reason) {
6053
6575
  return reason.toLowerCase().replace(/_/g, "-");
6054
6576
  }
@@ -6112,6 +6634,21 @@ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary
6112
6634
  }
6113
6635
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
6114
6636
  }
6637
+ async function writeDelimitedFiles(fs, directory, format, sheets) {
6638
+ await fs.mkdir?.(directory);
6639
+ for (const sheet of sheets) {
6640
+ await fs.writeFile(
6641
+ path.join(directory, delimitedFileName(sheet.locale, format)),
6642
+ buildDelimited(sheet, format)
6643
+ );
6644
+ }
6645
+ await writeExportManifest(
6646
+ fs,
6647
+ directory,
6648
+ format,
6649
+ sheets.map((sheet) => sheet.locale)
6650
+ );
6651
+ }
6115
6652
  async function exportWorkbook(input, deps = {}) {
6116
6653
  const config = input.config;
6117
6654
  const cwd = input.cwd ?? process.cwd();
@@ -6134,10 +6671,17 @@ async function exportWorkbook(input, deps = {}) {
6134
6671
  return { locale, rows };
6135
6672
  })
6136
6673
  );
6137
- const model = { sheets };
6138
- const bytes = await buildWorkbook(model);
6139
- const path$1 = path.resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
6140
- await fs.writeBytes(path$1, bytes);
6674
+ const format = input.format ?? "xlsx";
6675
+ const path$1 = path.resolve(
6676
+ cwd,
6677
+ input.out ?? (isDelimitedFormat(format) ? DEFAULT_DELIMITED_PATH : DEFAULT_WORKBOOK_PATH)
6678
+ );
6679
+ if (isDelimitedFormat(format)) {
6680
+ await writeDelimitedFiles(fs, path$1, format, sheets);
6681
+ } else {
6682
+ const model = { sheets };
6683
+ await fs.writeBytes(path$1, await buildWorkbook(model));
6684
+ }
6141
6685
  return {
6142
6686
  path: path$1,
6143
6687
  locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
@@ -6266,6 +6810,7 @@ function importLocale(params) {
6266
6810
 
6267
6811
  // src/flow/workbook/import-workbook.ts
6268
6812
  var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
6813
+ var MAX_DELIMITED_FILE_BYTES = 32 * 1024 * 1024;
6269
6814
  async function readWorkbookBytes(path, fs) {
6270
6815
  const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
6271
6816
  if (read.kind === "missing") {
@@ -6279,6 +6824,75 @@ async function readWorkbookBytes(path, fs) {
6279
6824
  }
6280
6825
  return read.bytes;
6281
6826
  }
6827
+ async function readDelimitedText(path, fs) {
6828
+ const read = await fs.readFileBounded(path, MAX_DELIMITED_FILE_BYTES);
6829
+ if (read.kind === "missing") {
6830
+ return void 0;
6831
+ }
6832
+ if (read.kind === "too-large") {
6833
+ throw new SdkError(
6834
+ "SOURCE_INVALID",
6835
+ `The interchange file at ${path} exceeds the maximum allowed size of ${MAX_DELIMITED_FILE_BYTES} bytes.`
6836
+ );
6837
+ }
6838
+ return read.content;
6839
+ }
6840
+ async function collectDelimitedSources(path$1, config, fs, format) {
6841
+ const single = await readDelimitedText(path$1, fs);
6842
+ if (single !== void 0) {
6843
+ return { sources: [{ locale: path.basename(path$1, `.${format}`), text: single }], staleLocales: [] };
6844
+ }
6845
+ const exported = await readExportedLocales(fs, path$1, format);
6846
+ const sources = [];
6847
+ const staleLocales = [];
6848
+ for (const locale of config.targetLocales) {
6849
+ const text = await readDelimitedText(path.join(path$1, delimitedFileName(locale, format)), fs);
6850
+ if (text === void 0) {
6851
+ continue;
6852
+ }
6853
+ if (exported !== void 0 && !exported.has(locale)) {
6854
+ staleLocales.push(locale);
6855
+ continue;
6856
+ }
6857
+ sources.push({ locale, text });
6858
+ }
6859
+ if (sources.length === 0 && staleLocales.length === 0) {
6860
+ throw new SdkError(
6861
+ "SOURCE_UNREADABLE",
6862
+ `No ${format} file was found at ${path$1}, and it holds no <locale>.${format} file for any configured target locale.`
6863
+ );
6864
+ }
6865
+ return { sources, staleLocales };
6866
+ }
6867
+ function parseDelimitedSources(sources, format) {
6868
+ const sheets = [];
6869
+ const malformedRows = [];
6870
+ const duplicateKeys = [];
6871
+ for (const source of sources) {
6872
+ const data = readDelimited({ text: source.text, locale: source.locale, format });
6873
+ sheets.push(...data.sheets);
6874
+ malformedRows.push(...data.malformedRows);
6875
+ duplicateKeys.push(...data.duplicateKeys);
6876
+ }
6877
+ return { sheets, malformedRows, duplicateKeys };
6878
+ }
6879
+ async function readImportData(path, config, fs, format) {
6880
+ try {
6881
+ if (isDelimitedFormat(format)) {
6882
+ const handoff = await collectDelimitedSources(path, config, fs, format);
6883
+ return {
6884
+ data: parseDelimitedSources(handoff.sources, format),
6885
+ staleLocales: handoff.staleLocales
6886
+ };
6887
+ }
6888
+ return { data: await readWorkbook(await readWorkbookBytes(path, fs)), staleLocales: [] };
6889
+ } catch (error) {
6890
+ if (error instanceof SdkError) {
6891
+ throw error;
6892
+ }
6893
+ throw new SdkError("SOURCE_INVALID", errorMessage(error));
6894
+ }
6895
+ }
6282
6896
  function mergeAccepted(target, accepted) {
6283
6897
  const merged = new Map(target.entries);
6284
6898
  for (const [key, { value, source }] of accepted) {
@@ -6319,18 +6933,43 @@ function computeLockEntries2(source, merged, baseline, accepted) {
6319
6933
  }
6320
6934
  var MissingSheetError = class extends Error {
6321
6935
  code = "WORKBOOK_SHEET_MISSING";
6322
- constructor(locale) {
6936
+ constructor(locale, format) {
6323
6937
  super(
6324
- `The workbook has no sheet (tab) for the configured target locale "${locale}". The tab may have been renamed, deleted, or reordered out of the workbook.`
6938
+ isDelimitedFormat(format) ? `The handoff has no "${delimitedFileName(locale, format)}" file for the configured target locale "${locale}". The file may have been renamed, deleted, or left out of the directory.` : `The workbook has no sheet (tab) for the configured target locale "${locale}". The tab may have been renamed, deleted, or reordered out of the workbook.`
6325
6939
  );
6326
6940
  this.name = "MissingSheetError";
6327
6941
  }
6328
6942
  };
6943
+ var StaleHandoffFileError = class extends Error {
6944
+ code = "HANDOFF_FILE_STALE";
6945
+ constructor(locale, format) {
6946
+ super(
6947
+ `The file "${delimitedFileName(locale, format)}" is left over from an earlier export that included the target locale "${locale}"; the most recent export into this directory did not. Its rows were not applied, because they reflect that earlier run. Re-export the locale to refresh the file, or delete it.`
6948
+ );
6949
+ this.name = "StaleHandoffFileError";
6950
+ }
6951
+ };
6952
+ function absentLocaleFailures(config, sheets, format, staleLocales) {
6953
+ const present = new Set(sheets.map((sheet) => sheet.locale));
6954
+ const stale = new Set(staleLocales);
6955
+ const failures = [];
6956
+ for (const locale of config.targetLocales) {
6957
+ if (present.has(locale)) {
6958
+ continue;
6959
+ }
6960
+ const error = stale.has(locale) && isDelimitedFormat(format) ? new StaleHandoffFileError(locale, format) : new MissingSheetError(locale, format);
6961
+ failures.push(failureSummary(locale, error));
6962
+ }
6963
+ return failures;
6964
+ }
6965
+ function lineOf(reported) {
6966
+ return reported.line === void 0 ? {} : { line: reported.line };
6967
+ }
6329
6968
  async function runSheet(ctx, sheet, lock) {
6330
6969
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
6331
6970
  throw new SdkError(
6332
6971
  "CONFIG_INVALID",
6333
- `The workbook has a sheet named "${sheet.locale}", which is not a configured target locale. It may be a renamed, added, or reordered tab; leave every language tab named exactly as exported.`
6972
+ isDelimitedFormat(ctx.format) ? `The handoff has a file named "${sheet.locale}.${ctx.format}", whose locale is not a configured target locale. Name every interchange file exactly as it was exported.` : `The workbook has a sheet named "${sheet.locale}", which is not a configured target locale. It may be a renamed, added, or reordered tab; leave every language tab named exactly as exported.`
6334
6973
  );
6335
6974
  }
6336
6975
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -6342,15 +6981,15 @@ async function runSheet(ctx, sheet, lock) {
6342
6981
  baseline,
6343
6982
  adapter: ctx.adapter,
6344
6983
  sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6345
- malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6346
- duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row }))
6984
+ malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column, ...lineOf(problem) })),
6985
+ duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row, ...lineOf(duplicate) }))
6347
6986
  });
6348
6987
  if (ctx.dryRun) {
6349
6988
  return { summary, lockEntries: {}, cacheAdditions: {} };
6350
6989
  }
6351
6990
  const merged = mergeAccepted(target, accepted);
6352
6991
  if (accepted.size > 0) {
6353
- const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
6992
+ const path = createLocalePathResolver(ctx.cwd, ctx.config).pathFor(sheet.locale);
6354
6993
  await ctx.adapter.write(
6355
6994
  {
6356
6995
  locale: sheet.locale,
@@ -6374,14 +7013,13 @@ async function importWorkbook(input, deps = {}) {
6374
7013
  const fs = deps.fs ?? defaultFs;
6375
7014
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
6376
7015
  const source = await readSource(config, cwd, fs, adapter);
6377
- const workbookPath = path.resolve(cwd, input.workbook);
6378
- const bytes = await readWorkbookBytes(workbookPath, fs);
6379
- let data;
6380
- try {
6381
- data = await readWorkbook(bytes);
6382
- } catch (error) {
6383
- throw new SdkError("SOURCE_INVALID", error.message);
6384
- }
7016
+ const format = input.format ?? "xlsx";
7017
+ const { data, staleLocales } = await readImportData(
7018
+ path.resolve(cwd, input.workbook),
7019
+ config,
7020
+ fs,
7021
+ format
7022
+ );
6385
7023
  const lock = await readLockFile(lockFilePath(cwd), fs);
6386
7024
  const ctx = {
6387
7025
  config,
@@ -6392,7 +7030,8 @@ async function importWorkbook(input, deps = {}) {
6392
7030
  sourceInvalidIcuKeys: source.invalidIcuKeys,
6393
7031
  dryRun,
6394
7032
  malformedRows: data.malformedRows,
6395
- duplicateKeys: data.duplicateKeys
7033
+ duplicateKeys: data.duplicateKeys,
7034
+ format
6396
7035
  };
6397
7036
  const summaries = [];
6398
7037
  const cacheAdditions = /* @__PURE__ */ new Map();
@@ -6417,12 +7056,7 @@ async function importWorkbook(input, deps = {}) {
6417
7056
  summaries.push(failureSummary(sheet.locale, error));
6418
7057
  }
6419
7058
  }
6420
- const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6421
- for (const locale of config.targetLocales) {
6422
- if (!presentLocales.has(locale)) {
6423
- summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6424
- }
6425
- }
7059
+ summaries.push(...absentLocaleFailures(config, data.sheets, format, staleLocales));
6426
7060
  if (!dryRun) {
6427
7061
  await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6428
7062
  }
@@ -6466,19 +7100,13 @@ function defaultRunTranslate(deps) {
6466
7100
 
6467
7101
  // src/watch/watch.ts
6468
7102
  var DEFAULT_DEBOUNCE_MS = 300;
6469
- function describeError2(error) {
6470
- if (error instanceof Error) {
6471
- const code = error.code;
6472
- return { code: typeof code === "string" ? code : "WATCH_RUN_FAILED", message: error.message };
6473
- }
6474
- return { code: "WATCH_RUN_FAILED", message: String(error) };
6475
- }
6476
7103
  async function watch(input, deps = {}) {
6477
7104
  const cwd = input.cwd ?? process.cwd();
6478
7105
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6479
7106
  const fs = deps.fs ?? defaultFs;
6480
7107
  resolveRunConcurrency(input.concurrency, false, input.config);
6481
- const sourcePath = localeFilePath(cwd, input.config.files.pattern, input.config.sourceLocale);
7108
+ const resolver = createLocalePathResolver(cwd, input.config);
7109
+ const sourcePath = resolver.pathFor(input.config.sourceLocale);
6482
7110
  if (!await fs.fileExists(sourcePath)) {
6483
7111
  throw new SdkError(
6484
7112
  "SOURCE_UNREADABLE",
@@ -6504,7 +7132,7 @@ async function watch(input, deps = {}) {
6504
7132
  try {
6505
7133
  input.onRun({ status: "succeeded", summary: await runTranslate(runInput) });
6506
7134
  } catch (error) {
6507
- input.onRun({ status: "failed", error: describeError2(error) });
7135
+ input.onRun({ status: "failed", error: describeError(error, "WATCH_RUN_FAILED") });
6508
7136
  }
6509
7137
  }
6510
7138
  function startRun() {
@@ -6562,10 +7190,12 @@ async function watch(input, deps = {}) {
6562
7190
  }
6563
7191
 
6564
7192
  exports.CACHE_FILE_NAME = CACHE_FILE_NAME;
7193
+ exports.DEFAULT_DELIMITED_PATH = DEFAULT_DELIMITED_PATH;
6565
7194
  exports.DEFAULT_WORKBOOK_PATH = DEFAULT_WORKBOOK_PATH;
6566
7195
  exports.LOCK_FILE_NAME = LOCK_FILE_NAME;
6567
7196
  exports.SdkError = SdkError;
6568
7197
  exports.check = check;
7198
+ exports.createLocalePathResolver = createLocalePathResolver;
6569
7199
  exports.defineConfig = defineConfig;
6570
7200
  exports.diff = diff;
6571
7201
  exports.diffLocaleSnapshots = diffLocaleSnapshots;