solid-translate 1.4.5 → 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,57 +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, keyCount) => {
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
- // Provider-default output ceilings (often 4k tokens) truncate large
82
- // batches mid-JSON ("Unterminated string in JSON"). Scale the ceiling
83
- // with batch size so the full object always fits.
84
- maxTokens: Math.min(32e3, 2e3 + 400 * keyCount)
85
- });
86
- return collectBatchTranslations(extractJsonObject(text), keys);
87
- };
88
- let { translations, missing } = await attempt(basePrompt, keys.length);
89
- if (missing.length > 0) {
90
- const retryEntries = {};
91
- for (const key of missing) retryEntries[key] = entries[key];
92
- const retry = await attempt(
93
- [
93
+ prompt: [
94
94
  `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
95
95
  `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
96
- contextSection,
97
- JSON.stringify(retryEntries, null, 2)
96
+ contextSectionFor(subset),
97
+ JSON.stringify(subset, null, 2)
98
98
  ].join("\n"),
99
- missing.length
100
- );
101
- translations = { ...translations, ...retry.translations };
102
- missing = retry.missing;
103
- }
104
- if (missing.length > 0) {
105
- const sample = missing.slice(0, 3).join('", "');
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})` : "";
106
143
  throw new Error(
107
- `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}`
108
145
  );
109
146
  }
110
147
  return translations;
@@ -144,6 +181,8 @@ async function translateMarkdown(model, content, targetLocale, sourceLocale, sys
144
181
  export {
145
182
  extractJsonObject,
146
183
  collectBatchTranslations,
184
+ batchMaxTokens,
185
+ backoffDelay,
147
186
  translateBatch,
148
187
  translateMarkdown
149
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-36PYT2MX.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-PTFH3PFU.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-36PYT2MX.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,57 +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, keyCount) => {
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
- // Provider-default output ceilings (often 4k tokens) truncate large
14693
- // batches mid-JSON ("Unterminated string in JSON"). Scale the ceiling
14694
- // with batch size so the full object always fits.
14695
- maxTokens: Math.min(32e3, 2e3 + 400 * keyCount)
14696
- });
14697
- return collectBatchTranslations(extractJsonObject(text), keys);
14698
- };
14699
- let { translations, missing } = await attempt(basePrompt, keys.length);
14700
- if (missing.length > 0) {
14701
- const retryEntries = {};
14702
- for (const key of missing) retryEntries[key] = entries[key];
14703
- const retry = await attempt(
14704
- [
14704
+ prompt: [
14705
14705
  `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
14706
14706
  `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
14707
- contextSection,
14708
- JSON.stringify(retryEntries, null, 2)
14707
+ contextSectionFor(subset),
14708
+ JSON.stringify(subset, null, 2)
14709
14709
  ].join("\n"),
14710
- missing.length
14711
- );
14712
- translations = { ...translations, ...retry.translations };
14713
- missing = retry.missing;
14714
- }
14715
- if (missing.length > 0) {
14716
- const sample = missing.slice(0, 3).join('", "');
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})` : "";
14717
14754
  throw new Error(
14718
- `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}`
14719
14756
  );
14720
14757
  }
14721
14758
  return translations;