@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.
- package/dist/analysis/analyze.js +1 -1
- package/dist/detection/stack.d.ts +42 -0
- package/dist/detection/stack.js +277 -0
- package/dist/django/catalog.d.ts +92 -0
- package/dist/django/catalog.js +677 -0
- package/dist/django/extract.d.ts +20 -0
- package/dist/django/extract.js +79 -0
- package/dist/django/inject-file.d.ts +6 -0
- package/dist/django/inject-file.js +52 -0
- package/dist/django/inject.d.ts +11 -0
- package/dist/django/inject.js +205 -0
- package/dist/django/keys.d.ts +21 -0
- package/dist/django/keys.js +63 -0
- package/dist/django/locale-names.d.ts +13 -0
- package/dist/django/locale-names.js +60 -0
- package/dist/django/mo-file.d.ts +6 -0
- package/dist/django/mo-file.js +60 -0
- package/dist/django/plural-forms.d.ts +34 -0
- package/dist/django/plural-forms.js +216 -0
- package/dist/django/po-file.d.ts +38 -0
- package/dist/django/po-file.js +273 -0
- package/dist/django/python-extract.d.ts +34 -0
- package/dist/django/python-extract.js +619 -0
- package/dist/django/python-lexer.d.ts +65 -0
- package/dist/django/python-lexer.js +398 -0
- package/dist/django/template-extract.d.ts +32 -0
- package/dist/django/template-extract.js +680 -0
- package/dist/django/types.d.ts +49 -0
- package/dist/django/types.js +15 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +16 -2
- package/dist/local/pseudo-provider.js +5 -4
- package/dist/local/translate-core.d.ts +5 -2
- package/dist/local/translate-core.js +43 -5
- package/dist/local/translate-messages.js +7 -9
- package/dist/modules/extractor/extract.js +22 -14
- package/dist/modules/finalization/django-finalize.d.ts +15 -0
- package/dist/modules/finalization/django-finalize.js +156 -19
- package/dist/modules/finalization/finalize.d.ts +2 -0
- package/dist/modules/finalization/finalize.js +58 -30
- package/dist/modules/finalization/nextjs-finalize.d.ts +4 -1
- package/dist/modules/finalization/nextjs-finalize.js +8 -9
- package/dist/modules/finalization/writes.d.ts +29 -0
- package/dist/modules/finalization/writes.js +78 -0
- package/dist/modules/format-injection/index.js +3 -2
- package/dist/modules/glossary/index.d.ts +1 -2
- package/dist/modules/glossary/index.js +14 -6
- package/dist/modules/glossary/rules.d.ts +81 -0
- package/dist/modules/glossary/rules.js +244 -0
- package/dist/modules/injection/inject.js +14 -30
- package/dist/modules/keygen/keygen.js +7 -0
- package/dist/modules/locale-builder/locales.js +23 -0
- package/dist/modules/lockfile/index.d.ts +1 -1
- package/dist/modules/lockfile/index.js +2 -1
- package/dist/modules/lockfile/lockfile-manager.d.ts +9 -0
- package/dist/modules/lockfile/lockfile-manager.js +16 -1
- package/dist/modules/lockfile/schema.d.ts +42 -0
- package/dist/modules/lockfile/schema.js +11 -0
- package/dist/modules/translator/icu-branches.d.ts +138 -0
- package/dist/modules/translator/icu-branches.js +393 -0
- package/dist/modules/translator/translate.d.ts +40 -4
- package/dist/modules/translator/translate.js +297 -173
- package/dist/modules/workspace/validate.js +67 -32
- package/dist/orchestrator.d.ts +5 -0
- package/dist/orchestrator.js +125 -14
- package/dist/providers/types.d.ts +10 -2
- package/dist/qa/check-project.d.ts +2 -1
- package/dist/qa/check-project.js +50 -7
- package/dist/qa/gettext-files.d.ts +15 -0
- package/dist/qa/gettext-files.js +115 -0
- package/dist/qa/locale-qa.js +20 -3
- package/dist/runtime/config.js +1 -0
- package/dist/runtime/types.d.ts +64 -0
- package/dist/utils/extraction-options.d.ts +9 -3
- package/dist/utils/extraction-options.js +13 -3
- package/dist/utils/file-utils.js +7 -2
- package/dist/utils/icu-message.d.ts +2 -2
- package/dist/utils/icu-message.js +43 -7
- package/dist/utils/jsx-message.js +7 -0
- package/dist/utils/metadata.d.ts +4 -2
- package/dist/utils/metadata.js +16 -10
- package/dist/utils/not-injected.js +2 -0
- package/dist/utils/placeholder-protection.d.ts +18 -1
- package/dist/utils/placeholder-protection.js +58 -13
- package/dist/utils/translation-sanitizer.d.ts +1 -0
- package/dist/utils/translation-sanitizer.js +23 -9
- package/dist/utils/translator-load-balancer.js +5 -1
- package/dist/utils/untranslated.d.ts +15 -0
- package/dist/utils/untranslated.js +34 -0
- package/package.json +2 -6
- package/dist/injection/django/django-template-injector.d.ts +0 -29
- package/dist/injection/django/django-template-injector.js +0 -216
- package/dist/modules/glossary/glossary.d.ts +0 -9
- package/dist/modules/glossary/glossary.js +0 -27
- package/dist/modules/glossary/masker.d.ts +0 -23
- package/dist/modules/glossary/masker.js +0 -56
- package/dist/modules/locale-builder/po.d.ts +0 -33
- package/dist/modules/locale-builder/po.js +0 -131
- package/dist/modules/translator/translator.d.ts +0 -5
- package/dist/modules/translator/translator.js +0 -46
- package/dist/utils/dtl-mask.d.ts +0 -25
- package/dist/utils/dtl-mask.js +0 -58
- package/dist/utils/py-extractor.d.ts +0 -14
- 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 };
|