@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
@@ -14,16 +14,31 @@ export declare const lockfileEntrySchema: z.ZodObject<{
14
14
  inTm: z.ZodOptional<z.ZodBoolean>;
15
15
  /** Pseudo-localized (`--provider pseudo`), not a translation: any real run re-translates it. */
16
16
  pseudo: z.ZodOptional<z.ZodBoolean>;
17
+ /**
18
+ * Fingerprint of the glossary rules that applied to this key's source
19
+ * for the locale (see glossaryFingerprint). Absent: none applied.
20
+ * A run whose rules give a different fingerprint re-translates it.
21
+ */
22
+ glossaryHash: z.ZodOptional<z.ZodString>;
23
+ /**
24
+ * The translation came from the project's own files (e.g. a .po
25
+ * catalogue), not from Babelize: glossary edits don't overwrite it.
26
+ */
27
+ external: z.ZodOptional<z.ZodBoolean>;
17
28
  }, "strip", z.ZodTypeAny, {
18
29
  sourceHash?: string | undefined;
19
30
  fromMemory?: boolean | undefined;
20
31
  inTm?: boolean | undefined;
21
32
  pseudo?: boolean | undefined;
33
+ glossaryHash?: string | undefined;
34
+ external?: boolean | undefined;
22
35
  }, {
23
36
  sourceHash?: string | undefined;
24
37
  fromMemory?: boolean | undefined;
25
38
  inTm?: boolean | undefined;
26
39
  pseudo?: boolean | undefined;
40
+ glossaryHash?: string | undefined;
41
+ external?: boolean | undefined;
27
42
  }>>>;
28
43
  }, "strip", z.ZodTypeAny, {
29
44
  sourceHash: string;
@@ -34,6 +49,8 @@ export declare const lockfileEntrySchema: z.ZodObject<{
34
49
  fromMemory?: boolean | undefined;
35
50
  inTm?: boolean | undefined;
36
51
  pseudo?: boolean | undefined;
52
+ glossaryHash?: string | undefined;
53
+ external?: boolean | undefined;
37
54
  }> | undefined;
38
55
  }, {
39
56
  sourceHash: string;
@@ -44,6 +61,8 @@ export declare const lockfileEntrySchema: z.ZodObject<{
44
61
  fromMemory?: boolean | undefined;
45
62
  inTm?: boolean | undefined;
46
63
  pseudo?: boolean | undefined;
64
+ glossaryHash?: string | undefined;
65
+ external?: boolean | undefined;
47
66
  }> | undefined;
48
67
  }>;
49
68
  export type LockfileEntry = z.infer<typeof lockfileEntrySchema>;
@@ -66,16 +85,31 @@ export declare const lockfileDataSchema: z.ZodObject<{
66
85
  inTm: z.ZodOptional<z.ZodBoolean>;
67
86
  /** Pseudo-localized (`--provider pseudo`), not a translation: any real run re-translates it. */
68
87
  pseudo: z.ZodOptional<z.ZodBoolean>;
88
+ /**
89
+ * Fingerprint of the glossary rules that applied to this key's source
90
+ * for the locale (see glossaryFingerprint). Absent: none applied.
91
+ * A run whose rules give a different fingerprint re-translates it.
92
+ */
93
+ glossaryHash: z.ZodOptional<z.ZodString>;
94
+ /**
95
+ * The translation came from the project's own files (e.g. a .po
96
+ * catalogue), not from Babelize: glossary edits don't overwrite it.
97
+ */
98
+ external: z.ZodOptional<z.ZodBoolean>;
69
99
  }, "strip", z.ZodTypeAny, {
70
100
  sourceHash?: string | undefined;
71
101
  fromMemory?: boolean | undefined;
72
102
  inTm?: boolean | undefined;
73
103
  pseudo?: boolean | undefined;
104
+ glossaryHash?: string | undefined;
105
+ external?: boolean | undefined;
74
106
  }, {
75
107
  sourceHash?: string | undefined;
76
108
  fromMemory?: boolean | undefined;
77
109
  inTm?: boolean | undefined;
78
110
  pseudo?: boolean | undefined;
111
+ glossaryHash?: string | undefined;
112
+ external?: boolean | undefined;
79
113
  }>>>;
