solid-translate 1.4.4 → 1.5.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/README.md CHANGED
@@ -409,6 +409,7 @@ on every pull request.
409
409
  "provider": "openrouter",
410
410
  "model": "openai/gpt-4o-mini",
411
411
  "batchSize": 50,
412
+ "maxAttempts": 3,
412
413
  "include": ["src/**/*.tsx", "src/**/*.ts"],
413
414
  "files": {
414
415
  "json": {
@@ -424,6 +425,20 @@ on every pull request.
424
425
  }
425
426
  ```
426
427
 
428
+ ### Reliability: retries, splitting and batch size
429
+
430
+ Every batch is retried with exponential backoff (default `maxAttempts: 3`), and each
431
+ retry asks only for the keys the model has not translated yet. A batch that comes back
432
+ with nothing usable — typically a response truncated mid-JSON — is split in half and
433
+ each half recovers on its own, down to single keys. Only keys that fail every attempt
434
+ are reported, and they are left out of the lock file so the next `translate` run
435
+ retries exactly those.
436
+
437
+ The output-token ceiling scales with both the number of keys and the amount of source
438
+ text in the batch. If your strings are long paragraphs, a smaller `batchSize` (8–10)
439
+ keeps each response well inside model limits and makes a single bad response cheap to
440
+ retry.
441
+
427
442
  The `[locale]` placeholder is replaced with each target locale. Source files are found by replacing `[locale]` with the source locale.
428
443
 
429
444
  ### Environment Variables
@@ -41,9 +41,31 @@ function collectBatchTranslations(parsed, requestedKeys) {
41
41
  }
42
42
  return { translations, missing };
43
43
  }
44
- async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts) {
44
+ var DEFAULT_MAX_ATTEMPTS = 3;
45
+ var DEFAULT_BASE_DELAY_MS = 500;
46
+ function batchMaxTokens(entries) {
47
+ const keyCount = Object.keys(entries).length;
48
+ let sourceChars = 0;
49
+ for (const [key, value] of Object.entries(entries)) {
50
+ sourceChars += key.length + value.length;
51
+ }
52
+ return Math.min(32e3, 2e3 + 400 * keyCount + Math.ceil(sourceChars * 1.5));
53
+ }
54
+ function backoffDelay(attempt, baseDelayMs) {
55
+ const exp = Math.min(3e4, baseDelayMs * 2 ** Math.max(0, attempt - 1));
56
+ return Math.round(exp * (0.75 + Math.random() * 0.5));
57
+ }
58
+ function defaultSleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts, options = {}) {
45
62
  const keys = Object.keys(entries);
46
63
  if (keys.length === 0) return {};
64
+ const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
65
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
66
+ const sleep = options.sleep ?? defaultSleep;
67
+ const log = options.log ?? (() => {
68
+ });
47
69
  const defaultSystem = [
48
70
  `You are a professional translator specializing in software localization.`,
49
71
  `Translate text from "${sourceLocale}" to "${targetLocale}".`,
@@ -54,52 +76,72 @@ async function translateBatch(model, entries, targetLocale, sourceLocale, system
54
76
  `- Do not add or remove content`,
55
77
  `- Return natural, idiomatic translations`
56
78
  ].join("\n");
57
- let contextSection = "";
58
- if (contexts && Object.keys(contexts).length > 0) {
59
- const contextLines = Object.entries(contexts).filter(([key]) => key in entries).map(([key, ctx]) => ` "${key}": ${ctx}`);
60
- if (contextLines.length > 0) {
61
- contextSection = [
62
- ``,
63
- `Context hints for disambiguation:`,
64
- ...contextLines,
65
- ``
66
- ].join("\n");
67
- }
68
- }
79
+ const contextSectionFor = (subset) => {
80
+ if (!contexts || Object.keys(contexts).length === 0) return "";
81
+ const contextLines = Object.entries(contexts).filter(([key]) => key in subset).map(([key, ctx]) => ` "${key}": ${ctx}`);
82
+ if (contextLines.length === 0) return "";
83
+ return [``, `Context hints for disambiguation:`, ...contextLines, ``].join(
84
+ "\n"
85
+ );
86
+ };
69
87
  const generateText = await loadGenerateText();
70
- const basePrompt = [
71
- `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
72
- `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
73
- contextSection,
74
- JSON.stringify(entries, null, 2)
75
- ].join("\n");
76
- const attempt = async (prompt) => {
88
+ const attempt = async (subset) => {
89
+ const subsetKeys = Object.keys(subset);
77
90
  const { text } = await generateText({
78
91
  model,
79
92
  system: systemPrompt || defaultSystem,
80
- prompt
81
- });
82
- return collectBatchTranslations(extractJsonObject(text), keys);
83
- };
84
- let { translations, missing } = await attempt(basePrompt);
85
- if (missing.length > 0) {
86
- const retryEntries = {};
87
- for (const key of missing) retryEntries[key] = entries[key];
88
- const retry = await attempt(
89
- [
93
+ prompt: [
90
94
  `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
91
95
  `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
92
- contextSection,
93
- JSON.stringify(retryEntries, null, 2)
94
- ].join("\n")
95
- );
96
- translations = { ...translations, ...retry.translations };
97
- missing = retry.missing;
98
- }
99
- if (missing.length > 0) {
100
- const sample = missing.slice(0, 3).join('", "');
96
+ contextSectionFor(subset),
97
+ JSON.stringify(subset, null, 2)
98
+ ].join("\n"),
99
+ maxTokens: batchMaxTokens(subset)
100
+ });
101
+ return collectBatchTranslations(extractJsonObject(text), subsetKeys);
102
+ };
103
+ const translations = {};
104
+ const failed = [];
105
+ let lastError;
106
+ const recover = async (pending, depth) => {
107
+ let remaining = pending;
108
+ for (let n = 1; n <= maxAttempts && remaining.length > 0; n++) {
109
+ const subset = {};
110
+ for (const key of remaining) subset[key] = entries[key];
111
+ let missing;
112
+ try {
113
+ const result = await attempt(subset);
114
+ Object.assign(translations, result.translations);
115
+ missing = result.missing;
116
+ } catch (err) {
117
+ lastError = err;
118
+ missing = remaining;
119
+ log(
120
+ `translate ${targetLocale}: attempt ${n}/${maxAttempts} for ${remaining.length} key(s) failed: ${err instanceof Error ? err.message : String(err)}`
121
+ );
122
+ }
123
+ if (missing.length === 0) return;
124
+ const progressed = missing.length < remaining.length;
125
+ remaining = missing;
126
+ if (!progressed && remaining.length > 1) {
127
+ const mid = Math.ceil(remaining.length / 2);
128
+ log(
129
+ `translate ${targetLocale}: splitting ${remaining.length} key(s) into ${mid} + ${remaining.length - mid}`
130
+ );
131
+ await recover(remaining.slice(0, mid), depth + 1);
132
+ await recover(remaining.slice(mid), depth + 1);
133
+ return;
134
+ }
135
+ if (n < maxAttempts) await sleep(backoffDelay(n, baseDelayMs));
136
+ }
137
+ failed.push(...remaining);
138
+ };
139
+ await recover(keys, 0);
140
+ if (failed.length > 0) {
141
+ const sample = failed.slice(0, 3).join('", "');
142
+ const cause = lastError instanceof Error ? ` (last error: ${lastError.message})` : "";
101
143
  throw new Error(
102
- `model returned no translation for ${missing.length} of ${keys.length} keys (e.g. "${sample}")`
144
+ `model returned no translation for ${failed.length} of ${keys.length} keys after ${maxAttempts} attempts (e.g. "${sample}")${cause}`
103
145
  );
104
146
  }
105
147
  return translations;
@@ -123,6 +165,9 @@ async function translateMarkdown(model, content, targetLocale, sourceLocale, sys
123
165
  translated: z.string()
124
166
  }),
125
167
  system: systemPrompt || defaultSystem,
168
+ // Scale the output ceiling with document size so long documents are not
169
+ // truncated by provider-default limits.
170
+ maxTokens: Math.min(32e3, 2e3 + Math.ceil(content.length / 2)),
126
171
  prompt: [
127
172
  `Translate this Markdown/MDX content from "${sourceLocale}" to "${targetLocale}".`,
128
173
  `Return the complete translated document.`,
@@ -136,6 +181,8 @@ async function translateMarkdown(model, content, targetLocale, sourceLocale, sys
136
181
  export {
137
182
  extractJsonObject,
138
183
  collectBatchTranslations,
184
+ batchMaxTokens,
185
+ backoffDelay,
139
186
  translateBatch,
140
187
  translateMarkdown
141
188
  };
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  translateBatch
4
- } from "./chunk-P6RTZPHZ.js";
4
+ } from "./chunk-VAKU3D24.js";
5
5
  import {
6
6
  __commonJS,
7
7
  __toESM
@@ -15570,13 +15570,14 @@ Run \`solid-translate translate\` to refresh ${sourceLocale} \u2192 targets.`
15570
15570
  );
