@babelize/engine 0.1.0 → 0.1.2

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 (102) hide show
  1. package/dist/analysis/analyze.js +1 -1
  2. package/dist/detection/stack.d.ts +42 -0
  3. package/dist/detection/stack.js +277 -0
  4. package/dist/django/catalog.d.ts +92 -0
  5. package/dist/django/catalog.js +677 -0
  6. package/dist/django/extract.d.ts +20 -0
  7. package/dist/django/extract.js +79 -0
  8. package/dist/django/inject-file.d.ts +6 -0
  9. package/dist/django/inject-file.js +52 -0
  10. package/dist/django/inject.d.ts +11 -0
  11. package/dist/django/inject.js +205 -0
  12. package/dist/django/keys.d.ts +21 -0
  13. package/dist/django/keys.js +63 -0
  14. package/dist/django/locale-names.d.ts +13 -0
  15. package/dist/django/locale-names.js +60 -0
  16. package/dist/django/mo-file.d.ts +6 -0
  17. package/dist/django/mo-file.js +60 -0
  18. package/dist/django/plural-forms.d.ts +34 -0
  19. package/dist/django/plural-forms.js +216 -0
  20. package/dist/django/po-file.d.ts +38 -0
  21. package/dist/django/po-file.js +273 -0
  22. package/dist/django/python-extract.d.ts +34 -0
  23. package/dist/django/python-extract.js +619 -0
  24. package/dist/django/python-lexer.d.ts +65 -0
  25. package/dist/django/python-lexer.js +398 -0
  26. package/dist/django/template-extract.d.ts +32 -0
  27. package/dist/django/template-extract.js +680 -0
  28. package/dist/django/types.d.ts +49 -0
  29. package/dist/django/types.js +15 -0
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.js +16 -2
  32. package/dist/local/pseudo-provider.js +5 -4
  33. package/dist/local/translate-core.d.ts +5 -2
  34. package/dist/local/translate-core.js +43 -5
  35. package/dist/local/translate-messages.js +7 -9
  36. package/dist/modules/extractor/extract.js +22 -14
  37. package/dist/modules/finalization/django-finalize.d.ts +15 -0
  38. package/dist/modules/finalization/django-finalize.js +156 -19
  39. package/dist/modules/finalization/finalize.d.ts +2 -0
  40. package/dist/modules/finalization/finalize.js +58 -30
  41. package/dist/modules/finalization/nextjs-finalize.d.ts +4 -1
  42. package/dist/modules/finalization/nextjs-finalize.js +8 -9
  43. package/dist/modules/finalization/writes.d.ts +29 -0
  44. package/dist/modules/finalization/writes.js +78 -0
  45. package/dist/modules/format-injection/index.js +3 -2
  46. package/dist/modules/glossary/index.d.ts +1 -2
  47. package/dist/modules/glossary/index.js +14 -6
  48. package/dist/modules/glossary/rules.d.ts +81 -0
  49. package/dist/modules/glossary/rules.js +244 -0
  50. package/dist/modules/injection/inject.js +14 -30
  51. package/dist/modules/keygen/keygen.js +7 -0
  52. package/dist/modules/locale-builder/locales.js +23 -0
  53. package/dist/modules/lockfile/index.d.ts +1 -1
  54. package/dist/modules/lockfile/index.js +2 -1
  55. package/dist/modules/lockfile/lockfile-manager.d.ts +9 -0
  56. package/dist/modules/lockfile/lockfile-manager.js +16 -1
  57. package/dist/modules/lockfile/schema.d.ts +42 -0
  58. package/dist/modules/lockfile/schema.js +11 -0
  59. package/dist/modules/translator/icu-branches.d.ts +138 -0
  60. package/dist/modules/translator/icu-branches.js +393 -0
  61. package/dist/modules/translator/translate.d.ts +40 -4
  62. package/dist/modules/translator/translate.js +285 -170
  63. package/dist/modules/workspace/validate.js +67 -32
  64. package/dist/orchestrator.d.ts +5 -0
  65. package/dist/orchestrator.js +125 -14
  66. package/dist/providers/types.d.ts +10 -2
  67. package/dist/qa/check-project.d.ts +2 -1
  68. package/dist/qa/check-project.js +50 -7
  69. package/dist/qa/gettext-files.d.ts +15 -0
  70. package/dist/qa/gettext-files.js +115 -0
  71. package/dist/qa/locale-qa.js +20 -3
  72. package/dist/runtime/config.js +1 -0
  73. package/dist/runtime/types.d.ts +64 -0
  74. package/dist/utils/extraction-options.d.ts +9 -3
  75. package/dist/utils/extraction-options.js +13 -3
  76. package/dist/utils/file-utils.js +7 -2
  77. package/dist/utils/jsx-message.js +7 -0
  78. package/dist/utils/metadata.d.ts +4 -2
  79. package/dist/utils/metadata.js +16 -10
  80. package/dist/utils/not-injected.js +2 -0
  81. package/dist/utils/placeholder-protection.d.ts +18 -1
  82. package/dist/utils/placeholder-protection.js +58 -13
  83. package/dist/utils/translation-sanitizer.d.ts +1 -0
  84. package/dist/utils/translation-sanitizer.js +23 -9
  85. package/dist/utils/translator-load-balancer.js +5 -1
  86. package/dist/utils/untranslated.d.ts +15 -0
  87. package/dist/utils/untranslated.js +34 -0
  88. package/package.json +2 -2
  89. package/dist/injection/django/django-template-injector.d.ts +0 -29
  90. package/dist/injection/django/django-template-injector.js +0 -216
  91. package/dist/modules/glossary/glossary.d.ts +0 -9
  92. package/dist/modules/glossary/glossary.js +0 -27
  93. package/dist/modules/glossary/masker.d.ts +0 -23
  94. package/dist/modules/glossary/masker.js +0 -56
  95. package/dist/modules/locale-builder/po.d.ts +0 -33
  96. package/dist/modules/locale-builder/po.js +0 -131
  97. package/dist/modules/translator/translator.d.ts +0 -5
  98. package/dist/modules/translator/translator.js +0 -46
  99. package/dist/utils/dtl-mask.d.ts +0 -25
  100. package/dist/utils/dtl-mask.js +0 -58
  101. package/dist/utils/py-extractor.d.ts +0 -14
  102. package/dist/utils/py-extractor.js +0 -285
