@babelize/engine 0.1.0 → 0.1.3

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 (104) 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 +297 -173
  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/icu-message.d.ts +2 -2
  78. package/dist/utils/icu-message.js +43 -7
  79. package/dist/utils/jsx-message.js +7 -0
  80. package/dist/utils/metadata.d.ts +4 -2
  81. package/dist/utils/metadata.js +16 -10
  82. package/dist/utils/not-injected.js +2 -0
  83. package/dist/utils/placeholder-protection.d.ts +18 -1
  84. package/dist/utils/placeholder-protection.js +58 -13
  85. package/dist/utils/translation-sanitizer.d.ts +1 -0
  86. package/dist/utils/translation-sanitizer.js +23 -9
  87. package/dist/utils/translator-load-balancer.js +5 -1
  88. package/dist/utils/untranslated.d.ts +15 -0
  89. package/dist/utils/untranslated.js +34 -0
  90. package/package.json +2 -6
  91. package/dist/injection/django/django-template-injector.d.ts +0 -29
  92. package/dist/injection/django/django-template-injector.js +0 -216
  93. package/dist/modules/glossary/glossary.d.ts +0 -9
  94. package/dist/modules/glossary/glossary.js +0 -27
  95. package/dist/modules/glossary/masker.d.ts +0 -23
  96. package/dist/modules/glossary/masker.js +0 -56
  97. package/dist/modules/locale-builder/po.d.ts +0 -33
  98. package/dist/modules/locale-builder/po.js +0 -131
  99. package/dist/modules/translator/translator.d.ts +0 -5
  100. package/dist/modules/translator/translator.js +0 -46
  101. package/dist/utils/dtl-mask.d.ts +0 -25
  102. package/dist/utils/dtl-mask.js +0 -58
  103. package/dist/utils/py-extractor.d.ts +0 -14
  104. 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,21 +15,37 @@ 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
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);
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;
37
+ /**
38
+ * A plain source whose stored translation is ICU only fits a project whose
39
+ * runtime formats ICU. A stored ICU translation stored before a check it now
40
+ * fails (e.g. Czech without its `few` branch) is translated again.
41
+ */
42
+ function reusableTranslation(source, translated, targetPlurals, targetLocale) {
43
+ if (!targetPlurals && !(0, icu_message_1.isIcuMessage)(source) && (0, icu_message_1.isIcuMessage)(translated))
44
+ return false;
45
+ if (!(0, icu_message_1.isIcuMessage)(translated))
46
+ return true;
47
+ const pluralizable = (0, icu_message_1.isIcuMessage)(source) ? [] : (0, icu_message_1.countArguments)(source);
48
+ return !(0, icu_message_1.compareIcuMessages)(source, translated, targetLocale, { pluralizable }).some((i) => i.severity === 'error');
65
49
  }
66
50
  /** Per-entry instruction: pluralize a count argument if the target language needs it. */
