@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.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from 'fs';
4
4
  import { cosmiconfig } from 'cosmiconfig';
5
5
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
6
6
  import { randomUUID } from 'crypto';
7
- import { rm, access, mkdir, open, writeFile, rename } from 'fs/promises';
7
+ import { mkdir, rm, access, open, writeFile, rename } from 'fs/promises';
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
9
  import * as deepl from 'deepl-node';
10
10
  import { createRequire } from 'module';
@@ -122,6 +122,18 @@ function defineConfig(config) {
122
122
  }
123
123
 
124
124
  // src/errors.ts
125
+ function errorMessage(error) {
126
+ return error instanceof Error ? error.message : String(error);
127
+ }
128
+ function stringCode(error) {
129
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
130
+ return error.code;
131
+ }
132
+ return void 0;
133
+ }
134
+ function describeError(error, fallbackCode) {
135
+ return { code: stringCode(error) ?? fallbackCode, message: errorMessage(error) };
136
+ }
125
137
  var SdkError = class extends Error {
126
138
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
127
139
  code;
@@ -246,6 +258,9 @@ var defaultFs = {
246
258
  createExclusive: (path, data) => createExclusive(path, data),
247
259
  deleteFile: async (path) => {
248
260
  await rm(path, { force: true });
261
+ },
262
+ mkdir: async (path) => {
263
+ await mkdir(path, { recursive: true });
249
264
  }
250
265
  };
251
266
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
@@ -484,9 +499,109 @@ function assessValueDegeneracy(sourceValue, candidateValue) {
484
499
  }
485
500
  return { degenerate: hasRunawayRepetition(candidate) };
486
501
  }
502
+
503
+ // src/locale-path/pattern.ts
487
504
  var LOCALE_TOKEN = "{locale}";
488
- function localeFilePath(cwd, pattern, locale) {
489
- return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
505
+ var SEPARATOR = /[/\\]/;
506
+ function tokenOccupiesWholeSegments(pattern) {
507
+ return pattern.split(SEPARATOR).every((segment) => segment === LOCALE_TOKEN || !segment.includes(LOCALE_TOKEN));
508
+ }
509
+ function expandPattern(pattern, spelling) {
510
+ return pattern.replaceAll(LOCALE_TOKEN, () => spelling);
511
+ }
512
+
513
+ // src/locale-path/tag.ts
514
+ var LANGUAGE = /^[a-z]{2,3}$/;
515
+ var SCRIPT = /^[a-z]{4}$/;
516
+ var REGION = /^([a-z]{2}|[0-9]{3})$/;
517
+ var NUMERIC_REGION = /^[0-9]{3}$/;
518
+ var VARIANT = /^([a-z0-9]{5,8}|[0-9][a-z0-9]{3})$/;
519
+ function subtagIf(subtags, index, pattern) {
520
+ const subtag = subtags[index];
521
+ return subtag !== void 0 && pattern.test(subtag) ? subtag : void 0;
522
+ }
523
+ function parseLocaleTag(locale) {
524
+ const subtags = locale.toLowerCase().split("-");
525
+ const language = subtags[0];
526
+ if (language === void 0 || !LANGUAGE.test(language)) {
527
+ return void 0;
528
+ }
529
+ let index = 1;
530
+ const script = subtagIf(subtags, index, SCRIPT);
531
+ index += script === void 0 ? 0 : 1;
532
+ const region = subtagIf(subtags, index, REGION);
533
+ index += region === void 0 ? 0 : 1;
534
+ const variants = subtags.slice(index);
535
+ if (!variants.every((variant) => VARIANT.test(variant))) {
536
+ return void 0;
537
+ }
538
+ return { language, script, region, variants };
539
+ }
540
+ function conventionalSubtags(tag) {
541
+ const subtags = [tag.language];
542
+ if (tag.script !== void 0) {
543
+ subtags.push(tag.script.charAt(0).toUpperCase() + tag.script.slice(1));
544
+ }
545
+ if (tag.region !== void 0) {
546
+ subtags.push(tag.region.toUpperCase());
547
+ }
548
+ subtags.push(...tag.variants);
549
+ return subtags;
550
+ }
551
+
552
+ // src/locale-path/android.ts
553
+ var SOURCE_SEGMENT = "values";
554
+ var MAX_BCP47_SUBTAGS = 4;
555
+ var UI_MODE_LANGUAGE_COLLISION = "car";
556
+ function needsBcp47(tag, subtagCount) {
557
+ 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;
558
+ }
559
+ function legacySegment(tag) {
560
+ return tag.region === void 0 ? `${SOURCE_SEGMENT}-${tag.language}` : `${SOURCE_SEGMENT}-${tag.language}-r${tag.region.toUpperCase()}`;
561
+ }
562
+ function androidSegment(locale, isSourceLocale) {
563
+ if (isSourceLocale) {
564
+ return SOURCE_SEGMENT;
565
+ }
566
+ const tag = parseLocaleTag(locale);
567
+ if (tag === void 0) {
568
+ return void 0;
569
+ }
570
+ const subtags = conventionalSubtags(tag);
571
+ if (!needsBcp47(tag, subtags.length)) {
572
+ return legacySegment(tag);
573
+ }
574
+ if (subtags.length > MAX_BCP47_SUBTAGS) {
575
+ return void 0;
576
+ }
577
+ return `${SOURCE_SEGMENT}-b+${subtags.join("+")}`;
578
+ }
579
+
580
+ // src/locale-path/style.ts
581
+ var LOCALE_STYLES = ["literal", "posix", "android"];
582
+ var SEGMENT_STYLES = /* @__PURE__ */ new Set(["android"]);
583
+ var UNSAFE_IN_SEGMENT = /[/\\\0]/;
584
+ function isSegmentStyle(style) {
585
+ return SEGMENT_STYLES.has(style);
586
+ }
587
+ function posixSpelling(locale) {
588
+ const tag = parseLocaleTag(locale);
589
+ if (tag === void 0 || tag.script !== void 0 || tag.variants.length > 0) {
590
+ return void 0;
591
+ }
592
+ if (tag.region !== void 0 && NUMERIC_REGION.test(tag.region)) {
593
+ return void 0;
594
+ }
595
+ return locale.replaceAll("-", "_");
596
+ }
597
+ function spellLocale(locale, style, isSourceLocale) {
598
+ if (style === "android") {
599
+ return androidSegment(locale, isSourceLocale);
600
+ }
601
+ return style === "posix" ? posixSpelling(locale) : locale;
602
+ }
603
+ function isSafeSpelling(spelling) {
604
+ return spelling.length > 0 && !UNSAFE_IN_SEGMENT.test(spelling) && spelling !== "." && spelling !== "..";
490
605
  }
491
606
  function checkBatchIntegrity(inputs, extract, compare) {
492
607
  const integrity = /* @__PURE__ */ new Map();
@@ -659,7 +774,7 @@ function deriveJsonSchema(schema) {
659
774
  result[key] = value;
660
775
  }
661
776
  }
662
- return result;
777
+ return { ...result, type: "object" };
663
778
  }