@@ -1,43 +1,11 @@
1
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
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.FALLBACK_QA_WARNING = void 0;
4
+ exports.loadProjectTranslationRules = loadProjectTranslationRules;
37
5
  exports.translateLocales = translateLocales;
38
6
  exports.backfillTranslationMemory = backfillTranslationMemory;
39
- const path = __importStar(require("path"));
40
7
  const cache_keys_1 = require("../../utils/cache-keys");
8
+ const untranslated_1 = require("../../utils/untranslated");
41
9
  const logger_1 = require("../../utils/logger");
42
10
  const translation_quality_1 = require("../../utils/translation-quality");
43
11
  const file_utils_1 = require("../../utils/file-utils");
@@ -47,18 +15,25 @@ const keygen_1 = require("../keygen");
47
15
  const concurrent_map_1 = require("../../utils/concurrent-map");
48
16
  const language_codes_1 = require("./language-codes");
49
17
  const glossary_1 = require("../glossary");
18
+ const check_project_1 = require("../../qa/check-project");
50
19
  const gemini_provider_1 = require("./client/gemini.provider");
51
20
  const memory_matcher_1 = require("./memory/memory-matcher");
52
21
  const default_translation_provider_1 = require("../../providers/default-translation-provider");
53
22
  const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
54
23
  const glossary_match_1 = require("../../utils/glossary-match");
55
24
  const icu_spans_1 = require("../../utils/icu-spans");
25
+ const icu_branches_1 = require("./icu-branches");
56
26
  const BATCH_SIZE = 15;
57
27
  /** QA note on keys the backup (NLLB) translator produced. */
58
28
  exports.FALLBACK_QA_WARNING = 'translated with the backup translator; will be retried with the AI translator next run';
59
29
  /** Keys re-sent after failing validation go in small batches with per-key notes. */
60
30
  const REPAIR_BATCH_SIZE = 5;
61
31
  const TRANSLATE_CONCURRENCY = 3; // Max concurrent locales/batches
32
+ /**
33
+ * ICU branches sent to a plain-text translator go in requests of up to this
34
+ * many sentences (the translator service's batch limit).
35
+ */
36
+ const BRANCH_BATCH_SIZE = 50;
62
37
  /** A plain source whose stored translation is ICU only fits a project whose runtime formats ICU. */