67
51
  function pluralHint(args, targetLocale) {
@@ -85,12 +69,6 @@ function isUnchangedSentence(sentText, returnedText) {
85
69
  const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
86
70
  return words.length >= 3 && norm(sentText) === norm(returnedText);
87
71
  }
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
72
  /** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
95
73
  function missingSpans(source, translated, neverTranslateTerms) {
96
74
  const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
@@ -98,11 +76,24 @@ function missingSpans(source, translated, neverTranslateTerms) {
98
76
  return [...want].filter((t) => !have.has(t.toLowerCase()));
99
77
  }
100
78
  /** Glossary terms in the source whose required translation is missing from the output. */
101
- function glossaryWarnings(source, translated, glossary) {
79
+ function glossaryWarnings(source, translated, glossary, neverTranslateTerms) {
102
80
  return glossary
103
- .filter((g) => containsTerm(source, g.term) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
81
+ .filter((g) => (0, glossary_1.sourceHasTerm)(source, g.term, neverTranslateTerms) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
104
82
  .map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
105
83
  }
84
+ /**
85
+ * Why a stored translation (cache or TM) can't be reused under the project's
86
+ * current glossary, or null if it can: a placeholder, tag, URL or
87
+ * never-translate term of the source is missing, or an "always translate as"
88
+ * term's required translation is. Such a hit is treated as a miss.
89
+ */
90
+ function storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale) {
91
+ const lost = missingSpans(source, stored, neverTranslateTerms);
92
+ if (lost.length > 0)
93
+ return `missing ${lost.join(', ')}`;
94
+ const glossary = glossaryWarnings(source, stored, glossaryForLocale, neverTranslateTerms);
95
+ return glossary.length > 0 ? glossary.join('; ') : null;
96
+ }
106
97
  const SCRIPT_RANGES = [
107
98
  [0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
108
99
  [0x0370, 0x03FF], // Greek
@@ -156,29 +147,22 @@ function hasMixedScripts(text) {
156
147
  * project-specific wording may be baked in.
157
148
  */
158
149
  async function loadProjectTranslationRules(projectId, rules) {
159
- let neverTranslateTerms = [];
160
- let alwaysTranslateAsEntries = [];
150
+ let glossary = glossary_1.EMPTY_GLOSSARY;
161
151
  let brandVoice = null;
162
152
  let complete = true;
163
153
  if (!rules)
164
- return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
154
+ return { glossary, brandVoice, complete };
165
155
  try {
166
156
  const entries = await rules.getGlossary(projectId);
167
157
  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
- }));
158
+ glossary = (0, glossary_1.normalizeGlossary)(entries);
178
159
  logger_1.engineLogger.info('Loaded project glossary', {
179
- neverTranslate: neverTranslateTerms.length,
180
- alwaysTranslateAs: alwaysTranslateAsEntries.length,
160
+ neverTranslate: glossary.neverTranslate.length,
161
+ alwaysTranslateAs: glossary.alwaysTranslateAs.length,
162
+ conflicts: glossary.conflicts.length,
181
163
  });
164
+ for (const c of glossary.conflicts)
165
+ logger_1.engineLogger.warn(c.message, { projectId });
182
166
  }
183
167
  }
184
168
  catch (err) {
@@ -198,16 +182,32 @@ async function loadProjectTranslationRules(projectId, rules) {
198
182
  complete = false;
199
183
  logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
200
184
  }
201
- return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
185
+ return { glossary, brandVoice, complete };
202
186
  }
203
187
  /**
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.
188
+ * The glossary of a run without a cloud project (CLI/MCP, no projectId):
189
+ * `.babelize/config.json` `glossary`, the same file and schema `babelize
190
+ * check` reads.
207
191
  */
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));
192
+ async function loadWorkspaceGlossary(workspacePath) {
193
+ try {
194
+ const { glossary } = await (0, check_project_1.readProjectLocaleConfig)(workspacePath);
195
+ if (!glossary)
196
+ return glossary_1.EMPTY_GLOSSARY;
197
+ const rules = (0, glossary_1.normalizeGlossary)([
198
+ ...(glossary.neverTranslate ?? []).map((term) => ({ term, type: 'never_translate' })),
199
+ ...(glossary.alwaysTranslateAs ?? []).map((e) => ({ term: e.term, type: 'always_translate_as', translations: e.translations })),
200
+ ]);
201
+ logger_1.engineLogger.info('Loaded glossary from workspace config', {
202
+ neverTranslate: rules.neverTranslate.length,
203
+ alwaysTranslateAs: rules.alwaysTranslateAs.length,
204
+ });
205
+ return rules;
206
+ }
207
+ catch (err) {
208
+ logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
209
+ return glossary_1.EMPTY_GLOSSARY;
210
+ }
211
211
  }
212
212
  async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
213
213
  const locales = [];
@@ -231,50 +231,16 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
231
231
  delete flatSource[key];
232
232
  }
233
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
- }
234
+ // The project's glossary and brand voice (injected rules store), or for a
235
+ // run without a project the workspace's .babelize/config.json glossary.
236
+ const rules = options.rules
237
+ ?? (projectId
238
+ ? await loadProjectTranslationRules(projectId, providers.rules)
239
+ : { glossary: await loadWorkspaceGlossary(workspace.path), brandVoice: null, complete: true });
240
+ const glossary = rules.glossary;
241
+ const neverTranslateTerms = glossary.neverTranslate;
242
+ const brandVoice = rules.brandVoice;
243
+ const rulesComplete = rules.complete;
278
244
  // Check if translator is configured before processing locales
279
245
  if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
280
246
  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 +274,8 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
308
274
  const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
309
275
  // LLM translators (BYOK, or an injected one such as Babelize-hosted)
310
276
  // 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);
277
+ const hasBrandVoiceInPrompt = !!((byokConfig || (providers.translator && !providers.translator.plainText)) && brandVoice);
278
+ 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
279
  const entriesResult = translationResult.result;
