@babelize/engine 0.0.0-stage → 0.1.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.
Files changed (246) hide show
  1. package/LICENSE +202 -0
  2. package/dist/BabelizeEngine.d.ts +12 -0
  3. package/dist/BabelizeEngine.js +47 -0
  4. package/dist/analysis/analyze.d.ts +106 -0
  5. package/dist/analysis/analyze.js +323 -0
  6. package/dist/config/env.d.ts +9 -0
  7. package/dist/config/env.js +16 -0
  8. package/dist/config.d.ts +74 -0
  9. package/dist/config.js +47 -0
  10. package/dist/index.d.ts +54 -0
  11. package/dist/index.js +126 -0
  12. package/dist/injection/InjectionStrategy.d.ts +62 -0
  13. package/dist/injection/InjectionStrategy.js +2 -0
  14. package/dist/injection/StrategyFactory.d.ts +4 -0
  15. package/dist/injection/StrategyFactory.js +29 -0
  16. package/dist/injection/django/django-template-injector.d.ts +29 -0
  17. package/dist/injection/django/django-template-injector.js +216 -0
  18. package/dist/injection/html/html-injector.d.ts +11 -0
  19. package/dist/injection/html/html-injector.js +173 -0
  20. package/dist/injection/strategies/NextIntlStrategy.d.ts +25 -0
  21. package/dist/injection/strategies/NextIntlStrategy.js +141 -0
  22. package/dist/injection/strategies/ReactI18nextStrategy.d.ts +19 -0
  23. package/dist/injection/strategies/ReactI18nextStrategy.js +116 -0
  24. package/dist/injection/strategies/VanillaStrategy.d.ts +11 -0
  25. package/dist/injection/strategies/VanillaStrategy.js +73 -0
  26. package/dist/injection/strategies/VueI18nStrategy.d.ts +11 -0
  27. package/dist/injection/strategies/VueI18nStrategy.js +84 -0
  28. package/dist/local/formatter.d.ts +26 -0
  29. package/dist/local/formatter.js +105 -0
  30. package/dist/local/git.d.ts +23 -0
  31. package/dist/local/git.js +136 -0
  32. package/dist/local/hosted-provider.d.ts +68 -0
  33. package/dist/local/hosted-provider.js +215 -0
  34. package/dist/local/index.d.ts +23 -0
  35. package/dist/local/index.js +75 -0
  36. package/dist/local/provider.d.ts +56 -0
  37. package/dist/local/provider.js +119 -0
  38. package/dist/local/pseudo-provider.d.ts +18 -0
  39. package/dist/local/pseudo-provider.js +207 -0
  40. package/dist/local/report.d.ts +177 -0
  41. package/dist/local/report.js +186 -0
  42. package/dist/local/translate-core.d.ts +215 -0
  43. package/dist/local/translate-core.js +461 -0
  44. package/dist/local/translate-messages.d.ts +49 -0
  45. package/dist/local/translate-messages.js +91 -0
  46. package/dist/local/workspace-copy.d.ts +91 -0
  47. package/dist/local/workspace-copy.js +355 -0
  48. package/dist/modules/extractor/extract.d.ts +20 -0
  49. package/dist/modules/extractor/extract.js +701 -0
  50. package/dist/modules/extractor/index.d.ts +2 -0
  51. package/dist/modules/extractor/index.js +63 -0
  52. package/dist/modules/finalization/django-finalize.d.ts +13 -0
  53. package/dist/modules/finalization/django-finalize.js +275 -0
  54. package/dist/modules/finalization/finalize.d.ts +36 -0
  55. package/dist/modules/finalization/finalize.js +717 -0
  56. package/dist/modules/finalization/index.d.ts +1 -0
  57. package/dist/modules/finalization/index.js +5 -0
  58. package/dist/modules/finalization/nextjs-finalize.d.ts +37 -0
  59. package/dist/modules/finalization/nextjs-finalize.js +329 -0
  60. package/dist/modules/finalization/static-runtime.d.ts +9 -0
  61. package/dist/modules/finalization/static-runtime.js +261 -0
  62. package/dist/modules/format-injection/index.d.ts +19 -0
  63. package/dist/modules/format-injection/index.js +272 -0
  64. package/dist/modules/format-injection/inject-formats.d.ts +31 -0
  65. package/dist/modules/format-injection/inject-formats.js +208 -0
  66. package/dist/modules/glossary/glossary.d.ts +9 -0
  67. package/dist/modules/glossary/glossary.js +27 -0
  68. package/dist/modules/glossary/index.d.ts +3 -0
  69. package/dist/modules/glossary/index.js +8 -0
  70. package/dist/modules/glossary/masker.d.ts +23 -0
  71. package/dist/modules/glossary/masker.js +56 -0
  72. package/dist/modules/glossary/types.d.ts +22 -0
  73. package/dist/modules/glossary/types.js +2 -0
  74. package/dist/modules/injection/index.d.ts +1 -0
  75. package/dist/modules/injection/index.js +6 -0
  76. package/dist/modules/injection/inject.d.ts +29 -0
  77. package/dist/modules/injection/inject.js +1066 -0
  78. package/dist/modules/keygen/index.d.ts +1 -0
  79. package/dist/modules/keygen/index.js +11 -0
  80. package/dist/modules/keygen/keygen.d.ts +4 -0
  81. package/dist/modules/keygen/keygen.js +116 -0
  82. package/dist/modules/keygen/locale-data.d.ts +7 -0
  83. package/dist/modules/keygen/locale-data.js +73 -0
  84. package/dist/modules/keygen/public.d.ts +2 -0
  85. package/dist/modules/keygen/public.js +12 -0
  86. package/dist/modules/locale-builder/index.d.ts +1 -0
  87. package/dist/modules/locale-builder/index.js +7 -0
  88. package/dist/modules/locale-builder/locales.d.ts +13 -0
  89. package/dist/modules/locale-builder/locales.js +107 -0
  90. package/dist/modules/locale-builder/po.d.ts +33 -0
  91. package/dist/modules/locale-builder/po.js +131 -0
  92. package/dist/modules/lockfile/hasher.d.ts +7 -0
  93. package/dist/modules/lockfile/hasher.js +13 -0
  94. package/dist/modules/lockfile/index.d.ts +3 -0
  95. package/dist/modules/lockfile/index.js +21 -0
  96. package/dist/modules/lockfile/lockfile-manager.d.ts +90 -0
  97. package/dist/modules/lockfile/lockfile-manager.js +482 -0
  98. package/dist/modules/lockfile/schema.d.ts +154 -0
  99. package/dist/modules/lockfile/schema.js +45 -0
  100. package/dist/modules/parser/index.d.ts +2 -0
  101. package/dist/modules/parser/index.js +9 -0
  102. package/dist/modules/summary/index.d.ts +1 -0
  103. package/dist/modules/summary/index.js +7 -0
  104. package/dist/modules/summary/summary.d.ts +9 -0
  105. package/dist/modules/summary/summary.js +195 -0
  106. package/dist/modules/sync-detector/index.d.ts +2 -0
  107. package/dist/modules/sync-detector/index.js +7 -0
  108. package/dist/modules/sync-detector/reporter.d.ts +3 -0
  109. package/dist/modules/sync-detector/reporter.js +16 -0
  110. package/dist/modules/sync-detector/sync-detector.d.ts +39 -0
  111. package/dist/modules/sync-detector/sync-detector.js +374 -0
  112. package/dist/modules/translator/batch-optimizer.d.ts +12 -0
  113. package/dist/modules/translator/batch-optimizer.js +30 -0
  114. package/dist/modules/translator/client/byok.provider.d.ts +64 -0
  115. package/dist/modules/translator/client/byok.provider.js +319 -0
  116. package/dist/modules/translator/client/direct-translator.client.d.ts +12 -0
  117. package/dist/modules/translator/client/direct-translator.client.js +71 -0
  118. package/dist/modules/translator/client/gemini.provider.d.ts +1 -0
  119. package/dist/modules/translator/client/gemini.provider.js +110 -0
  120. package/dist/modules/translator/client/groq.provider.d.ts +1 -0
  121. package/dist/modules/translator/client/groq.provider.js +111 -0
  122. package/dist/modules/translator/client/request.types.d.ts +1 -0
  123. package/dist/modules/translator/client/request.types.js +2 -0
  124. package/dist/modules/translator/client/retry.d.ts +29 -0
  125. package/dist/modules/translator/client/retry.js +94 -0
  126. package/dist/modules/translator/client/test-utils/mock.client.d.ts +31 -0
  127. package/dist/modules/translator/client/test-utils/mock.client.js +59 -0
  128. package/dist/modules/translator/client/translator.interface.d.ts +1 -0
  129. package/dist/modules/translator/client/translator.interface.js +2 -0
  130. package/dist/modules/translator/language-codes/errors.d.ts +10 -0
  131. package/dist/modules/translator/language-codes/errors.js +18 -0
  132. package/dist/modules/translator/language-codes/flores-map.d.ts +1 -0
  133. package/dist/modules/translator/language-codes/flores-map.js +107 -0
  134. package/dist/modules/translator/language-codes/index.d.ts +11 -0
  135. package/dist/modules/translator/language-codes/index.js +24 -0
  136. package/dist/modules/translator/language-codes/language-mapper.d.ts +25 -0
  137. package/dist/modules/translator/language-codes/language-mapper.js +132 -0
  138. package/dist/modules/translator/language-codes/rtl-languages.d.ts +2 -0
  139. package/dist/modules/translator/language-codes/rtl-languages.js +9 -0
  140. package/dist/modules/translator/memory/memory-matcher.d.ts +8 -0
  141. package/dist/modules/translator/memory/memory-matcher.js +218 -0
  142. package/dist/modules/translator/memory/types.d.ts +22 -0
  143. package/dist/modules/translator/memory/types.js +2 -0
  144. package/dist/modules/translator/translate.d.ts +38 -0
  145. package/dist/modules/translator/translate.js +932 -0
  146. package/dist/modules/translator/translator.d.ts +5 -0
  147. package/dist/modules/translator/translator.js +46 -0
  148. package/dist/modules/vue/migrate.d.ts +13 -0
  149. package/dist/modules/vue/migrate.js +40 -0
  150. package/dist/modules/vue/script-inject.d.ts +16 -0
  151. package/dist/modules/vue/script-inject.js +115 -0
  152. package/dist/modules/vue/sfc.d.ts +11 -0
  153. package/dist/modules/vue/sfc.js +51 -0
  154. package/dist/modules/vue/template-rewrite.d.ts +25 -0
  155. package/dist/modules/vue/template-rewrite.js +76 -0
  156. package/dist/modules/vue/template-scan.d.ts +29 -0
  157. package/dist/modules/vue/template-scan.js +264 -0
  158. package/dist/modules/workspace/index.d.ts +1 -0
  159. package/dist/modules/workspace/index.js +5 -0
  160. package/dist/modules/workspace/validate.d.ts +13 -0
  161. package/dist/modules/workspace/validate.js +298 -0
  162. package/dist/orchestrator.d.ts +8 -0
  163. package/dist/orchestrator.js +616 -0
  164. package/dist/providers/default-translation-provider.d.ts +20 -0
  165. package/dist/providers/default-translation-provider.js +30 -0
  166. package/dist/providers/index.d.ts +2 -0
  167. package/dist/providers/index.js +21 -0
  168. package/dist/providers/types.d.ts +145 -0
  169. package/dist/providers/types.js +2 -0
  170. package/dist/qa/check-project.d.ts +49 -0
  171. package/dist/qa/check-project.js +150 -0
  172. package/dist/qa/locale-files.d.ts +44 -0
  173. package/dist/qa/locale-files.js +159 -0
  174. package/dist/qa/locale-qa.d.ts +61 -0
  175. package/dist/qa/locale-qa.js +325 -0
  176. package/dist/qa/review.d.ts +43 -0
  177. package/dist/qa/review.js +175 -0
  178. package/dist/runtime/config.d.ts +8 -0
  179. package/dist/runtime/config.js +36 -0
  180. package/dist/runtime/index.d.ts +3 -0
  181. package/dist/runtime/index.js +8 -0
  182. package/dist/runtime/types.d.ts +363 -0
  183. package/dist/runtime/types.js +38 -0
  184. package/dist/types.d.ts +134 -0
  185. package/dist/types.js +12 -0
  186. package/dist/utils/ast-helpers.d.ts +57 -0
  187. package/dist/utils/ast-helpers.js +608 -0
  188. package/dist/utils/cache-keys.d.ts +8 -0
  189. package/dist/utils/cache-keys.js +18 -0
  190. package/dist/utils/circuit-breaker.d.ts +43 -0
  191. package/dist/utils/circuit-breaker.js +119 -0
  192. package/dist/utils/concurrent-map.d.ts +4 -0
  193. package/dist/utils/concurrent-map.js +21 -0
  194. package/dist/utils/detect-format.d.ts +5 -0
  195. package/dist/utils/detect-format.js +125 -0
  196. package/dist/utils/dtl-mask.d.ts +25 -0
  197. package/dist/utils/dtl-mask.js +58 -0
  198. package/dist/utils/extraction-options.d.ts +23 -0
  199. package/dist/utils/extraction-options.js +23 -0
  200. package/dist/utils/file-utils.d.ts +33 -0
  201. package/dist/utils/file-utils.js +262 -0
  202. package/dist/utils/glossary-match.d.ts +9 -0
  203. package/dist/utils/glossary-match.js +25 -0
  204. package/dist/utils/html-extractor.d.ts +8 -0
  205. package/dist/utils/html-extractor.js +317 -0
  206. package/dist/utils/i18next-icu.d.ts +38 -0
  207. package/dist/utils/i18next-icu.js +159 -0
  208. package/dist/utils/icu-message.d.ts +65 -0
  209. package/dist/utils/icu-message.js +346 -0
  210. package/dist/utils/icu-spans.d.ts +10 -0
  211. package/dist/utils/icu-spans.js +126 -0
  212. package/dist/utils/jsx-message.d.ts +93 -0
  213. package/dist/utils/jsx-message.js +621 -0
  214. package/dist/utils/key-usage.d.ts +35 -0
  215. package/dist/utils/key-usage.js +247 -0
  216. package/dist/utils/logger.d.ts +24 -0
  217. package/dist/utils/logger.js +33 -0
  218. package/dist/utils/metadata.d.ts +80 -0
  219. package/dist/utils/metadata.js +255 -0
  220. package/dist/utils/module-constants.d.ts +30 -0
  221. package/dist/utils/module-constants.js +265 -0
  222. package/dist/utils/not-injected.d.ts +6 -0
  223. package/dist/utils/not-injected.js +36 -0
  224. package/dist/utils/placeholder-protection.d.ts +57 -0
  225. package/dist/utils/placeholder-protection.js +136 -0
  226. package/dist/utils/pseudo-markers.d.ts +9 -0
  227. package/dist/utils/pseudo-markers.js +16 -0
  228. package/dist/utils/py-extractor.d.ts +14 -0
  229. package/dist/utils/py-extractor.js +285 -0
  230. package/dist/utils/string-utils.d.ts +52 -0
  231. package/dist/utils/string-utils.js +270 -0
  232. package/dist/utils/translation-quality.d.ts +34 -0
  233. package/dist/utils/translation-quality.js +156 -0
  234. package/dist/utils/translation-sanitizer.d.ts +10 -0
  235. package/dist/utils/translation-sanitizer.js +57 -0
  236. package/dist/utils/translator-caller.d.ts +32 -0
  237. package/dist/utils/translator-caller.js +217 -0
  238. package/dist/utils/translator-load-balancer.d.ts +73 -0
  239. package/dist/utils/translator-load-balancer.js +293 -0
  240. package/dist/utils/ui-copy.d.ts +32 -0
  241. package/dist/utils/ui-copy.js +126 -0
  242. package/dist/utils/workspace.d.ts +14 -0
  243. package/dist/utils/workspace.js +89 -0
  244. package/package.json +64 -4
  245. package/schemas/translate-report.schema.json +297 -0
  246. package/README.md +0 -3