63
38
  function reusableTranslation(source, translated, targetPlurals) {
64
39
  return targetPlurals || (0, icu_message_1.isIcuMessage)(source) || !(0, icu_message_1.isIcuMessage)(translated);
@@ -85,12 +60,6 @@ function isUnchangedSentence(sentText, returnedText) {
85
60
  const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
86
61
  return words.length >= 3 && norm(sentText) === norm(returnedText);
87
62
  }
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
63
  /** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
95
64
  function missingSpans(source, translated, neverTranslateTerms) {
96
65
  const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
@@ -98,11 +67,24 @@ function missingSpans(source, translated, neverTranslateTerms) {
98
67
  return [...want].filter((t) => !have.has(t.toLowerCase()));
99
68
  }
100
69
  /** Glossary terms in the source whose required translation is missing from the output. */
101
- function glossaryWarnings(source, translated, glossary) {
70
+ function glossaryWarnings(source, translated, glossary, neverTranslateTerms) {
102
71
  return glossary
103
- .filter((g) => containsTerm(source, g.term) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
72
+ .filter((g) => (0, glossary_1.sourceHasTerm)(source, g.term, neverTranslateTerms) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
104
73
  .map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
105
74
  }
75
+ /**
76
+ * Why a stored translation (cache or TM) can't be reused under the project's
77
+ * current glossary, or null if it can: a placeholder, tag, URL or
78
+ * never-translate term of the source is missing, or an "always translate as"
79
+ * term's required translation is. Such a hit is treated as a miss.
80
+ */
81
+ function storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale) {
82
+ const lost = missingSpans(source, stored, neverTranslateTerms);
83
+ if (lost.length > 0)
84
+ return `missing ${lost.join(', ')}`;
85
+ const glossary = glossaryWarnings(source, stored, glossaryForLocale, neverTranslateTerms);
86
+ return glossary.length > 0 ? glossary.join('; ') : null;
87
+ }
106
88
  const SCRIPT_RANGES = [
107
89
  [0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
108
90
  [0x0370, 0x03FF], // Greek
@@ -156,29 +138,22 @@ function hasMixedScripts(text) {
156
138
  * project-specific wording may be baked in.
157
139
  */
158
140
  async function loadProjectTranslationRules(projectId, rules) {
159
- let neverTranslateTerms = [];
160
- let alwaysTranslateAsEntries = [];
141
+ let glossary = glossary_1.EMPTY_GLOSSARY;
161
142
  let brandVoice = null;
162
143
  let complete = true;
163
144
  if (!rules)
164
- return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
145
+ return { glossary, brandVoice, complete };
165
146
  try {
166
147
  const entries = await rules.getGlossary(projectId);
167
148
  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
- }));
149
+ glossary = (0, glossary_1.normalizeGlossary)(entries);
178
150
  logger_1.engineLogger.info('Loaded project glossary', {
179
- neverTranslate: neverTranslateTerms.length,
180
- alwaysTranslateAs: alwaysTranslateAsEntries.length,
151
+ neverTranslate: glossary.neverTranslate.length,
152
+ alwaysTranslateAs: glossary.alwaysTranslateAs.length,
153
+ conflicts: glossary.conflicts.length,
181
154
  });
155
+ for (const c of glossary.conflicts)
156
+ logger_1.engineLogger.warn(c.message, { projectId });
182
157
  }
183
158
  }
184
159
  catch (err) {
@@ -198,16 +173,32 @@ async function loadProjectTranslationRules(projectId, rules) {
198
173
  complete = false;
199
174
  logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
200
175
  }
201
- return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
176
+ return { glossary, brandVoice, complete };
202
177
  }
203
178
  /**
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.
179
+ * The glossary of a run without a cloud project (CLI/MCP, no projectId):
180
+ * `.babelize/config.json` `glossary`, the same file and schema `babelize
181
+ * check` reads.
207
182
  */
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));
183
+ async function loadWorkspaceGlossary(workspacePath) {
184
+ try {
185
+ const { glossary } = await (0, check_project_1.readProjectLocaleConfig)(workspacePath);
186
+ if (!glossary)
187
+ return glossary_1.EMPTY_GLOSSARY;
188
+ const rules = (0, glossary_1.normalizeGlossary)([
189
+ ...(glossary.neverTranslate ?? []).map((term) => ({ term, type: 'never_translate' })),
190
+ ...(glossary.alwaysTranslateAs ?? []).map((e) => ({ term: e.term, type: 'always_translate_as', translations: e.translations })),
191
+ ]);
192
+ logger_1.engineLogger.info('Loaded glossary from workspace config', {
193
+ neverTranslate: rules.neverTranslate.length,
194
+ alwaysTranslateAs: rules.alwaysTranslateAs.length,
195
+ });
196
+ return rules;
197
+ }
198
+ catch (err) {
199
+ logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
200
+ return glossary_1.EMPTY_GLOSSARY;
201
+ }
211
202
  }
212
203
  async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
213
204
  const locales = [];
@@ -231,50 +222,16 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
231
222
  delete flatSource[key];
232
223
  }