314
280
  const batchStats = translationResult.stats;
315
281
  const batchMetadata = translationResult.metadata;
@@ -325,6 +291,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
325
291
  let translatedEntries = entriesResult;
326
292
  // Post-hoc brand voice polish only for the NLLB service; LLM
327
293
  // translators already had the brand voice in the prompt.
294
+ const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
328
295
  if (brandVoice && !hasBrandVoiceInPrompt) {
329
296
  // TM hits are approved/previously-accepted text: polishing them again
330
297
  // would rewrite reviewed translations nondeterministically every run.
@@ -348,7 +315,10 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
348
315
  // ICU (in the source, or a plural the translation added) must
349
316
  // stay an equivalent message of the validated translation.
350
317
  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) &&
318
+ // ...and still uses every required glossary translation the validated text used.
319
+ const source = entriesToTranslate[k] ?? '';
320
+ const glossaryOk = glossaryWarnings(source, v, glossaryForLocale, neverTranslateTerms).length <= glossaryWarnings(source, before, glossaryForLocale, neverTranslateTerms).length;
321
+ const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) && glossaryOk &&
352
322
  (!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
353
323
  if (safe)
354
324
  accepted[k] = v;
@@ -368,7 +338,9 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
368
338
  ...translatedData,
369
339
  _meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
370
340
  };
371
- if (!dryRun)
341
+ // gettext catalogues are merged into the existing .po files once the
342
+ // whole run's translations are known (see django/catalog.ts).
343
+ if (!dryRun && workspace.localeFormat !== 'po')
372
344
  await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
373
345
  return {
374
346
  locale: targetLocale,
@@ -418,7 +390,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
418
390
  metadata: perLocaleMetadata
419
391
  };
420
392
  }
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 = {}) {
393
+ 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
394
  const result = {};
423
395
  const metadata = {};
424
396
  const keys = Object.keys(entries);
@@ -433,6 +405,14 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
433
405
  for (const k of keys)
434
406
  metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
435
407
  };
408
+ const neverTranslateTerms = glossary.neverTranslate;
409
+ // "Always translate as" terms with a translation for this locale (exact
410
+ // locale, else its base language).
411
+ const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
412
+ // Output shaped by such a rule is this project's wording: it must not go
413
+ // into the cache or TM, which are shared beyond the project.
414
+ const projectWording = (source) => (0, glossary_1.hasFixedTerm)(source, glossary, targetLocale);
415
+ const reuseProblem = (source, stored) => storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale);
436
416
  for (const key of keys) {
437
417
  const text = entries[key];
438
418
  const entryContext = contextMap?.[key];
@@ -440,8 +420,16 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
440
420
  const textHash = (0, cache_keys_1.hashText)(textForHash);
441
421
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
442
422
  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);
423
+ // A stored value that is the untranslated source (cached before this was
424
+ // rejected) is a miss, so the string is translated again instead of failing forever.
425
+ // So is one that breaks the project's glossary (a never-translate term
426
+ // translated, a required translation missing): glossary edits reach
427
+ // cached strings by re-translating them.
428
+ const cacheProblem = cached.hit && cached.data !== null ? reuseProblem(text, cached.data) : null;
429
+ if (cacheProblem)
430
+ logger_1.engineLogger.info('Cached translation breaks the glossary; translating again', { key, targetLocale, problem: cacheProblem });
431
+ if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
432
+ result[key] = cached.data;
445
433
  cacheHits++;
446
434
  // Reused, not translated this run (billing counts only fresh output).
447
435
  metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
@@ -449,8 +437,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
449
437
  // since cache entries written before TM stored pre-glossary text may
450
438
  // have them baked in — strings with project glossary rules or an
451
439
  // 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) });
440
+ if (rulesComplete && !entryContext && !brandVoice && !projectWording(text)) {
441
+ cacheBackfill.push({ key, sourceText: text, translatedText: cached.data });
454
442
  }
455
443
  }
456
444
  else {
@@ -514,13 +502,17 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
514
502
  continue;
515
503
  }
516
504
  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);
