@verbatra/sdk 0.6.4 → 0.7.1

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
@@ -288,6 +288,9 @@ var defaultFs = {
288
288
  createExclusive: (path, data) => createExclusive(path, data),
289
289
  deleteFile: async (path) => {
290
290
  await promises.rm(path, { force: true });
291
+ },
292
+ mkdir: async (path) => {
293
+ await promises.mkdir(path, { recursive: true });
291
294
  }
292
295
  };
293
296
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
@@ -526,9 +529,109 @@ function assessValueDegeneracy(sourceValue, candidateValue) {
526
529
  }
527
530
  return { degenerate: hasRunawayRepetition(candidate) };
528
531
  }
532
+
533
+ // src/locale-path/pattern.ts
529
534
  var LOCALE_TOKEN = "{locale}";
530
- function localeFilePath(cwd, pattern, locale) {
531
- 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 !== "..";
532
635
  }
533
636
  function checkBatchIntegrity(inputs, extract, compare) {
534
637
  const integrity = /* @__PURE__ */ new Map();
@@ -701,7 +804,7 @@ function deriveJsonSchema(schema) {
701
804
  result[key] = value;
702
805
  }
703
806
  }
704
- return result;
807
+ return { ...result, type: "object" };
705
808
  }
706
809
  function partitionTranslations(translations, requested) {
707
810
  const firstSeen = /* @__PURE__ */ new Map();
@@ -821,6 +924,8 @@ function assertNotTruncated(truncated) {
821
924
  var RATE_LIMITED_STATUS = 429;
822
925
  var TIMEOUT_STATUS = 408;
823
926
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
927
+ var SERVER_OUTAGE_MIN_STATUS = 500;
928
+ var SERVER_OUTAGE_MAX_STATUS = 599;
824
929
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
825
930
  "RateLimitError",
826
931
  "TooManyRequestsError"
@@ -842,6 +947,9 @@ function getErrorStatus(error) {
842
947
  const status = error.status;
843
948
  return typeof status === "number" ? status : void 0;
844
949
  }
950
+ function isServerOutageStatus(status) {
951
+ return status !== void 0 && status >= SERVER_OUTAGE_MIN_STATUS && status <= SERVER_OUTAGE_MAX_STATUS;
952
+ }
845
953
  function readClassName(error) {
846
954
  if (typeof error !== "object" || error === null) {
847
955
  return void 0;
@@ -859,6 +967,9 @@ function classifyProviderError(error) {
859
967
  if (status === TIMEOUT_STATUS) {
860
968
  return "TIMEOUT";
861
969
  }
970
+ if (isServerOutageStatus(status)) {
971
+ return "PROVIDER_UNAVAILABLE";
972
+ }
862
973
  const className = readClassName(error);
863
974
  if (className !== void 0) {
864
975
  if (RATE_LIMITED_CLASS_NAMES.has(className)) {
@@ -890,10 +1001,12 @@ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
890
1001
  var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
891
1002
  var TIMEOUT_MESSAGE = "The translation provider request timed out.";
892
1003
  var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
1004
+ var PROVIDER_UNAVAILABLE_MESSAGE = "The translation provider is currently unavailable.";
893
1005
  var MESSAGE_BY_CODE = {
894
1006
  RATE_LIMITED: RATE_LIMITED_MESSAGE,
895
1007
  TIMEOUT: TIMEOUT_MESSAGE,
896
1008
  AUTH_FAILED: AUTH_FAILED_MESSAGE,
1009
+ PROVIDER_UNAVAILABLE: PROVIDER_UNAVAILABLE_MESSAGE,
897
1010
  PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
898
1011
  };
899
1012
  async function guardProviderCall(call, signal) {
@@ -974,14 +1087,14 @@ function resolveOpenAiCompatibleKey(customEnvVar) {
974
1087
  }
975
1088
  return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
976
1089
  }
1090
+ function toMutableRequest(request) {
1091
+ return request;
1092
+ }
977
1093
  function createDefaultClient() {
978
1094
  const sdk = new Anthropic__default.default({ apiKey: requireAnthropicKey(), logLevel: "off" });
979
1095
  return {
980
1096
  messages: {
981
- create: async (body, options) => await sdk.messages.create(
982
- body,
983
- options
984
- )
1097
+ create: async (body, options) => await sdk.messages.create(toMutableRequest(body), options)
985
1098
  }
986
1099
  };
987
1100
  }
@@ -1399,7 +1512,7 @@ function createDefaultClient3() {
1399
1512
  models: {
1400
1513
  generateContent: (request) => withGeminiRetry(
1401
1514
  async () => await ai.models.generateContent(
1402
- request
1515
+ toMutableRequest(request)
1403
1516
  ),
1404
1517
  request.config.abortSignal
1405
1518
  )
@@ -1563,7 +1676,7 @@ function createDefaultClient4() {
1563
1676
  chat: {
1564
1677
  completions: {
1565
1678
  create: async (body, options) => await sdk.chat.completions.create(
1566
- body,
1679
+ toMutableRequest(body),
1567
1680
  options
1568
1681
  )
1569
1682
  }
@@ -1764,7 +1877,7 @@ function createDefaultClient5(config) {
1764
1877
  chat: {
1765
1878
  completions: {
1766
1879
  create: async (body, options) => await sdk.chat.completions.create(
1767
- body,
1880
+ toMutableRequest(body),
1768
1881
  options
1769
1882
  )
1770
1883
  }
@@ -1846,7 +1959,13 @@ var verbatraConfigSchema = zod.z.strictObject({
1846
1959
  targetLocales: zod.z.array(zod.z.string().min(1)).min(1),
1847
1960
  format: supportedFormatSchema,
1848
1961
  files: zod.z.strictObject({
1849
- 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()
1850
1969
  }),
1851
1970
  provider: providerConfigSchema,
1852
1971
  /**
@@ -1867,8 +1986,9 @@ var verbatraConfigSchema = zod.z.strictObject({
1867
1986
  * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
1868
1987
  * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
1869
1988
  * requires but the source does not supply (for example Polish few/many). The per-run
1870
- * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
1871
- * 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.
1872
1992
  */
1873
1993
  generatePlurals: zod.z.boolean().optional(),
1874
1994
  /**
@@ -2003,6 +2123,63 @@ async function loadConfig(options = {}) {
2003
2123
  const { config } = await loadConfigWithMeta(options);
2004
2124
  return config;
2005
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
+ }
2006
2183
  var LOCAL_DIR_NAME = ".verbatra-local";
2007
2184
  var LOCK_FILE_GUARD_STEM = "_lockfile";
2008
2185
  var DEFAULT_POLL_INTERVAL_MS = 100;
@@ -4024,7 +4201,7 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
4024
4201
 
4025
4202
  // src/flow/read-target.ts
4026
4203
  async function readTargetResource(input) {
4027
- const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
4204
+ const path = input.resolver.pathFor(input.locale);
4028
4205
  if (!await input.fs.fileExists(path)) {
4029
4206
  return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
4030
4207
  }
@@ -4051,7 +4228,7 @@ function selectLocales(config, requested) {
4051
4228
 
4052
4229
  // src/flow/source.ts
4053
4230
  async function readSource(config, cwd, fs, adapter) {
4054
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
4231
+ const sourcePath = createLocalePathResolver(cwd, config).pathFor(config.sourceLocale);
4055
4232
  if (!await fs.fileExists(sourcePath)) {
4056
4233
  throw new SdkError(
4057
4234
  "SOURCE_UNREADABLE",
@@ -4072,8 +4249,7 @@ async function readSource(config, cwd, fs, adapter) {
4072
4249
  // src/flow/diff-locales.ts
4073
4250
  async function readTarget(cwd, config, adapter, fs, locale) {
4074
4251
  return readTargetResource({
4075
- cwd,
4076
- filesPattern: config.files.pattern,
4252
+ resolver: createLocalePathResolver(cwd, config),
4077
4253
  format: config.format,
4078
4254
  locale,
4079
4255
  adapter,
@@ -4194,7 +4370,7 @@ async function editEntry(input, deps = {}) {
4194
4370
  }
4195
4371
  const merged = new Map(target.entries);
4196
4372
  merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
4197
- const path = localeFilePath(cwd, config.files.pattern, locale);
4373
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4198
4374
  await adapter.write(
4199
4375
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4200
4376
  path
@@ -4417,7 +4593,7 @@ async function retranslateEntry(input, deps = {}) {
4417
4593
  }
4418
4594
  const merged = new Map(target.entries);
4419
4595
  merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
4420
- const path = localeFilePath(cwd, config.files.pattern, locale);
4596
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4421
4597
  await adapter.write(
4422
4598
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4423
4599
  path
@@ -4836,6 +5012,9 @@ function syntheticEntry(item) {
4836
5012
  meaning: `CLDR plural category "${item.category}"`
4837
5013
  };
4838
5014
  }
5015
+ function isAdopted(item, targetKeys, baseline) {
5016
+ return targetKeys.has(item.targetKey) && !baseline.has(item.targetKey);
5017
+ }
4839
5018
  function staleItems(items, baseline) {
4840
5019
  return items.filter((item) => {
4841
5020
  const hash = generatedLockHash(item.governingEntries, item.category);
@@ -4855,7 +5034,10 @@ function buildRequest2(context, entries) {
4855
5034
  }
4856
5035
  async function generatePluralForms(context) {
4857
5036
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
4858
- 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);
4859
5041
  if (stale.length === 0) {
4860
5042
  return EMPTY_RESULT;
4861
5043
  }
@@ -5045,8 +5227,7 @@ async function shouldWriteTarget(params, path, changed) {
5045
5227
  }
5046
5228
  async function runLocale(params) {
5047
5229
  const target = await readTargetResource({
5048
- cwd: params.cwd,
5049
- filesPattern: params.filesPattern,
5230
+ resolver: params.resolver,
5050
5231
  format: params.format,
5051
5232
  locale: params.targetLocale,
5052
5233
  adapter: params.adapter,
@@ -5123,11 +5304,11 @@ async function runLocale(params) {
5123
5304
  merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
5124
5305
  }
5125
5306
  }
5126
- const generation = await runGeneration(params, provider);
5307
+ const generation = await runGeneration(params, provider, new Set(target.entries.keys()));
5127
5308
  for (const form of generation.accepted) {
5128
5309
  merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
5129
5310
  }
5130
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
5311
+ const path = params.resolver.pathFor(params.targetLocale);
5131
5312
  const writeNeeded = await shouldWriteTarget(params, path, {
5132
5313
  accepted: accepted.size,
5133
5314
  pruned: pruned.length,
@@ -5190,7 +5371,7 @@ var NO_GENERATION_RESULT = {
5190
5371
  usage: void 0,
5191
5372
  tripped: false
5192
5373
  };
5193
- async function runGeneration(params, provider) {
5374
+ async function runGeneration(params, provider, targetKeys) {
5194
5375
  if (!params.generatePlurals || provider.kind !== "llm") {
5195
5376
  return NO_GENERATION_RESULT;
5196
5377
  }
@@ -5204,6 +5385,7 @@ async function runGeneration(params, provider) {
5204
5385
  glossary: params.glossary,
5205
5386
  tone: params.tone,
5206
5387
  baseline: params.baseline,
5388
+ targetKeys,
5207
5389
  maxBatchSize: params.maxBatchSize,
5208
5390
  budget: params.budget
5209
5391
  });
@@ -5446,7 +5628,7 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
5446
5628
  adapter: context.adapter,
5447
5629
  provider: context.provider,
5448
5630
  cwd: context.cwd,
5449
- filesPattern: context.config.files.pattern,
5631
+ resolver: context.resolver,
5450
5632
  sourceLocale: context.config.sourceLocale,
5451
5633
  targetLocale,
5452
5634
  format: context.config.format,
@@ -5600,6 +5782,7 @@ async function translate2(input, deps = {}) {
5600
5782
  config.maxTokens,
5601
5783
  config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
5602
5784
  );
5785
+ const resolver = createLocalePathResolver(cwd, config);
5603
5786
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
5604
5787
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
5605
5788
  const source = await readSource(config, cwd, fs, adapter);
@@ -5610,6 +5793,7 @@ async function translate2(input, deps = {}) {
5610
5793
  provider,
5611
5794
  cwd,
5612
5795
  config,
5796
+ resolver,
5613
5797
  prune,
5614
5798
  generatePlurals,
5615
5799
  maxBatchSize,
@@ -5650,6 +5834,123 @@ var ExchangeError = class extends Error {
5650
5834
  this.code = code;
5651
5835
  }
5652
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
+ }
5653
5954
  var INSTRUCTIONS_LINES = [
5654
5955
  "How to use this workbook",
5655
5956
  "",
@@ -5678,30 +5979,6 @@ var INSTRUCTIONS_LINES = [
5678
5979
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
5679
5980
  "withheld (not written) and reported so you can fix and resubmit it."
5680
5981
  ];
5681
- var COLUMN = {
5682
- key: 1,
5683
- source: 2,
5684
- current: 3,
5685
- status: 4,
5686
- translation: 5,
5687
- sourceHash: 6,
5688
- context: 7,
5689
- reviewStatus: 8,
5690
- reviewReasons: 9
5691
- };
5692
- var HEADERS = [
5693
- "Key",
5694
- "Source",
5695
- "Current translation",
5696
- "Status",
5697
- "Translation",
5698
- "Source hash",
5699
- "Context",
5700
- "Review status",
5701
- "Review reasons"
5702
- ];
5703
- var HEADER_ROW = 1;
5704
- var INSTRUCTIONS_SHEET_NAME = "Instructions";
5705
5982
  var READ_ONLY_FILL = {
5706
5983
  type: "pattern",
5707
5984
  pattern: "solid",
@@ -5861,6 +6138,12 @@ async function buildWorkbook(model) {
5861
6138
  const protectedBytes = await protectWorkbookStructure(serialized);
5862
6139
  return Uint8Array.prototype.slice.call(protectedBytes);
5863
6140
  }
6141
+ var DEFAULT_DELIMITED_LIMITS = {
6142
+ maxInputBytes: 32 * 1024 * 1024,
6143
+ maxRowsPerFile: 1e5,
6144
+ maxFieldsPerRow: 64,
6145
+ maxFieldLength: 1024 * 1024
6146
+ };
5864
6147
  var DEFAULT_WORKBOOK_LIMITS = {
5865
6148
  maxDecompressedBytes: 64 * 1024 * 1024,
5866
6149
  maxEntryCount: 1024,
@@ -5868,6 +6151,205 @@ var DEFAULT_WORKBOOK_LIMITS = {
5868
6151
  maxRowsPerSheet: 1e5,
5869
6152
  maxCellsPerRow: 64
5870
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
+ }
5871
6353
  function assertNoDoctype2(name, xml) {
5872
6354
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
5873
6355
  throw new ExchangeError(
@@ -5944,17 +6426,6 @@ async function guardWorkbookBytes(bytes, limits) {
5944
6426
  assertNoDoctype2(file.name, content);
5945
6427
  }
5946
6428
  }
5947
- var rowSchema = zod.z.object({
5948
- key: zod.z.string().min(1),
5949
- source: zod.z.string(),
5950
- currentTarget: zod.z.string(),
5951
- status: zod.z.enum(["new", "changed", "unchanged"]),
5952
- sourceHash: zod.z.string(),
5953
- translation: zod.z.string(),
5954
- context: zod.z.string(),
5955
- reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
5956
- reviewReasons: zod.z.string().catch("")
5957
- });
5958
6429
  function cellString(cell) {
5959
6430
  const value = cell.value;
5960
6431
  if (value === null || value === void 0) {
@@ -5979,24 +6450,8 @@ function assertHeader(sheet) {
5979
6450
  );
5980
6451
  }
5981
6452
  }
5982
- var MALFORMED_ROW_COLUMN = "Status";
5983
6453
  function parseRow(row) {
5984
- const candidate = {
5985
- key: cellString(row.getCell(COLUMN.key)),
5986
- source: cellString(row.getCell(COLUMN.source)),
5987
- currentTarget: cellString(row.getCell(COLUMN.current)),
5988
- status: cellString(row.getCell(COLUMN.status)),
5989
- sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
5990
- translation: cellString(row.getCell(COLUMN.translation)).trim(),
5991
- context: cellString(row.getCell(COLUMN.context)),
5992
- reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
5993
- reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
5994
- };
5995
- const result = rowSchema.safeParse(candidate);
5996
- if (!result.success) {
5997
- return { ok: false, column: MALFORMED_ROW_COLUMN };
5998
- }
5999
- return { ok: true, row: result.data };
6454
+ return parseRowCells(HEADERS.map((_, index) => cellString(row.getCell(index + 1))));
6000
6455
  }
6001
6456
  function readDataSheet(sheet, limits) {
6002
6457
  assertHeader(sheet);
@@ -6070,8 +6525,52 @@ async function readWorkbook(bytes, options = {}) {
6070
6525
  return { sheets, malformedRows, duplicateKeys };
6071
6526
  }
6072
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
+
6073
6571
  // src/flow/workbook/export-workbook.ts
6074
6572
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
6573
+ var DEFAULT_DELIMITED_PATH = "verbatra-translations";
6075
6574
  function reasonLabel(reason) {
6076
6575
  return reason.toLowerCase().replace(/_/g, "-");
6077
6576
  }
@@ -6135,6 +6634,21 @@ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary
6135
6634
  }
6136
6635
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
6137
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
+ }
6138
6652
  async function exportWorkbook(input, deps = {}) {
6139
6653
  const config = input.config;
6140
6654
  const cwd = input.cwd ?? process.cwd();
@@ -6157,10 +6671,17 @@ async function exportWorkbook(input, deps = {}) {
6157
6671
  return { locale, rows };
6158
6672
  })
6159
6673
  );
6160
- const model = { sheets };
6161
- const bytes = await buildWorkbook(model);
6162
- const path$1 = path.resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
6163
- 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
+ }
6164
6685
  return {
6165
6686
  path: path$1,
6166
6687
  locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
@@ -6289,6 +6810,7 @@ function importLocale(params) {
6289
6810
 
6290
6811
  // src/flow/workbook/import-workbook.ts
6291
6812
  var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
6813
+ var MAX_DELIMITED_FILE_BYTES = 32 * 1024 * 1024;
6292
6814
  async function readWorkbookBytes(path, fs) {
6293
6815
  const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
6294
6816
  if (read.kind === "missing") {
@@ -6302,6 +6824,75 @@ async function readWorkbookBytes(path, fs) {
6302
6824
  }
6303
6825
  return read.bytes;
6304
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
+ }
6305
6896
  function mergeAccepted(target, accepted) {
6306
6897
  const merged = new Map(target.entries);
6307
6898
  for (const [key, { value, source }] of accepted) {
@@ -6342,18 +6933,43 @@ function computeLockEntries2(source, merged, baseline, accepted) {
6342
6933
  }
6343
6934
  var MissingSheetError = class extends Error {
6344
6935
  code = "WORKBOOK_SHEET_MISSING";
6345
- constructor(locale) {
6936
+ constructor(locale, format) {
6346
6937
  super(
6347
- `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.`
6348
6939
  );
6349
6940
  this.name = "MissingSheetError";
6350
6941
  }
6351
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
+ }
6352
6968
  async function runSheet(ctx, sheet, lock) {
6353
6969
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
6354
6970
  throw new SdkError(
6355
6971
  "CONFIG_INVALID",
6356
- `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.`
6357
6973
  );
6358
6974
  }
6359
6975
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -6365,15 +6981,15 @@ async function runSheet(ctx, sheet, lock) {
6365
6981
  baseline,
6366
6982
  adapter: ctx.adapter,
6367
6983
  sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6368
- malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6369
- 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) }))
6370
6986
  });
6371
6987
  if (ctx.dryRun) {
6372
6988
  return { summary, lockEntries: {}, cacheAdditions: {} };
6373
6989
  }
6374
6990
  const merged = mergeAccepted(target, accepted);
6375
6991
  if (accepted.size > 0) {
6376
- const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
6992
+ const path = createLocalePathResolver(ctx.cwd, ctx.config).pathFor(sheet.locale);
6377
6993
  await ctx.adapter.write(
6378
6994
  {
6379
6995
  locale: sheet.locale,
@@ -6397,14 +7013,13 @@ async function importWorkbook(input, deps = {}) {
6397
7013
  const fs = deps.fs ?? defaultFs;
6398
7014
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
6399
7015
  const source = await readSource(config, cwd, fs, adapter);
6400
- const workbookPath = path.resolve(cwd, input.workbook);
6401
- const bytes = await readWorkbookBytes(workbookPath, fs);
6402
- let data;
6403
- try {
6404
- data = await readWorkbook(bytes);
6405
- } catch (error) {
6406
- throw new SdkError("SOURCE_INVALID", errorMessage(error));
6407
- }
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
+ );
6408
7023
  const lock = await readLockFile(lockFilePath(cwd), fs);
6409
7024
  const ctx = {
6410
7025
  config,
@@ -6415,7 +7030,8 @@ async function importWorkbook(input, deps = {}) {
6415
7030
  sourceInvalidIcuKeys: source.invalidIcuKeys,
6416
7031
  dryRun,
6417
7032
  malformedRows: data.malformedRows,
6418
- duplicateKeys: data.duplicateKeys
7033
+ duplicateKeys: data.duplicateKeys,
7034
+ format
6419
7035
  };
6420
7036
  const summaries = [];
6421
7037
  const cacheAdditions = /* @__PURE__ */ new Map();
@@ -6440,12 +7056,7 @@ async function importWorkbook(input, deps = {}) {
6440
7056
  summaries.push(failureSummary(sheet.locale, error));
6441
7057
  }
6442
7058
  }
6443
- const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6444
- for (const locale of config.targetLocales) {
6445
- if (!presentLocales.has(locale)) {
6446
- summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6447
- }
6448
- }
7059
+ summaries.push(...absentLocaleFailures(config, data.sheets, format, staleLocales));
6449
7060
  if (!dryRun) {
6450
7061
  await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6451
7062
  }
@@ -6494,7 +7105,8 @@ async function watch(input, deps = {}) {
6494
7105
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6495
7106
  const fs = deps.fs ?? defaultFs;
6496
7107
  resolveRunConcurrency(input.concurrency, false, input.config);
6497
- 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);
6498
7110
  if (!await fs.fileExists(sourcePath)) {
6499
7111
  throw new SdkError(
6500
7112
  "SOURCE_UNREADABLE",
@@ -6578,10 +7190,12 @@ async function watch(input, deps = {}) {
6578
7190
  }
6579
7191
 
6580
7192
  exports.CACHE_FILE_NAME = CACHE_FILE_NAME;
7193
+ exports.DEFAULT_DELIMITED_PATH = DEFAULT_DELIMITED_PATH;
6581
7194
  exports.DEFAULT_WORKBOOK_PATH = DEFAULT_WORKBOOK_PATH;
6582
7195
  exports.LOCK_FILE_NAME = LOCK_FILE_NAME;
6583
7196
  exports.SdkError = SdkError;
6584
7197
  exports.check = check;
7198
+ exports.createLocalePathResolver = createLocalePathResolver;
6585
7199
  exports.defineConfig = defineConfig;
6586
7200
  exports.diff = diff;
6587
7201
  exports.diffLocaleSnapshots = diffLocaleSnapshots;