233
224
  }
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
- }
225
+ // The project's glossary and brand voice (injected rules store), or for a
226
+ // run without a project the workspace's .babelize/config.json glossary.
227
+ const rules = options.rules
228
+ ?? (projectId
229
+ ? await loadProjectTranslationRules(projectId, providers.rules)
230
+ : { glossary: await loadWorkspaceGlossary(workspace.path), brandVoice: null, complete: true });
231
+ const glossary = rules.glossary;
232
+ const neverTranslateTerms = glossary.neverTranslate;
233
+ const brandVoice = rules.brandVoice;
234
+ const rulesComplete = rules.complete;
278
235
  // Check if translator is configured before processing locales
279
236
  if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
280
237
  logger_1.engineLogger.error('TRANSLATOR_URL environment variable is not set and no BYOK config provided. Translations will fall back to source text.');
@@ -308,8 +265,8 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
308
265
  const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
309
266
  // LLM translators (BYOK, or an injected one such as Babelize-hosted)
310
267
  // 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);
268
+ const hasBrandVoiceInPrompt = !!((byokConfig || (providers.translator && !providers.translator.plainText)) && brandVoice);
269
+ const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, glossary, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options);
313
270
  const entriesResult = translationResult.result;
314
271
  const batchStats = translationResult.stats;
315
272
  const batchMetadata = translationResult.metadata;
@@ -325,6 +282,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
325
282
  let translatedEntries = entriesResult;
326
283
  // Post-hoc brand voice polish only for the NLLB service; LLM
327
284
  // translators already had the brand voice in the prompt.
285
+ const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
328
286
  if (brandVoice && !hasBrandVoiceInPrompt) {
329
287
  // TM hits are approved/previously-accepted text: polishing them again
330
288
  // would rewrite reviewed translations nondeterministically every run.
@@ -348,7 +306,10 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
348
306
  // ICU (in the source, or a plural the translation added) must
349
307
  // stay an equivalent message of the validated translation.
350
308
  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) &&
309
+ // ...and still uses every required glossary translation the validated text used.
310
+ const source = entriesToTranslate[k] ?? '';
311
+ const glossaryOk = glossaryWarnings(source, v, glossaryForLocale, neverTranslateTerms).length <= glossaryWarnings(source, before, glossaryForLocale, neverTranslateTerms).length;
312
+ const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) && glossaryOk &&
352
313
  (!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
353
314
  if (safe)
354
315
  accepted[k] = v;
@@ -368,7 +329,9 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
368
329
  ...translatedData,
369
330
  _meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
370
331
  };
371
- if (!dryRun)
332
+ // gettext catalogues are merged into the existing .po files once the
333
+ // whole run's translations are known (see django/catalog.ts).
334
+ if (!dryRun && workspace.localeFormat !== 'po')
372
335
  await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
373
336
  return {
374
337
  locale: targetLocale,
@@ -418,7 +381,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
418
381
  metadata: perLocaleMetadata
419
382
  };
420
383
  }
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 = {}) {
384
+ async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, glossary = glossary_1.EMPTY_GLOSSARY, targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}) {
422
385
  const result = {};
423
386
  const metadata = {};
424
387
  const keys = Object.keys(entries);
@@ -433,6 +396,14 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
433
396
  for (const k of keys)
434
397
  metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
435
398
  };
399
+ const neverTranslateTerms = glossary.neverTranslate;
400
+ // "Always translate as" terms with a translation for this locale (exact
401
+ // locale, else its base language).
402
+ const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
403
+ // Output shaped by such a rule is this project's wording: it must not go
404
+ // into the cache or TM, which are shared beyond the project.
405
+ const projectWording = (source) => (0, glossary_1.hasFixedTerm)(source, glossary, targetLocale);
406
+ const reuseProblem = (source, stored) => storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale);
436
407
  for (const key of keys) {
437
408
  const text = entries[key];
438
409
  const entryContext = contextMap?.[key];
@@ -440,8 +411,16 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
440
411
  const textHash = (0, cache_keys_1.hashText)(textForHash);
441
412
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
442
413
  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);
414
+ // A stored value that is the untranslated source (cached before this was
415
+ // rejected) is a miss, so the string is translated again instead of failing forever.
416
+ // So is one that breaks the project's glossary (a never-translate term
417
+ // translated, a required translation missing): glossary edits reach
418
+ // cached strings by re-translating them.
419
+ const cacheProblem = cached.hit && cached.data !== null ? reuseProblem(text, cached.data) : null;
420
+ if (cacheProblem)
421
+ logger_1.engineLogger.info('Cached translation breaks the glossary; translating again', { key, targetLocale, problem: cacheProblem });
422
+ if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
423
+ result[key] = cached.data;
445
424
  cacheHits++;