15571
15571
  }
15572
15572
  async function runTranslate() {
15573
- const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-SWLCBQ5Y.js");
15573
+ const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-FXWVMHH2.js");
15574
15574
  const config = await loadConfig();
15575
15575
  const root = process.cwd();
15576
15576
  const sourceLocale = config.sourceLocale || "en";
15577
15577
  const targetLocales = config.targetLocales;
15578
15578
  const localesDir = resolve(config.localesDir || "./src/locales");
15579
15579
  const batchSize = config.batchSize || 50;
15580
+ const maxAttempts = config.maxAttempts || 3;
15580
15581
  if (!targetLocales || targetLocales.length === 0) {
15581
15582
  console.error("No targetLocales configured.");
15582
15583
  process.exit(1);
@@ -15588,7 +15589,8 @@ async function runTranslate() {
15588
15589
  sourceLocale,
15589
15590
  targetLocales,
15590
15591
  batchSize,
15591
- config.systemPrompt
15592
+ config.systemPrompt,
15593
+ maxAttempts
15592
15594
  );
15593
15595
  if (config.files) {
15594
15596
  const { glob } = await import("glob");
@@ -15628,7 +15630,9 @@ async function runTranslate() {
15628
15630
  sourceDict,
15629
15631
  targetLocale,
15630
15632
  sourceLocale,
15631
- config.systemPrompt
15633
+ config.systemPrompt,
15634
+ void 0,
15635
+ { maxAttempts, log: (message) => console.log(message) }
15632
15636
  );
15633
15637
  mkdirSync(dirname(actualTarget), { recursive: true });
15634
15638
  writeFileSync2(
@@ -15672,7 +15676,7 @@ async function runTranslate() {
15672
15676
  }
15673
15677
  console.log("\nTranslation complete.");
15674
15678
  }
15675
- async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocales, batchSize, systemPrompt) {
15679
+ async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocales, batchSize, systemPrompt, maxAttempts) {
15676
15680
  const result = await syncLocaleFiles({
15677
15681
  localesDir,
15678
15682
  sourceLocale,
@@ -15684,7 +15688,8 @@ async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocal
15684
15688
  targetLocale,
15685
15689
  sourceLocale,
15686
15690
  systemPrompt,
15687
- contexts
15691
+ contexts,
15692
+ { maxAttempts, log: (message) => console.log(` ${message}`) }
15688
15693
  ),
15689
15694
  log: (message) => console.log(message)
15690
15695
  });
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ backoffDelay,
4
+ batchMaxTokens,
3
5
  collectBatchTranslations,
4
6
  extractJsonObject,
5
7
  translateBatch,
6
8
  translateMarkdown
7
- } from "./chunk-P6RTZPHZ.js";
9
+ } from "./chunk-VAKU3D24.js";
8
10
  import "./chunk-FYS2JH42.js";