80
114
  }, "strip", z.ZodTypeAny, {
81
115
  sourceHash: string;
@@ -86,6 +120,8 @@ export declare const lockfileDataSchema: z.ZodObject<{
86
120
  fromMemory?: boolean | undefined;
87
121
  inTm?: boolean | undefined;
88
122
  pseudo?: boolean | undefined;
123
+ glossaryHash?: string | undefined;
124
+ external?: boolean | undefined;
89
125
  }> | undefined;
90
126
  }, {
91
127
  sourceHash: string;
@@ -96,6 +132,8 @@ export declare const lockfileDataSchema: z.ZodObject<{
96
132
  fromMemory?: boolean | undefined;
97
133
  inTm?: boolean | undefined;
98
134
  pseudo?: boolean | undefined;
135
+ glossaryHash?: string | undefined;
136
+ external?: boolean | undefined;
99
137
  }> | undefined;
100
138
  }>>;
101
139
  }, "strip", z.ZodTypeAny, {
@@ -108,6 +146,8 @@ export declare const lockfileDataSchema: z.ZodObject<{
108
146
  fromMemory?: boolean | undefined;
109
147
  inTm?: boolean | undefined;
110
148
  pseudo?: boolean | undefined;
149
+ glossaryHash?: string | undefined;
150
+ external?: boolean | undefined;
111
151
  }> | undefined;
112
152
  }>;
113
153
  projectId: string;
@@ -123,6 +163,8 @@ export declare const lockfileDataSchema: z.ZodObject<{
123
163
  fromMemory?: boolean | undefined;
124
164
  inTm?: boolean | undefined;
125
165
  pseudo?: boolean | undefined;
166
+ glossaryHash?: string | undefined;
167
+ external?: boolean | undefined;
126
168
  }> | undefined;
127
169
  }>;
128
170
  projectId: string;
@@ -21,6 +21,17 @@ exports.lockfileEntrySchema = zod_1.z.object({
21
21
  inTm: zod_1.z.boolean().optional(),
22
22
  /** Pseudo-localized (`--provider pseudo`), not a translation: any real run re-translates it. */
23
23
  pseudo: zod_1.z.boolean().optional(),
24
+ /**
25
+ * Fingerprint of the glossary rules that applied to this key's source
26
+ * for the locale (see glossaryFingerprint). Absent: none applied.
27
+ * A run whose rules give a different fingerprint re-translates it.
28
+ */
29
+ glossaryHash: zod_1.z.string().optional(),
30
+ /**
31
+ * The translation came from the project's own files (e.g. a .po
32
+ * catalogue), not from Babelize: glossary edits don't overwrite it.
33
+ */
34
+ external: zod_1.z.boolean().optional(),
24
35
  })).optional(),
25
36
  });