505
+ // A TM entry that breaks this project's glossary is a miss (TM is shared across the owner's projects).
506
+ const tmProblem = lookup.hit && lookup.translatedText ? reuseProblem(entry.value, lookup.translatedText) : null;
507
+ if (tmProblem)
508
+ logger_1.engineLogger.info('TM translation breaks the glossary; translating again', { key: entry.key, targetLocale, problem: tmProblem });
509
+ if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
510
+ result[entry.key] = lookup.translatedText;
519
511
  tmHits++;
520
512
  metadata[entry.key] = { fromMemory: true, inTm: true };
521
- // Warm Redis cache (stored pre-glossary, like every other cache write)
513
+ // Warm the cache (never with this project's glossary wording)
522
514
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
523
- if (cacheProvider) {
515
+ if (cacheProvider && !projectWording(entry.value)) {
524
516
  cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
525
517
  logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
526
518
  });
@@ -553,30 +545,29 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
553
545
  });
554
546
  }
555
547
  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;
548
+ // LLM providers get structure-aware prompts and repair attempts. The NLLB
549
+ // service translates plain text only: ICU messages go branch by branch
550
+ // (./icu-branches), and mixed-script text fails up front.
551
+ const isLlm = !!byokConfig || (!!providers.translator && !providers.translator.plainText);
559
552
  // Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
560
553
  const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
561
554
  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
555
  // 1. Protect placeholders, tags and never-translate terms. Strings with
566
556
  // nothing translatable left (e.g. "{count}", a brand name) are their own
567
- // translation and never reach the translator.
557
+ // translation and never reach the translator. "Always translate as"
558
+ // terms are tokens too for a plain-text translator, restored to their
559
+ // fixed translation (it can't take a glossary); an LLM gets them as
560
+ // required terminology in the prompt instead.
568
561
  const prepared = [];