9
11
  export {
12
+ backoffDelay,
13
+ batchMaxTokens,
10
14
  collectBatchTranslations,
11
15
  extractJsonObject,
12
16
  translateBatch,
package/dist/vite.js CHANGED
@@ -14652,9 +14652,31 @@ function collectBatchTranslations(parsed, requestedKeys) {
14652
14652
  }
14653
14653
  return { translations, missing };
14654
14654
  }
14655
- async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts) {
14655
+ var DEFAULT_MAX_ATTEMPTS = 3;
14656
+ var DEFAULT_BASE_DELAY_MS = 500;
14657
+ function batchMaxTokens(entries) {
14658
+ const keyCount = Object.keys(entries).length;
14659
+ let sourceChars = 0;
14660
+ for (const [key, value] of Object.entries(entries)) {
14661
+ sourceChars += key.length + value.length;
14662
+ }
14663
+ return Math.min(32e3, 2e3 + 400 * keyCount + Math.ceil(sourceChars * 1.5));
14664
+ }
14665
+ function backoffDelay(attempt, baseDelayMs) {
14666
+ const exp = Math.min(3e4, baseDelayMs * 2 ** Math.max(0, attempt - 1));
14667
+ return Math.round(exp * (0.75 + Math.random() * 0.5));
14668
+ }
14669
+ function defaultSleep(ms) {
14670
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
14671
+ }
14672
+ async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts, options = {}) {
14656
14673
  const keys = Object.keys(entries);
14657
14674
  if (keys.length === 0) return {};
14675
+ const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
14676
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
14677
+ const sleep = options.sleep ?? defaultSleep;
14678
+ const log = options.log ?? (() => {
14679
+ });
14658
14680
  const defaultSystem = [
14659
14681
  `You are a professional translator specializing in software localization.`,
14660
14682
  `Translate text from "${sourceLocale}" to "${targetLocale}".`,
@@ -14665,52 +14687,72 @@ async function translateBatch(model, entries, targetLocale, sourceLocale, system
14665
14687
  `- Do not add or remove content`,
14666
14688
  `- Return natural, idiomatic translations`
14667
14689
  ].join("\n");
14668
- let contextSection = "";
14669
- if (contexts && Object.keys(contexts).length > 0) {
14670
- const contextLines = Object.entries(contexts).filter(([key]) => key in entries).map(([key, ctx]) => ` "${key}": ${ctx}`);
14671
- if (contextLines.length > 0) {
14672
- contextSection = [
14673
- ``,
14674
- `Context hints for disambiguation:`,
14675
- ...contextLines,
14676
- ``
14677
- ].join("\n");
14678
- }
14679
- }
14690
+ const contextSectionFor = (subset) => {
14691
+ if (!contexts || Object.keys(contexts).length === 0) return "";
14692
+ const contextLines = Object.entries(contexts).filter(([key]) => key in subset).map(([key, ctx]) => ` "${key}": ${ctx}`);
14693
+ if (contextLines.length === 0) return "";
14694
+ return [``, `Context hints for disambiguation:`, ...contextLines, ``].join(
14695
+ "\n"
14696
+ );
14697
+ };
14680
14698
  const generateText = await loadGenerateText();
14681
- const basePrompt = [
14682
- `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
14683
- `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
14684
- contextSection,
14685
- JSON.stringify(entries, null, 2)
14686
- ].join("\n");
14687
- const attempt = async (prompt) => {
14699
+ const attempt = async (subset) => {
14700
+ const subsetKeys = Object.keys(subset);
14688
14701
  const { text } = await generateText({
14689
14702
  model,
14690
14703
  system: systemPrompt || defaultSystem,
14691
- prompt
14692
- });
14693
- return collectBatchTranslations(extractJsonObject(text), keys);
14694
- };
14695
- let { translations, missing } = await attempt(basePrompt);
14696
- if (missing.length > 0) {
14697
- const retryEntries = {};
14698
- for (const key of missing) retryEntries[key] = entries[key];
14699
- const retry = await attempt(
14700
- [
14704
+ prompt: [
14701
14705
  `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
14702
14706
  `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
14703
- contextSection,
14704
- JSON.stringify(retryEntries, null, 2)
14705
- ].join("\n")
14706
- );
14707
- translations = { ...translations, ...retry.translations };
14708
- missing = retry.missing;
14709
- }
14710
- if (missing.length > 0) {
14711
- const sample = missing.slice(0, 3).join('", "');
14707
+ contextSectionFor(subset),
14708
+ JSON.stringify(subset, null, 2)
14709
+ ].join("\n"),
14710
+ maxTokens: batchMaxTokens(subset)
14711
+ });
14712
+ return collectBatchTranslations(extractJsonObject(text), subsetKeys);
14713
+ };
14714
+ const translations = {};
14715
+ const failed = [];
14716
+ let lastError;
14717
+ const recover = async (pending, depth) => {
14718
+ let remaining = pending;
14719
+ for (let n = 1; n <= maxAttempts && remaining.length > 0; n++) {
14720
+ const subset = {};
14721
+ for (const key of remaining) subset[key] = entries[key];
14722
+ let missing;
14723
+ try {
14724
+ const result = await attempt(subset);
14725
+ Object.assign(translations, result.translations);
14726
+ missing = result.missing;
14727
+ } catch (err) {
14728
+ lastError = err;
14729
+ missing = remaining;
14730
+ log(
14731
+ `translate ${targetLocale}: attempt ${n}/${maxAttempts} for ${remaining.length} key(s) failed: ${err instanceof Error ? err.message : String(err)}`
14732
+ );
14733
+ }
14734
+ if (missing.length === 0) return;
14735
+ const progressed = missing.length < remaining.length;
14736
+ remaining = missing;
14737
+ if (!progressed && remaining.length > 1) {
14738
+ const mid = Math.ceil(remaining.length / 2);
14739
+ log(
14740
+ `translate ${targetLocale}: splitting ${remaining.length} key(s) into ${mid} + ${remaining.length - mid}`
14741
+ );
14742
+ await recover(remaining.slice(0, mid), depth + 1);
14743
+ await recover(remaining.slice(mid), depth + 1);
14744
+ return;
14745
+ }
14746
+ if (n < maxAttempts) await sleep(backoffDelay(n, baseDelayMs));
14747
+ }
14748
+ failed.push(...remaining);
14749
+ };
14750
+ await recover(keys, 0);
14751
+ if (failed.length > 0) {
14752
+ const sample = failed.slice(0, 3).join('", "');
14753
+ const cause = lastError instanceof Error ? ` (last error: ${lastError.message})` : "";
14712
14754
  throw new Error(
14713
- `model returned no translation for ${missing.length} of ${keys.length} keys (e.g. "${sample}")`
14755
+ `model returned no translation for ${failed.length} of ${keys.length} keys after ${maxAttempts} attempts (e.g. "${sample}")${cause}`
14714
14756
  );
14715
14757
  }
14716
14758
  return translations;