26
37
  // ─────────────────────────────────────────────────────────────
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ICU plural/select messages for plain-text machine translation (NLLB).
3
+ *
4
+ * NLLB translates sentences, not ICU syntax. So an ICU message is taken apart
5
+ * into the plain sentences it can render, each sentence is translated on its
6
+ * own, and the translations are put back together as an ICU message for the
7
+ * target language:
8
+ *
9
+ * - Text around a selector is moved into its branches, so every translated
10
+ * unit is a whole sentence ("You have {count, plural, one {# file} other
11
+ * {# files}}." → one {You have # file.} other {You have # files.}).
12
+ * - A plural gets the TARGET language's CLDR categories (Polish one/few/many/
13
+ * other, Japanese only other). Each category is translated from the source
14
+ * branch of the same name, or the source `other`; exact `=n` branches stay.
15
+ * - The count (`#`, `{count}`, or a gettext `%(count)d`) is replaced by a real
16
+ * number of that category (Polish few → 2, many → 5, Arabic many → 11), so
17
+ * the translator inflects the words around it. The same number must come
18
+ * back exactly where it can be turned back into the count; if it doesn't,
19
+ * that branch — and so the message — fails.
20
+ * - `select` branches keep their keys; nested selectors are expanded the same
21
+ * way. Anything that can't be expressed fails with a reason; a message is
22
+ * never put together from a partial set of branches.
23
+ *
24
+ * Infrastructure-free: the caller sends the units to its translator and hands
25
+ * the per-unit results back to `compose`.
26
+ */
27
+ import { type IcuNode } from '../../utils/icu-message';
28
+ import { type FixedTerm } from '../../utils/placeholder-protection';
29
+ import type { KeyFailure, KeyResult } from '../../providers/types';
30
+ /** Leaves (translated sentences) one message may expand to. */
31
+ export declare const MAX_BRANCH_UNITS = 64;
32
+ type Selector = Extract<IcuNode, {
33
+ options: unknown;
34
+ }>;
35
+ type SelectorType = Selector['type'];
36
+ /** A number standing in for a plural's value in one unit. */
37
+ interface NumberSlot {
38
+ /** As written in the text sent to the translator, e.g. "5" or "1.5". */
39
+ text: string;
40
+ /** Occurrences in the sent text; the translation must have exactly as many. */
41
+ count: number;
42
+ /** What each occurrence turns back into (a sentinel). */
43
+ restore: string;
44
+ /** For messages: "#", "{count}", "%(count)d". */
45
+ label: string;
46
+ /**
47
+ * The branch stands for this one number only (`=1`, German/Polish "one",
48
+ * Arabic "zero"/"two"): the translation may spell it out ("eine Datei")
49
+ * instead of showing it, and the branch is still exact.
50
+ */
51
+ mayBeSpelledOut: boolean;
52
+ }
53
+ export interface BranchUnit {
54
+ /** Unique within the plan. */
55
+ id: string;
56
+ /** Which branch combination, e.g. "count=few" ("message" with no selectors). */
57
+ label: string;
58
+ /** Text for the translator: protected spans as [#VAR_n#] tokens, counts as numbers. */
59
+ sendText: string;
60
+ tokens: string[];
61
+ /** What each token is restored to (a fixed glossary term becomes its translation). */
62
+ restore: string[];
63
+ slots: NumberSlot[];
64
+ /** Units with nothing to translate (only a number, tags, placeholders) are not sent. */
65
+ passthrough: boolean;
66
+ /** Formatted arguments of the unit, by sentinel index. */
67
+ args: string[];
68
+ /** Literal text restored verbatim (gettext count placeholders), by sentinel index. */
69
+ literals: string[];
70
+ /** The unit's parent selector is a plural: `#` and quoting follow plural rules. */
71
+ inPlural: boolean;
72
+ /** For a passthrough unit: the text with sentinels, used as is. */
73
+ sentinelText: string;
74
+ /** Spaces were put between a count and a token it touches (see makeUnit). */
75
+ spaced: boolean;
76
+ }
77
+ export type BranchFailure = {
78
+ ok: false;
79
+ reason: KeyFailure['reason'];
80
+ retryable: boolean;
81
+ message: string;
82
+ };
83
+ export type BranchComposeResult = {
84
+ ok: true;
85
+ value: string;
86
+ } | BranchFailure;
87
+ export interface BranchPlan {
88
+ units: BranchUnit[];
89
+ /**
90
+ * Put the message together from one translator result per unit (by unit
91
+ * id). `checkUnit` adds the caller's own per-branch checks (e.g. "came back
92
+ * untranslated"); it returns a problem message or null.
93
+ */
94
+ compose(results: Map<string, KeyResult | undefined>, checkUnit?: (unit: BranchUnit, translated: string) => BranchFailure | null): BranchComposeResult;
95
+ }
96
+ export interface PlanOptions {
97
+ neverTranslateTerms?: string[];
98
+ /** "Always translate as" terms: tokenized, and restored to their translation. */
99
+ fixedTerms?: FixedTerm[];
100
+ /**
101
+ * Literal text standing for the value of the plural it appears in, besides
102
+ * `#` — e.g. "%(count)d" in a gettext plural turned into ICU.
103
+ */
104
+ valuePlaceholder?: string;
105
+ /**
106
+ * Plain (non-ICU) source: wrap it in a plural over each of these arguments
107
+ * for the target language (target-side plurals for `{count} files`).
108
+ */
109
+ pluralizeArgs?: string[];
110
+ }
111
+ /** Plural categories of a locale, CLDR order. */
112
+ export declare function targetCategories(locale: string, type: 'cardinal' | 'ordinal'): string[];
113
+ /** Candidate numbers for a category (or the exact value of an `=n` branch). */
114
+ declare function numbersFor(locale: string, type: SelectorType, key: string): number[];
115
+ /**
116
+ * Plan the branch-by-branch translation of `source` into `targetLocale`, or
117
+ * explain why it can't be done.
118
+ */
119
+ export declare function planIcuBranches(source: string, targetLocale: string, options?: PlanOptions): BranchPlan | {
120
+ error: string;
121
+ };
122
+ /** "١٢" → "12", "1,5" → "1.5". */
123
+ declare function normalizeNumber(s: string): string;
124
+ /** Replace each slot's number in the translation by its sentinel; null when a number is missing or repeated. */
125
+ declare function restoreNumbers(translated: string, slots: NumberSlot[]): {
126
+ text: string;
127
+ } | {
128
+ problem: string;
129
+ };
130
+ /** ICU-escape literal text: apostrophes before syntax, and `{`, `}` (and `#` in a plural) quoted. */
131
+ export declare function escapeIcuText(text: string, inPlural: boolean): string;
132
+ /** Exposed for tests. */
133
+ export declare const __test: {
134
+ normalizeNumber: typeof normalizeNumber;
135
+ restoreNumbers: typeof restoreNumbers;
136
+ numbersFor: typeof numbersFor;
137
+ };
138
+ export {};
@@ -0,0 +1,393 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.__test = exports.MAX_BRANCH_UNITS = void 0;
4
+ exports.targetCategories = targetCategories;
5
+ exports.planIcuBranches = planIcuBranches;
6
+ exports.escapeIcuText = escapeIcuText;
7
+ /**
8
+ * ICU plural/select messages for plain-text machine translation (NLLB).
9
+ *
10
+ * NLLB translates sentences, not ICU syntax. So an ICU message is taken apart
11
+ * into the plain sentences it can render, each sentence is translated on its
12
+ * own, and the translations are put back together as an ICU message for the
13
+ * target language:
14
+ *
15
+ * - Text around a selector is moved into its branches, so every translated
16
+ * unit is a whole sentence ("You have {count, plural, one {# file} other
17
+ * {# files}}." → one {You have # file.} other {You have # files.}).
18
+ * - A plural gets the TARGET language's CLDR categories (Polish one/few/many/
19
+ * other, Japanese only other). Each category is translated from the source
20
+ * branch of the same name, or the source `other`; exact `=n` branches stay.
21
+ * - The count (`#`, `{count}`, or a gettext `%(count)d`) is replaced by a real
22
+ * number of that category (Polish few → 2, many → 5, Arabic many → 11), so
23
+ * the translator inflects the words around it. The same number must come
24
+ * back exactly where it can be turned back into the count; if it doesn't,
25
+ * that branch — and so the message — fails.
26
+ * - `select` branches keep their keys; nested selectors are expanded the same
27
+ * way. Anything that can't be expressed fails with a reason; a message is
28
+ * never put together from a partial set of branches.
29
+ *
30
+ * Infrastructure-free: the caller sends the units to its translator and hands
31
+ * the per-unit results back to `compose`.
32
+ */
33
+ const icu_message_1 = require("../../utils/icu-message");
34
+ const placeholder_protection_1 = require("../../utils/placeholder-protection");
35
+ const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
36
+ /** Leaves (translated sentences) one message may expand to. */
37
+ exports.MAX_BRANCH_UNITS = 64;
38
+ const CLDR_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'];
39
+ /**
40
+ * Numbers tried for a plural category, most natural first. NLLB tends to
41
+ * spell out small numbers ("five files", "fünf Akten"), which loses the
42
+ * count, so two- and three-digit numbers come first; 1 and 0 only where
43
+ * nothing else fits (a branch for exactly one number may be spelled out).
44
+ */
45
+ const CANDIDATES = (() => {
46
+ const preferred = [25, 22, 23, 24, 21, 26, 27, 28, 29, 103, 104, 101, 102, 111, 11, 12, 13, 14, 15, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1, 0];
47
+ const rest = [];
48
+ for (let n = 16; n <= 200; n++)
49
+ if (!preferred.includes(n))
50
+ rest.push(n);
51
+ // Russian/Polish `other` (and similar) is only reached by fractions.
52
+ return [...preferred, ...rest, 1.5, 2.5, 0.5, 3.5];
53
+ })();
54
+ const ARG_SENTINEL = /\uE001(\d+)\uE001/g;
55
+ const POUND_SENTINEL = '\uE002';
56
+ const TOKEN_PATTERN = /\[\s*#\s*VAR_\d+\s*#\s*\]/gi;
57
+ class PlanError extends Error {
58
+ }
59
+ /** Plural categories of a locale, CLDR order. */
60
+ function targetCategories(locale, type) {
61
+ try {
62
+ const found = new Set(new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories);
63
+ return CLDR_ORDER.filter((c) => found.has(c));
64
+ }
65
+ catch {
66
+ return ['one', 'other'];
67
+ }
68
+ }
69
+ function rulesFor(locale, type) {
70
+ return new Intl.PluralRules(locale, { type: type === 'selectordinal' ? 'ordinal' : 'cardinal' });
71
+ }
72
+ /** Candidate numbers for a category (or the exact value of an `=n` branch). */
73
+ function numbersFor(locale, type, key) {
74
+ if (/^=\d+$/.test(key))
75
+ return [Number(key.slice(1))];
76
+ const rules = rulesFor(locale, type);
77
+ return CANDIDATES.filter((n) => rules.select(n) === key);
78
+ }
79
+ /** Keys a plural gets in the target language: the source's exact `=n` keys, then each target category that has a usable number. */
80
+ function targetKeys(sel, locale) {
81
+ const exact = Object.keys(sel.options).filter((k) => /^=\d+$/.test(k));
82
+ const cats = targetCategories(locale, sel.type === 'selectordinal' ? 'ordinal' : 'cardinal')
83
+ // A category reached only by huge numbers (French "many" = millions) is left
84
+ // to ICU's `other` fallback: such a number doesn't survive translation intact.
85
+ .filter((c) => c === 'other' || numbersFor(locale, sel.type, c).length > 0);
86
+ return [...exact, ...cats];
87
+ }
88
+ function sourceBranch(sel, key) {
89
+ const branch = sel.options[key] ?? sel.options.other;
90
+ if (!branch)
91
+ throw new PlanError(`{${sel.name}, ${sel.type}} has no "other" branch`);
92
+ return branch;
93
+ }
94
+ const isSelector = (n) => n.type === 'plural' || n.type === 'select' || n.type === 'selectordinal';
95
+ /**
96
+ * Plan the branch-by-branch translation of `source` into `targetLocale`, or
97
+ * explain why it can't be done.
98
+ */
99
+ function planIcuBranches(source, targetLocale, options = {}) {
100
+ try {
101
+ if (/,\s*(?:plural|selectordinal)\s*,\s*offset\s*:/.test(source)) {
102
+ throw new PlanError('plural offsets are not supported');
103
+ }
104
+ let nodes = (0, icu_message_1.parseIcu)(source);
105
+ for (const name of [...(options.pluralizeArgs ?? [])].reverse()) {
106
+ nodes = [{ type: 'plural', name, options: { other: nodes } }];
107
+ }
108
+ const units = [];
109
+ const tree = build(nodes, [], { units, targetLocale, options });
110
+ return { units, compose: (results, checkUnit) => compose(tree, results, checkUnit) };
111
+ }
112
+ catch (err) {
113
+ if (err instanceof PlanError || err instanceof icu_message_1.IcuParseError)
114
+ return { error: err.message };
115
+ throw err;
116
+ }
117
+ }
118
+ function build(nodes, path, state) {
119
+ const at = nodes.findIndex(isSelector);
120
+ if (at === -1) {
121
+ if (state.units.length >= exports.MAX_BRANCH_UNITS) {
122
+ throw new PlanError(`the message has more than ${exports.MAX_BRANCH_UNITS} branch combinations`);
123
+ }
124
+ const unit = makeUnit(nodes, path, state);
125
+ state.units.push(unit);
126
+ return { kind: 'leaf', unit };
127
+ }
128
+ const sel = nodes[at];
129
+ const prefix = nodes.slice(0, at);
130
+ const suffix = nodes.slice(at + 1);
131
+ // Moving text into the branches of `sel` must not move a `#` of an outer
132
+ // plural under another selector: it would then mean something else.
133
+ if ([...prefix, ...suffix].some((n) => n.type === 'pound')) {
134
+ throw new PlanError(`"#" outside the nested {${sel.name}, ${sel.type}} can't be translated branch by branch`);
135
+ }
136
+ if (path.some((p) => p.name === sel.name)) {
137
+ throw new PlanError(`{${sel.name}} is selected on twice`);
138
+ }
139
+ const keys = sel.type === 'select' ? Object.keys(sel.options) : targetKeys(sel, state.targetLocale);
140
+ if (sel.type === 'select' && !keys.includes('other')) {
141
+ throw new PlanError(`{${sel.name}, select} has no "other" branch`);
142
+ }
143
+ const options = keys.map((key) => [
144
+ key,
145
+ build([...prefix, ...sourceBranch(sel, key), ...suffix], [...path, { type: sel.type, name: sel.name, key }], state),
146
+ ]);
147
+ return { kind: 'selector', type: sel.type, name: sel.name, options };
148
+ }
149
+ function argSource(n) {
150
+ return n.format ? `{${n.name}, ${n.format.replace(/^(\w+),\s*/, '$1, ')}}` : `{${n.name}}`;
151
+ }
152
+ /** Digits already in the text (so a representative number is not confused with them). */
153
+ function digitRuns(text) {
154
+ return new Set([...text.replace(TOKEN_PATTERN, ' ').matchAll(/\p{Nd}+(?:[.,٫]\p{Nd}+)?/gu)].map((m) => normalizeNumber(m[0])));
155
+ }
156
+ function makeUnit(nodes, path, state) {
157
+ const id = String(state.units.length);
158
+ const parent = path[path.length - 1];
159
+ const inPlural = !!parent && parent.type !== 'select';
160
+ const label = path.length ? path.map((p) => `${p.name}=${p.key}`).join(', ') : 'message';
161
+ const plurals = path.filter((p) => p.type !== 'select');
162
+ const innermostPlural = plurals[plurals.length - 1];
163
+ // 1. Text with sentinels: arguments, `#`, and count literals become placeholders.
164
+ const args = [];
165
+ const literals = [];
166
+ const occurrences = [];
167
+ let text = '';
168
+ const literal = state.options.valuePlaceholder;
169
+ for (const n of nodes) {
170
+ if (n.type === 'text') {
171
+ if (literal && innermostPlural && n.value.includes(literal)) {
172
+ const parts = n.value.split(literal);
173
+ parts.forEach((part, i) => {
174
+ text += part;
175
+ if (i < parts.length - 1) {
176
+ literals.push(literal);
177
+ const restore = `\uE003${literals.length - 1}\uE003`;
178
+ occurrences.push({ owner: innermostPlural, kind: 'literal', restore, label: literal });
179
+ text += `\uE004${occurrences.length - 1}\uE004`;
180
+ }
181
+ });
182
+ }
183
+ else {
184
+ text += n.value;
185
+ }
186
+ }
187
+ else if (n.type === 'pound') {
188
+ // Only ever directly under its plural (see build()).
189
+ occurrences.push({ owner: parent, kind: 'pound', restore: POUND_SENTINEL, label: '#' });
190
+ text += `\uE004${occurrences.length - 1}\uE004`;
191
+ }
192
+ else if (n.type === 'argument') {
193
+ const owner = !n.format ? plurals.find((p) => p.name === n.name) : undefined;
194
+ args.push(argSource(n));
195
+ const restore = `\uE001${args.length - 1}\uE001`;
196
+ if (owner) {
197
+ occurrences.push({ owner, kind: `arg:${n.name}`, restore, label: `{${n.name}}` });
198
+ text += `\uE004${occurrences.length - 1}\uE004`;
199
+ }
200
+ else {
201
+ text += restore;
202
+ }
203
+ }
204
+ }
205
+ // 2. A number of the branch's category for each count occurrence; one number per (plural, kind).
206
+ const taken = digitRuns(text.replace(/[\uE001\uE003\uE004]\d+[\uE001\uE003\uE004]/g, ' '));
207
+ const slotsByKind = new Map();
208
+ const used = new Set();
209
+ for (const occ of occurrences) {
210
+ const kindKey = `${occ.owner.name}\u0000${occ.kind}`;
211
+ let slot = slotsByKind.get(kindKey);
212
+ if (!slot) {
213
+ const numbers = numbersFor(state.targetLocale, occ.owner.type, occ.owner.key).map(String);
214
+ const pick = numbers.find((n) => !taken.has(n) && !used.has(n));
215
+ if (pick === undefined) {
216
+ throw new PlanError(`no number to stand for ${occ.label} in the "${occ.owner.name}=${occ.owner.key}" branch`);
217
+ }
218
+ used.add(pick);
219
+ // A plural added over a plain count must show it in every branch (compareIcuMessages), so no spelling out there.
220
+ 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 };
222
+ slotsByKind.set(kindKey, slot);
223
+ }
224
+ slot.count++;
225
+ }
226
+ // Every occurrence of a literal restores the same text, so one slot (and its first sentinel) serves them all.
227
+ const numbered = text.replace(/\uE004(\d+)\uE004/g, (_, i) => slotsByKind.get(`${occurrences[Number(i)].owner.name}\u0000${occurrences[Number(i)].kind}`).text);
228
+ // 3. Protect tags, URLs and terms; arguments left as sentinels become tokens too.
229
+ const never = state.options.neverTranslateTerms ?? [];
230
+ const fixedTerms = state.options.fixedTerms ?? [];
231
+ const prot = (0, placeholder_protection_1.protectText)(numbered, never, { fixedTerms });
232
+ const tokens = [...prot.tokens];
233
+ const restore = [...prot.restore];
234
+ const tokenized = prot.text.replace(ARG_SENTINEL, (m) => {
235
+ tokens.push(m);
236
+ restore.push(m);
237
+ return `[#VAR_${tokens.length - 1}#]`;
238
+ });
239
+ // The translator service masks tokens as numbers (7007): a count glued to a
240
+ // token ("<b>5</b>") would merge into one digit run and break both. Keep
241
+ // them apart while translating; the space is taken out again afterwards.
242
+ const sendText = tokenized
243
+ .replace(/(\[#VAR_\d+#\])(?=\p{Nd})/gu, '$1 ')
244
+ .replace(/(?<=\p{Nd})(\[#VAR_\d+#\])/gu, ' $1');
245
+ return {
246
+ id,
247
+ label,
248
+ sendText,
249
+ spaced: sendText !== tokenized,
250
+ tokens,
251
+ restore,
252
+ slots: [...slotsByKind.values()],
253
+ passthrough: (0, placeholder_protection_1.hasNothingToTranslate)(sendText),
254
+ args,
255
+ literals,
256
+ inPlural,
257
+ sentinelText: withFixedTerms(text.replace(/\uE004(\d+)\uE004/g, (_, i) => occurrences[Number(i)].restore), never, fixedTerms),
258
+ };
259
+ }
260
+ const ZEROS = [0x30, 0x660, 0x6f0, 0x7c0, 0x966, 0x9e6, 0xa66, 0xae6, 0xb66, 0xbe6, 0xc66, 0xce6, 0xd66, 0xde6, 0xe50, 0xed0, 0xf20, 0x1040, 0x1090, 0x17e0, 0x1810, 0x1946, 0x19d0, 0xff10];
261
+ /** "١٢" → "12", "1,5" → "1.5". */
262
+ function normalizeNumber(s) {
263
+ let out = '';
264
+ for (const ch of s) {
265
+ const cp = ch.codePointAt(0);
266
+ const zero = ZEROS.find((z) => cp >= z && cp <= z + 9);
267
+ out += zero !== undefined ? String(cp - zero) : '.';
268
+ }
269
+ return out;
270
+ }
271
+ /** Replace each slot's number in the translation by its sentinel; null when a number is missing or repeated. */
272
+ function restoreNumbers(translated, slots) {
273
+ if (slots.length === 0)
274
+ return { text: translated };
275
+ const found = new Map();
276
+ // Numbers only count outside [#VAR_n#] tokens.
277
+ const pieces = translated.split(/(\[\s*#\s*VAR_\d+\s*#\s*\])/i);
278
+ const out = pieces.map((piece, i) => {
279
+ if (i % 2 === 1)
280
+ return piece;
281
+ return piece.replace(/(?<![\p{Nd}.,٫])\p{Nd}+(?:[.,٫]\p{Nd}+)?(?![\p{Nd}])/gu, (m) => {
282
+ const value = normalizeNumber(m);
283
+ const slot = slots.find((s) => s.text === value);
284
+ if (!slot)
285
+ return m;
286
+ found.set(slot, (found.get(slot) ?? 0) + 1);
287
+ return slot.restore;
288
+ });
289
+ }).join('');
290
+ for (const slot of slots) {
291
+ const n = found.get(slot) ?? 0;
292
+ if (n === 0 && slot.mayBeSpelledOut)
293
+ continue;
294
+ if (n !== slot.count) {
295
+ return {
296
+ problem: n === 0
297
+ ? `the number ${slot.text} standing for ${slot.label} was dropped`
298
+ : `the number ${slot.text} standing for ${slot.label} appears ${n} times (expected ${slot.count})`,
299
+ };
300
+ }
301
+ }
302
+ return { text: out };
303
+ }
304
+ /** ICU-escape literal text: apostrophes before syntax, and `{`, `}` (and `#` in a plural) quoted. */
305
+ function escapeIcuText(text, inPlural) {
306
+ const special = inPlural ? /[{}#]/ : /[{}]/;
307
+ let out = '';
308
+ for (let i = 0; i < text.length; i++) {
309
+ const ch = text[i];
310
+ if (ch === "'") {
311
+ const next = text[i + 1];
312
+ out += next === undefined || /[{}#']/.test(next) ? "''" : "'";
313
+ }
314
+ else if (special.test(ch)) {
315
+ let j = i;
316
+ while (j < text.length && special.test(text[j]))
317
+ j++;
318
+ out += `'${text.slice(i, j)}'`;
319
+ i = j - 1;
320
+ }
321
+ else {
322
+ out += ch;
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+ /** Text with sentinels → ICU source for a unit. */
328
+ /** A passthrough branch is used as is, except that fixed glossary terms become their translation. */
329
+ function withFixedTerms(text, never, fixedTerms) {
330
+ if (fixedTerms.length === 0)
331
+ return text;
332
+ const p = (0, placeholder_protection_1.protectText)(text, never, { fixedTerms });
333
+ return (0, placeholder_protection_1.restoreText)(p.text, p.restore);
334
+ }
335
+ function serializeUnit(unit, sentinelText) {
336
+ const parts = sentinelText.split(/(\uE001\d+\uE001|\uE002|\uE003\d+\uE003)/);
337
+ return parts.map((p, i) => {
338
+ if (i % 2 === 0)
339
+ return escapeIcuText(p, unit.inPlural);
340
+ if (p === POUND_SENTINEL)
341
+ return '#';
342
+ const arg = /^\uE001(\d+)\uE001$/.exec(p);
343
+ if (arg)
344
+ return unit.args[Number(arg[1])];
345
+ const lit = /^\uE003(\d+)\uE003$/.exec(p);
346
+ return escapeIcuText(unit.literals[Number(lit[1])], unit.inPlural);
347
+ }).join('');
348
+ }
349
+ function translateUnit(unit, result, checkUnit) {
350
+ const fail = (reason, retryable, message) => ({ ok: false, reason, retryable, message: `${unit.label}: ${message}` });
351
+ if (unit.passthrough)
352
+ return { ok: true, value: serializeUnit(unit, unit.sentinelText) };
353
+ if (!result)
354
+ return fail('missing_from_response', true, 'the translator returned no translation for this branch');
355
+ if (result.status === 'failed')
356
+ return fail(result.reason, result.retryable, result.message ?? `translator reported ${result.reason}`);
357
+ const raw = (0, translation_sanitizer_1.sanitizeTranslation)(result.value, { nllb: true, source: unit.sendText });
358
+ if (!raw.trim())
359
+ return fail('empty_value', true, 'the translator returned an empty string');
360
+ const tokenCheck = (0, placeholder_protection_1.checkTokens)(raw, unit.tokens.length);
361
+ if (!tokenCheck.ok)
362
+ return fail('placeholder_lost', true, `Placeholder problem: ${(0, placeholder_protection_1.describeTokenProblem)(tokenCheck, unit.tokens)}`);
363
+ const numbers = restoreNumbers(raw, unit.slots);
364
+ if ('problem' in numbers)
365
+ return fail('placeholder_lost', true, `Placeholder problem: ${numbers.problem}`);
366
+ const extra = checkUnit?.(unit, raw);
367
+ if (extra)
368
+ return { ...extra, message: `${unit.label}: ${extra.message}` };
369
+ // Tokens back to their text; argument tokens become sentinels again.
370
+ let restored = numbers.text;
371
+ if (unit.spaced) {
372
+ // Glue counts and tokens back together where the source had them glued.
373
+ restored = restored
374
+ .replace(/(\[\s*#\s*VAR_\d+\s*#\s*\])[ \u00A0]+(?=\uE002|\uE001\d+\uE001|\uE003\d+\uE003)/gi, '$1')
375
+ .replace(/(\uE002|\uE001\d+\uE001|\uE003\d+\uE003)[ \u00A0]+(?=\[\s*#\s*VAR_)/gi, '$1');
376
+ }
377
+ const sentinelText = restored.replace(/\[\s*#\s*VAR_(\d+)\s*#\s*\]/gi, (_, i) => unit.restore[Number(i)]);
378
+ return { ok: true, value: serializeUnit(unit, sentinelText) };
379
+ }
380
+ function compose(tree, results, checkUnit) {
381
+ if (tree.kind === 'leaf')
382
+ return translateUnit(tree.unit, results.get(tree.unit.id), checkUnit);
383
+ const parts = [];
384
+ for (const [key, child] of tree.options) {
385
+ const r = compose(child, results, checkUnit);
386
+ if (!r.ok)
387
+ return r;
388
+ parts.push(`${key} {${r.value}}`);
389
+ }
390
+ return { ok: true, value: `{${tree.name}, ${tree.type}, ${parts.join(' ')}}` };
391
+ }
392
+ /** Exposed for tests. */
393
+ exports.__test = { normalizeNumber, restoreNumbers, numbersFor };