569
562
  for (const entry of uncachedEntries) {
570
563
  const icu = (0, icu_message_1.isIcuMessage)(entry.value);
571
- if (!isLlm && (icu || hasMixedScripts(entry.value) || !nllbTarget)) {
564
+ if (!isLlm && (hasMixedScripts(entry.value) || !nllbTarget)) {
572
565
  failures[entry.key] = {
573
566
  reason: 'unsupported_by_provider',
574
567
  retryable: false,
575
568
  message: !nllbTarget
576
569
  ? `${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',
570
+ : 'Text mixing writing systems needs an LLM translation provider',
580
571
  };
581
572
  continue;
582
573
  }
@@ -584,24 +575,44 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
584
575
  // Japanese keeps only "other"), so ICU text is sent untokenized and its
585
576
  // tags/URLs/terms are checked as a set after translation instead. The
586
577
  // 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)) {
578
+ // uploaded"): an LLM may return a plural for a language that needs one;
579
+ // for NLLB one is built branch by branch when the language has more
580
+ // than one plural category.
581
+ const countArgs = options.targetPlurals && !icu && (isLlm || (0, icu_branches_1.targetCategories)(targetLocale, 'cardinal').length > 1)
582
+ ? (0, icu_message_1.countArguments)(entry.value)
583
+ : [];
584
+ const ownTokens = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
585
+ if ((0, placeholder_protection_1.hasNothingToTranslate)(ownTokens.text)) {
593
586
  result[entry.key] = entry.value;
594
587
  // Copied through (placeholders/codes only); the translator never saw it.
595
588
  metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
596
589
  continue;
597
590
  }
591
+ // Never-translate terms win over a fixed term spelled the same, and
592
+ // placeholders/URLs/tags over both (see protectText).
593
+ const withFixed = glossaryForLocale.length > 0
594
+ ? (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms, { fixedTerms: glossaryForLocale })
595
+ : ownTokens;
596
+ if (!icu && (0, placeholder_protection_1.hasNothingToTranslate)(withFixed.text)) {
597
+ // Only glossary terms and placeholders ("Settings" → "Konfiguration"):
598
+ // the rule is the translation; no translator needed (or billed).
599
+ result[entry.key] = (0, placeholder_protection_1.restoreText)(withFixed.text, withFixed.restore);
600
+ metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true, glossary: true };
601
+ continue;
602
+ }
603
+ const tokenized = isLlm ? ownTokens : withFixed;
604
+ const sendRaw = icu || countArgs.length > 0;
605
+ const prot = sendRaw ? { text: entry.value, tokens: [], restore: [] } : tokenized;
598
606
  prepared.push({
599
607
  ...entry,
600
608
  sendText: prot.text,
601
609
  tokens: prot.tokens,
610
+ restore: prot.restore,
602
611
  icu: sendRaw,
603
612
  countArgs: countArgs.length ? countArgs : undefined,
604
- plain: countArgs.length ? { sendText: tokenized.text, tokens: tokenized.tokens } : undefined,
613
+ plain: countArgs.length || (isLlm && !icu)
614
+ ? { sendText: withFixed.text, tokens: withFixed.tokens, restore: withFixed.restore }
615
+ : undefined,
605
616
  });
606
617
  }
607
618
  // 2. Translate in batches; judge every key independently.
@@ -623,7 +634,11 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
623
634
  const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
624
635
  if (!tokenCheck.ok)
625
636
  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);
637
+ const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb, source: entry.sendText }), entry.restore);
638
+ // NLLB sometimes answers a short string with only its language tag
639
+ // ("eng_Latn"), which the sanitizer strips to nothing: no translation.
640
+ if (!value.trim())
641
+ return fail('empty_value', true, 'The translator returned no usable text');
627
642
  const warnings = [];
628
643
  // A plural added for a count placeholder is validated as ICU too.
629
644
  if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
@@ -642,7 +657,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
642
657
  }
643
658
  warnings.push(...issues.map((i) => i.message));
644
659
  }
645
- if (isUnchangedSentence(entry.sendText, keyResult.value)) {
660
+ if (isUnchangedSentence(entry.sendText, keyResult.value) || (0, untranslated_1.isUntranslatedInto)(entry.value, value, targetLocale)) {
646
661
  return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
647
662
  }
648
663
  const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
@@ -658,10 +673,10 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
658
673
  };
659
674
  const translateBatch = async (batch, hints) => {
660
675
  const batchGlossary = [
661
- ...glossaryForLocale.filter((g) => batch.some((b) => containsTerm(b.value, g.term))),
676
+ ...glossaryForLocale.filter((g) => batch.some((b) => (0, glossary_1.sourceHasTerm)(b.value, g.term, neverTranslateTerms))),
662
677
  // Untokenized ICU text: keep never-translate terms as-is via the glossary.
663
678
  ...neverTranslateTerms
664
- .filter((t) => batch.some((b) => b.icu && containsTerm(b.value, t)))
679
+ .filter((t) => batch.some((b) => b.icu && (0, glossary_1.sourceHasTerm)(b.value, t)))
665
680
  .map((t) => ({ term: t, translation: t })),
666
681
  ];
667
682
  const keyResults = await translator.translateBatch({
@@ -708,14 +723,104 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
708
723
  if (byokError)
709
724
  throw byokError;
710
725
  };
726
+ /**
727
+ * Translate ICU entries with a plain-text translator, branch by branch:
728
+ * the branch sentences of all entries go out in as few requests as
729
+ * possible, then each entry is put back together and validated like any
730
+ * ICU translation. One outcome per entry.
731
+ */
732
+ const translateByBranches = async (entries, provider) => {
733
+ const out = new Map();
734
+ const plans = new Map();
735
+ const requests = [];
736
+ const unitKey = (key, id) => `${key}#branch${id}`;
737
+ for (const b of entries) {
738
+ const plan = (0, icu_branches_1.planIcuBranches)(b.value, targetLocale, {
739
+ neverTranslateTerms,
740
+ // Always a plain-text translator here: fixed terms go as tokens.
741
+ fixedTerms: glossaryForLocale,
742
+ valuePlaceholder: options.pluralValuePlaceholders?.[b.key],
743
+ pluralizeArgs: b.countArgs,
744
+ });
745
+ if ('error' in plan) {
746
+ 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}` });
747
+ continue;
748
+ }
749
+ plans.set(b.key, { entry: b, plan });
750
+ for (const u of plan.units) {
751
+ if (u.passthrough)
752
+ continue;
753
+ requests.push({ key: unitKey(b.key, u.id), value: u.sendText, parentContext: contextMap?.[b.key], extractionContext: extractionContextMap?.[b.key] });
754
+ }
755
+ }
756
+ const results = new Map();
757
+ const sendAll = async (list) => {
758
+ const batches = chunk(list, BRANCH_BATCH_SIZE);
759
+ await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
760
+ onProgress?.(`Translating plural/select branches ${i + 1}/${batches.length} for ${targetLang}...`);
761
+ try {
762
+ const keyResults = await provider.translateBatch({ entries: batch, sourceLang, targetLang, targetLocale });
763
+ for (const r of keyResults)
764
+ results.set(r.key, r);
765
+ }
766
+ catch (error) {
767
+ const message = error instanceof Error ? error.message : String(error);
768
+ logger_1.engineLogger.error('Branch translation batch failed', { error: message, sourceLang, targetLang });
769
+ for (const e of batch)
770
+ results.set(e.key, { key: e.key, status: 'failed', reason: 'provider_error', retryable: true, message });
771
+ }
772
+ }, TRANSLATE_CONCURRENCY);
773
+ };
774
+ await sendAll(requests);
775
+ // Branches the translator skipped get one more request.
776
+ const skipped = requests.filter((r) => {
777
+ const res = results.get(r.key);
778
+ return !res || (res.status === 'failed' && res.reason === 'missing_from_response');
779
+ });
780
+ if (skipped.length > 0)
781
+ await sendAll(skipped);
782
+ const checkUnit = (unit, translated) => isUnchangedSentence(unit.sendText, translated) || (0, untranslated_1.isUntranslatedInto)(unit.sendText, translated, targetLocale)
783
+ ? { ok: false, reason: 'untranslated', retryable: true, message: `returned unchanged instead of translated into ${targetLocale}` }
784
+ : null;
785
+ for (const [key, { entry, plan }] of plans) {
786
+ const composed = plan.compose(new Map(plan.units.map((u) => [u.id, results.get(unitKey(key, u.id))])), checkUnit);
787
+ if (!composed.ok) {
788
+ out.set(key, { ok: false, reason: composed.reason, retryable: composed.retryable, message: composed.message });
789
+ continue;
790
+ }
791
+ // Each branch was cleaned up on its own; the message is checked as a whole.
792
+ out.set(key, judge(entry, { key, status: 'ok', value: composed.value }, false));
793
+ }
794
+ return out;
795
+ };
796
+ // With a plain-text translator, ICU entries (and counts that may become
797
+ // plurals) go branch by branch; everything else as before.
798
+ const viaBranches = new Set(isLlm ? [] : prepared.filter((b) => b.icu).map((b) => b.key));
711
799
  const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
712
- await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
800
+ await runBatches(chunk(prepared.filter((b) => !viaBranches.has(b.key)), BATCH_SIZE), isLlm ? firstPassHint : undefined);
801
+ if (viaBranches.size > 0) {
802
+ const branchEntries = prepared.filter((b) => viaBranches.has(b.key));
803
+ for (const [key, o] of await translateByBranches(branchEntries, translator))
804
+ outcomes.set(key, o);
805
+ // A count whose plural forms couldn't be built is translated as the
806
+ // plain message it is (as before target-side plurals), and says so.
807
+ const noPlural = branchEntries.filter((b) => b.plain && outcomes.get(b.key)?.ok === false);
808
+ if (noPlural.length > 0) {
809
+ const why = new Map(noPlural.map((b) => [b.key, outcomes.get(b.key).message]));
810
+ await runBatches(chunk(noPlural.map((b) => ({ ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined })), BATCH_SIZE));
811
+ for (const b of noPlural) {
812
+ const o = outcomes.get(b.key);
813
+ if (o?.ok)
814
+ o.warnings.push(`Translated as plain text: ${targetLocale} plural forms couldn't be built (${why.get(b.key)})`);
815
+ }
816
+ }
817
+ }
713
818
  // 3. One targeted repair attempt for keys that failed validation, telling
714
819
  // the model exactly what was wrong. NLLB is deterministic, so for it
715
820
  // only keys it skipped are worth asking for again.
716
821
  const toRepair = prepared.filter((b) => {
717
822
  const o = outcomes.get(b.key);
718
- return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
823
+ return o && !o.ok && o.retryable && o.reason !== 'provider_error' && !viaBranches.has(b.key) && (isLlm || o.reason === 'missing_from_response');
719
824
  });
720
825
  if (toRepair.length > 0) {
721
826
  logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
@@ -732,8 +837,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
732
837
  : undefined);
733
838
  }
734
839
  // 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.
840
+ // attempt on the fallback translator, if there is one. It's NLLB-class:
841
+ // ICU messages go branch by branch, mixed-script strings stay failed.
737
842
  const fellBack = new Set();
738
843
  const fallback = providers.fallbackTranslator;
739
844
  if (fallback && isLlm && nllbTarget) {
@@ -766,6 +871,19 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
766
871
  }
767
872
  }