@@ -0,0 +1,932 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FALLBACK_QA_WARNING = void 0;
37
+ exports.translateLocales = translateLocales;
38
+ exports.backfillTranslationMemory = backfillTranslationMemory;
39
+ const path = __importStar(require("path"));
40
+ const cache_keys_1 = require("../../utils/cache-keys");
41
+ const logger_1 = require("../../utils/logger");
42
+ const translation_quality_1 = require("../../utils/translation-quality");
43
+ const file_utils_1 = require("../../utils/file-utils");
44
+ const placeholder_protection_1 = require("../../utils/placeholder-protection");
45
+ const icu_message_1 = require("../../utils/icu-message");
46
+ const keygen_1 = require("../keygen");
47
+ const concurrent_map_1 = require("../../utils/concurrent-map");
48
+ const language_codes_1 = require("./language-codes");
49
+ const glossary_1 = require("../glossary");
50
+ const gemini_provider_1 = require("./client/gemini.provider");
51
+ const memory_matcher_1 = require("./memory/memory-matcher");
52
+ const default_translation_provider_1 = require("../../providers/default-translation-provider");
53
+ const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
54
+ const glossary_match_1 = require("../../utils/glossary-match");
55
+ const icu_spans_1 = require("../../utils/icu-spans");
56
+ const BATCH_SIZE = 15;
57
+ /** QA note on keys the backup (NLLB) translator produced. */
58
+ exports.FALLBACK_QA_WARNING = 'translated with the backup translator; will be retried with the AI translator next run';
59
+ /** Keys re-sent after failing validation go in small batches with per-key notes. */
60
+ const REPAIR_BATCH_SIZE = 5;
61
+ const TRANSLATE_CONCURRENCY = 3; // Max concurrent locales/batches
62
+ /** A plain source whose stored translation is ICU only fits a project whose runtime formats ICU. */
63
+ function reusableTranslation(source, translated, targetPlurals) {
64
+ return targetPlurals || (0, icu_message_1.isIcuMessage)(source) || !(0, icu_message_1.isIcuMessage)(translated);
65
+ }
66
+ /** Per-entry instruction: pluralize a count argument if the target language needs it. */
67
+ function pluralHint(args, targetLocale) {
68
+ const list = args.map((a) => `{${a}}`).join(', ');
69
+ return `${list} ${args.length === 1 ? 'is a number' : 'are numbers'}. If ${targetLocale} changes the words around it by number, return an ICU plural over it, e.g. {${args[0]}, plural, one {…} other {…}} with every ${targetLocale} plural category and "#" in each branch; otherwise keep ${list} as plain text. Keep every {argument} name and tag exactly.`;
70
+ }
71
+ function chunk(items, size) {
72
+ const out = [];
73
+ for (let i = 0; i < items.length; i += size)
74
+ out.push(items.slice(i, i + size));
75
+ return out;
76
+ }
77
+ /**
78
+ * A full sentence (3+ real words) that came back identical to the source was
79
+ * not translated. Short strings ("OK", "Email", a product name) legitimately
80
+ * stay the same in many languages. Both arguments are the tokenized text, so
81
+ * placeholders and terms neither count as words nor hide a difference.
82
+ */
83
+ function isUnchangedSentence(sentText, returnedText) {
84
+ const norm = (t) => t.replace(/\[\s*#\s*VAR_\d+\s*#\s*\]/gi, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
85
+ const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
86
+ return words.length >= 3 && norm(sentText) === norm(returnedText);
87
+ }
88
+ function escapeTerm(term) {
89
+ return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
+ }
91
+ function containsTerm(text, term) {
92
+ return new RegExp(`(?<![\\w])${escapeTerm(term)}(?![\\w])`, 'i').test(text);
93
+ }
94
+ /** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
95
+ function missingSpans(source, translated, neverTranslateTerms) {
96
+ const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
97
+ const want = new Set((0, placeholder_protection_1.protectText)(source, neverTranslateTerms, { icu: true }).tokens);
98
+ return [...want].filter((t) => !have.has(t.toLowerCase()));
99
+ }
100
+ /** Glossary terms in the source whose required translation is missing from the output. */
101
+ function glossaryWarnings(source, translated, glossary) {
102
+ return glossary
103
+ .filter((g) => containsTerm(source, g.term) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
104
+ .map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
105
+ }
106
+ const SCRIPT_RANGES = [
107
+ [0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
108
+ [0x0370, 0x03FF], // Greek
109
+ [0x0400, 0x04FF], // Cyrillic
110
+ [0x0590, 0x05FF], // Hebrew
111
+ [0x0600, 0x06FF], // Arabic
112
+ [0x0900, 0x097F], // Devanagari
113
+ [0x0980, 0x09FF], // Bengali
114
+ [0x0E00, 0x0E7F], // Thai
115
+ [0x1100, 0x11FF], // Hangul Jamo
116
+ [0x3040, 0x309F], // Hiragana
117
+ [0x30A0, 0x30FF], // Katakana
118
+ [0x4E00, 0x9FFF], // CJK Unified
119
+ [0xAC00, 0xD7AF], // Hangul Syllables
120
+ ];
121
+ function detectScript(ch) {
122
+ const code = ch.codePointAt(0);
123
+ for (let i = 0; i < SCRIPT_RANGES.length; i++) {
124
+ if (code >= SCRIPT_RANGES[i][0] && code <= SCRIPT_RANGES[i][1])
125
+ return i;
126
+ }
127
+ return -1;
128
+ }
129
+ /**
130
+ * True when the text's letters come from more than one writing system
131
+ * (e.g. "Hello 世界"). Only letters count: "Café", "© 2024", "10 °C" and
132
+ * non-breaking spaces are plain Latin text.
133
+ */
134
+ function hasMixedScripts(text) {
135
+ let seen = -1;
136
+ for (const ch of text) {
137
+ if (!/\p{L}/u.test(ch))
138
+ continue;
139
+ const script = detectScript(ch);
140
+ if (script === -1)
141
+ continue;
142
+ if (seen === -1) {
143
+ seen = script;
144
+ }
145
+ else if (script !== seen) {
146
+ return true;
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+ /**
152
+ * Load a project's glossary and brand voice. Never throws: a failure means
153
+ * translating without that rule, as before. The two lookups are independent,
154
+ * so a glossary failure doesn't also drop the brand voice. `complete` is false
155
+ * if either failed — callers that copy text into shared TM must then assume
156
+ * project-specific wording may be baked in.
157
+ */
158
+ async function loadProjectTranslationRules(projectId, rules) {
159
+ let neverTranslateTerms = [];
160
+ let alwaysTranslateAsEntries = [];
161
+ let brandVoice = null;
162
+ let complete = true;
163
+ if (!rules)
164
+ return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
165
+ try {
166
+ const entries = await rules.getGlossary(projectId);
167
+ if (entries?.length) {
168
+ neverTranslateTerms = entries
169
+ .filter((e) => e.type === 'never_translate')
170
+ .map((e) => e.term);
171
+ alwaysTranslateAsEntries = entries
172
+ .filter((e) => e.type === 'always_translate_as')
173
+ .map((e) => ({
174
+ term: e.term,
175
+ type: 'always_translate_as',
176
+ translations: e.translations || {},
177
+ }));
178
+ logger_1.engineLogger.info('Loaded project glossary', {
179
+ neverTranslate: neverTranslateTerms.length,
180
+ alwaysTranslateAs: alwaysTranslateAsEntries.length,
181
+ });
182
+ }
183
+ }
184
+ catch (err) {
185
+ complete = false;
186
+ logger_1.engineLogger.warn('Failed to load glossary entries', { error: err });
187
+ }
188
+ try {
189
+ brandVoice = await rules.getBrandVoice(projectId);
190
+ if (brandVoice) {
191
+ logger_1.engineLogger.info('Brand voice configured', {
192
+ length: brandVoice.length,
193
+ hash: (0, cache_keys_1.hashText)(brandVoice),
194
+ });
195
+ }
196
+ }
197
+ catch (err) {
198
+ complete = false;
199
+ logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
200
+ }
201
+ return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
202
+ }
203
+ /**
204
+ * Whether a source string is subject to one of the project's "always
205
+ * translate as" rules for this locale, i.e. its stored translation has
206
+ * project-specific wording baked in.
207
+ */
208
+ function hasAlwaysTranslateAsTerm(sourceText, entries, targetLocale) {
209
+ return entries.some((e) => !!e.translations[targetLocale] &&
210
+ new RegExp(`\\b${e.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(sourceText));
211
+ }
212
+ async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
213
+ const locales = [];
214
+ const errors = [];
215
+ const failures = [];
216
+ let tmHitsTotal = 0;
217
+ const perLocaleMetadata = {};
218
+ // Ensure locale output directories exist for all target locales
219
+ if (!dryRun) {
220
+ for (const locale of targetLocales) {
221
+ const { dir } = (0, file_utils_1.localeFilePath)(workspace.path, workspace.publicDir, workspace.localeFormat, locale);
222
+ await (0, file_utils_1.ensureDir)(dir);
223
+ }
224
+ }
225
+ const flatSource = (0, keygen_1.flattenLocaleData)(sourceLocale.data);
226
+ // Strip _meta.* entries from flatSource so meta fields are never sent to the
227
+ // translation engine, never stored in the lockfile, and never overwrite the
228
+ // target locale's own computed _meta values.
229
+ for (const key of Object.keys(flatSource)) {
230
+ if (key === '_meta' || key.startsWith('_meta.')) {
231
+ delete flatSource[key];
232
+ }
233
+ }
234
+ // Fetch glossary entries for this project
235
+ let neverTranslateTerms = [];
236
+ let alwaysTranslateAsEntries = [];
237
+ let brandVoice = null;
238
+ let rulesComplete = true;
239
+ if (projectId) {
240
+ const rules = await loadProjectTranslationRules(projectId, providers.rules);
241
+ neverTranslateTerms = rules.neverTranslateTerms;
242
+ alwaysTranslateAsEntries = rules.alwaysTranslateAsEntries;
243
+ brandVoice = rules.brandVoice;
244
+ rulesComplete = rules.complete;
245
+ }
246
+ // Fall back to workspace config for CLI/MCP runs (no projectId)
247
+ if (!projectId) {
248
+ try {
249
+ const babelizeConfigPath = path.join(workspace.path, '.babelize', 'config.json');
250
+ const fileUtils = await Promise.resolve().then(() => __importStar(require('../../utils/file-utils')));
251
+ const { readJsonFile } = fileUtils;
252
+ if (await fileUtils.pathExists(babelizeConfigPath)) {
253
+ const config = await readJsonFile(babelizeConfigPath);
254
+ if (config?.customTranslations) {
255
+ for (const [term, entry] of Object.entries(config.customTranslations)) {
256
+ if (entry.neverTranslate) {
257
+ neverTranslateTerms.push(term);
258
+ }
259
+ if (entry.translations) {
260
+ alwaysTranslateAsEntries.push({
261
+ term,
262
+ type: 'always_translate_as',
263
+ translations: entry.translations,
264
+ });
265
+ }
266
+ }
267
+ logger_1.engineLogger.info('Loaded glossary from workspace config', {
268
+ neverTranslate: neverTranslateTerms.length,
269
+ alwaysTranslateAs: alwaysTranslateAsEntries.length,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ catch (err) {
275
+ logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
276
+ }
277
+ }
278
+ // Check if translator is configured before processing locales
279
+ if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
280
+ logger_1.engineLogger.error('TRANSLATOR_URL environment variable is not set and no BYOK config provided. Translations will fall back to source text.');
281
+ }
282
+ const localeTasks = targetLocales
283
+ .filter((l) => l !== sourceLocale.locale && (0, language_codes_1.isSupported)(l));
284
+ const { results: localeResults, errors: localeErrors } = await (0, concurrent_map_1.concurrentMap)(localeTasks, async (targetLocale) => {
285
+ const targetLangCode = (0, language_codes_1.toFlores)(targetLocale);
286
+ const localePaths = (0, file_utils_1.localeFilePath)(workspace.path, workspace.publicDir, workspace.localeFormat, targetLocale);
287
+ const targetPath = localePaths.file;
288
+ const relativePath = localePaths.relativeFile;
289
+ let entriesToTranslate = flatSource;
290
+ if (keysToTranslateByLocale && keysToTranslateByLocale[targetLocale]) {
291
+ entriesToTranslate = {};
292
+ for (const key of keysToTranslateByLocale[targetLocale]) {
293
+ if (flatSource[key]) {
294
+ entriesToTranslate[key] = flatSource[key];
295
+ }
296
+ }
297
+ }
298
+ if (Object.keys(entriesToTranslate).length === 0) {
299
+ return {
300
+ locale: targetLocale,
301
+ file: { locale: targetLocale, path: relativePath, data: {}, isNew: true },
302
+ tmHits: 0,
303
+ metadata: {},
304
+ failures: {},
305
+ error: null,
306
+ };
307
+ }
308
+ const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
309
+ // LLM translators (BYOK, or an injected one such as Babelize-hosted)
310
+ // take the brand voice in the prompt; NLLB output is polished after.
311
+ const hasBrandVoiceInPrompt = !!((byokConfig || providers.translator) && brandVoice);
312
+ const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options);
313
+ const entriesResult = translationResult.result;
314
+ const batchStats = translationResult.stats;
315
+ const batchMetadata = translationResult.metadata;
316
+ const keyFailures = translationResult.failures;
317
+ if (Object.keys(entriesResult).length === 0 && Object.keys(entriesToTranslate).length > 0) {
318
+ // Nothing translated: write no file. Every key is in keyFailures with
319
+ // its reason, so the caller can report exactly what went wrong.
320
+ logger_1.engineLogger.error(`No strings could be translated for locale "${targetLocale}"`, {
321
+ locale: targetLocale, sourceKeys: Object.keys(entriesToTranslate).length,
322
+ });
323
+ return { locale: targetLocale, file: null, tmHits: batchStats.tmHits, metadata: batchMetadata, failures: keyFailures, error: null };
324
+ }
325
+ let translatedEntries = entriesResult;
326
+ // Post-hoc brand voice polish only for the NLLB service; LLM
327
+ // translators already had the brand voice in the prompt.
328
+ if (brandVoice && !hasBrandVoiceInPrompt) {
329
+ // TM hits are approved/previously-accepted text: polishing them again
330
+ // would rewrite reviewed translations nondeterministically every run.
331
+ const toPolish = {};
332
+ for (const [k, v] of Object.entries(translatedEntries)) {
333
+ if (!batchMetadata[k]?.fromMemory)
334
+ toPolish[k] = v;
335
+ }
336
+ if (Object.keys(toPolish).length > 0) {
337
+ const polished = await (0, gemini_provider_1.polishWithBrandVoice)(toPolish, brandVoice, targetLocale);
338
+ if (polished !== toPolish) {
339
+ // The polish is a free rewrite of already-validated text: keep a
340
+ // rewrite only if it still has exactly the same placeholders, tags
341
+ // and terms (and, for ICU, is still a valid equivalent message).
342
+ const accepted = {};
343
+ let rejected = 0;
344
+ for (const [k, v] of Object.entries(polished)) {
345
+ const before = toPolish[k];
346
+ if (before === undefined || typeof v !== 'string' || !v.trim())
347
+ continue;
348
+ // ICU (in the source, or a plural the translation added) must
349
+ // stay an equivalent message of the validated translation.
350
+ const icu = (0, icu_message_1.isIcuMessage)(entriesToTranslate[k] ?? '') || (0, icu_message_1.isIcuMessage)(before) || (0, icu_message_1.isIcuMessage)(v);
351
+ const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) &&
352
+ (!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
353
+ if (safe)
354
+ accepted[k] = v;
355
+ else
356
+ rejected++;
357
+ }
358
+ logger_1.engineLogger.info('Brand voice polish applied', { locale: targetLocale, accepted: Object.keys(accepted).length, rejected });
359
+ translatedEntries = { ...translatedEntries, ...accepted };
360
+ }
361
+ }
362
+ }
363
+ let translatedData = (0, keygen_1.unflattenLocaleData)(translatedEntries);
364
+ const existingTranslatedMeta = typeof translatedData._meta === 'object' && translatedData._meta !== null
365
+ ? translatedData._meta
366
+ : {};
367
+ translatedData = {
368
+ ...translatedData,
369
+ _meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
370
+ };
371
+ if (!dryRun)
372
+ await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
373
+ return {
374
+ locale: targetLocale,
375
+ file: {
376
+ locale: targetLocale,
377
+ path: relativePath,
378
+ data: translatedData,
379
+ isNew: true,
380
+ isRTL: (0, language_codes_1.isRTL)(targetLocale),
381
+ },
382
+ tmHits: batchStats.tmHits,
383
+ metadata: batchMetadata,
384
+ failures: keyFailures,
385
+ error: null,
386
+ };
387
+ }, TRANSLATE_CONCURRENCY);
388
+ for (const r of localeResults) {
389
+ if (!r)
390
+ continue;
391
+ for (const [key, f] of Object.entries(r.failures ?? {})) {
392
+ failures.push({ locale: r.locale, key, source: flatSource[key] ?? '', ...f });
393
+ }
394
+ if (r.error) {
395
+ errors.push({ locale: r.locale, error: r.error });
396
+ }
397
+ else if (r.file) {
398
+ locales.push(r.file);
399
+ tmHitsTotal += r.tmHits;
400
+ perLocaleMetadata[r.locale] = r.metadata;
401
+ }
402
+ }
403
+ // Collect errors from unsupported locales
404
+ for (const targetLocale of targetLocales) {
405
+ if (targetLocale === sourceLocale.locale)
406
+ continue;
407
+ if (!(0, language_codes_1.isSupported)(targetLocale)) {
408
+ errors.push({ locale: targetLocale, error: `Unsupported language code: ${targetLocale}` });
409
+ }
410
+ }
411
+ // Fail immediately when BYOK translation errors occurred
412
+ if (byokConfig && (errors.length > 0 || localeErrors.length > 0)) {
413
+ throw localeErrors.length > 0 ? localeErrors[0] : new Error(errors[0].error);
414
+ }
415
+ return {
416
+ translation: { locales, errors, failures },
417
+ stats: { tmHits: tmHitsTotal },
418
+ metadata: perLocaleMetadata
419
+ };
420
+ }
421
+ async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, neverTranslateTerms = [], alwaysTranslateAsEntries = [], targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}) {
422
+ const result = {};
423
+ const metadata = {};
424
+ const keys = Object.keys(entries);
425
+ // Cache and TM key: the FLORES code, plus the region for regional variants
426
+ // (pt-BR vs pt-PT share por_Latn but must not share translations).
427
+ const storeLang = targetLocale && (0, language_codes_1.isSupported)(targetLocale) ? (0, language_codes_1.storageLangCode)(targetLocale) : targetLang;
428
+ let uncachedEntries = [];
429
+ let cacheHits = 0;
430
+ let tmHits = 0;
431
+ const cacheBackfill = [];
432
+ const markInTm = (keys) => {
433
+ for (const k of keys)
434
+ metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
435
+ };
436
+ for (const key of keys) {
437
+ const text = entries[key];
438
+ const entryContext = contextMap?.[key];
439
+ const textForHash = entryContext ? `${text}:::${entryContext}` : text;
440
+ const textHash = (0, cache_keys_1.hashText)(textForHash);
441
+ const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
442
+ const cached = cacheProvider && !forceTranslation ? await cacheProvider.get(cacheKey) : { hit: false, data: null };
443
+ if (cached.hit && cached.data !== null && reusableTranslation(text, cached.data, !!options.targetPlurals)) {
444
+ result[key] = applyGlossaryPostProcessing(text, cached.data, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale);
445
+ cacheHits++;
446
+ // Reused, not translated this run (billing counts only fresh output).
447
+ metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
448
+ // Keep out of shared TM: sentence fragments (context-dependent), and —
449
+ // since cache entries written before TM stored pre-glossary text may
450
+ // have them baked in — strings with project glossary rules or an
451
+ // in-prompt brand voice, which must not leak into other projects.
452
+ if (rulesComplete && !entryContext && !brandVoice && !hasAlwaysTranslateAsTerm(text, alwaysTranslateAsEntries, targetLocale)) {
453
+ cacheBackfill.push({ key, sourceText: text, translatedText: restoreNeverTranslate(text, cached.data, neverTranslateTerms) });
454
+ }
455
+ }
456
+ else {
457
+ uncachedEntries.push({ key, value: text, hash: textHash, context: entryContext });
458
+ }
459
+ }
460
+ const memory = providers.memory;
461
+ const translator = providers.translator ?? (0, default_translation_provider_1.createDefaultTranslationProvider)(byokConfig);
462
+ let tmContext = null;
463
+ if (projectId && memory) {
464
+ try {
465
+ tmContext = await memory.getProjectContext(projectId);
466
+ }
467
+ catch (err) {
468
+ logger_1.engineLogger.error('Failed to resolve TM owner; TM disabled for this locale', { projectId, error: err });
469
+ }
470
+ }
471
+ const ownerId = memory ? tmContext?.ownerId ?? null : null;
472
+ if (cacheBackfill.length > 0 && projectId && tmContext && !dryRun) {
473
+ // Redis hits never reach the translator, so without this a re-run would
474
+ // never populate TM for strings translated before TM writes worked.
475
+ if (await persistToTM(memory, cacheBackfill, projectId, tmContext, sourceLang, storeLang, { overwrite: false })) {
476
+ markInTm(cacheBackfill.map((i) => i.key));
477
+ }
478
+ }
479
+ // When the TM lookup fails we can't tell which strings already have a
480
+ // (possibly human-edited) TM entry, so later writes must not overwrite.
481
+ let tmLookupFailed = false;
482
+ // Source texts whose TM entry a human approved: a forced re-translate still
483
+ // uses fresh output for this run but must not overwrite those entries.
484
+ const approvedSourceTexts = new Set();
485
+ if (forceTranslation && ownerId && projectId && !dryRun && uncachedEntries.length > 0) {
486
+ try {
487
+ const existing = await memory.getAllForOwner(ownerId, sourceLang, storeLang);
488
+ for (const m of existing)
489
+ if (m.approvedAt)
490
+ approvedSourceTexts.add(m.sourceText);
491
+ }
492
+ catch (err) {
493
+ tmLookupFailed = true;
494
+ logger_1.engineLogger.error('Failed to read Translation Memory before forced re-translate; TM writes will not overwrite', { error: err });
495
+ }
496
+ }
497
+ if (uncachedEntries.length > 0 && ownerId && !forceTranslation) {
498
+ try {
499
+ logger_1.engineLogger.info('Checking Memory for entries', {
500
+ count: uncachedEntries.length,
501
+ ownerId,
502
+ targetLocale,
503
+ });
504
+ // 1. Fetch all TM for owner (for fuzzy matching)
505
+ // TM rows store FLORES codes (eng_Latn → fra_Latn), so look up by the
506
+ // FLORES codes, not the ISO locale.
507
+ const allMemory = await memory.getAllForOwner(ownerId, sourceLang, storeLang);
508
+ const stillUncached = [];
509
+ for (const entry of uncachedEntries) {
510
+ // TM is context-free; a sentence fragment's translation depends on
511
+ // the sentence around it, so never auto-apply TM to one.
512
+ if (entry.context) {
513
+ stillUncached.push(entry);
514
+ continue;
515
+ }
516
+ const lookup = (0, memory_matcher_1.matchFromMemory)(allMemory, entry.value, storeLang);
517
+ if (lookup.hit && lookup.translatedText && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals)) {
518
+ result[entry.key] = applyGlossaryPostProcessing(entry.value, lookup.translatedText, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale);
519
+ tmHits++;
520
+ metadata[entry.key] = { fromMemory: true, inTm: true };
521
+ // Warm Redis cache (stored pre-glossary, like every other cache write)
522
+ const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
523
+ if (cacheProvider) {
524
+ cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
525
+ logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
526
+ });
527
+ }
528
+ }
529
+ else if (lookup.fuzzyMatches && lookup.fuzzyMatches.length > 0) {
530
+ // Store fuzzy matches as metadata so they can be surfaced as suggestions in dashboard
531
+ metadata[entry.key] = { fuzzyMatches: lookup.fuzzyMatches };
532
+ stillUncached.push(entry);
533
+ }
534
+ else {
535
+ stillUncached.push(entry);
536
+ }
537
+ }
538
+ uncachedEntries = stillUncached;
539
+ }
540
+ catch (err) {
541
+ tmLookupFailed = true;
542
+ logger_1.engineLogger.error('Failed to query Translation Memory; new TM writes will not overwrite existing entries', { error: err });
543
+ }
544
+ }
545
+ if (keys.length > 0) {
546
+ const hitRate = Math.round(((cacheHits + tmHits) / keys.length) * 100);
547
+ logger_1.engineLogger.info('Translation cache stats', {
548
+ cacheHits,
549
+ tmHits,
550
+ total: keys.length,
551
+ hitRate,
552
+ toTranslate: uncachedEntries.length,
553
+ });
554
+ }
555
+ const failures = {};
556
+ // LLM providers get structure-aware prompts and repair attempts; the NLLB
557
+ // service can't handle ICU or mixed-script text, so those fail up front.
558
+ const isLlm = !!byokConfig || !!providers.translator;
559
+ // Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
560
+ const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
561
+ if (uncachedEntries.length > 0) {
562
+ const glossaryForLocale = alwaysTranslateAsEntries
563
+ .filter((e) => e.translations[targetLocale])
564
+ .map((e) => ({ term: e.term, translation: e.translations[targetLocale] }));
565
+ // 1. Protect placeholders, tags and never-translate terms. Strings with
566
+ // nothing translatable left (e.g. "{count}", a brand name) are their own
567
+ // translation and never reach the translator.
568
+ const prepared = [];
569
+ for (const entry of uncachedEntries) {
570
+ const icu = (0, icu_message_1.isIcuMessage)(entry.value);
571
+ if (!isLlm && (icu || hasMixedScripts(entry.value) || !nllbTarget)) {
572
+ failures[entry.key] = {
573
+ reason: 'unsupported_by_provider',
574
+ retryable: false,
575
+ message: !nllbTarget
576
+ ? `${targetLocale} needs an LLM translation provider`
577
+ : icu
578
+ ? 'ICU plural/select messages need an LLM translation provider'
579
+ : 'Text mixing writing systems needs an LLM translation provider',
580
+ };
581
+ continue;
582
+ }
583
+ // ICU branches legitimately repeat or drop spans (Polish adds branches,
584
+ // Japanese keeps only "other"), so ICU text is sent untokenized and its
585
+ // tags/URLs/terms are checked as a set after translation instead. The
586
+ // same goes for a plain message with a count ("{files} files were
587
+ // uploaded"): an LLM may return a plural for a language that needs one.
588
+ const countArgs = isLlm && options.targetPlurals && !icu ? (0, icu_message_1.countArguments)(entry.value) : [];
589
+ const tokenized = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
590
+ const sendRaw = icu || countArgs.length > 0;
591
+ const prot = sendRaw ? { text: entry.value, tokens: [] } : tokenized;
592
+ if ((0, placeholder_protection_1.hasNothingToTranslate)(tokenized.text)) {
593
+ result[entry.key] = entry.value;
594
+ // Copied through (placeholders/codes only); the translator never saw it.
595
+ metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
596
+ continue;
597
+ }
598
+ prepared.push({
599
+ ...entry,
600
+ sendText: prot.text,
601
+ tokens: prot.tokens,
602
+ icu: sendRaw,
603
+ countArgs: countArgs.length ? countArgs : undefined,
604
+ plain: countArgs.length ? { sendText: tokenized.text, tokens: tokenized.tokens } : undefined,
605
+ });
606
+ }
607
+ // 2. Translate in batches; judge every key independently.
608
+ const outcomes = new Map();
609
+ const judge = (entry, keyResult, nllb = !isLlm) => {
610
+ const fail = (reason, retryable, message) => ({ ok: false, reason, retryable, message });
611
+ if (!keyResult)
612
+ return fail('missing_from_response', true, 'The translator returned no translation for this string');
613
+ if (keyResult.status === 'failed') {
614
+ return fail(keyResult.reason, keyResult.retryable, keyResult.message ?? `Translator reported ${keyResult.reason}`);
615
+ }
616
+ if (!keyResult.value.trim())
617
+ return fail('empty_value', true, 'The translator returned an empty string');
618
+ // A plain source must stay plain unless a plural was allowed for it.
619
+ if (!(0, icu_message_1.isIcuMessage)(entry.value) && !entry.countArgs && (0, icu_message_1.isIcuMessage)(keyResult.value)) {
620
+ const args = (0, icu_message_1.extractSimpleArguments)(entry.value);
621
+ return fail('icu_invalid', true, `The source is plain text: return plain text, not ICU plural/select syntax${args.length ? `, and keep ${args.map((a) => `{${a}}`).join(', ')} as is` : ''}`);
622
+ }
623
+ const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
624
+ if (!tokenCheck.ok)
625
+ return fail('placeholder_lost', true, `Placeholder problem: ${(0, placeholder_protection_1.describeTokenProblem)(tokenCheck, entry.tokens)}`);
626
+ const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb }), entry.tokens);
627
+ const warnings = [];
628
+ // A plural added for a count placeholder is validated as ICU too.
629
+ if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
630
+ const issues = (0, icu_message_1.compareIcuMessages)(entry.value, value, targetLocale, { pluralizable: entry.countArgs ?? [] });
631
+ const error = issues.find((i) => i.severity === 'error');
632
+ if (error)
633
+ return fail('icu_invalid', true, error.message);
634
+ const lost = missingSpans(entry.value, value, neverTranslateTerms);
635
+ if (lost.length > 0)
636
+ return fail('placeholder_lost', true, `Placeholder problem: dropped ${lost.join(', ')}`);
637
+ // Every branch must keep its own tags/URLs/terms, not just one of them.
638
+ const branch = (0, icu_spans_1.branchSpanProblems)(entry.value, value, neverTranslateTerms);
639
+ if (branch.length > 0) {
640
+ const reason = branch.some((p) => p.lost.length) ? 'placeholder_lost' : 'markup_mismatch';
641
+ return fail(reason, true, `Placeholder problem in ${branch.map(icu_spans_1.describeBranchSpanProblem).join('; ')} (each branch needs the source's tags and links)`);
642
+ }
643
+ warnings.push(...issues.map((i) => i.message));
644
+ }
645
+ if (isUnchangedSentence(entry.sendText, keyResult.value)) {
646
+ return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
647
+ }
648
+ const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
649
+ // Tag counts legitimately change with an ICU message's branch count;
650
+ // its tags were already checked as a set above.
651
+ if (!entry.icu && !(0, icu_message_1.isIcuMessage)(value) && quality.issues?.some((i) => i.type === 'html_tag_mismatch')) {
652
+ return fail('markup_mismatch', true, 'HTML/JSX tags differ from the source');
653
+ }
654
+ if (quality.issues?.some((i) => i.type === 'suspicious_length')) {
655
+ warnings.push('Length differs a lot from the source; check it fits the UI');
656
+ }
657
+ return { ok: true, value, warnings };
658
+ };
659
+ const translateBatch = async (batch, hints) => {
660
+ const batchGlossary = [
661
+ ...glossaryForLocale.filter((g) => batch.some((b) => containsTerm(b.value, g.term))),
662
+ // Untokenized ICU text: keep never-translate terms as-is via the glossary.
663
+ ...neverTranslateTerms
664
+ .filter((t) => batch.some((b) => b.icu && containsTerm(b.value, t)))
665
+ .map((t) => ({ term: t, translation: t })),
666
+ ];
667
+ const keyResults = await translator.translateBatch({
668
+ entries: batch.map((b) => ({
669
+ key: b.key,
670
+ value: b.sendText,
671
+ parentContext: contextMap?.[b.key],
672
+ extractionContext: extractionContextMap?.[b.key],
673
+ })),
674
+ sourceLang,
675
+ targetLang,
676
+ brandVoice,
677
+ targetLocale,
678
+ glossary: batchGlossary.length > 0 ? batchGlossary : undefined,
679
+ hints,
680
+ });
681
+ const byKey = new Map(keyResults.map((r) => [r.key, r]));
682
+ for (const b of batch)
683
+ outcomes.set(b.key, judge(b, byKey.get(b.key)));
684
+ };
685
+ // A failed request fails only its own keys — except BYOK, where it means
686
+ // the customer's key/quota is the problem and the whole run should stop
687
+ // with that error rather than produce a partial result.
688
+ const runBatches = async (batches, hintsFor) => {
689
+ let byokError = null;
690
+ await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
691
+ onProgress?.(`Translating batch ${i + 1}/${batches.length} for ${targetLang}...`);
692
+ const hints = hintsFor
693
+ ? Object.fromEntries(batch.map((b) => [b.key, hintsFor(b)]).filter(([, h]) => !!h))
694
+ : undefined;
695
+ try {
696
+ await translateBatch(batch, hints);
697
+ }
698
+ catch (error) {
699
+ const message = error instanceof Error ? error.message : String(error);
700
+ logger_1.engineLogger.error('Translation batch failed', { error: message, sourceLang, targetLang });
701
+ if (byokConfig)
702
+ byokError ?? (byokError = error instanceof Error ? error : new Error(message));
703
+ for (const b of batch) {
704
+ outcomes.set(b.key, { ok: false, reason: 'provider_error', retryable: true, message });
705
+ }
706
+ }
707
+ }, TRANSLATE_CONCURRENCY);
708
+ if (byokError)
709
+ throw byokError;
710
+ };
711
+ const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
712
+ await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
713
+ // 3. One targeted repair attempt for keys that failed validation, telling
714
+ // the model exactly what was wrong. NLLB is deterministic, so for it
715
+ // only keys it skipped are worth asking for again.
716
+ const toRepair = prepared.filter((b) => {
717
+ const o = outcomes.get(b.key);
718
+ return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
719
+ });
720
+ if (toRepair.length > 0) {
721
+ logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
722
+ onProgress?.(`Retrying ${toRepair.length} string(s) that failed checks for ${targetLang}...`);
723
+ const firstAttempt = new Map(toRepair.map((b) => [b.key, outcomes.get(b.key)]));
724
+ await runBatches(chunk(toRepair, REPAIR_BATCH_SIZE), isLlm
725
+ ? (b) => {
726
+ const o = firstAttempt.get(b.key);
727
+ if (!o || o.ok)
728
+ return undefined;
729
+ const fix = `A previous translation was rejected — ${o.message}. Fix that, and copy every [#VAR_n#] token exactly once.`;
730
+ return b.countArgs ? `${fix} ${pluralHint(b.countArgs, targetLocale)}` : fix;
731
+ }
732
+ : undefined);
733
+ }
734
+ // 3b. Keys the LLM couldn't be reached for (rate limit, outage) get one
735
+ // attempt on the fallback translator, if there is one. It's NLLB-class,
736
+ // so ICU and mixed-script strings stay failed.
737
+ const fellBack = new Set();
738
+ const fallback = providers.fallbackTranslator;
739
+ if (fallback && isLlm && nllbTarget) {
740
+ const toFallback = prepared.filter((b) => {
741
+ const o = outcomes.get(b.key);
742
+ // A count entry is a plain message: the fallback translates its tokenized form (no plural).
743
+ return o && !o.ok && o.reason === 'provider_error' && (!b.icu || !!b.plain) && !hasMixedScripts(b.value);
744
+ }).map((b) => (b.plain ? { ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined } : b));
745
+ if (toFallback.length > 0) {
746
+ logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator', { count: toFallback.length, targetLang });
747
+ for (const batch of chunk(toFallback, BATCH_SIZE)) {
748
+ try {
749
+ const keyResults = await fallback.translateBatch({
750
+ entries: batch.map((b) => ({ key: b.key, value: b.sendText, parentContext: contextMap?.[b.key] })),
751
+ sourceLang,
752
+ targetLang,
753
+ targetLocale,
754
+ });
755
+ const byKey = new Map(keyResults.map((r) => [r.key, r]));
756
+ for (const b of batch) {
757
+ const o = judge(b, byKey.get(b.key), true);
758
+ if (o.ok) {
759
+ outcomes.set(b.key, o);
760
+ fellBack.add(b.key);
761
+ }
762
+ }
763
+ }
764
+ catch (error) {
765
+ logger_1.engineLogger.error('Fallback translation batch failed', { error: error instanceof Error ? error.message : String(error), targetLang });
766
+ }
767
+ }
768
+ }
769
+ }
770
+ // 4. Record results. Failed keys get no value at all: the locale file
771
+ // omits them (the app shows the source string, honestly), the lockfile
772
+ // doesn't mark them done, and the next run retries them.
773
+ const tmInserts = [];
774
+ for (const entry of prepared) {
775
+ const outcome = outcomes.get(entry.key);
776
+ if (!outcome || !outcome.ok) {
777
+ failures[entry.key] = outcome && !outcome.ok
778
+ ? { reason: outcome.reason, retryable: outcome.retryable, message: outcome.message }
779
+ : { reason: 'missing_from_response', retryable: true };
780
+ continue;
781
+ }
782
+ const finalValue = applyGlossaryPostProcessing(entry.value, outcome.value, [], alwaysTranslateAsEntries, targetLocale);
783
+ result[entry.key] = finalValue;
784
+ const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
785
+ if (qa.length > 0)
786
+ metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
787
+ // Fallback output is a stopgap: keep it out of the shared cache and TM
788
+ // so they only ever hold the primary translator's work.
789
+ if (fellBack.has(entry.key)) {
790
+ const prev = metadata[entry.key] ?? {};
791
+ metadata[entry.key] = {
792
+ ...prev,
793
+ fallback: true,
794
+ qa: [...(prev.qa ?? []), exports.FALLBACK_QA_WARNING],
795
+ };
796
+ continue;
797
+ }
798
+ // Cache and TM store the translation *before* project glossary rules
799
+ // ("always translate as") are applied; those rules are re-applied on
800
+ // every hit, so glossary edits reach cached strings and one project's
801
+ // glossary never leaks into the owner's other projects through TM.
802
+ const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
803
+ if (cacheProvider) {
804
+ cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
805
+ logger_1.engineLogger.error('Failed to cache translation', { cacheKey, error: err });
806
+ });
807
+ }
808
+ if (projectId && !contextMap?.[entry.key] && !approvedSourceTexts.has(entry.value)) {
809
+ tmInserts.push({ key: entry.key, sourceText: entry.value, translatedText: outcome.value });
810
+ }
811
+ }
812
+ if (tmInserts.length > 0 && projectId && tmContext && !dryRun) {
813
+ // Normally the translator only sees strings TM had no entry for, so
814
+ // overwriting is safe; a forced re-translate overwrites on purpose.
815
+ // If the lookup failed, existing entries may be human edits — keep them.
816
+ const saved = await persistToTM(memory, tmInserts, projectId, tmContext, sourceLang, storeLang, { overwrite: !tmLookupFailed });
817
+ if (saved)
818
+ markInTm(tmInserts.map((i) => i.key));
819
+ }
820
+ }
821
+ const failureCount = Object.keys(failures).length;
822
+ if (failureCount > 0) {
823
+ logger_1.engineLogger.warn('Some strings could not be translated', { targetLang, failed: failureCount, of: keys.length });
824
+ }
825
+ // Every key this run produced gets metadata (possibly empty) so the lockfile
826
+ // replaces the previous run's — cache hits, fallbacks and mixed-script keys
827
+ // included — instead of keeping a stale fromMemory/fuzzyMatches.
828
+ for (const key of Object.keys(result)) {
829
+ if (!metadata[key])
830
+ metadata[key] = {};
831
+ }
832
+ return { result, stats: { tmHits }, metadata, failures };
833
+ }
834
+ /** Put back never-translate terms that were masked before translation. */
835
+ function restoreNeverTranslate(originalText, translatedText, neverTranslateTerms) {
836
+ if (neverTranslateTerms.length === 0)
837
+ return translatedText;
838
+ const { terms: usedTerms } = (0, glossary_1.applyGlossaryMask)(originalText, neverTranslateTerms);
839
+ return (0, glossary_1.restoreGlossaryMask)(translatedText, usedTerms);
840
+ }
841
+ function applyGlossaryPostProcessing(originalText, translatedText, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale) {
842
+ let processed = restoreNeverTranslate(originalText, translatedText, neverTranslateTerms);
843
+ if (alwaysTranslateAsEntries.length > 0 && targetLocale) {
844
+ processed = (0, glossary_1.applyAlwaysTranslateAs)(processed, alwaysTranslateAsEntries, targetLocale);
845
+ }
846
+ return processed;
847
+ }
848
+ /**
849
+ * Persist translations to the project owner's TM. Never throws: a TM failure
850
+ * must not fail the run, but it is logged at error level rather than skipped.
851
+ * `sourceLang`/`targetLang` are FLORES codes, matching what TM lookups use.
852
+ */
853
+ async function persistToTM(memory, inserts, projectId, tmContext, sourceLang, targetLang, options = {}) {
854
+ if (!tmContext.ownerId) {
855
+ logger_1.engineLogger.error('TM persistence skipped: project has no resolvable owner', {
856
+ projectId,
857
+ count: inserts.length,
858
+ });
859
+ return false;
860
+ }
861
+ try {
862
+ await memory.saveBatch(tmContext.ownerId, projectId, inserts.map((insert) => ({
863
+ sourceText: insert.sourceText,
864
+ sourceLocale: sourceLang,
865
+ targetLocale: targetLang,
866
+ translatedText: insert.translatedText,
867
+ isoKey: insert.key,
868
+ projectName: tmContext.projectName ?? undefined,
869
+ })), options);
870
+ logger_1.engineLogger.info('TM persistence complete', { count: inserts.length, projectId, overwrite: options.overwrite ?? true });
871
+ return true;
872
+ }
873
+ catch (err) {
874
+ logger_1.engineLogger.error('TM persistence failed', {
875
+ count: inserts.length,
876
+ projectId,
877
+ error: err instanceof Error ? err.message : String(err),
878
+ });
879
+ return false;
880
+ }
881
+ }
882
+ /**
883
+ * Backfill TM from translations that were reused without calling the
884
+ * translator (lockfile hits). Existing TM rows are left untouched.
885
+ * Locales are ISO codes here; identity translations (source === target) are
886
+ * skipped since they mean a previous run had no working translator.
887
+ */
888
+ async function backfillTranslationMemory(projectId, sourceLocale, flatSource, translationsByLocale, excludeKeys = new Set(), providers = {}) {
889
+ const memory = providers.memory;
890
+ if (!memory)
891
+ return {};
892
+ const tmContext = await memory.getProjectContext(projectId);
893
+ const sourceLang = (0, language_codes_1.toFlores)(sourceLocale);
894
+ // Lockfile values are final output: project glossary substitutions and
895
+ // brand-voice rewording are baked in and can't be separated out. Shared TM
896
+ // must not carry them into the owner's other projects, so skip those.
897
+ const rules = await loadProjectTranslationRules(projectId, providers.rules);
898
+ if (rules.brandVoice) {
899
+ logger_1.engineLogger.info('Skipping lockfile TM backfill: project has a brand voice', { projectId });
900
+ return {};
901
+ }
902
+ if (!rules.complete) {
903
+ // Can't tell whether glossary/brand-voice wording is baked into these
904
+ // values; retry on a later run rather than risk leaking it into shared TM.
905
+ logger_1.engineLogger.warn('Skipping lockfile TM backfill: project rules failed to load', { projectId });
906
+ return {};
907
+ }
908
+ // Keys whose TM state is settled for a locale: written, or deliberately
909
+ // skipped. The caller records these so later runs don't redo them.
910
+ const settled = {};
911
+ for (const [locale, translations] of Object.entries(translationsByLocale)) {
912
+ if (locale === sourceLocale || !(0, language_codes_1.isSupported)(locale))
913
+ continue;
914
+ const inserts = [];
915
+ const skipped = [];
916
+ for (const [key, translatedText] of Object.entries(translations)) {
917
+ const sourceText = flatSource[key];
918
+ if (excludeKeys.has(key) ||
919
+ hasAlwaysTranslateAsTerm(sourceText ?? '', rules.alwaysTranslateAsEntries, locale) ||
920
+ !sourceText || !translatedText || sourceText === translatedText) {
921
+ skipped.push(key);
922
+ continue;
923
+ }
924
+ inserts.push({ key, sourceText, translatedText });
925
+ }
926
+ const saved = inserts.length === 0 ||
927
+ await persistToTM(memory, inserts, projectId, tmContext, sourceLang, (0, language_codes_1.storageLangCode)(locale), { overwrite: false });
928
+ if (saved)
929
+ settled[locale] = [...skipped, ...inserts.map((i) => i.key)];
930
+ }
931
+ return settled;
932
+ }