664
779
  function partitionTranslations(translations, requested) {
665
780
  const firstSeen = /* @__PURE__ */ new Map();
@@ -779,6 +894,8 @@ function assertNotTruncated(truncated) {
779
894
  var RATE_LIMITED_STATUS = 429;
780
895
  var TIMEOUT_STATUS = 408;
781
896
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
897
+ var SERVER_OUTAGE_MIN_STATUS = 500;
898
+ var SERVER_OUTAGE_MAX_STATUS = 599;
782
899
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
783
900
  "RateLimitError",
784
901
  "TooManyRequestsError"
@@ -800,6 +917,9 @@ function getErrorStatus(error) {
800
917
  const status = error.status;
801
918
  return typeof status === "number" ? status : void 0;
802
919
  }
920
+ function isServerOutageStatus(status) {
921
+ return status !== void 0 && status >= SERVER_OUTAGE_MIN_STATUS && status <= SERVER_OUTAGE_MAX_STATUS;
922
+ }
803
923
  function readClassName(error) {
804
924
  if (typeof error !== "object" || error === null) {
805
925
  return void 0;
@@ -817,6 +937,9 @@ function classifyProviderError(error) {
817
937
  if (status === TIMEOUT_STATUS) {
818
938
  return "TIMEOUT";
819
939
  }
940
+ if (isServerOutageStatus(status)) {
941
+ return "PROVIDER_UNAVAILABLE";
942
+ }
820
943
  const className = readClassName(error);
821
944
  if (className !== void 0) {
822
945
  if (RATE_LIMITED_CLASS_NAMES.has(className)) {
@@ -848,10 +971,12 @@ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
848
971
  var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
849
972
  var TIMEOUT_MESSAGE = "The translation provider request timed out.";
850
973
  var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
974
+ var PROVIDER_UNAVAILABLE_MESSAGE = "The translation provider is currently unavailable.";
851
975
  var MESSAGE_BY_CODE = {
852
976
  RATE_LIMITED: RATE_LIMITED_MESSAGE,
853
977
  TIMEOUT: TIMEOUT_MESSAGE,
854
978
  AUTH_FAILED: AUTH_FAILED_MESSAGE,
979
+ PROVIDER_UNAVAILABLE: PROVIDER_UNAVAILABLE_MESSAGE,
855
980
  PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
856
981
  };
857
982
  async function guardProviderCall(call, signal) {
@@ -932,14 +1057,14 @@ function resolveOpenAiCompatibleKey(customEnvVar) {
932
1057
  }
933
1058
  return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
934
1059
  }
1060
+ function toMutableRequest(request) {
1061
+ return request;
1062
+ }
935
1063
  function createDefaultClient() {
936
1064
  const sdk = new Anthropic({ apiKey: requireAnthropicKey(), logLevel: "off" });
937
1065
  return {
938
1066
  messages: {
939
- create: async (body, options) => await sdk.messages.create(
940
- body,
941
- options
942
- )
1067
+ create: async (body, options) => await sdk.messages.create(toMutableRequest(body), options)
943
1068
  }
944
1069
  };
945
1070
  }
@@ -1357,7 +1482,7 @@ function createDefaultClient3() {
1357
1482
  models: {
1358
1483
  generateContent: (request) => withGeminiRetry(
1359
1484
  async () => await ai.models.generateContent(
1360
- request
1485
+ toMutableRequest(request)
1361
1486
  ),
1362
1487
  request.config.abortSignal
1363
1488
  )
@@ -1521,7 +1646,7 @@ function createDefaultClient4() {
1521
1646
  chat: {
1522
1647
  completions: {
1523
1648
  create: async (body, options) => await sdk.chat.completions.create(
1524
- body,
1649
+ toMutableRequest(body),
1525
1650
  options
1526
1651
  )
1527
1652
  }
@@ -1722,7 +1847,7 @@ function createDefaultClient5(config) {
1722
1847
  chat: {
1723
1848
  completions: {
1724
1849
  create: async (body, options) => await sdk.chat.completions.create(
1725
- body,
1850
+ toMutableRequest(body),
1726
1851
  options
1727
1852
  )
1728
1853
  }
@@ -1804,7 +1929,13 @@ var verbatraConfigSchema = z.strictObject({
1804
1929
  targetLocales: z.array(z.string().min(1)).min(1),
1805
1930
  format: supportedFormatSchema,
1806
1931
  files: z.strictObject({
1807
- pattern: z.string().min(1)
1932
+ pattern: z.string().min(1),
1933
+ /**
1934
+ * How the `{locale}` token in `pattern` is spelled for each locale. Absent means `"literal"`,
1935
+ * the configured tag verbatim, which is what every layout except Android and the POSIX
1936
+ * underscore layouts needs. See `LocaleStyle` for what each style produces.
1937
+ */
1938
+ localeStyle: z.enum(LOCALE_STYLES).optional()
1808
1939
  }),
1809
1940
  provider: providerConfigSchema,
1810
1941
  /**
@@ -1825,8 +1956,9 @@ var verbatraConfigSchema = z.strictObject({
1825
1956
  * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
1826
1957
  * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
1827
1958
  * requires but the source does not supply (for example Polish few/many). The per-run
1828
- * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
1829
- * unknown language) fall back to the per-locale plural warning.
1959
+ * `generatePlurals` option on `translate` overrides this. A plural form the target file already holds
1960
+ * is adopted, never generated over. Unsupported cases (DeepL, non-i18next, an unknown language) fall
1961
+ * back to the per-locale plural warning.
1830
1962
  */
1831
1963
  generatePlurals: z.boolean().optional(),
1832
1964
  /**
@@ -1919,7 +2051,7 @@ async function loadExplicitWithMeta(explorer, configPath, cwd, fs) {
1919
2051
  try {
1920
2052
  result = await explorer.load(resolved);
1921
2053
  } catch (error) {
1922
- const detail = error instanceof Error ? error.message : String(error);
2054
+ const detail = errorMessage(error);
1923
2055
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1924
2056
  }
1925
2057
  const parsed = parseConfig(result?.config);
@@ -1944,7 +2076,7 @@ async function loadConfigWithMeta(options = {}) {
1944
2076
  try {
1945
2077
  result = await explorer.search(options.cwd);
1946
2078
  } catch (error) {
1947
- const detail = error instanceof Error ? error.message : String(error);
2079
+ const detail = errorMessage(error);
1948
2080
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
1949
2081
  }
1950
2082
  if (result === null || result.isEmpty === true) {
@@ -1961,6 +2093,63 @@ async function loadConfig(options = {}) {
1961
2093
  const { config } = await loadConfigWithMeta(options);
1962
2094
  return config;
1963
2095
  }
2096
+ var DEFAULT_LOCALE_STYLE = "literal";
2097
+ function validatePattern(pattern, style) {
2098
+ if (!pattern.includes(LOCALE_TOKEN)) {
2099
+ throw new SdkError(
2100
+ "LOCALE_LAYOUT_INVALID",
2101
+ `The files pattern "${pattern}" must contain the ${LOCALE_TOKEN} token.`
2102
+ );
2103
+ }
2104
+ if (isSegmentStyle(style) && !tokenOccupiesWholeSegments(pattern)) {
2105
+ throw new SdkError(
2106
+ "LOCALE_LAYOUT_INVALID",
2107
+ `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.`
2108
+ );
2109
+ }
2110
+ }
2111
+ function safeSpelling(locale, style, sourceLocale) {
2112
+ const spelling = spellLocale(locale, style, locale === sourceLocale);
2113
+ if (spelling === void 0) {
2114
+ throw new SdkError(
2115
+ "LOCALE_LAYOUT_INVALID",
2116
+ `The "${style}" locale style has no valid path spelling for the locale "${locale}".`
2117
+ );
2118
+ }
2119
+ if (!isSafeSpelling(spelling)) {
2120
+ throw new SdkError(
2121
+ "LOCALE_LAYOUT_INVALID",
2122
+ `The locale "${locale}" expands to "${spelling}" under the "${style}" locale style, which is not a single path segment.`
2123
+ );
2124
+ }
2125
+ return spelling;
2126
+ }
2127
+ function buildForwardMap(config, pathFor) {
2128
+ const forward = /* @__PURE__ */ new Map();
2129
+ for (const locale of [config.sourceLocale, ...config.targetLocales]) {
2130
+ const path = pathFor(locale);
2131
+ const claimed = forward.get(path);
2132
+ if (claimed !== void 0 && claimed !== locale) {
2133
+ throw new SdkError(
2134
+ "LOCALE_PATH_COLLISION",
2135
+ `The locales "${claimed}" and "${locale}" both resolve to ${path}.`
2136
+ );
2137
+ }
2138
+ forward.set(path, locale);
2139
+ }
2140
+ return forward;
2141
+ }
2142
+ function createLocalePathResolver(cwd, config) {
2143
+ const style = config.files.localeStyle ?? DEFAULT_LOCALE_STYLE;
2144
+ const pattern = config.files.pattern;
2145
+ validatePattern(pattern, style);
2146
+ const pathFor = (locale) => resolve(cwd, expandPattern(pattern, safeSpelling(locale, style, config.sourceLocale)));
2147
+ const forward = buildForwardMap(config, pathFor);
2148
+ return {
2149
+ pathFor,
2150
+ localeFor: (absolutePath) => forward.get(resolve(cwd, absolutePath))
2151
+ };
2152
+ }
1964
2153
  var LOCAL_DIR_NAME = ".verbatra-local";
1965
2154
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1966
2155
  var DEFAULT_POLL_INTERVAL_MS = 100;
@@ -2029,15 +2218,15 @@ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
2029
2218
  if (await fs.createExclusive(path, lockPayload())) {
2030
2219
  return;
2031
2220
  }
2221
+ if (notify !== void 0) {
2222
+ await notify();
2223
+ }
2032
2224
  if (Date.now() >= deadline) {
2033
2225
  throw new SdkError(
2034
2226
  "LOCK_CONTENDED",
2035
2227
  `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.`
2036
2228
  );
2037
2229
  }
2038
- if (notify !== void 0) {
2039
- await notify();
2040
- }
2041
2230
  const jitter = Math.random() * pollIntervalMs;
2042
2231
  await sleep(pollIntervalMs + jitter);
2043
2232
  }
@@ -2627,6 +2816,9 @@ async function fsyncDirBestEffort(path) {
2627
2816
  }
2628
2817
  }
2629
2818
  var nodeOps = {
2819
+ mkdir: async (path) => {
2820
+ await mkdir(path, { recursive: true });
2821
+ },
2630
2822
  writeFile: (path, data) => writeFile(path, data, "utf8"),
2631
2823
  fsyncFile: (path) => fsyncPath(path),
2632
2824
  rename: (from, to) => rename(from, to),
@@ -2643,8 +2835,10 @@ function tempFileName2(path) {
2643
2835
  return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
2644
2836
  }
2645
2837
  async function atomicWriteFile(path, data, ops = nodeOps) {
2838
+ const directory = dirname(path);
2646
2839
  const tmp = tempFileName2(path);
2647
2840
  try {
2841
+ await ops.mkdir(directory);
2648
2842
  await ops.writeFile(tmp, data);
2649
2843
  await ops.fsyncFile(tmp);
2650
2844
  await ops.rename(tmp, path);
@@ -2653,7 +2847,7 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
2653
2847
  throw error;
2654
2848
  }
2655
2849
  try {
2656
- await ops.fsyncDir(dirname(path));
2850
+ await ops.fsyncDir(directory);
2657
2851
  } catch {
2658
2852
  }
2659
2853
  }
@@ -3977,7 +4171,7 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
3977
4171
 
3978
4172
  // src/flow/read-target.ts
3979
4173
  async function readTargetResource(input) {
3980
- const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
4174
+ const path = input.resolver.pathFor(input.locale);
3981
4175
  if (!await input.fs.fileExists(path)) {
3982
4176
  return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
3983
4177
  }
@@ -4004,7 +4198,7 @@ function selectLocales(config, requested) {
4004
4198
 
4005
4199
  // src/flow/source.ts
4006
4200
  async function readSource(config, cwd, fs, adapter) {
4007
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
4201
+ const sourcePath = createLocalePathResolver(cwd, config).pathFor(config.sourceLocale);
4008
4202
  if (!await fs.fileExists(sourcePath)) {
4009
4203
  throw new SdkError(
4010
4204
  "SOURCE_UNREADABLE",
@@ -4014,7 +4208,7 @@ async function readSource(config, cwd, fs, adapter) {
4014
4208
  try {
4015
4209
  return await adapter.read(sourcePath, config.sourceLocale);
4016
4210
  } catch (error) {
4017
- const detail = error instanceof Error ? error.message : String(error);
4211
+ const detail = errorMessage(error);
4018
4212
  throw new SdkError(
4019
4213
  "SOURCE_INVALID",
4020
4214
  `The source locale file at ${sourcePath} could not be read: ${detail}`
@@ -4025,8 +4219,7 @@ async function readSource(config, cwd, fs, adapter) {
4025
4219
  // src/flow/diff-locales.ts
4026
4220
  async function readTarget(cwd, config, adapter, fs, locale) {
4027
4221
  return readTargetResource({
4028
- cwd,
4029
- filesPattern: config.files.pattern,
4222
+ resolver: createLocalePathResolver(cwd, config),
4030
4223
  format: config.format,
4031
4224
  locale,
4032
4225
  adapter,
@@ -4147,7 +4340,7 @@ async function editEntry(input, deps = {}) {
4147
4340
  }
4148
4341
  const merged = new Map(target.entries);
4149
4342
  merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
4150
- const path = localeFilePath(cwd, config.files.pattern, locale);
4343
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4151
4344
  await adapter.write(
4152
4345
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4153
4346
  path
@@ -4314,7 +4507,7 @@ function selectProvider(config, createProvider = buildProvider) {
4314
4507
  try {
4315
4508
  return createProvider(config);
4316
4509
  } catch (error) {
4317
- const detail = error instanceof Error ? error.message : String(error);
4510
+ const detail = errorMessage(error);
4318
4511
  throw new SdkError(
4319
4512
  "PROVIDER_CONSTRUCTION_FAILED",
4320
4513
  `Failed to construct provider "${config.id}": ${detail}`
@@ -4370,7 +4563,7 @@ async function retranslateEntry(input, deps = {}) {
4370
4563
  }
4371
4564
  const merged = new Map(target.entries);
4372
4565
  merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
4373
- const path = localeFilePath(cwd, config.files.pattern, locale);
4566
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4374
4567
  await adapter.write(
4375
4568
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4376
4569
  path
@@ -4537,13 +4730,6 @@ function budgetExceededNotice(tracker) {
4537
4730
  }
4538
4731
 
4539
4732
  // src/flow/locale-failure.ts
4540
- function describeError(error) {
4541
- if (error instanceof Error) {
4542
- const code = error.code;
4543
- return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
4544
- }
4545
- return { code: "LOCALE_FAILED", message: String(error) };
4546
- }
4547
4733
  function failureSummary(locale, error) {
4548
4734
  return {
4549
4735
  locale,
@@ -4563,7 +4749,7 @@ function failureSummary(locale, error) {
4563
4749
  unfilled: [],
4564
4750
  malformedRows: [],
4565
4751
  duplicateKeys: [],
4566
- error: describeError(error)
4752
+ error: describeError(error, "LOCALE_FAILED")
4567
4753
  };
4568
4754
  }
4569
4755
  function deriveLocaleStatus(parts) {
@@ -4796,6 +4982,9 @@ function syntheticEntry(item) {
4796
4982
  meaning: `CLDR plural category "${item.category}"`
4797
4983
  };
4798
4984
  }
4985
+ function isAdopted(item, targetKeys, baseline) {
4986
+ return targetKeys.has(item.targetKey) && !baseline.has(item.targetKey);
4987
+ }
4799
4988
  function staleItems(items, baseline) {
4800
4989
  return items.filter((item) => {
4801
4990
  const hash = generatedLockHash(item.governingEntries, item.category);
@@ -4815,7 +5004,10 @@ function buildRequest2(context, entries) {
4815
5004
  }
4816
5005
  async function generatePluralForms(context) {
4817
5006
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
4818
- const stale = staleItems(plan.items, context.baseline);
5007
+ const candidates = plan.items.filter(
5008
+ (item) => !isAdopted(item, context.targetKeys, context.baseline)
5009
+ );
5010
+ const stale = staleItems(candidates, context.baseline);
4819
5011
  if (stale.length === 0) {
4820
5012
  return EMPTY_RESULT;
4821
5013
  }
@@ -4997,10 +5189,15 @@ function buildRequest3(params, entries) {
4997
5189
  ...params.adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: params.adapter.comparePlaceholders } : {}
4998
5190
  };
4999
5191
  }
5192
+ async function shouldWriteTarget(params, path, changed) {
5193
+ if (changed.accepted > 0 || changed.pruned > 0 || changed.generated > 0) {
5194
+ return true;
5195
+ }
5196
+ return !await params.fs.fileExists(path);
5197
+ }
5000
5198
  async function runLocale(params) {
5001
5199
  const target = await readTargetResource({
5002
- cwd: params.cwd,
5003
- filesPattern: params.filesPattern,
5200
+ resolver: params.resolver,
5004
5201
  format: params.format,
5005
5202
  locale: params.targetLocale,
5006
5203
  adapter: params.adapter,
@@ -5077,20 +5274,27 @@ async function runLocale(params) {
5077
5274
  merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
5078
5275
  }
5079
5276
  }
5080
- const generation = await runGeneration(params, provider);
5277
+ const generation = await runGeneration(params, provider, new Set(target.entries.keys()));
5081
5278
  for (const form of generation.accepted) {
5082
5279
  merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
5083
5280
  }
5084
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
5085
- await params.adapter.write(
5086
- {
5087
- locale: params.targetLocale,
5088
- namespace: target.namespace,
5089
- format: params.format,
5090
- entries: merged
5091
- },
5092
- path
5093
- );
5281
+ const path = params.resolver.pathFor(params.targetLocale);
5282
+ const writeNeeded = await shouldWriteTarget(params, path, {
5283
+ accepted: accepted.size,
5284
+ pruned: pruned.length,
5285
+ generated: generation.accepted.length
5286
+ });
5287
+ if (writeNeeded) {
5288
+ await params.adapter.write(
5289
+ {
5290
+ locale: params.targetLocale,
5291
+ namespace: target.namespace,
5292
+ format: params.format,
5293
+ entries: merged
5294
+ },
5295
+ path
5296
+ );
5297
+ }
5094
5298
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
5095
5299
  const notices = [
5096
5300
  ...pluralNotices,
@@ -5137,7 +5341,7 @@ var NO_GENERATION_RESULT = {
5137
5341
  usage: void 0,
5138
5342
  tripped: false
5139
5343
  };
5140
- async function runGeneration(params, provider) {
5344
+ async function runGeneration(params, provider, targetKeys) {
5141
5345
  if (!params.generatePlurals || provider.kind !== "llm") {
5142
5346
  return NO_GENERATION_RESULT;
5143
5347
  }
@@ -5151,6 +5355,7 @@ async function runGeneration(params, provider) {
5151
5355
  glossary: params.glossary,
5152
5356
  tone: params.tone,
5153
5357
  baseline: params.baseline,
5358
+ targetKeys,
5154
5359
  maxBatchSize: params.maxBatchSize,
5155
5360
  budget: params.budget
5156
5361
  });
@@ -5393,7 +5598,7 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
5393
5598
  adapter: context.adapter,
5394
5599
  provider: context.provider,
5395
5600
  cwd: context.cwd,
5396
- filesPattern: context.config.files.pattern,
5601
+ resolver: context.resolver,
5397
5602
  sourceLocale: context.config.sourceLocale,
5398
5603
  targetLocale,
5399
5604
  format: context.config.format,
@@ -5547,6 +5752,7 @@ async function translate2(input, deps = {}) {
5547
5752
  config.maxTokens,
5548
5753
  config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
5549
5754
  );
5755
+ const resolver = createLocalePathResolver(cwd, config);
5550
5756
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
5551
5757
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
5552
5758
  const source = await readSource(config, cwd, fs, adapter);
@@ -5557,6 +5763,7 @@ async function translate2(input, deps = {}) {
5557
5763
  provider,
5558
5764
  cwd,
5559
5765
  config,
5766
+ resolver,
5560
5767
  prune,
5561
5768
  generatePlurals,
5562
5769
  maxBatchSize,
@@ -5597,6 +5804,123 @@ var ExchangeError = class extends Error {
5597
5804
  this.code = code;
5598
5805
  }
5599
5806
  };
5807
+ var DELIMITER = {
5808
+ csv: ",",
5809
+ tsv: " "
5810
+ };
5811
+ var FORBIDDEN_FILE_NAME_CHARS = /[\\/:*?"<>|\p{Cc}]/u;
5812
+ function assertPlainFileLocale(locale) {
5813
+ if (locale.length === 0 || locale === "." || locale === "..") {
5814
+ throw new ExchangeError(
5815
+ "WORKBOOK_INVALID",
5816
+ `The locale "${locale}" cannot be an interchange file name: it must name a file, not a directory.`
5817
+ );
5818
+ }
5819
+ if (FORBIDDEN_FILE_NAME_CHARS.test(locale)) {
5820
+ throw new ExchangeError(
5821
+ "WORKBOOK_INVALID",
5822
+ `The locale "${locale}" cannot be an interchange file name: it must not contain a path separator, a control character, or any of : * ? " < > | .`
5823
+ );
5824
+ }
5825
+ }
5826
+ function delimitedFileName(locale, format) {
5827
+ assertPlainFileLocale(locale);
5828
+ return `${locale}.${format}`;
5829
+ }
5830
+ var COLUMN = {
5831
+ key: 1,
5832
+ source: 2,
5833
+ current: 3,
5834
+ status: 4,
5835
+ translation: 5,
5836
+ sourceHash: 6,
5837
+ context: 7,
5838
+ reviewStatus: 8,
5839
+ reviewReasons: 9
5840
+ };
5841
+ var HEADERS = [
5842
+ "Key",
5843
+ "Source",
5844
+ "Current translation",
5845
+ "Status",
5846
+ "Translation",
5847
+ "Source hash",
5848
+ "Context",
5849
+ "Review status",
5850
+ "Review reasons"
5851
+ ];
5852
+ var HEADER_ROW = 1;
5853
+ var INSTRUCTIONS_SHEET_NAME = "Instructions";
5854
+ var rowSchema = z.object({
5855
+ key: z.string().min(1),
5856
+ source: z.string(),
5857
+ currentTarget: z.string(),
5858
+ status: z.enum(["new", "changed", "unchanged"]),
5859
+ sourceHash: z.string(),
5860
+ translation: z.string(),
5861
+ context: z.string(),
5862
+ reviewStatus: z.enum(["ok", "review"]).catch("ok"),
5863
+ reviewReasons: z.string().catch("")
5864
+ });
5865
+ var MALFORMED_ROW_COLUMN = "Status";
5866
+ function cellAt(cells, column) {
5867
+ return cells[column - 1] ?? "";
5868
+ }
5869
+ function parseRowCells(cells) {
5870
+ const result = rowSchema.safeParse({
5871
+ key: cellAt(cells, COLUMN.key),
5872
+ source: cellAt(cells, COLUMN.source),
5873
+ currentTarget: cellAt(cells, COLUMN.current),
5874
+ status: cellAt(cells, COLUMN.status),
5875
+ sourceHash: cellAt(cells, COLUMN.sourceHash),
5876
+ translation: cellAt(cells, COLUMN.translation).trim(),
5877
+ context: cellAt(cells, COLUMN.context),
5878
+ reviewStatus: cellAt(cells, COLUMN.reviewStatus),
5879
+ reviewReasons: cellAt(cells, COLUMN.reviewReasons)
5880
+ });
5881
+ if (!result.success) {
5882
+ return { ok: false, column: MALFORMED_ROW_COLUMN };
5883
+ }
5884
+ return { ok: true, row: result.data };
5885
+ }
5886
+ function rowCells(row) {
5887
+ const cells = new Array(HEADERS.length).fill("");
5888
+ cells[COLUMN.key - 1] = row.key;
5889
+ cells[COLUMN.source - 1] = row.source;
5890
+ cells[COLUMN.current - 1] = row.currentTarget;
5891
+ cells[COLUMN.status - 1] = row.status;
5892
+ cells[COLUMN.translation - 1] = row.translation;
5893
+ cells[COLUMN.sourceHash - 1] = row.sourceHash;
5894
+ cells[COLUMN.context - 1] = row.context;
5895
+ cells[COLUMN.reviewStatus - 1] = row.reviewStatus;
5896
+ cells[COLUMN.reviewReasons - 1] = row.reviewReasons;
5897
+ return cells;
5898
+ }
5899
+ var QUOTE = '"';
5900
+ var ESCAPED_QUOTE = '""';
5901
+ var LINE_BREAK = "\n";
5902
+ var UTF8_BOM = "\uFEFF";
5903
+ function needsQuoting(value, delimiter) {
5904
+ return value.includes(delimiter) || value.includes(QUOTE) || value.includes("\n") || value.includes("\r") || value !== value.trim();
5905
+ }
5906
+ function encodeField(value, delimiter) {
5907
+ if (!needsQuoting(value, delimiter)) {
5908
+ return value;
5909
+ }
5910
+ return `${QUOTE}${value.replaceAll(QUOTE, ESCAPED_QUOTE)}${QUOTE}`;
5911
+ }
5912
+ function encodeRecord(fields, delimiter) {
5913
+ return fields.map((field) => encodeField(field, delimiter)).join(delimiter);
5914
+ }
5915
+ function buildDelimited(sheet, format) {
5916
+ const delimiter = DELIMITER[format];
5917
+ const records = [encodeRecord(HEADERS, delimiter)];
5918
+ for (const row of sheet.rows) {
5919
+ records.push(encodeRecord(rowCells(row), delimiter));
5920
+ }
5921
+ const text = `${records.join(LINE_BREAK)}${LINE_BREAK}`;
5922
+ return format === "csv" ? `${UTF8_BOM}${text}` : text;
5923
+ }
5600
5924
  var INSTRUCTIONS_LINES = [
5601
5925
  "How to use this workbook",
5602
5926
  "",
@@ -5625,30 +5949,6 @@ var INSTRUCTIONS_LINES = [
5625
5949
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
5626
5950
  "withheld (not written) and reported so you can fix and resubmit it."
5627
5951
  ];
5628
- var COLUMN = {
5629
- key: 1,
5630
- source: 2,
5631
- current: 3,
5632
- status: 4,
5633
- translation: 5,
5634
- sourceHash: 6,
5635
- context: 7,
5636
- reviewStatus: 8,
5637
- reviewReasons: 9
5638
- };
5639
- var HEADERS = [
5640
- "Key",
5641
- "Source",
5642
- "Current translation",
5643
- "Status",
5644
- "Translation",
5645
- "Source hash",
5646
- "Context",
5647
- "Review status",
5648
- "Review reasons"
5649
- ];
5650
- var HEADER_ROW = 1;
5651
- var INSTRUCTIONS_SHEET_NAME = "Instructions";
5652
5952
  var READ_ONLY_FILL = {
5653
5953
  type: "pattern",
5654
5954
  pattern: "solid",
@@ -5808,6 +6108,12 @@ async function buildWorkbook(model) {
5808
6108
  const protectedBytes = await protectWorkbookStructure(serialized);
5809
6109
  return Uint8Array.prototype.slice.call(protectedBytes);
5810
6110
  }
6111
+ var DEFAULT_DELIMITED_LIMITS = {
6112
+ maxInputBytes: 32 * 1024 * 1024,
6113
+ maxRowsPerFile: 1e5,
6114
+ maxFieldsPerRow: 64,
6115
+ maxFieldLength: 1024 * 1024
6116
+ };
5811
6117
  var DEFAULT_WORKBOOK_LIMITS = {
5812
6118
  maxDecompressedBytes: 64 * 1024 * 1024,
5813
6119
  maxEntryCount: 1024,
@@ -5815,6 +6121,205 @@ var DEFAULT_WORKBOOK_LIMITS = {
5815
6121
  maxRowsPerSheet: 1e5,
5816
6122
  maxCellsPerRow: 64
5817
6123
  };
6124
+ var QUOTE2 = '"';
6125
+ var HEADER_RECORD = 1;
6126
+ function scanQuotedField(text, start) {
6127
+ const parts = [];
6128
+ let cursor = start;
6129
+ for (; ; ) {
6130
+ const quote = text.indexOf(QUOTE2, cursor);
6131
+ if (quote === -1) {
6132
+ parts.push(text.slice(cursor));
6133
+ return { value: parts.join(""), next: text.length };
6134
+ }
6135
+ parts.push(text.slice(cursor, quote));
6136
+ if (text[quote + 1] !== QUOTE2) {
6137
+ return { value: parts.join(""), next: quote + 1 };
6138
+ }
6139
+ parts.push(QUOTE2);
6140
+ cursor = quote + 2;
6141
+ }
6142
+ }
6143
+ function scanPlainField(text, start, delimiter) {
6144
+ let cursor = start;
6145
+ while (cursor < text.length) {
6146
+ const char = text[cursor];
6147
+ if (char === delimiter || char === "\n" || char === "\r") {
6148
+ break;
6149
+ }
6150
+ cursor += 1;
6151
+ }
6152
+ return { value: text.slice(start, cursor), next: cursor };
6153
+ }
6154
+ function scanField(text, start, delimiter) {
6155
+ if (text[start] !== QUOTE2) {
6156
+ return scanPlainField(text, start, delimiter);
6157
+ }
6158
+ const quoted = scanQuotedField(text, start + 1);
6159
+ const trailing = scanPlainField(text, quoted.next, delimiter);
6160
+ return { value: `${quoted.value}${trailing.value}`, next: trailing.next };
6161
+ }
6162
+ function countLineBreaks(value) {
6163
+ let breaks = 0;
6164
+ for (let index = 0; index < value.length; index += 1) {
6165
+ const char = value[index];
6166
+ if (char === "\n") {
6167
+ breaks += 1;
6168
+ } else if (char === "\r") {
6169
+ breaks += 1;
6170
+ if (value[index + 1] === "\n") {
6171
+ index += 1;
6172
+ }
6173
+ }
6174
+ }
6175
+ return breaks;
6176
+ }
6177
+ function scanRecord(text, start, delimiter, limits) {
6178
+ const fields = [];
6179
+ let breaks = 0;
6180
+ let cursor = start;
6181
+ for (; ; ) {
6182
+ const field = scanField(text, cursor, delimiter);
6183
+ assertFieldLength(field.value, limits);
6184
+ fields.push(field.value);
6185
+ breaks += countLineBreaks(field.value);
6186
+ assertFieldCount(fields.length, limits);
6187
+ cursor = field.next;
6188
+ if (text[cursor] !== delimiter) {
6189
+ return { fields, breaks, next: cursor };
6190
+ }
6191
+ cursor += 1;
6192
+ }
6193
+ }
6194
+ function consumeLineBreak(text, cursor) {
6195
+ if (text[cursor] === "\r") {
6196
+ return text[cursor + 1] === "\n" ? cursor + 2 : cursor + 1;
6197
+ }
6198
+ return text[cursor] === "\n" ? cursor + 1 : cursor;
6199
+ }
6200
+ function scanRecords(text, delimiter, limits) {
6201
+ const records = [];
6202
+ let cursor = 0;
6203
+ let line = 1;
6204
+ while (cursor < text.length) {
6205
+ const record = scanRecord(text, cursor, delimiter, limits);
6206
+ records.push({ fields: record.fields, line });
6207
+ assertRecordCount(records.length, limits);
6208
+ const next = consumeLineBreak(text, record.next);
6209
+ if (next === record.next) {
6210
+ break;
6211
+ }
6212
+ line += record.breaks + 1;
6213
+ cursor = next;
6214
+ }
6215
+ return records;
6216
+ }
6217
+ function assertInputBytes(text, limits) {
6218
+ if (Buffer.byteLength(text, "utf8") > limits.maxInputBytes) {
6219
+ throw new ExchangeError(
6220
+ "WORKBOOK_INVALID",
6221
+ `The interchange file is larger than the maximum of ${limits.maxInputBytes} bytes.`
6222
+ );
6223
+ }
6224
+ }
6225
+ function assertFieldLength(value, limits) {
6226
+ if (value.length > limits.maxFieldLength) {
6227
+ throw new ExchangeError(
6228
+ "WORKBOOK_INVALID",
6229
+ `The interchange file has a field longer than the maximum of ${limits.maxFieldLength} characters.`
6230
+ );
6231
+ }
6232
+ }
6233
+ function assertFieldCount(count, limits) {
6234
+ if (count > limits.maxFieldsPerRow) {
6235
+ throw new ExchangeError(
6236
+ "WORKBOOK_INVALID",
6237
+ `The interchange file has a row with more than the maximum of ${limits.maxFieldsPerRow} fields.`
6238
+ );
6239
+ }
6240
+ }
6241
+ function assertRecordCount(count, limits) {
6242
+ if (count - HEADER_RECORD > limits.maxRowsPerFile) {
6243
+ throw new ExchangeError(
6244
+ "WORKBOOK_INVALID",
6245
+ `The interchange file has more than the maximum of ${limits.maxRowsPerFile} rows.`
6246
+ );
6247
+ }
6248
+ }
6249
+ function assertHeaderRecord(records) {
6250
+ const header = records[0]?.fields;
6251
+ if (header === void 0 || header.length !== HEADERS.length) {
6252
+ throw new ExchangeError(
6253
+ "WORKBOOK_INVALID",
6254
+ `The interchange file does not start with the expected header line of ${HEADERS.length} columns.`
6255
+ );
6256
+ }
6257
+ for (const [index, label] of HEADERS.entries()) {
6258
+ if (header[index] !== label) {
6259
+ throw new ExchangeError(
6260
+ "WORKBOOK_INVALID",
6261
+ `The interchange file's header line does not match the expected columns (column ${index + 1} should be "${label}").`
6262
+ );
6263
+ }
6264
+ }
6265
+ }
6266
+ function fieldCountColumn(fieldCount) {
6267
+ const label = HEADERS.at(Math.min(fieldCount, HEADERS.length - 1));
6268
+ if (label === void 0) {
6269
+ return MALFORMED_ROW_COLUMN;
6270
+ }
6271
+ return label;
6272
+ }
6273
+ function isBlankRecord(fields) {
6274
+ return fields.length === 1 && fields[0] === "";
6275
+ }
6276
+ function readRecord(fields, locale, at, into) {
6277
+ const { row, line } = at;
6278
+ if (isBlankRecord(fields)) {
6279
+ return;
6280
+ }
6281
+ if (fields.length !== HEADERS.length) {
6282
+ into.malformed.push({ locale, row, line, column: fieldCountColumn(fields.length) });
6283
+ return;
6284
+ }
6285
+ if (fields[COLUMN.key - 1] === "") {
6286
+ return;
6287
+ }
6288
+ const outcome = parseRowCells(fields);
6289
+ if (!outcome.ok) {
6290
+ into.malformed.push({ locale, row, line, column: outcome.column });
6291
+ return;
6292
+ }
6293
+ if (into.seenKeys.has(outcome.row.key)) {
6294
+ into.duplicates.push({ locale, key: outcome.row.key, row, line });
6295
+ return;
6296
+ }
6297
+ into.seenKeys.add(outcome.row.key);
6298
+ into.rows.push(outcome.row);
6299
+ }
6300
+ function readDelimited(input, options = {}) {
6301
+ const limits = options.limits ?? DEFAULT_DELIMITED_LIMITS;
6302
+ assertInputBytes(input.text, limits);
6303
+ const text = input.text.startsWith(UTF8_BOM) ? input.text.slice(UTF8_BOM.length) : input.text;
6304
+ const records = scanRecords(text, DELIMITER[input.format], limits);
6305
+ assertHeaderRecord(records);
6306
+ const into = {
6307
+ rows: [],
6308
+ malformed: [],
6309
+ duplicates: [],
6310
+ seenKeys: /* @__PURE__ */ new Set()
6311
+ };
6312
+ for (const [index, record] of records.entries()) {
6313
+ if (index >= HEADER_RECORD) {
6314
+ readRecord(record.fields, input.locale, { row: index + 1, line: record.line }, into);
6315
+ }
6316
+ }
6317
+ return {
6318
+ sheets: [{ locale: input.locale, rows: into.rows }],
6319
+ malformedRows: into.malformed,
6320
+ duplicateKeys: into.duplicates
6321
+ };
6322
+ }
5818
6323
  function assertNoDoctype2(name, xml) {
5819
6324
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
5820
6325
  throw new ExchangeError(
@@ -5891,17 +6396,6 @@ async function guardWorkbookBytes(bytes, limits) {
5891
6396
  assertNoDoctype2(file.name, content);
5892
6397
  }
5893
6398
  }
5894
- var rowSchema = z.object({
5895
- key: z.string().min(1),
5896
- source: z.string(),
5897
- currentTarget: z.string(),
5898
- status: z.enum(["new", "changed", "unchanged"]),
5899
- sourceHash: z.string(),
5900
- translation: z.string(),
5901
- context: z.string(),
5902
- reviewStatus: z.enum(["ok", "review"]).catch("ok"),
5903
- reviewReasons: z.string().catch("")
5904
- });
5905
6399
  function cellString(cell) {
5906
6400
  const value = cell.value;
5907
6401
  if (value === null || value === void 0) {
@@ -5926,24 +6420,8 @@ function assertHeader(sheet) {
5926
6420
  );
5927
6421
  }
5928
6422
  }
5929
- var MALFORMED_ROW_COLUMN = "Status";
5930
6423
  function parseRow(row) {
5931
- const candidate = {
5932
- key: cellString(row.getCell(COLUMN.key)),
5933
- source: cellString(row.getCell(COLUMN.source)),
5934
- currentTarget: cellString(row.getCell(COLUMN.current)),
5935
- status: cellString(row.getCell(COLUMN.status)),
5936
- sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
5937
- translation: cellString(row.getCell(COLUMN.translation)).trim(),
5938
- context: cellString(row.getCell(COLUMN.context)),
5939
- reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
5940
- reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
5941
- };
5942
- const result = rowSchema.safeParse(candidate);
5943
- if (!result.success) {
5944
- return { ok: false, column: MALFORMED_ROW_COLUMN };
5945
- }
5946
- return { ok: true, row: result.data };
6424
+ return parseRowCells(HEADERS.map((_, index) => cellString(row.getCell(index + 1))));
5947
6425
  }
5948
6426
  function readDataSheet(sheet, limits) {
5949
6427
  assertHeader(sheet);
@@ -6017,8 +6495,52 @@ async function readWorkbook(bytes, options = {}) {
6017
6495
  return { sheets, malformedRows, duplicateKeys };
6018
6496
  }
6019
6497
 
6498
+ // src/flow/workbook/exchange-format.ts
6499
+ function isDelimitedFormat(format) {
6500
+ return format === "csv" || format === "tsv";
6501
+ }
6502
+ var MANIFEST_VERSION = 1;
6503
+ var MAX_MANIFEST_BYTES = 1024 * 1024;
6504
+ function exportManifestFileName(format) {
6505
+ return `.verbatra-export-${format}.json`;
6506
+ }
6507
+ var manifestSchema = z.object({
6508
+ version: z.number().int().positive(),
6509
+ format: z.enum(["csv", "tsv"]),
6510
+ locales: z.array(z.string())
6511
+ });
6512
+ async function writeExportManifest(fs, directory, format, locales) {
6513
+ const manifest = { version: MANIFEST_VERSION, format, locales: [...locales] };
6514
+ await fs.writeFile(
6515
+ join(directory, exportManifestFileName(format)),
6516
+ `${JSON.stringify(manifest, null, 2)}
6517
+ `
6518
+ );
6519
+ }
6520
+ async function readExportedLocales(fs, directory, format) {
6521
+ const read = await fs.readFileBounded(
6522
+ join(directory, exportManifestFileName(format)),
6523
+ MAX_MANIFEST_BYTES
6524
+ );
6525
+ if (read.kind !== "ok") {
6526
+ return void 0;
6527
+ }
6528
+ let parsed;
6529
+ try {
6530
+ parsed = JSON.parse(read.content);
6531
+ } catch {
6532
+ return void 0;
6533
+ }
6534
+ const result = manifestSchema.safeParse(parsed);
6535
+ if (!result.success || result.data.version !== MANIFEST_VERSION || result.data.format !== format) {
6536
+ return void 0;
6537
+ }
6538
+ return new Set(result.data.locales);
6539
+ }
6540
+
6020
6541
  // src/flow/workbook/export-workbook.ts
6021
6542
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
6543
+ var DEFAULT_DELIMITED_PATH = "verbatra-translations";
6022
6544
  function reasonLabel(reason) {
6023
6545
  return reason.toLowerCase().replace(/_/g, "-");
6024
6546
  }
@@ -6082,6 +6604,21 @@ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary
6082
6604
  }
6083
6605
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
6084
6606
  }
6607
+ async function writeDelimitedFiles(fs, directory, format, sheets) {
6608
+ await fs.mkdir?.(directory);
6609
+ for (const sheet of sheets) {
6610
+ await fs.writeFile(
6611
+ join(directory, delimitedFileName(sheet.locale, format)),
6612
+ buildDelimited(sheet, format)
6613
+ );
6614
+ }
6615
+ await writeExportManifest(
6616
+ fs,
6617
+ directory,
6618
+ format,
6619
+ sheets.map((sheet) => sheet.locale)
6620
+ );
6621
+ }
6085
6622
  async function exportWorkbook(input, deps = {}) {
6086
6623
  const config = input.config;
6087
6624
  const cwd = input.cwd ?? process.cwd();
@@ -6104,10 +6641,17 @@ async function exportWorkbook(input, deps = {}) {
6104
6641
  return { locale, rows };
6105
6642
  })
6106
6643
  );
6107
- const model = { sheets };
6108
- const bytes = await buildWorkbook(model);
6109
- const path = resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
6110
- await fs.writeBytes(path, bytes);
6644
+ const format = input.format ?? "xlsx";
6645
+ const path = resolve(
6646
+ cwd,
6647
+ input.out ?? (isDelimitedFormat(format) ? DEFAULT_DELIMITED_PATH : DEFAULT_WORKBOOK_PATH)
6648
+ );
6649
+ if (isDelimitedFormat(format)) {
6650
+ await writeDelimitedFiles(fs, path, format, sheets);
6651
+ } else {
6652
+ const model = { sheets };
6653
+ await fs.writeBytes(path, await buildWorkbook(model));
6654
+ }
6111
6655
  return {
6112
6656
  path,
6113
6657
  locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
@@ -6236,6 +6780,7 @@ function importLocale(params) {
6236
6780
 
6237
6781
  // src/flow/workbook/import-workbook.ts
6238
6782
  var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
6783
+ var MAX_DELIMITED_FILE_BYTES = 32 * 1024 * 1024;
6239
6784
  async function readWorkbookBytes(path, fs) {
6240
6785
  const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
6241
6786
  if (read.kind === "missing") {
@@ -6249,6 +6794,75 @@ async function readWorkbookBytes(path, fs) {
6249
6794
  }
6250
6795
  return read.bytes;
6251
6796
  }
6797
+ async function readDelimitedText(path, fs) {
6798
+ const read = await fs.readFileBounded(path, MAX_DELIMITED_FILE_BYTES);
6799
+ if (read.kind === "missing") {
6800
+ return void 0;
6801
+ }
6802
+ if (read.kind === "too-large") {
6803
+ throw new SdkError(
6804
+ "SOURCE_INVALID",
6805
+ `The interchange file at ${path} exceeds the maximum allowed size of ${MAX_DELIMITED_FILE_BYTES} bytes.`
6806
+ );
6807
+ }
6808
+ return read.content;
6809
+ }
6810
+ async function collectDelimitedSources(path, config, fs, format) {
6811
+ const single = await readDelimitedText(path, fs);
6812
+ if (single !== void 0) {
6813
+ return { sources: [{ locale: basename(path, `.${format}`), text: single }], staleLocales: [] };
6814
+ }
6815
+ const exported = await readExportedLocales(fs, path, format);
6816
+ const sources = [];
6817
+ const staleLocales = [];
6818
+ for (const locale of config.targetLocales) {
6819
+ const text = await readDelimitedText(join(path, delimitedFileName(locale, format)), fs);
6820
+ if (text === void 0) {
6821
+ continue;
6822
+ }
6823
+ if (exported !== void 0 && !exported.has(locale)) {
6824
+ staleLocales.push(locale);
6825
+ continue;
6826
+ }
6827
+ sources.push({ locale, text });
6828
+ }
6829
+ if (sources.length === 0 && staleLocales.length === 0) {
6830
+ throw new SdkError(
6831
+ "SOURCE_UNREADABLE",
6832
+ `No ${format} file was found at ${path}, and it holds no <locale>.${format} file for any configured target locale.`
6833
+ );
6834
+ }
6835
+ return { sources, staleLocales };
6836
+ }
6837
+ function parseDelimitedSources(sources, format) {
6838
+ const sheets = [];
6839
+ const malformedRows = [];
6840
+ const duplicateKeys = [];
6841
+ for (const source of sources) {
6842
+ const data = readDelimited({ text: source.text, locale: source.locale, format });
6843
+ sheets.push(...data.sheets);
6844
+ malformedRows.push(...data.malformedRows);
6845
+ duplicateKeys.push(...data.duplicateKeys);
6846
+ }
6847
+ return { sheets, malformedRows, duplicateKeys };
6848
+ }
6849
+ async function readImportData(path, config, fs, format) {
6850
+ try {
6851
+ if (isDelimitedFormat(format)) {
6852
+ const handoff = await collectDelimitedSources(path, config, fs, format);
6853
+ return {
6854
+ data: parseDelimitedSources(handoff.sources, format),
6855
+ staleLocales: handoff.staleLocales
6856
+ };
6857
+ }
6858
+ return { data: await readWorkbook(await readWorkbookBytes(path, fs)), staleLocales: [] };
6859
+ } catch (error) {
6860
+ if (error instanceof SdkError) {
6861
+ throw error;
6862
+ }
6863
+ throw new SdkError("SOURCE_INVALID", errorMessage(error));
6864
+ }
6865
+ }
6252
6866
  function mergeAccepted(target, accepted) {
6253
6867
  const merged = new Map(target.entries);
6254
6868
  for (const [key, { value, source }] of accepted) {
@@ -6289,18 +6903,43 @@ function computeLockEntries2(source, merged, baseline, accepted) {
6289
6903
  }
6290
6904
  var MissingSheetError = class extends Error {
6291
6905
  code = "WORKBOOK_SHEET_MISSING";
6292
- constructor(locale) {
6906
+ constructor(locale, format) {
6293
6907
  super(
6294
- `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.`
6908
+ 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.`
6295
6909
  );
6296
6910
  this.name = "MissingSheetError";
6297
6911
  }
6298
6912
  };
6913
+ var StaleHandoffFileError = class extends Error {
6914
+ code = "HANDOFF_FILE_STALE";
6915
+ constructor(locale, format) {
6916
+ super(
6917
+ `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.`
6918
+ );
6919
+ this.name = "StaleHandoffFileError";
6920
+ }
6921
+ };
6922
+ function absentLocaleFailures(config, sheets, format, staleLocales) {
6923
+ const present = new Set(sheets.map((sheet) => sheet.locale));
6924
+ const stale = new Set(staleLocales);
6925
+ const failures = [];
6926
+ for (const locale of config.targetLocales) {
6927
+ if (present.has(locale)) {
6928
+ continue;
6929
+ }
6930
+ const error = stale.has(locale) && isDelimitedFormat(format) ? new StaleHandoffFileError(locale, format) : new MissingSheetError(locale, format);
6931
+ failures.push(failureSummary(locale, error));
6932
+ }
6933
+ return failures;
6934
+ }
6935
+ function lineOf(reported) {
6936
+ return reported.line === void 0 ? {} : { line: reported.line };
6937
+ }
6299
6938
  async function runSheet(ctx, sheet, lock) {
6300
6939
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
6301
6940
  throw new SdkError(
6302
6941
  "CONFIG_INVALID",
6303
- `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.`
6942
+ 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.`
6304
6943
  );
6305
6944
  }
6306
6945
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -6312,15 +6951,15 @@ async function runSheet(ctx, sheet, lock) {
6312
6951
  baseline,
6313
6952
  adapter: ctx.adapter,
6314
6953
  sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6315
- malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6316
- duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row }))
6954
+ malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column, ...lineOf(problem) })),
6955
+ duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row, ...lineOf(duplicate) }))
6317
6956
  });
6318
6957
  if (ctx.dryRun) {
6319
6958
  return { summary, lockEntries: {}, cacheAdditions: {} };
6320
6959
  }
6321
6960
  const merged = mergeAccepted(target, accepted);
6322
6961
  if (accepted.size > 0) {
6323
- const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
6962
+ const path = createLocalePathResolver(ctx.cwd, ctx.config).pathFor(sheet.locale);
6324
6963
  await ctx.adapter.write(
6325
6964
  {
6326
6965
  locale: sheet.locale,
@@ -6344,14 +6983,13 @@ async function importWorkbook(input, deps = {}) {
6344
6983
  const fs = deps.fs ?? defaultFs;
6345
6984
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
6346
6985
  const source = await readSource(config, cwd, fs, adapter);
6347
- const workbookPath = resolve(cwd, input.workbook);
6348
- const bytes = await readWorkbookBytes(workbookPath, fs);
6349
- let data;
6350
- try {
6351
- data = await readWorkbook(bytes);
6352
- } catch (error) {
6353
- throw new SdkError("SOURCE_INVALID", error.message);
6354
- }
6986
+ const format = input.format ?? "xlsx";
6987
+ const { data, staleLocales } = await readImportData(
6988
+ resolve(cwd, input.workbook),
6989
+ config,
6990
+ fs,
6991
+ format
6992
+ );
6355
6993
  const lock = await readLockFile(lockFilePath(cwd), fs);
6356
6994
  const ctx = {
6357
6995
  config,
@@ -6362,7 +7000,8 @@ async function importWorkbook(input, deps = {}) {
6362
7000
  sourceInvalidIcuKeys: source.invalidIcuKeys,
6363
7001
  dryRun,
6364
7002
  malformedRows: data.malformedRows,
6365
- duplicateKeys: data.duplicateKeys
7003
+ duplicateKeys: data.duplicateKeys,
7004
+ format
6366
7005
  };
6367
7006
  const summaries = [];
6368
7007
  const cacheAdditions = /* @__PURE__ */ new Map();
@@ -6387,12 +7026,7 @@ async function importWorkbook(input, deps = {}) {
6387
7026
  summaries.push(failureSummary(sheet.locale, error));
6388
7027
  }
6389
7028
  }
6390
- const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6391
- for (const locale of config.targetLocales) {
6392
- if (!presentLocales.has(locale)) {
6393
- summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6394
- }
6395
- }
7029
+ summaries.push(...absentLocaleFailures(config, data.sheets, format, staleLocales));
6396
7030
  if (!dryRun) {
6397
7031
  await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6398
7032
  }
@@ -6436,19 +7070,13 @@ function defaultRunTranslate(deps) {
6436
7070
 
6437
7071
  // src/watch/watch.ts
6438
7072
  var DEFAULT_DEBOUNCE_MS = 300;
6439
- function describeError2(error) {
6440
- if (error instanceof Error) {
6441
- const code = error.code;
6442
- return { code: typeof code === "string" ? code : "WATCH_RUN_FAILED", message: error.message };
6443
- }
6444
- return { code: "WATCH_RUN_FAILED", message: String(error) };
6445
- }
6446
7073
  async function watch(input, deps = {}) {
6447
7074
  const cwd = input.cwd ?? process.cwd();
6448
7075
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6449
7076
  const fs = deps.fs ?? defaultFs;
6450
7077
  resolveRunConcurrency(input.concurrency, false, input.config);
6451
- const sourcePath = localeFilePath(cwd, input.config.files.pattern, input.config.sourceLocale);
7078
+ const resolver = createLocalePathResolver(cwd, input.config);
7079
+ const sourcePath = resolver.pathFor(input.config.sourceLocale);
6452
7080
  if (!await fs.fileExists(sourcePath)) {
6453
7081
  throw new SdkError(
6454
7082
  "SOURCE_UNREADABLE",
@@ -6474,7 +7102,7 @@ async function watch(input, deps = {}) {
6474
7102
  try {
6475
7103
  input.onRun({ status: "succeeded", summary: await runTranslate(runInput) });
6476
7104
  } catch (error) {
6477
- input.onRun({ status: "failed", error: describeError2(error) });
7105
+ input.onRun({ status: "failed", error: describeError(error, "WATCH_RUN_FAILED") });
6478
7106
  }
6479
7107
  }
6480
7108
  function startRun() {
@@ -6531,6 +7159,6 @@ async function watch(input, deps = {}) {
6531
7159
  return { stop };
6532
7160
  }
6533
7161
 
6534
- export { CACHE_FILE_NAME, DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
7162
+ export { CACHE_FILE_NAME, DEFAULT_DELIMITED_PATH, DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, createLocalePathResolver, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
6535
7163
  //# sourceMappingURL=index.js.map
6536
7164
  //# sourceMappingURL=index.js.map