446
425
  // Reused, not translated this run (billing counts only fresh output).
447
426
  metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
@@ -449,8 +428,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
449
428
  // since cache entries written before TM stored pre-glossary text may
450
429
  // have them baked in — strings with project glossary rules or an
451
430
  // 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) });
431
+ if (rulesComplete && !entryContext && !brandVoice && !projectWording(text)) {
432
+ cacheBackfill.push({ key, sourceText: text, translatedText: cached.data });
454
433
  }
455
434
  }
456
435
  else {
@@ -514,13 +493,17 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
514
493
  continue;
515
494
  }
516
495
  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);
496
+ // A TM entry that breaks this project's glossary is a miss (TM is shared across the owner's projects).
497
+ const tmProblem = lookup.hit && lookup.translatedText ? reuseProblem(entry.value, lookup.translatedText) : null;
498
+ if (tmProblem)
499
+ logger_1.engineLogger.info('TM translation breaks the glossary; translating again', { key: entry.key, targetLocale, problem: tmProblem });
500
+ if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
501
+ result[entry.key] = lookup.translatedText;
519
502
  tmHits++;
520
503
  metadata[entry.key] = { fromMemory: true, inTm: true };
521
- // Warm Redis cache (stored pre-glossary, like every other cache write)
504
+ // Warm the cache (never with this project's glossary wording)
522
505
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
523
- if (cacheProvider) {
506
+ if (cacheProvider && !projectWording(entry.value)) {
524
507
  cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
525
508
  logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
526
509
  });
@@ -553,30 +536,29 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
553
536
  });
554
537
  }
555
538
  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;
539
+ // LLM providers get structure-aware prompts and repair attempts. The NLLB
540
+ // service translates plain text only: ICU messages go branch by branch
541
+ // (./icu-branches), and mixed-script text fails up front.
542
+ const isLlm = !!byokConfig || (!!providers.translator && !providers.translator.plainText);
559
543
  // Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
560
544
  const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
561
545
  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
546
  // 1. Protect placeholders, tags and never-translate terms. Strings with
566
547
  // nothing translatable left (e.g. "{count}", a brand name) are their own
567
- // translation and never reach the translator.
548
+ // translation and never reach the translator. "Always translate as"
549
+ // terms are tokens too for a plain-text translator, restored to their
550
+ // fixed translation (it can't take a glossary); an LLM gets them as
551
+ // required terminology in the prompt instead.
568
552
  const prepared = [];