768
873
  }
874
+ const icuToFallback = prepared.filter((b) => {
875
+ const o = outcomes.get(b.key);
876
+ return o && !o.ok && o.reason === 'provider_error' && b.icu && !b.plain && !hasMixedScripts(b.value);
877
+ });
878
+ if (icuToFallback.length > 0) {
879
+ logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator branch by branch for ICU messages', { count: icuToFallback.length, targetLang });
880
+ for (const [key, o] of await translateByBranches(icuToFallback, fallback)) {
881
+ if (o.ok) {
882
+ outcomes.set(key, o);
883
+ fellBack.add(key);
884
+ }
885
+ }
886
+ }
769
887
  }
770
888
  // 4. Record results. Failed keys get no value at all: the locale file
771
889
  // omits them (the app shows the source string, honestly), the lockfile
@@ -779,9 +897,13 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
779
897
  : { reason: 'missing_from_response', retryable: true };
780
898
  continue;
781
899
  }
782
- const finalValue = applyGlossaryPostProcessing(entry.value, outcome.value, [], alwaysTranslateAsEntries, targetLocale);
900
+ // No post-hoc glossary rewriting: a plain-text translator's output
901
+ // already has the fixed terms (restored from tokens, then validated);
902
+ // an LLM had them in the prompt, and if it ignored one that is a QA
903
+ // warning, never a blind replace that could hit a placeholder or URL.
904
+ const finalValue = outcome.value;
783
905
  result[entry.key] = finalValue;
