@babelize/engine 0.3.0 → 0.3.1

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.
@@ -16,6 +16,8 @@ export declare const HOSTED_MAX_BATCH: 100;
16
16
  export declare const HOSTED_REQUEST_TIMEOUT_MS = 150000;
17
17
  /** Response header naming the model that served a batch. */
18
18
  export declare const HOSTED_MODEL_HEADER = "x-babelize-model";
19
+ /** Whole credits the server charged for one batch. */
20
+ export declare const HOSTED_CREDITS_HEADER = "x-credits-charged";
19
21
  export declare const API_KEYS_URL = "https://app.babelize.co/api-keys";
20
22
  export declare const BILLING_URL = "https://app.babelize.co/billing";
21
23
  export type HostedErrorCode =
@@ -75,6 +77,8 @@ export declare class HostedTranslationProvider implements TranslationProvider {
75
77
  * before sending, as it does for cloud runs. Set by detectCapabilities().
76
78
  */
77
79
  plainText: boolean;
80
+ /** Credits charged so far by this provider's batches (X-Credits-Charged). */
81
+ creditsCharged: number;
78
82
  /** Set once a request fails in a way no retry can fix (bad key, no credits). */
79
83
  fatalError?: HostedTranslationError;
80
84
  private readonly opts;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HostedTranslationProvider = exports.HostedTranslationError = exports.BILLING_URL = exports.API_KEYS_URL = exports.HOSTED_MODEL_HEADER = exports.HOSTED_REQUEST_TIMEOUT_MS = exports.HOSTED_MAX_BATCH = void 0;
3
+ exports.HostedTranslationProvider = exports.HostedTranslationError = exports.BILLING_URL = exports.API_KEYS_URL = exports.HOSTED_CREDITS_HEADER = exports.HOSTED_MODEL_HEADER = exports.HOSTED_REQUEST_TIMEOUT_MS = exports.HOSTED_MAX_BATCH = void 0;
4
4
  exports.parseRetryAfterMs = parseRetryAfterMs;
5
5
  const node_crypto_1 = require("node:crypto");
6
6
  const language_codes_1 = require("../modules/translator/language-codes");
@@ -22,6 +22,8 @@ exports.HOSTED_MAX_BATCH = types_1.TRANSLATE_BATCH_LIMITS.maxEntries;
22
22
  exports.HOSTED_REQUEST_TIMEOUT_MS = 150000;
23
23
  /** Response header naming the model that served a batch. */
24
24
  exports.HOSTED_MODEL_HEADER = 'x-babelize-model';
25
+ /** Whole credits the server charged for one batch. */
26
+ exports.HOSTED_CREDITS_HEADER = 'x-credits-charged';
25
27
  exports.API_KEYS_URL = 'https://app.babelize.co/api-keys';
26
28
  exports.BILLING_URL = 'https://app.babelize.co/billing';
27
29
  class HostedTranslationError extends Error {
@@ -55,6 +57,8 @@ class HostedTranslationProvider {
55
57
  * before sending, as it does for cloud runs. Set by detectCapabilities().
56
58
  */
57
59
  this.plainText = false;
60
+ /** Credits charged so far by this provider's batches (X-Credits-Charged). */
61
+ this.creditsCharged = 0;
58
62
  this.token = options.apiKey;
59
63
  this.opts = {
60
64
  fetch: globalThis.fetch.bind(globalThis),
@@ -194,6 +198,9 @@ class HostedTranslationProvider {
194
198
  throw new HostedTranslationError(`Lost the response from Babelize at ${this.opts.apiUrl} (${err.message}).`, 'hosted_unavailable');
195
199
  }
196
200
  this.model = res.headers.get(exports.HOSTED_MODEL_HEADER) ?? this.model;
201
+ const charged = Number(res.headers.get(exports.HOSTED_CREDITS_HEADER));
202
+ if (Number.isFinite(charged) && charged > 0)
203
+ this.creditsCharged += charged;
197
204
  return Array.isArray(data.results) ? data.results : [];
198
205
  }
199
206
  // An expired session: refresh it once and try again with the same key.
@@ -62,6 +62,8 @@ export interface TranslateReport {
62
62
  name: string;
63
63
  model?: string;
64
64
  } | null;
65
+ /** Babelize credits the run was charged (hosted runs only). */
66
+ creditsCharged?: number;
65
67
  sourceLocale: string;
66
68
  targetLocales: string[];
67
69
  keptLocales: string[];
@@ -107,6 +107,7 @@ function buildTranslateReport(r) {
107
107
  framework: r.framework,
108
108
  frameworkStatus: r.frameworkStatus,
109
109
  provider: r.provider ? { provider: r.provider.provider, name: r.provider.name, ...(r.provider.model ? { model: r.provider.model } : {}) } : null,
110
+ ...(r.creditsCharged !== undefined ? { creditsCharged: r.creditsCharged } : {}),
110
111
  sourceLocale: r.sourceLocale,
111
112
  targetLocales: r.targetLocales,
112
113
  keptLocales: r.keptLocales,
@@ -123,6 +123,8 @@ export interface LocalTranslateReport {
123
123
  name: string;
124
124
  model?: string;
125
125
  };
126
+ /** Babelize credits this run was charged; set for hosted runs only. */
127
+ creditsCharged?: number;
126
128
  sourceLocale: string;
127
129
  /** Locales translated in this run: `--to` plus earlier locales (unless replace). */
128
130
  targetLocales: string[];
@@ -462,6 +462,7 @@ async function runScoped(opts, deps, engineLog, state) {
462
462
  framework,
463
463
  frameworkStatus,
464
464
  provider,
465
+ ...(deps.hosted ? { creditsCharged: deps.hosted.creditsCharged } : {}),
465
466
  sourceLocale: opts.sourceLocale,
466
467
  targetLocales,
467
468
  keptLocales,
@@ -49,6 +49,13 @@ interface NumberSlot {
49
49
  * instead of showing it, and the branch is still exact.
50
50
  */
51
51
  mayBeSpelledOut: boolean;
52
+ /**
53
+ * Words for "one" in the target language, when this slot is the number 1 in
54
+ * a branch that also covers other numbers (French "one" is 0 and 1). The
55
+ * translator often writes "Vous avez un article" for "You have 1 item"; the
56
+ * word is put back as the count, which keeps the branch right for 0 too.
57
+ */
58
+ oneWords?: string[];
52
59
  }
53
60
  export interface BranchUnit {
54
61
  /** Unique within the plan. */
@@ -54,6 +54,21 @@ const CANDIDATES = (() => {
54
54
  const ARG_SENTINEL = /\uE001(\d+)\uE001/g;
55
55
  const POUND_SENTINEL = '\uE002';
56
56
  const TOKEN_PATTERN = /\[\s*#\s*VAR_\d+\s*#\s*\]/gi;
57
+ /** Spelled-out "one" (as an article or numeral) in languages whose "one" category has several numbers. */
58
+ const ONE_WORDS = {
59
+ fr: ['un', 'une'],
60
+ es: ['un', 'una', 'uno'],
61
+ pt: ['um', 'uma'],
62
+ it: ['un', 'uno', 'una'],
63
+ ca: ['un', 'una'],
64
+ ro: ['un', 'o'],
65
+ hi: ['एक'],
66
+ bn: ['এক', 'একটি'],
67
+ gu: ['એક'],
68
+ mr: ['एक'],
69
+ fa: ['یک'],
70
+ hy: ['մեկ'],
71
+ };
57
72
  class PlanError extends Error {
58
73
  }
59
74
  /** Plural categories of a locale, CLDR order. */
@@ -218,7 +233,8 @@ function makeUnit(nodes, path, state) {
218
233
  used.add(pick);
219
234
  // A plural added over a plain count must show it in every branch (compareIcuMessages), so no spelling out there.
220
235
  const mayBeSpelledOut = numbers.length === 1 && !(state.options.pluralizeArgs ?? []).includes(occ.owner.name);
221
- slot = { text: pick, count: 0, restore: occ.restore, label: occ.label, mayBeSpelledOut };
236
+ const oneWords = pick === '1' && !mayBeSpelledOut ? ONE_WORDS[state.targetLocale.split(/[-_]/)[0].toLowerCase()] : undefined;
237
+ slot = { text: pick, count: 0, restore: occ.restore, label: occ.label, mayBeSpelledOut, ...(oneWords ? { oneWords } : {}) };
222
238
  slotsByKind.set(kindKey, slot);
223
239
  }
224
240
  slot.count++;
@@ -287,10 +303,20 @@ function restoreNumbers(translated, slots) {
287
303
  return slot.restore;
288
304
  });
289
305
  }).join('');
306
+ let text = out;
290
307
  for (const slot of slots) {
291
- const n = found.get(slot) ?? 0;
308
+ let n = found.get(slot) ?? 0;
292
309
  if (n === 0 && slot.mayBeSpelledOut)
293
310
  continue;
311
+ if (n === 0 && slot.count === 1 && slot.oneWords) {
312
+ // "Vous avez un article" → "Vous avez # article", when the word is there exactly once.
313
+ const word = new RegExp(`(?<![\\p{L}\\p{M}])(?:${slot.oneWords.join('|')})(?![\\p{L}\\p{M}])`, 'giu');
314
+ const hits = text.match(word) ?? [];
315
+ if (hits.length === 1) {
316
+ text = text.replace(word, slot.restore);
317
+ n = 1;
318
+ }
319
+ }
294
320
  if (n !== slot.count) {
295
321
  return {
296
322
  problem: n === 0
@@ -299,7 +325,7 @@ function restoreNumbers(translated, slots) {
299
325
  };
300
326
  }
301
327
  }
302
- return { text: out };
328
+ return { text };
303
329
  }
304
330
  /** ICU-escape literal text: apostrophes before syntax, and `{`, `}` (and `#` in a plural) quoted. */
305
331
  function escapeIcuText(text, inPlural) {
@@ -208,6 +208,23 @@ function integersOf(locale, type, category) {
208
208
  return [];
209
209
  }
210
210
  }
211
+ /**
212
+ * Whether ordinary counts ever pick `category`: a whole number under 1000 or
213
+ * a simple fraction. Spanish, French, Italian, Portuguese and Catalan `many`
214
+ * is only reached by exact millions ("1 millón de archivos"), where `other`
215
+ * reads fine, so a missing branch for it isn't worth a warning.
216
+ */
217
+ function reachedByOrdinaryCounts(locale, type, category) {
218
+ if (integersOf(locale, type, category).length > 0)
219
+ return true;
220
+ try {
221
+ const rules = new Intl.PluralRules(locale, { type });
222
+ return [0.5, 1.5, 2.5, 3.5, 0.1, 1.1, 2.1, 0.25].some((n) => rules.select(n) === category);
223
+ }
224
+ catch {
225
+ return true;
226
+ }
227
+ }
211
228
  /**
212
229
  * Compare a translated ICU message to its source. Errors mean the message is
213
230
  * broken at runtime (unparseable, arguments renamed, select branches changed,
@@ -326,11 +343,14 @@ function compareIcuMessages(source, translated, targetLocale, options = {}) {
326
343
  });
327
344
  }
328
345
  else {
329
- issues.push({
330
- severity: 'warning',
331
- code: 'icu_plural_category_missing',
332
- message: `{${target.name}, ${target.kind}} has no [${missing.join(', ')}] branch for ${targetLocale}; "other" will be used`,
333
- });
346
+ const noticeable = missing.filter((c) => reachedByOrdinaryCounts(targetLocale, type, c));
347
+ if (noticeable.length) {
348
+ issues.push({
349
+ severity: 'warning',
350
+ code: 'icu_plural_category_missing',
351
+ message: `{${target.name}, ${target.kind}} has no [${noticeable.join(', ')}] branch for ${targetLocale}; "other" will be used`,
352
+ });
353
+ }
334
354
  }
335
355
  }
336
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@babelize/engine",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Babelize translation pipeline (extract, keygen, translate, inject). Infrastructure is injected through provider/store interfaces.",
5
5
  "keywords": [
6
6
  "babelize",