569
553
  for (const entry of uncachedEntries) {
570
554
  const icu = (0, icu_message_1.isIcuMessage)(entry.value);
571
- if (!isLlm && (icu || hasMixedScripts(entry.value) || !nllbTarget)) {
555
+ if (!isLlm && (hasMixedScripts(entry.value) || !nllbTarget)) {
572
556
  failures[entry.key] = {
573
557
  reason: 'unsupported_by_provider',
574
558
  retryable: false,
575
559
  message: !nllbTarget
576
560
  ? `${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',
561
+ : 'Text mixing writing systems needs an LLM translation provider',
580
562
  };
581
563
  continue;
582
564
  }
@@ -584,24 +566,44 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
584
566
  // Japanese keeps only "other"), so ICU text is sent untokenized and its
585
567
  // tags/URLs/terms are checked as a set after translation instead. The
586
568
  // 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)) {
569
+ // uploaded"): an LLM may return a plural for a language that needs one;
570
+ // for NLLB one is built branch by branch when the language has more
571
+ // than one plural category.
572
+ const countArgs = options.targetPlurals && !icu && (isLlm || (0, icu_branches_1.targetCategories)(targetLocale, 'cardinal').length > 1)
573
+ ? (0, icu_message_1.countArguments)(entry.value)
574
+ : [];
575
+ const ownTokens = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
576
+ if ((0, placeholder_protection_1.hasNothingToTranslate)(ownTokens.text)) {
593
577
  result[entry.key] = entry.value;
594
578
  // Copied through (placeholders/codes only); the translator never saw it.
595
579
  metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
596
580
  continue;
597
581
  }
582
+ // Never-translate terms win over a fixed term spelled the same, and
583
+ // placeholders/URLs/tags over both (see protectText).
584
+ const withFixed = glossaryForLocale.length > 0
585
+ ? (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms, { fixedTerms: glossaryForLocale })
586
+ : ownTokens;
587
+ if (!icu && (0, placeholder_protection_1.hasNothingToTranslate)(withFixed.text)) {
588
+ // Only glossary terms and placeholders ("Settings" → "Konfiguration"):
589
+ // the rule is the translation; no translator needed (or billed).
590
+ result[entry.key] = (0, placeholder_protection_1.restoreText)(withFixed.text, withFixed.restore);
591
+ metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true, glossary: true };
592
+ continue;
593
+ }
594
+ const tokenized = isLlm ? ownTokens : withFixed;
595
+ const sendRaw = icu || countArgs.length > 0;
596
+ const prot = sendRaw ? { text: entry.value, tokens: [], restore: [] } : tokenized;
598
597
  prepared.push({
599
598
  ...entry,
600
599
  sendText: prot.text,
601
600
  tokens: prot.tokens,
601
+ restore: prot.restore,
602
602
  icu: sendRaw,
603
603
  countArgs: countArgs.length ? countArgs : undefined,
604
- plain: countArgs.length ? { sendText: tokenized.text, tokens: tokenized.tokens } : undefined,
604
+ plain: countArgs.length || (isLlm && !icu)
605
+ ? { sendText: withFixed.text, tokens: withFixed.tokens, restore: withFixed.restore }
606
+ : undefined,
605
607
  });
606
608
  }
607
609
  // 2. Translate in batches; judge every key independently.
@@ -623,7 +625,11 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
623
625
  const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
624
626
  if (!tokenCheck.ok)
625
627
  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);
628
+ const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb, source: entry.sendText }), entry.restore);
629
+ // NLLB sometimes answers a short string with only its language tag
630
+ // ("eng_Latn"), which the sanitizer strips to nothing: no translation.
631
+ if (!value.trim())
632
+ return fail('empty_value', true, 'The translator returned no usable text');
627
633
  const warnings = [];
628
634
  // A plural added for a count placeholder is validated as ICU too.
629
635
  if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
@@ -642,7 +648,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
642
648
  }
643
649
  warnings.push(...issues.map((i) => i.message));
644
650
  }
645
- if (isUnchangedSentence(entry.sendText, keyResult.value)) {
651
+ if (isUnchangedSentence(entry.sendText, keyResult.value) || (0, untranslated_1.isUntranslatedInto)(entry.value, value, targetLocale)) {
646
652
  return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
647
653
  }
648
654
  const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
@@ -658,10 +664,10 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
658
664
  };
659
665
  const translateBatch = async (batch, hints) => {
660
666
  const batchGlossary = [
661
- ...glossaryForLocale.filter((g) => batch.some((b) => containsTerm(b.value, g.term))),
667
+ ...glossaryForLocale.filter((g) => batch.some((b) => (0, glossary_1.sourceHasTerm)(b.value, g.term, neverTranslateTerms))),
662
668
  // Untokenized ICU text: keep never-translate terms as-is via the glossary.
663
669
  ...neverTranslateTerms
664
- .filter((t) => batch.some((b) => b.icu && containsTerm(b.value, t)))
670
+ .filter((t) => batch.some((b) => b.icu && (0, glossary_1.sourceHasTerm)(b.value, t)))
665
671
  .map((t) => ({ term: t, translation: t })),
666
672
  ];
667
673
  const keyResults = await translator.translateBatch({
@@ -708,14 +714,104 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
708
714
  if (byokError)
709
715
  throw byokError;
710
716
  };
717
+ /**
718
+ * Translate ICU entries with a plain-text translator, branch by branch:
719
+ * the branch sentences of all entries go out in as few requests as
720
+ * possible, then each entry is put back together and validated like any
721
+ * ICU translation. One outcome per entry.
722
+ */
723
+ const translateByBranches = async (entries, provider) => {
724
+ const out = new Map();
725
+ const plans = new Map();
726
+ const requests = [];
727
+ const unitKey = (key, id) => `${key}#branch${id}`;
728
+ for (const b of entries) {
729
+ const plan = (0, icu_branches_1.planIcuBranches)(b.value, targetLocale, {
730
+ neverTranslateTerms,
731
+ // Always a plain-text translator here: fixed terms go as tokens.
732
+ fixedTerms: glossaryForLocale,
733
+ valuePlaceholder: options.pluralValuePlaceholders?.[b.key],
734
+ pluralizeArgs: b.countArgs,
735
+ });
736
+ if ('error' in plan) {
737
+ out.set(b.key, { ok: false, reason: 'unsupported_by_provider', retryable: false, message: `This ICU message can't be translated branch by branch: ${plan.error}` });
738
+ continue;
739
+ }
740
+ plans.set(b.key, { entry: b, plan });
741
+ for (const u of plan.units) {
742
+ if (u.passthrough)
743
+ continue;
744
+ requests.push({ key: unitKey(b.key, u.id), value: u.sendText, parentContext: contextMap?.[b.key], extractionContext: extractionContextMap?.[b.key] });
745
+ }
746
+ }
747
+ const results = new Map();
748
+ const sendAll = async (list) => {
749
+ const batches = chunk(list, BRANCH_BATCH_SIZE);
750
+ await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
751
+ onProgress?.(`Translating plural/select branches ${i + 1}/${batches.length} for ${targetLang}...`);
752
+ try {
753
+ const keyResults = await provider.translateBatch({ entries: batch, sourceLang, targetLang, targetLocale });
754
+ for (const r of keyResults)
755
+ results.set(r.key, r);
756
+ }
757
+ catch (error) {
758
+ const message = error instanceof Error ? error.message : String(error);
759
+ logger_1.engineLogger.error('Branch translation batch failed', { error: message, sourceLang, targetLang });
760
+ for (const e of batch)
761
+ results.set(e.key, { key: e.key, status: 'failed', reason: 'provider_error', retryable: true, message });
762
+ }
763
+ }, TRANSLATE_CONCURRENCY);
764
+ };
765
+ await sendAll(requests);
766
+ // Branches the translator skipped get one more request.
767
+ const skipped = requests.filter((r) => {
768
+ const res = results.get(r.key);
769
+ return !res || (res.status === 'failed' && res.reason === 'missing_from_response');
770
+ });
771
+ if (skipped.length > 0)
772
+ await sendAll(skipped);
773
+ const checkUnit = (unit, translated) => isUnchangedSentence(unit.sendText, translated) || (0, untranslated_1.isUntranslatedInto)(unit.sendText, translated, targetLocale)
774
+ ? { ok: false, reason: 'untranslated', retryable: true, message: `returned unchanged instead of translated into ${targetLocale}` }
775
+ : null;
776
+ for (const [key, { entry, plan }] of plans) {
777
+ const composed = plan.compose(new Map(plan.units.map((u) => [u.id, results.get(unitKey(key, u.id))])), checkUnit);
778
+ if (!composed.ok) {
779
+ out.set(key, { ok: false, reason: composed.reason, retryable: composed.retryable, message: composed.message });
780
+ continue;
781
+ }
782
+ // Each branch was cleaned up on its own; the message is checked as a whole.
783
+ out.set(key, judge(entry, { key, status: 'ok', value: composed.value }, false));
784
+ }
785
+ return out;
786
+ };
787
+ // With a plain-text translator, ICU entries (and counts that may become
788
+ // plurals) go branch by branch; everything else as before.
789
+ const viaBranches = new Set(isLlm ? [] : prepared.filter((b) => b.icu).map((b) => b.key));
711
790
  const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
712
- await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
791
+ await runBatches(chunk(prepared.filter((b) => !viaBranches.has(b.key)), BATCH_SIZE), isLlm ? firstPassHint : undefined);
792
+ if (viaBranches.size > 0) {
793
+ const branchEntries = prepared.filter((b) => viaBranches.has(b.key));
794
+ for (const [key, o] of await translateByBranches(branchEntries, translator))
795
+ outcomes.set(key, o);
796
+ // A count whose plural forms couldn't be built is translated as the
797
+ // plain message it is (as before target-side plurals), and says so.
798
+ const noPlural = branchEntries.filter((b) => b.plain && outcomes.get(b.key)?.ok === false);
799
+ if (noPlural.length > 0) {
800
+ const why = new Map(noPlural.map((b) => [b.key, outcomes.get(b.key).message]));
801
+ await runBatches(chunk(noPlural.map((b) => ({ ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined })), BATCH_SIZE));
802
+ for (const b of noPlural) {
803
+ const o = outcomes.get(b.key);
804
+ if (o?.ok)
805
+ o.warnings.push(`Translated as plain text: ${targetLocale} plural forms couldn't be built (${why.get(b.key)})`);
806
+ }
807
+ }
808
+ }
713
809
  // 3. One targeted repair attempt for keys that failed validation, telling
714
810
  // the model exactly what was wrong. NLLB is deterministic, so for it
715
811
  // only keys it skipped are worth asking for again.
716
812
  const toRepair = prepared.filter((b) => {
717
813
  const o = outcomes.get(b.key);
718
- return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
814
+ return o && !o.ok && o.retryable && o.reason !== 'provider_error' && !viaBranches.has(b.key) && (isLlm || o.reason === 'missing_from_response');
719
815
  });
720
816
  if (toRepair.length > 0) {
721
817
  logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
@@ -732,8 +828,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
732
828
  : undefined);
733
829
  }