784
- const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
906
+ const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale, neverTranslateTerms)];
785
907
  if (qa.length > 0)
786
908
  metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
787
909
  // Fallback output is a stopgap: keep it out of the shared cache and TM
@@ -795,10 +917,15 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
795
917
  };
796
918
  continue;
797
919
  }
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.
920
+ if (options.progress)
921
+ options.progress.freshTranslations++;
922
+ // A translation shaped by an "always translate as" rule is this
923
+ // project's wording: the cache and TM are shared beyond the project,
924
+ // so it stays out of both (the lockfile keeps it for this project).
925
+ // Hits are checked against the glossary before reuse, so edits to the
926
+ // glossary re-translate anything stored that no longer complies.
927
+ if (projectWording(entry.value))
928
+ continue;
802
929
  const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
803
930
  if (cacheProvider) {
804
931
  cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
@@ -826,25 +953,22 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
826
953
  // replaces the previous run's — cache hits, fallbacks and mixed-script keys
827
954
  // included — instead of keeping a stale fromMemory/fuzzyMatches.
828
955
  for (const key of Object.keys(result)) {
829
- if (!metadata[key])
830
- metadata[key] = {};
956
+ const meta = { ...(metadata[key] ?? {}) };
957
+ // The glossary rules this translation was made (or checked) under: the
958
+ // lockfile re-translates the key when they change. Not recorded when the
959
+ // rules failed to load, so the next run that has them redoes the key.
960
+ if (rulesComplete) {
961
+ const fingerprint = (0, glossary_1.glossaryFingerprint)(entries[key], targetLocale, glossary);
962
+ if (fingerprint)
963
+ meta.glossaryHash = fingerprint;
964
+ }
965
+ const notes = (0, glossary_1.conflictNotes)(entries[key], glossary);
966
+ if (notes.length > 0)
967
+ meta.qa = [...(meta.qa ?? []), ...notes];
968
+ metadata[key] = meta;
831
969
  }
832
970
  return { result, stats: { tmHits }, metadata, failures };
833
971
  }
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
972
  /**
849
973
  * Persist translations to the project owner's TM. Never throws: a TM failure
850
974
  * must not fail the run, but it is logged at error level rather than skipped.
@@ -916,7 +1040,7 @@ async function backfillTranslationMemory(projectId, sourceLocale, flatSource, tr
916
1040
  for (const [key, translatedText] of Object.entries(translations)) {
917
1041
  const sourceText = flatSource[key];
918
1042
  if (excludeKeys.has(key) ||
919
- hasAlwaysTranslateAsTerm(sourceText ?? '', rules.alwaysTranslateAsEntries, locale) ||
1043
+ (0, glossary_1.hasFixedTerm)(sourceText ?? '', rules.glossary, locale) ||
920
1044
  !sourceText || !translatedText || sourceText === translatedText) {
921
1045
  skipped.push(key);
922
1046
  continue;