@verbatra/sdk 0.6.4 → 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';
@@ -258,6 +258,9 @@ var defaultFs = {
258
258
  createExclusive: (path, data) => createExclusive(path, data),
259
259
  deleteFile: async (path) => {
260
260
  await rm(path, { force: true });
261
+ },
262
+ mkdir: async (path) => {
263
+ await mkdir(path, { recursive: true });
261
264
  }
262
265
  };
263
266
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
@@ -496,9 +499,109 @@ function assessValueDegeneracy(sourceValue, candidateValue) {
496
499
  }
497
500
  return { degenerate: hasRunawayRepetition(candidate) };
498
501
  }
502
+
503
+ // src/locale-path/pattern.ts
499
504
  var LOCALE_TOKEN = "{locale}";
500
- function localeFilePath(cwd, pattern, locale) {
501
- 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 !== "..";
502
605
  }
503
606
  function checkBatchIntegrity(inputs, extract, compare) {
504
607
  const integrity = /* @__PURE__ */ new Map();
@@ -671,7 +774,7 @@ function deriveJsonSchema(schema) {
671
774
  result[key] = value;
672
775
  }
673
776
  }
674
- return result;
777
+ return { ...result, type: "object" };
675
778
  }
676
779
  function partitionTranslations(translations, requested) {
677
780
  const firstSeen = /* @__PURE__ */ new Map();
@@ -791,6 +894,8 @@ function assertNotTruncated(truncated) {
791
894
  var RATE_LIMITED_STATUS = 429;
792
895
  var TIMEOUT_STATUS = 408;
793
896
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
897
+ var SERVER_OUTAGE_MIN_STATUS = 500;
898
+ var SERVER_OUTAGE_MAX_STATUS = 599;
794
899
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
795
900
  "RateLimitError",
796
901
  "TooManyRequestsError"
@@ -812,6 +917,9 @@ function getErrorStatus(error) {
812
917
  const status = error.status;
813
918
  return typeof status === "number" ? status : void 0;
814
919
  }
920
+ function isServerOutageStatus(status) {
921
+ return status !== void 0 && status >= SERVER_OUTAGE_MIN_STATUS && status <= SERVER_OUTAGE_MAX_STATUS;
922
+ }
815
923
  function readClassName(error) {
816
924
  if (typeof error !== "object" || error === null) {
817
925
  return void 0;
@@ -829,6 +937,9 @@ function classifyProviderError(error) {
829
937
  if (status === TIMEOUT_STATUS) {
830
938
  return "TIMEOUT";
831
939
  }
940
+ if (isServerOutageStatus(status)) {
941
+ return "PROVIDER_UNAVAILABLE";
942
+ }
832
943
  const className = readClassName(error);
833
944
  if (className !== void 0) {
834
945
  if (RATE_LIMITED_CLASS_NAMES.has(className)) {
@@ -860,10 +971,12 @@ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
860
971
  var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
861
972
  var TIMEOUT_MESSAGE = "The translation provider request timed out.";
862
973
  var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
974
+ var PROVIDER_UNAVAILABLE_MESSAGE = "The translation provider is currently unavailable.";
863
975
  var MESSAGE_BY_CODE = {
864
976
  RATE_LIMITED: RATE_LIMITED_MESSAGE,
865
977
  TIMEOUT: TIMEOUT_MESSAGE,
866
978
  AUTH_FAILED: AUTH_FAILED_MESSAGE,
979
+ PROVIDER_UNAVAILABLE: PROVIDER_UNAVAILABLE_MESSAGE,
867
980
  PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
868
981
  };
869
982
  async function guardProviderCall(call, signal) {
@@ -944,14 +1057,14 @@ function resolveOpenAiCompatibleKey(customEnvVar) {
944
1057
  }
945
1058
  return OPENAI_COMPATIBLE_KEY_PLACEHOLDER;
946
1059
  }
1060
+ function toMutableRequest(request) {
1061
+ return request;
1062
+ }
947
1063
  function createDefaultClient() {
948
1064
  const sdk = new Anthropic({ apiKey: requireAnthropicKey(), logLevel: "off" });
949
1065
  return {
950
1066
  messages: {
951
- create: async (body, options) => await sdk.messages.create(
952
- body,
953
- options
954
- )
1067
+ create: async (body, options) => await sdk.messages.create(toMutableRequest(body), options)
955
1068
  }
956
1069
  };
957
1070
  }
@@ -1369,7 +1482,7 @@ function createDefaultClient3() {
1369
1482
  models: {
1370
1483
  generateContent: (request) => withGeminiRetry(
1371
1484
  async () => await ai.models.generateContent(
1372
- request
1485
+ toMutableRequest(request)
1373
1486
  ),
1374
1487
  request.config.abortSignal
1375
1488
  )
@@ -1533,7 +1646,7 @@ function createDefaultClient4() {
1533
1646
  chat: {
1534
1647
  completions: {
1535
1648
  create: async (body, options) => await sdk.chat.completions.create(
1536
- body,
1649
+ toMutableRequest(body),
1537
1650
  options
1538
1651
  )
1539
1652
  }
@@ -1734,7 +1847,7 @@ function createDefaultClient5(config) {
1734
1847
  chat: {
1735
1848
  completions: {
1736
1849
  create: async (body, options) => await sdk.chat.completions.create(
1737
- body,
1850
+ toMutableRequest(body),
1738
1851
  options
1739
1852
  )
1740
1853
  }
@@ -1816,7 +1929,13 @@ var verbatraConfigSchema = z.strictObject({
1816
1929
  targetLocales: z.array(z.string().min(1)).min(1),
1817
1930
  format: supportedFormatSchema,
1818
1931
  files: z.strictObject({
1819
- 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()
1820
1939
  }),
1821
1940
  provider: providerConfigSchema,
1822
1941
  /**
@@ -1837,8 +1956,9 @@ var verbatraConfigSchema = z.strictObject({
1837
1956
  * Opt-in plural-category generation, off by default. When true, and only for an i18next-JSON project
1838
1957
  * translated by an LLM provider, verbatra synthesizes the CLDR plural forms a target language
1839
1958
  * requires but the source does not supply (for example Polish few/many). The per-run
1840
- * `generatePlurals` option on `translate` overrides this. Unsupported cases (DeepL, non-i18next, an
1841
- * 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.
1842
1962
  */
1843
1963
  generatePlurals: z.boolean().optional(),
1844
1964
  /**
@@ -1973,6 +2093,63 @@ async function loadConfig(options = {}) {
1973
2093
  const { config } = await loadConfigWithMeta(options);
1974
2094
  return config;
1975
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
+ }
1976
2153
  var LOCAL_DIR_NAME = ".verbatra-local";
1977
2154
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1978
2155
  var DEFAULT_POLL_INTERVAL_MS = 100;
@@ -3994,7 +4171,7 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
3994
4171
 
3995
4172
  // src/flow/read-target.ts
3996
4173
  async function readTargetResource(input) {
3997
- const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
4174
+ const path = input.resolver.pathFor(input.locale);
3998
4175
  if (!await input.fs.fileExists(path)) {
3999
4176
  return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
4000
4177
  }
@@ -4021,7 +4198,7 @@ function selectLocales(config, requested) {
4021
4198
 
4022
4199
  // src/flow/source.ts
4023
4200
  async function readSource(config, cwd, fs, adapter) {
4024
- const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
4201
+ const sourcePath = createLocalePathResolver(cwd, config).pathFor(config.sourceLocale);
4025
4202
  if (!await fs.fileExists(sourcePath)) {
4026
4203
  throw new SdkError(
4027
4204
  "SOURCE_UNREADABLE",
@@ -4042,8 +4219,7 @@ async function readSource(config, cwd, fs, adapter) {
4042
4219
  // src/flow/diff-locales.ts
4043
4220
  async function readTarget(cwd, config, adapter, fs, locale) {
4044
4221
  return readTargetResource({
4045
- cwd,
4046
- filesPattern: config.files.pattern,
4222
+ resolver: createLocalePathResolver(cwd, config),
4047
4223
  format: config.format,
4048
4224
  locale,
4049
4225
  adapter,
@@ -4164,7 +4340,7 @@ async function editEntry(input, deps = {}) {
4164
4340
  }
4165
4341
  const merged = new Map(target.entries);
4166
4342
  merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
4167
- const path = localeFilePath(cwd, config.files.pattern, locale);
4343
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4168
4344
  await adapter.write(
4169
4345
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4170
4346
  path
@@ -4387,7 +4563,7 @@ async function retranslateEntry(input, deps = {}) {
4387
4563
  }
4388
4564
  const merged = new Map(target.entries);
4389
4565
  merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
4390
- const path = localeFilePath(cwd, config.files.pattern, locale);
4566
+ const path = createLocalePathResolver(cwd, config).pathFor(locale);
4391
4567
  await adapter.write(
4392
4568
  { locale, namespace: target.namespace, format: config.format, entries: merged },
4393
4569
  path
@@ -4806,6 +4982,9 @@ function syntheticEntry(item) {
4806
4982
  meaning: `CLDR plural category "${item.category}"`
4807
4983
  };
4808
4984
  }
4985
+ function isAdopted(item, targetKeys, baseline) {
4986
+ return targetKeys.has(item.targetKey) && !baseline.has(item.targetKey);
4987
+ }
4809
4988
  function staleItems(items, baseline) {
4810
4989
  return items.filter((item) => {
4811
4990
  const hash = generatedLockHash(item.governingEntries, item.category);
@@ -4825,7 +5004,10 @@ function buildRequest2(context, entries) {
4825
5004
  }
4826
5005
  async function generatePluralForms(context) {
4827
5006
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
4828
- 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);
4829
5011
  if (stale.length === 0) {
4830
5012
  return EMPTY_RESULT;
4831
5013
  }
@@ -5015,8 +5197,7 @@ async function shouldWriteTarget(params, path, changed) {
5015
5197
  }
5016
5198
  async function runLocale(params) {
5017
5199
  const target = await readTargetResource({
5018
- cwd: params.cwd,
5019
- filesPattern: params.filesPattern,
5200
+ resolver: params.resolver,
5020
5201
  format: params.format,
5021
5202
  locale: params.targetLocale,
5022
5203
  adapter: params.adapter,
@@ -5093,11 +5274,11 @@ async function runLocale(params) {
5093
5274
  merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
5094
5275
  }
5095
5276
  }
5096
- const generation = await runGeneration(params, provider);
5277
+ const generation = await runGeneration(params, provider, new Set(target.entries.keys()));
5097
5278
  for (const form of generation.accepted) {
5098
5279
  merged.set(form.targetKey, { ...form.entry, namespace: target.namespace });
5099
5280
  }
5100
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
5281
+ const path = params.resolver.pathFor(params.targetLocale);
5101
5282
  const writeNeeded = await shouldWriteTarget(params, path, {
5102
5283
  accepted: accepted.size,
5103
5284
  pruned: pruned.length,
@@ -5160,7 +5341,7 @@ var NO_GENERATION_RESULT = {
5160
5341
  usage: void 0,
5161
5342
  tripped: false
5162
5343
  };
5163
- async function runGeneration(params, provider) {
5344
+ async function runGeneration(params, provider, targetKeys) {
5164
5345
  if (!params.generatePlurals || provider.kind !== "llm") {
5165
5346
  return NO_GENERATION_RESULT;
5166
5347
  }
@@ -5174,6 +5355,7 @@ async function runGeneration(params, provider) {
5174
5355
  glossary: params.glossary,
5175
5356
  tone: params.tone,
5176
5357
  baseline: params.baseline,
5358
+ targetKeys,
5177
5359
  maxBatchSize: params.maxBatchSize,
5178
5360
  budget: params.budget
5179
5361
  });
@@ -5416,7 +5598,7 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
5416
5598
  adapter: context.adapter,
5417
5599
  provider: context.provider,
5418
5600
  cwd: context.cwd,
5419
- filesPattern: context.config.files.pattern,
5601
+ resolver: context.resolver,
5420
5602
  sourceLocale: context.config.sourceLocale,
5421
5603
  targetLocale,
5422
5604
  format: context.config.format,
@@ -5570,6 +5752,7 @@ async function translate2(input, deps = {}) {
5570
5752
  config.maxTokens,
5571
5753
  config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
5572
5754
  );
5755
+ const resolver = createLocalePathResolver(cwd, config);
5573
5756
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
5574
5757
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
5575
5758
  const source = await readSource(config, cwd, fs, adapter);
@@ -5580,6 +5763,7 @@ async function translate2(input, deps = {}) {
5580
5763
  provider,
5581
5764
  cwd,
5582
5765
  config,
5766
+ resolver,
5583
5767
  prune,
5584
5768
  generatePlurals,
5585
5769
  maxBatchSize,
@@ -5620,6 +5804,123 @@ var ExchangeError = class extends Error {
5620
5804
  this.code = code;
5621
5805
  }
5622
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
+ }
5623
5924
  var INSTRUCTIONS_LINES = [
5624
5925
  "How to use this workbook",
5625
5926
  "",
@@ -5648,30 +5949,6 @@ var INSTRUCTIONS_LINES = [
5648
5949
  "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
5649
5950
  "withheld (not written) and reported so you can fix and resubmit it."
5650
5951
  ];
5651
- var COLUMN = {
5652
- key: 1,
5653
- source: 2,
5654
- current: 3,
5655
- status: 4,
5656
- translation: 5,
5657
- sourceHash: 6,
5658
- context: 7,
5659
- reviewStatus: 8,
5660
- reviewReasons: 9
5661
- };
5662
- var HEADERS = [
5663
- "Key",
5664
- "Source",
5665
- "Current translation",
5666
- "Status",
5667
- "Translation",
5668
- "Source hash",
5669
- "Context",
5670
- "Review status",
5671
- "Review reasons"
5672
- ];
5673
- var HEADER_ROW = 1;
5674
- var INSTRUCTIONS_SHEET_NAME = "Instructions";
5675
5952
  var READ_ONLY_FILL = {
5676
5953
  type: "pattern",
5677
5954
  pattern: "solid",
@@ -5831,6 +6108,12 @@ async function buildWorkbook(model) {
5831
6108
  const protectedBytes = await protectWorkbookStructure(serialized);
5832
6109
  return Uint8Array.prototype.slice.call(protectedBytes);
5833
6110
  }
6111
+ var DEFAULT_DELIMITED_LIMITS = {
6112
+ maxInputBytes: 32 * 1024 * 1024,
6113
+ maxRowsPerFile: 1e5,
6114
+ maxFieldsPerRow: 64,
6115
+ maxFieldLength: 1024 * 1024
6116
+ };
5834
6117
  var DEFAULT_WORKBOOK_LIMITS = {
5835
6118
  maxDecompressedBytes: 64 * 1024 * 1024,
5836
6119
  maxEntryCount: 1024,
@@ -5838,6 +6121,205 @@ var DEFAULT_WORKBOOK_LIMITS = {
5838
6121
  maxRowsPerSheet: 1e5,
5839
6122
  maxCellsPerRow: 64
5840
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
+ }
5841
6323
  function assertNoDoctype2(name, xml) {
5842
6324
  if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
5843
6325
  throw new ExchangeError(
@@ -5914,17 +6396,6 @@ async function guardWorkbookBytes(bytes, limits) {
5914
6396
  assertNoDoctype2(file.name, content);
5915
6397
  }
5916
6398
  }
5917
- var rowSchema = z.object({
5918
- key: z.string().min(1),
5919
- source: z.string(),
5920
- currentTarget: z.string(),
5921
- status: z.enum(["new", "changed", "unchanged"]),
5922
- sourceHash: z.string(),
5923
- translation: z.string(),
5924
- context: z.string(),
5925
- reviewStatus: z.enum(["ok", "review"]).catch("ok"),
5926
- reviewReasons: z.string().catch("")
5927
- });
5928
6399
  function cellString(cell) {
5929
6400
  const value = cell.value;
5930
6401
  if (value === null || value === void 0) {
@@ -5949,24 +6420,8 @@ function assertHeader(sheet) {
5949
6420
  );
5950
6421
  }
5951
6422
  }
5952
- var MALFORMED_ROW_COLUMN = "Status";
5953
6423
  function parseRow(row) {
5954
- const candidate = {
5955
- key: cellString(row.getCell(COLUMN.key)),
5956
- source: cellString(row.getCell(COLUMN.source)),
5957
- currentTarget: cellString(row.getCell(COLUMN.current)),
5958
- status: cellString(row.getCell(COLUMN.status)),
5959
- sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
5960
- translation: cellString(row.getCell(COLUMN.translation)).trim(),
5961
- context: cellString(row.getCell(COLUMN.context)),
5962
- reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
5963
- reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
5964
- };
5965
- const result = rowSchema.safeParse(candidate);
5966
- if (!result.success) {
5967
- return { ok: false, column: MALFORMED_ROW_COLUMN };
5968
- }
5969
- return { ok: true, row: result.data };
6424
+ return parseRowCells(HEADERS.map((_, index) => cellString(row.getCell(index + 1))));
5970
6425
  }
5971
6426
  function readDataSheet(sheet, limits) {
5972
6427
  assertHeader(sheet);
@@ -6040,8 +6495,52 @@ async function readWorkbook(bytes, options = {}) {
6040
6495
  return { sheets, malformedRows, duplicateKeys };
6041
6496
  }
6042
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
+
6043
6541
  // src/flow/workbook/export-workbook.ts
6044
6542
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
6543
+ var DEFAULT_DELIMITED_PATH = "verbatra-translations";
6045
6544
  function reasonLabel(reason) {
6046
6545
  return reason.toLowerCase().replace(/_/g, "-");
6047
6546
  }
@@ -6105,6 +6604,21 @@ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary
6105
6604
  }
6106
6605
  return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
6107
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
+ }
6108
6622
  async function exportWorkbook(input, deps = {}) {
6109
6623
  const config = input.config;
6110
6624
  const cwd = input.cwd ?? process.cwd();
@@ -6127,10 +6641,17 @@ async function exportWorkbook(input, deps = {}) {
6127
6641
  return { locale, rows };
6128
6642
  })
6129
6643
  );
6130
- const model = { sheets };
6131
- const bytes = await buildWorkbook(model);
6132
- const path = resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
6133
- 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
+ }
6134
6655
  return {
6135
6656
  path,
6136
6657
  locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
@@ -6259,6 +6780,7 @@ function importLocale(params) {
6259
6780
 
6260
6781
  // src/flow/workbook/import-workbook.ts
6261
6782
  var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
6783
+ var MAX_DELIMITED_FILE_BYTES = 32 * 1024 * 1024;
6262
6784
  async function readWorkbookBytes(path, fs) {
6263
6785
  const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
6264
6786
  if (read.kind === "missing") {
@@ -6272,6 +6794,75 @@ async function readWorkbookBytes(path, fs) {
6272
6794
  }
6273
6795
  return read.bytes;
6274
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
+ }
6275
6866
  function mergeAccepted(target, accepted) {
6276
6867
  const merged = new Map(target.entries);
6277
6868
  for (const [key, { value, source }] of accepted) {
@@ -6312,18 +6903,43 @@ function computeLockEntries2(source, merged, baseline, accepted) {
6312
6903
  }
6313
6904
  var MissingSheetError = class extends Error {
6314
6905
  code = "WORKBOOK_SHEET_MISSING";
6315
- constructor(locale) {
6906
+ constructor(locale, format) {
6316
6907
  super(
6317
- `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.`
6318
6909
  );
6319
6910
  this.name = "MissingSheetError";
6320
6911
  }
6321
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
+ }
6322
6938
  async function runSheet(ctx, sheet, lock) {
6323
6939
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
6324
6940
  throw new SdkError(
6325
6941
  "CONFIG_INVALID",
6326
- `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.`
6327
6943
  );
6328
6944
  }
6329
6945
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -6335,15 +6951,15 @@ async function runSheet(ctx, sheet, lock) {
6335
6951
  baseline,
6336
6952
  adapter: ctx.adapter,
6337
6953
  sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6338
- malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6339
- 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) }))
6340
6956
  });
6341
6957
  if (ctx.dryRun) {
6342
6958
  return { summary, lockEntries: {}, cacheAdditions: {} };
6343
6959
  }
6344
6960
  const merged = mergeAccepted(target, accepted);
6345
6961
  if (accepted.size > 0) {
6346
- const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
6962
+ const path = createLocalePathResolver(ctx.cwd, ctx.config).pathFor(sheet.locale);
6347
6963
  await ctx.adapter.write(
6348
6964
  {
6349
6965
  locale: sheet.locale,
@@ -6367,14 +6983,13 @@ async function importWorkbook(input, deps = {}) {
6367
6983
  const fs = deps.fs ?? defaultFs;
6368
6984
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
6369
6985
  const source = await readSource(config, cwd, fs, adapter);
6370
- const workbookPath = resolve(cwd, input.workbook);
6371
- const bytes = await readWorkbookBytes(workbookPath, fs);
6372
- let data;
6373
- try {
6374
- data = await readWorkbook(bytes);
6375
- } catch (error) {
6376
- throw new SdkError("SOURCE_INVALID", errorMessage(error));
6377
- }
6986
+ const format = input.format ?? "xlsx";
6987
+ const { data, staleLocales } = await readImportData(
6988
+ resolve(cwd, input.workbook),
6989
+ config,
6990
+ fs,
6991
+ format
6992
+ );
6378
6993
  const lock = await readLockFile(lockFilePath(cwd), fs);
6379
6994
  const ctx = {
6380
6995
  config,
@@ -6385,7 +7000,8 @@ async function importWorkbook(input, deps = {}) {
6385
7000
  sourceInvalidIcuKeys: source.invalidIcuKeys,
6386
7001
  dryRun,
6387
7002
  malformedRows: data.malformedRows,
6388
- duplicateKeys: data.duplicateKeys
7003
+ duplicateKeys: data.duplicateKeys,
7004
+ format
6389
7005
  };
6390
7006
  const summaries = [];
6391
7007
  const cacheAdditions = /* @__PURE__ */ new Map();
@@ -6410,12 +7026,7 @@ async function importWorkbook(input, deps = {}) {
6410
7026
  summaries.push(failureSummary(sheet.locale, error));
6411
7027
  }
6412
7028
  }
6413
- const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6414
- for (const locale of config.targetLocales) {
6415
- if (!presentLocales.has(locale)) {
6416
- summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6417
- }
6418
- }
7029
+ summaries.push(...absentLocaleFailures(config, data.sheets, format, staleLocales));
6419
7030
  if (!dryRun) {
6420
7031
  await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6421
7032
  }
@@ -6464,7 +7075,8 @@ async function watch(input, deps = {}) {
6464
7075
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6465
7076
  const fs = deps.fs ?? defaultFs;
6466
7077
  resolveRunConcurrency(input.concurrency, false, input.config);
6467
- 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);
6468
7080
  if (!await fs.fileExists(sourcePath)) {
6469
7081
  throw new SdkError(
6470
7082
  "SOURCE_UNREADABLE",
@@ -6547,6 +7159,6 @@ async function watch(input, deps = {}) {
6547
7159
  return { stop };
6548
7160
  }
6549
7161
 
6550
- 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 };
6551
7163
  //# sourceMappingURL=index.js.map
6552
7164
  //# sourceMappingURL=index.js.map