734
830
  // 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.
831
+ // attempt on the fallback translator, if there is one. It's NLLB-class:
832
+ // ICU messages go branch by branch, mixed-script strings stay failed.
737
833
  const fellBack = new Set();
738
834
  const fallback = providers.fallbackTranslator;
739
835
  if (fallback && isLlm && nllbTarget) {
@@ -766,6 +862,19 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
766
862
  }
767
863
  }
768
864
  }
865
+ const icuToFallback = prepared.filter((b) => {
866
+ const o = outcomes.get(b.key);
867
+ return o && !o.ok && o.reason === 'provider_error' && b.icu && !b.plain && !hasMixedScripts(b.value);
868
+ });
869
+ if (icuToFallback.length > 0) {
870
+ logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator branch by branch for ICU messages', { count: icuToFallback.length, targetLang });
871
+ for (const [key, o] of await translateByBranches(icuToFallback, fallback)) {
872
+ if (o.ok) {
873
+ outcomes.set(key, o);
874
+ fellBack.add(key);
875
+ }
876
+ }
877
+ }
769
878
  }
770
879
  // 4. Record results. Failed keys get no value at all: the locale file
771
880
  // omits them (the app shows the source string, honestly), the lockfile
@@ -779,9 +888,13 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
779
888
  : { reason: 'missing_from_response', retryable: true };
780
889
  continue;
781
890
  }
782
- const finalValue = applyGlossaryPostProcessing(entry.value, outcome.value, [], alwaysTranslateAsEntries, targetLocale);
891
+ // No post-hoc glossary rewriting: a plain-text translator's output
892
+ // already has the fixed terms (restored from tokens, then validated);
893
+ // an LLM had them in the prompt, and if it ignored one that is a QA
894
+ // warning, never a blind replace that could hit a placeholder or URL.
895
+ const finalValue = outcome.value;
783
896
  result[entry.key] = finalValue;
784
- const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
897
+ const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale, neverTranslateTerms)];
785
898
  if (qa.length > 0)
786
899
  metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
787
900
  // Fallback output is a stopgap: keep it out of the shared cache and TM
@@ -795,10 +908,15 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
795
908
  };
796
909
  continue;
797
910
  }
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.
911
+ if (options.progress)
912
+ options.progress.freshTranslations++;
913
+ // A translation shaped by an "always translate as" rule is this
914
+ // project's wording: the cache and TM are shared beyond the project,
915
+ // so it stays out of both (the lockfile keeps it for this project).
916
+ // Hits are checked against the glossary before reuse, so edits to the
917
+ // glossary re-translate anything stored that no longer complies.
918
+ if (projectWording(entry.value))
919
+ continue;
802
920
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
803
921
  if (cacheProvider) {
804
922
  cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
@@ -826,25 +944,22 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
826
944
  // replaces the previous run's — cache hits, fallbacks and mixed-script keys
827
945
  // included — instead of keeping a stale fromMemory/fuzzyMatches.
828
946
  for (const key of Object.keys(result)) {
829
- if (!metadata[key])
830
- metadata[key] = {};
947
+ const meta = { ...(metadata[key] ?? {}) };
948
+ // The glossary rules this translation was made (or checked) under: the
949
+ // lockfile re-translates the key when they change. Not recorded when the
950
+ // rules failed to load, so the next run that has them redoes the key.
951
+ if (rulesComplete) {
952
+ const fingerprint = (0, glossary_1.glossaryFingerprint)(entries[key], targetLocale, glossary);
953
+ if (fingerprint)
954
+ meta.glossaryHash = fingerprint;
955
+ }
956
+ const notes = (0, glossary_1.conflictNotes)(entries[key], glossary);
957
+ if (notes.length > 0)
958
+ meta.qa = [...(meta.qa ?? []), ...notes];
959
+ metadata[key] = meta;
831
960
  }
832
961
  return { result, stats: { tmHits }, metadata, failures };
833
962
  }
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
963
  /**
849
964
  * Persist translations to the project owner's TM. Never throws: a TM failure
850
965
  * must not fail the run, but it is logged at error level rather than skipped.
@@ -916,7 +1031,7 @@ async function backfillTranslationMemory(projectId, sourceLocale, flatSource, tr
916
1031
  for (const [key, translatedText] of Object.entries(translations)) {
917
1032
  const sourceText = flatSource[key];
918
1033
  if (excludeKeys.has(key) ||
919
- hasAlwaysTranslateAsTerm(sourceText ?? '', rules.alwaysTranslateAsEntries, locale) ||
1034
+ (0, glossary_1.hasFixedTerm)(sourceText ?? '', rules.glossary, locale) ||
920
1035
  !sourceText || !translatedText || sourceText === translatedText) {
921
1036
  skipped.push(key);
922
1037
  continue;