prosebook 0.2.0 → 0.3.0
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/README.md +274 -29
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/bundle-KD-DiiFa.d.ts +6 -0
- package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-xF3F4oIp.d.ts +16 -0
- package/dist/compile-xF3F4oIp.d.ts.map +1 -0
- package/dist/{config-D--RITk2.js → config-BPSmoYel.js} +36 -212
- package/dist/config-BPSmoYel.js.map +1 -0
- package/dist/{config-_4HfnJQu.d.ts → config-DdMkKt_N.d.ts} +61 -2
- package/dist/config-DdMkKt_N.d.ts.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-CnUfCkIk.js +96 -0
- package/dist/endpoints-CnUfCkIk.js.map +1 -0
- package/dist/generate-9pR3i5Bj.js +5173 -0
- package/dist/generate-9pR3i5Bj.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-CI8HOokm.js → sarif-CWyTAQ0y.js} +103 -87
- package/dist/sarif-CWyTAQ0y.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -90
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +3 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +29 -109
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +60 -23
- package/src/tools/config.ts +39 -0
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +2 -0
- package/src/tools/icu.ts +63 -7
- package/src/tools/index.ts +6 -3
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +36 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +280 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +28 -114
- package/dist/config-D--RITk2.js.map +0 -1
- package/dist/config-_4HfnJQu.d.ts.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-CI8HOokm.js.map +0 -1
- package/dist/scan-Ch2BfIUD.js +0 -2561
- package/dist/scan-Ch2BfIUD.js.map +0 -1
package/src/tools/mf2.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The tree holds what a message does, not how it was written. `.input {$n :number}` becomes a number
|
|
7
7
|
* element at each `{$n}`, and `.match` becomes plural and select elements, one level per selector,
|
|
8
|
-
* built so the runtime's ICU-style selection picks the variant MF2's would (see `matchTree`).
|
|
9
|
-
* values are
|
|
8
|
+
* built so the runtime's ICU-style selection picks the variant MF2's would (see `matchTree`). Literal
|
|
9
|
+
* option values are resolved here into `Intl` options, so the runtime is the same for both. An option
|
|
10
|
+
* an argument gives (`currency=$code`, of those in `FROM_ARGUMENT`) rides on the style as the `Intl`
|
|
11
|
+
* option it sets, which the compiled message passes beside the hoisted ones.
|
|
10
12
|
*/
|
|
11
13
|
import { SKELETON_TYPE, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
12
14
|
import type {
|
|
@@ -21,7 +23,7 @@ import type {
|
|
|
21
23
|
} from '@formatjs/icu-messageformat-parser';
|
|
22
24
|
import { MessageSyntaxError, parseMessage, stringifyMessage, validate, type Model } from 'messageformat';
|
|
23
25
|
|
|
24
|
-
import { walk, type MessageFormatElement } from './icu.ts';
|
|
26
|
+
import { selectionOf, walk, type MessageFormatElement, type OptionArgs, type Placeholder, type Selection } from './icu.ts';
|
|
25
27
|
|
|
26
28
|
type Element = MessageFormatElement;
|
|
27
29
|
|
|
@@ -29,6 +31,10 @@ type Element = MessageFormatElement;
|
|
|
29
31
|
export interface Mf2Function {
|
|
30
32
|
name: string;
|
|
31
33
|
options: Record<string, string>;
|
|
34
|
+
/** Options given by an argument, as `currency=$code`: the option's name to the argument's. */
|
|
35
|
+
args?: Record<string, string>;
|
|
36
|
+
/** `u:dir`: the direction the value is written in, which picks the mark isolating it. */
|
|
37
|
+
dir?: 'ltr' | 'rtl' | 'auto';
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
type Value = { kind: 'literal'; value: string; fn?: Mf2Function } | { kind: 'variable'; name: string; fn?: Mf2Function; offset: number };
|
|
@@ -39,8 +45,23 @@ interface Selector {
|
|
|
39
45
|
name: string;
|
|
40
46
|
kind: 'plural' | 'ordinal' | 'exact' | 'string';
|
|
41
47
|
offset: number;
|
|
48
|
+
/** For a number selected on other than as a bare `:number`. */
|
|
49
|
+
selection?: Selection;
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/** The `Intl.NumberFormat` options `Intl.PluralRules` takes too, which change a number's category. */
|
|
53
|
+
const PLURAL_OPTIONS = new Set([
|
|
54
|
+
'minimumIntegerDigits',
|
|
55
|
+
'minimumFractionDigits',
|
|
56
|
+
'maximumFractionDigits',
|
|
57
|
+
'minimumSignificantDigits',
|
|
58
|
+
'maximumSignificantDigits',
|
|
59
|
+
'roundingIncrement',
|
|
60
|
+
'roundingMode',
|
|
61
|
+
'roundingPriority',
|
|
62
|
+
'trailingZeroDisplay',
|
|
63
|
+
]);
|
|
64
|
+
|
|
44
65
|
export const NUMBER_LITERAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
45
66
|
const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
46
67
|
const NAME = /^[\p{L}_][\p{L}\p{N}_.-]*$/u;
|
|
@@ -96,6 +117,26 @@ const ENUMS: Record<string, readonly string[]> = {
|
|
|
96
117
|
hour12: ['true', 'false'],
|
|
97
118
|
};
|
|
98
119
|
|
|
120
|
+
const oneOf = (values: readonly string[]) => values.map((v) => JSON.stringify(v)).join(' | ');
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Options an argument may give, with the `Intl` options each sets and the argument's type. These pass
|
|
124
|
+
* to `Intl` as they are, so the runtime merges them in; the rest change what the formatter is (a
|
|
125
|
+
* date's fields, how a number selects) and are settled when the message compiles.
|
|
126
|
+
*/
|
|
127
|
+
const FROM_ARGUMENT: Record<string, { intl: readonly string[]; type: string }> = {
|
|
128
|
+
currency: { intl: ['currency'], type: 'string' },
|
|
129
|
+
currencyDisplay: { intl: ['currencyDisplay'], type: oneOf(ENUMS.currencyDisplay!) },
|
|
130
|
+
currencySign: { intl: ['currencySign'], type: oneOf(['standard', 'accounting']) },
|
|
131
|
+
unit: { intl: ['unit'], type: 'string' },
|
|
132
|
+
unitDisplay: { intl: ['unitDisplay'], type: oneOf(['long', 'short', 'narrow']) },
|
|
133
|
+
signDisplay: { intl: ['signDisplay'], type: oneOf(['auto', 'always', 'exceptZero', 'negative', 'never']) },
|
|
134
|
+
fractionDigits: { intl: ['minimumFractionDigits', 'maximumFractionDigits'], type: 'number' },
|
|
135
|
+
...Object.fromEntries(DIGITS.filter((d) => d !== 'roundingIncrement').map((d) => [d, { intl: [d], type: 'number' }])),
|
|
136
|
+
timeZone: { intl: ['timeZone'], type: 'string' },
|
|
137
|
+
calendar: { intl: ['calendar'], type: 'string' },
|
|
138
|
+
};
|
|
139
|
+
|
|
99
140
|
/** Options an operand's function doesn't pass on to these, per the spec. */
|
|
100
141
|
const DISCARDED: Record<string, readonly string[]> = {
|
|
101
142
|
integer: ['minimumFractionDigits', 'maximumFractionDigits', 'minimumSignificantDigits'],
|
|
@@ -144,7 +185,8 @@ function syntaxProblem(text: string, e: unknown): string {
|
|
|
144
185
|
}
|
|
145
186
|
|
|
146
187
|
/** An MF2 message as the element tree. Throws, with the fix, where it can't be read or compiled. */
|
|
147
|
-
export function parseMf2(text: string, locale: string): Element[] {
|
|
188
|
+
export function parseMf2(text: string, locale: string, isolate: Isolate = 'rtl'): Element[] {
|
|
189
|
+
const reading: Reading = { locale, isolate, dir: directionOf(locale) };
|
|
148
190
|
let message: Model.Message;
|
|
149
191
|
try {
|
|
150
192
|
message = parseMessage(text);
|
|
@@ -156,42 +198,93 @@ export function parseMf2(text: string, locale: string): Element[] {
|
|
|
156
198
|
}
|
|
157
199
|
const scope: Scope = new Map();
|
|
158
200
|
for (const declaration of message.declarations)
|
|
159
|
-
// `.input` reads the argument itself; a `.local` can build on what is declared before it.
|
|
160
|
-
|
|
201
|
+
// `.input` reads the argument itself; a `.local` can build on what is declared before it. The
|
|
202
|
+
// options of either can name what is declared before them.
|
|
203
|
+
scope.set(declaration.name, resolve(declaration.value, declaration.type === 'input' ? new Map() : scope, scope));
|
|
161
204
|
|
|
162
|
-
if (message.type === 'message') return pattern(message.pattern, scope,
|
|
205
|
+
if (message.type === 'message') return pattern(message.pattern, scope, reading);
|
|
163
206
|
const selectors = message.selectors.map((s) => selectorOf(s.name, scope));
|
|
164
207
|
const candidates = message.variants.map((variant) => {
|
|
165
208
|
const keys = variant.keys.map((k) => (k.type === '*' ? null : k.value));
|
|
166
209
|
keys.forEach((key, i) => checkKey(selectors[i]!, key, message.selectors[i]!.name));
|
|
167
|
-
return { keys, prefs: [], value: pattern(variant.value, scope,
|
|
210
|
+
return { keys, prefs: [], value: pattern(variant.value, scope, reading) };
|
|
168
211
|
});
|
|
169
212
|
return matchTree(selectors, candidates, 0, locale);
|
|
170
213
|
}
|
|
171
214
|
|
|
172
|
-
|
|
215
|
+
/**
|
|
216
|
+
* When a placeholder is isolated from the text around it, so a value written the other way can't
|
|
217
|
+
* reorder it: `'rtl'` in a locale not known to run left to right, or where `u:dir` asks; `'always'`
|
|
218
|
+
* also for every value of unknown direction (a string), as the spec's default strategy has it.
|
|
219
|
+
*/
|
|
220
|
+
export type Isolate = 'rtl' | 'always' | false;
|
|
221
|
+
|
|
222
|
+
type Direction = 'ltr' | 'rtl' | 'auto';
|
|
223
|
+
|
|
224
|
+
interface Reading {
|
|
225
|
+
locale: string;
|
|
226
|
+
isolate: Isolate;
|
|
227
|
+
/** The locale's direction, which the message's is. */
|
|
228
|
+
dir: Direction;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The scripts CLDR writes right to left, as the `messageformat` package lists them.
|
|
232
|
+
const RTL_SCRIPTS = new Set(['Adlm', 'Arab', 'Hebr', 'Mand', 'Nkoo', 'Rohg', 'Syrc', 'Thaa']);
|
|
233
|
+
const [LRI, RLI, FSI, PDI] = ['\u2066', '\u2067', '\u2068', '\u2069'];
|
|
234
|
+
|
|
235
|
+
function directionOf(locale: string): Direction {
|
|
236
|
+
try {
|
|
237
|
+
const script = new Intl.Locale(locale).maximize().script;
|
|
238
|
+
return script === undefined ? 'auto' : RTL_SCRIPTS.has(script) ? 'rtl' : 'ltr';
|
|
239
|
+
} catch {
|
|
240
|
+
return 'auto';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The fallback a placeholder shows, and the marks isolating it where `isolate` and `u:dir` ask. */
|
|
245
|
+
function placeholderFor(name: string, fn: Mf2Function | undefined, r: Reading): Placeholder {
|
|
246
|
+
const fallback = `{$${name}}`;
|
|
247
|
+
if (r.isolate === false) return { fallback };
|
|
248
|
+
// A number or date is written as the locale writes; a string's direction is its own.
|
|
249
|
+
const dir = fn?.dir ?? (fn === undefined || fn.name === 'string' ? 'auto' : r.dir);
|
|
250
|
+
const isolate = r.dir !== 'ltr' || (dir !== 'ltr' && (r.isolate === 'always' || fn?.dir !== undefined));
|
|
251
|
+
if (!isolate) return { fallback };
|
|
252
|
+
return { fallback, isolate: [dir === 'ltr' ? LRI : dir === 'rtl' ? RLI : FSI, PDI] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function functionOf(ref: Model.FunctionRef, scope: Scope): Mf2Function {
|
|
173
256
|
const allowed = OPTIONS[ref.name];
|
|
174
257
|
if (allowed === undefined) {
|
|
175
258
|
const known = Object.keys(OPTIONS).map((n) => `:${n}`);
|
|
176
259
|
throw new Error(`:${ref.name} is not a function prosebook knows: ${known.join(', ')}`);
|
|
177
260
|
}
|
|
178
261
|
const options: Record<string, string> = {};
|
|
262
|
+
const args: Record<string, string> = {};
|
|
263
|
+
let dir: Mf2Function['dir'];
|
|
179
264
|
for (const [key, value] of Object.entries(ref.options ?? {})) {
|
|
180
|
-
// `u:id`
|
|
181
|
-
if (key === 'u:id'
|
|
182
|
-
if (
|
|
183
|
-
|
|
265
|
+
// `u:id` names a part of the formatted output, which this runtime doesn't hand out.
|
|
266
|
+
if (key === 'u:id') continue;
|
|
267
|
+
if (key === 'u:dir') {
|
|
268
|
+
const dirs = ['ltr', 'rtl', 'auto', 'inherit'];
|
|
269
|
+
if (value.type !== 'literal' || !dirs.includes(value.value))
|
|
270
|
+
throw new Error(`:${ref.name} u:dir is one of ${dirs.join(', ')}, written in the message`);
|
|
271
|
+
// `inherit` is the direction it would have had anyway.
|
|
272
|
+
if (value.value !== 'inherit') dir = value.value as Mf2Function['dir'];
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
184
275
|
if (!allowed.includes(key))
|
|
185
276
|
throw new Error(`:${ref.name} has no option "${key}"${allowed.length > 0 ? `: ${allowed.join(', ')}` : ''}`);
|
|
277
|
+
const literal = value.type === 'literal' ? value.value : optionArg(ref.name, key, value.name, scope);
|
|
278
|
+
if (typeof literal !== 'string') {
|
|
279
|
+
args[key] = literal.arg;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
186
282
|
const values = ENUMS[key];
|
|
187
|
-
if (values !== undefined && !values.includes(
|
|
188
|
-
throw new Error(`:${ref.name} ${key}=${
|
|
189
|
-
if (DIGITS.includes(key) && !/^\d+$/.test(
|
|
190
|
-
options[key] =
|
|
191
|
-
}
|
|
192
|
-
if (ref.name === 'currency' && options.currency === undefined)
|
|
193
|
-
throw new Error(':currency needs its currency named: {$price :currency currency=EUR}');
|
|
194
|
-
if (ref.name === 'unit' && options.unit === undefined) throw new Error(':unit needs its unit named: {$distance :unit unit=kilometer}');
|
|
283
|
+
if (values !== undefined && !values.includes(literal))
|
|
284
|
+
throw new Error(`:${ref.name} ${key}=${literal} is not a value it takes: ${values.join(', ')}`);
|
|
285
|
+
if (DIGITS.includes(key) && !/^\d+$/.test(literal)) throw new Error(`:${ref.name} ${key}=${literal} is not a whole number`);
|
|
286
|
+
options[key] = literal;
|
|
287
|
+
}
|
|
195
288
|
if (ref.name === 'unit' && ref.options?.usage !== undefined)
|
|
196
289
|
throw new Error(":unit usage converts between units, which `Intl` can't do: convert the value before passing it");
|
|
197
290
|
if (options.timeZone === 'input') throw new Error(`:${ref.name} timeZone=input needs a zoned value, which a JavaScript Date isn't`);
|
|
@@ -200,14 +293,48 @@ function functionOf(ref: Model.FunctionRef): Mf2Function {
|
|
|
200
293
|
if (given.length !== 1 || !/^\d+$/.test(options[given[0]!]!))
|
|
201
294
|
throw new Error(':offset takes one of add or subtract, as a whole number: {$count :offset subtract=1}');
|
|
202
295
|
}
|
|
203
|
-
return { name: ref.name, options };
|
|
296
|
+
return { name: ref.name, options, ...(Object.keys(args).length > 0 && { args }), ...(dir && { dir }) };
|
|
204
297
|
}
|
|
205
298
|
|
|
206
|
-
|
|
207
|
-
|
|
299
|
+
/**
|
|
300
|
+
* The argument an option names, or the literal a `.local` it names holds. A variable declared with a
|
|
301
|
+
* function of its own is refused: what it would give the option is its formatted text.
|
|
302
|
+
*/
|
|
303
|
+
function optionArg(fn: string, key: string, name: string, scope: Scope): string | { arg: string } {
|
|
304
|
+
const bound = scope.get(name);
|
|
305
|
+
const plain = bound?.fn === undefined || bound.fn.name === 'string';
|
|
306
|
+
if (bound?.kind === 'literal' && plain) return bound.value;
|
|
307
|
+
if (FROM_ARGUMENT[key] === undefined) {
|
|
308
|
+
const which = Object.keys(FROM_ARGUMENT).filter((k) => OPTIONS[fn]!.includes(k));
|
|
309
|
+
const can = which.length > 0 ? `: of :${fn}'s, only ${which.join(', ')} can` : '';
|
|
310
|
+
throw new Error(`:${fn} ${key}=$${name}: ${key} is settled as the message compiles, so write its value in the message${can}`);
|
|
311
|
+
}
|
|
312
|
+
if (bound === undefined) return { arg: name };
|
|
313
|
+
if (bound.kind === 'variable' && plain && bound.offset === 0) return { arg: bound.name };
|
|
314
|
+
throw new Error(`:${fn} ${key}=$${name}: $${name} is formatted, so it can't give an option: name the argument itself`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function resolve(expr: Model.Expression, scope: Scope, optionScope = scope): Value {
|
|
318
|
+
const fn = expr.functionRef === undefined ? undefined : functionOf(expr.functionRef, optionScope);
|
|
208
319
|
if (expr.arg === undefined) throw new Error(`{:${fn!.name}} has nothing to format: give it a value, {$value :${fn!.name}}`);
|
|
209
|
-
|
|
210
|
-
|
|
320
|
+
const base: Value =
|
|
321
|
+
expr.arg.type === 'literal'
|
|
322
|
+
? { kind: 'literal', value: expr.arg.value }
|
|
323
|
+
: (scope.get(expr.arg.name) ?? { kind: 'variable', name: expr.arg.name, offset: 0 });
|
|
324
|
+
const value = apply(base, fn);
|
|
325
|
+
if (value.fn !== undefined) checkNamed(value.fn);
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A currency or a unit named, on the function or on the one its operand was declared with: after
|
|
331
|
+
* `.input {$price :currency currency=EUR}`, `{$price :currency fractionDigits=0}` is still in euros.
|
|
332
|
+
*/
|
|
333
|
+
function checkNamed(fn: Mf2Function): void {
|
|
334
|
+
const given = (key: string) => fn.options[key] !== undefined || fn.args?.[key] !== undefined;
|
|
335
|
+
if (fn.name === 'currency' && !given('currency'))
|
|
336
|
+
throw new Error(':currency needs its currency named: {$price :currency currency=EUR}, or currency=$code to take it from an argument');
|
|
337
|
+
if (fn.name === 'unit' && !given('unit')) throw new Error(':unit needs its unit named: {$distance :unit unit=kilometer}');
|
|
211
338
|
}
|
|
212
339
|
|
|
213
340
|
/** `fn` applied to a value that may already carry one: options carry over within a family, as the spec has it. */
|
|
@@ -221,8 +348,17 @@ function apply(base: Value, fn: Mf2Function | undefined): Value {
|
|
|
221
348
|
}
|
|
222
349
|
if (base.fn === undefined || family(base.fn.name) !== family(fn.name)) return { ...base, fn };
|
|
223
350
|
const inherited = { ...base.fn.options };
|
|
224
|
-
|
|
225
|
-
|
|
351
|
+
const inheritedArgs = { ...base.fn.args };
|
|
352
|
+
for (const key of DISCARDED[fn.name] ?? []) {
|
|
353
|
+
delete inherited[key];
|
|
354
|
+
delete inheritedArgs[key];
|
|
355
|
+
}
|
|
356
|
+
// An option given again replaces the one inherited, whether either is a literal or an argument.
|
|
357
|
+
for (const key of Object.keys(fn.options)) delete inheritedArgs[key];
|
|
358
|
+
for (const key of Object.keys(fn.args ?? {})) delete inherited[key];
|
|
359
|
+
const args = { ...inheritedArgs, ...fn.args };
|
|
360
|
+
const options = { ...inherited, ...fn.options };
|
|
361
|
+
return { ...base, fn: { name: fn.name, options, ...(Object.keys(args).length > 0 && { args }), ...(fn.dir && { dir: fn.dir }) } };
|
|
226
362
|
}
|
|
227
363
|
|
|
228
364
|
function literalNumber(value: string): string {
|
|
@@ -238,11 +374,27 @@ function selectorOf(name: string, scope: Scope): Selector {
|
|
|
238
374
|
throw new Error(`.match $${name} selects on a literal, which always picks the same variant: use that variant's text`);
|
|
239
375
|
const { fn } = value;
|
|
240
376
|
if (fn.name === 'string') return { name: value.name, kind: 'string', offset: 0 };
|
|
241
|
-
if (fn.name === 'number' || fn.name === 'integer') {
|
|
377
|
+
if (fn.name === 'number' || fn.name === 'integer' || fn.name === 'percent') {
|
|
242
378
|
const kind = (fn.options.select ?? 'plural') as Selector['kind'];
|
|
243
|
-
return { name: value.name, kind, offset: value.offset };
|
|
379
|
+
return { name: value.name, kind, offset: value.offset, ...selectionWith(fn) };
|
|
244
380
|
}
|
|
245
|
-
throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer or :string`);
|
|
381
|
+
throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer, :percent or :string`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* What a number is selected with beyond its kind, as the spec has it: its category as formatted
|
|
386
|
+
* (`1.0` is `other` in English), `:integer` on the value rounded, `:percent` on it times 100. None
|
|
387
|
+
* for a bare `:number`, which selects as MF1 does.
|
|
388
|
+
*/
|
|
389
|
+
function selectionWith(fn: Mf2Function): { selection?: Selection } {
|
|
390
|
+
const pick = <T>(from: Record<string, T>) => Object.fromEntries(Object.entries(from).filter(([k]) => PLURAL_OPTIONS.has(k)));
|
|
391
|
+
const options = pick(intlNumber(fn) as Record<string, unknown>) as Intl.PluralRulesOptions;
|
|
392
|
+
const args = pick(optionArgs(fn).args ?? {});
|
|
393
|
+
const value = fn.name === 'integer' ? 'round' : fn.name === 'percent' ? 'percent' : undefined;
|
|
394
|
+
const plain = Object.keys(options).length === 0 && Object.keys(args).length === 0 && value === undefined;
|
|
395
|
+
// `select=exact` is kept too, so it is written back.
|
|
396
|
+
if (plain && fn.options.select !== 'exact') return {};
|
|
397
|
+
return { selection: { fn, options, ...(Object.keys(args).length > 0 && { args }), ...(value && { value }) } };
|
|
246
398
|
}
|
|
247
399
|
|
|
248
400
|
function checkKey(selector: Selector, key: string | null, name: string): void {
|
|
@@ -295,7 +447,7 @@ function matchTree(selectors: Selector[], candidates: Candidate[], i: number, lo
|
|
|
295
447
|
arm('other', (key) => (key === null ? 1 : undefined));
|
|
296
448
|
return [{ type: TYPE.select, value: s.name, options }];
|
|
297
449
|
}
|
|
298
|
-
const rules = new Intl.PluralRules(locale, { type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' });
|
|
450
|
+
const rules = new Intl.PluralRules(locale, { ...s.selection?.options, type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' });
|
|
299
451
|
for (const k of keys.filter((key) => NUMBER_LITERAL.test(key))) {
|
|
300
452
|
const category = s.kind === 'exact' ? undefined : rules.select(Number(k));
|
|
301
453
|
// The runtime compares the value before its offset; the key is the value after it.
|
|
@@ -308,7 +460,8 @@ function matchTree(selectors: Selector[], candidates: Candidate[], i: number, lo
|
|
|
308
460
|
for (const c of categories) arm(c, (key) => (key === c ? 1 : key === null ? 2 : undefined));
|
|
309
461
|
}
|
|
310
462
|
if (!('other' in options)) arm('other', (key) => (key === null ? 2 : undefined));
|
|
311
|
-
|
|
463
|
+
const pluralType = s.kind === 'ordinal' ? 'ordinal' : 'cardinal';
|
|
464
|
+
return [{ type: TYPE.plural, value: s.name, offset: s.offset, pluralType, options, ...(s.selection && { selection: s.selection }) }];
|
|
312
465
|
}
|
|
313
466
|
|
|
314
467
|
function compare(a: number[], b: number[]): number {
|
|
@@ -316,7 +469,7 @@ function compare(a: number[], b: number[]): number {
|
|
|
316
469
|
return 0;
|
|
317
470
|
}
|
|
318
471
|
|
|
319
|
-
function pattern(parts: Model.Pattern, scope: Scope,
|
|
472
|
+
function pattern(parts: Model.Pattern, scope: Scope, r: Reading): Element[] {
|
|
320
473
|
const root: Element[] = [];
|
|
321
474
|
const open: { name: string; children: Element[] }[] = [];
|
|
322
475
|
const push = (el: Element) => {
|
|
@@ -329,7 +482,7 @@ function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[]
|
|
|
329
482
|
if (typeof part === 'string') {
|
|
330
483
|
push({ type: TYPE.literal, value: part });
|
|
331
484
|
} else if (part.type === 'expression') {
|
|
332
|
-
push(element(resolve(part, scope),
|
|
485
|
+
push(element(resolve(part, scope), r));
|
|
333
486
|
} else if (part.kind === 'close') {
|
|
334
487
|
const top = open.pop();
|
|
335
488
|
if (top?.name !== part.name) {
|
|
@@ -352,36 +505,55 @@ function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[]
|
|
|
352
505
|
return root;
|
|
353
506
|
}
|
|
354
507
|
|
|
355
|
-
function element(value: Value,
|
|
356
|
-
if (value.kind === 'literal') return { type: TYPE.literal, value: formatLiteral(value, locale) };
|
|
508
|
+
function element(value: Value, r: Reading): Element {
|
|
509
|
+
if (value.kind === 'literal') return { type: TYPE.literal, value: formatLiteral(value, r.locale) };
|
|
357
510
|
const { name, fn, offset } = value;
|
|
511
|
+
const placeholder = placeholderFor(name, fn, r);
|
|
358
512
|
// A value less its offset is what `#` prints inside a plural, so a plural with only `other` prints it.
|
|
359
|
-
if (offset !== 0)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
513
|
+
if (offset !== 0) {
|
|
514
|
+
const pound = { type: TYPE.pound, placeholder } as Element;
|
|
515
|
+
return { type: TYPE.plural, value: name, offset, pluralType: 'cardinal', options: { other: { value: [pound] } } };
|
|
516
|
+
}
|
|
517
|
+
if (fn === undefined || fn.name === 'string')
|
|
518
|
+
return { type: TYPE.argument, value: name, placeholder, ...(fn?.dir && { dir: fn.dir }) } as Element;
|
|
519
|
+
if (NUMERIC.has(fn.name)) return { type: TYPE.number, value: name, style: numberStyle(fn), placeholder } as Element;
|
|
520
|
+
return { type: TYPE.date, value: name, style: dateStyle(fn), placeholder } as Element;
|
|
364
521
|
}
|
|
365
522
|
|
|
366
523
|
/** A literal with a function is the same in every render, so it is formatted now. */
|
|
367
524
|
function formatLiteral(value: Extract<Value, { kind: 'literal' }>, locale: string): string {
|
|
368
525
|
const { fn } = value;
|
|
369
526
|
if (fn === undefined || fn.name === 'string') return value.value;
|
|
527
|
+
const [option, arg] = Object.entries(fn.args ?? {})[0] ?? [];
|
|
528
|
+
if (option !== undefined) {
|
|
529
|
+
const why = 'a literal is formatted as the message compiles, so its options are literals too';
|
|
530
|
+
throw new Error(`|${value.value}| :${fn.name} ${option}=$${arg}: ${why}`);
|
|
531
|
+
}
|
|
370
532
|
if (NUMERIC.has(fn.name)) return new Intl.NumberFormat(locale, intlNumber(fn)).format(Number(literalNumber(value.value)));
|
|
371
533
|
const date = new Date(value.value);
|
|
372
534
|
if (Number.isNaN(date.getTime())) throw new Error(`|${value.value}| is not a date: write it as an ISO 8601 date, like 2026-01-05`);
|
|
373
535
|
return new Intl.DateTimeFormat(locale, intlDate(fn)).format(date);
|
|
374
536
|
}
|
|
375
537
|
|
|
376
|
-
export type Mf2NumberSkeleton = NumberSkeleton & { mf2: Mf2Function };
|
|
377
|
-
export type Mf2DateSkeleton = DateTimeSkeleton & { mf2: Mf2Function };
|
|
538
|
+
export type Mf2NumberSkeleton = NumberSkeleton & { mf2: Mf2Function; args?: OptionArgs };
|
|
539
|
+
export type Mf2DateSkeleton = DateTimeSkeleton & { mf2: Mf2Function; args?: OptionArgs };
|
|
378
540
|
|
|
379
541
|
function numberStyle(fn: Mf2Function): Mf2NumberSkeleton {
|
|
380
|
-
|
|
542
|
+
const parsedOptions = intlNumber(fn) as NumberSkeleton['parsedOptions'];
|
|
543
|
+
return { type: SKELETON_TYPE.number, tokens: [], parsedOptions, mf2: fn, ...optionArgs(fn) };
|
|
381
544
|
}
|
|
382
545
|
|
|
383
546
|
function dateStyle(fn: Mf2Function): Mf2DateSkeleton {
|
|
384
|
-
return { type: SKELETON_TYPE.dateTime, pattern: '', parsedOptions: intlDate(fn), mf2: fn };
|
|
547
|
+
return { type: SKELETON_TYPE.dateTime, pattern: '', parsedOptions: intlDate(fn), mf2: fn, ...optionArgs(fn) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The function's options given by arguments, as the `Intl` options the runtime merges in. */
|
|
551
|
+
function optionArgs(fn: Mf2Function): { args?: OptionArgs } {
|
|
552
|
+
if (fn.args === undefined) return {};
|
|
553
|
+
const args: OptionArgs = {};
|
|
554
|
+
for (const [key, arg] of Object.entries(fn.args))
|
|
555
|
+
for (const intl of FROM_ARGUMENT[key]!.intl) args[intl] = { arg, type: FROM_ARGUMENT[key]!.type };
|
|
556
|
+
return { args };
|
|
385
557
|
}
|
|
386
558
|
|
|
387
559
|
/** The `Intl.NumberFormat` options a numeric function formats with. */
|
|
@@ -445,6 +617,8 @@ interface Found {
|
|
|
445
617
|
offset: number;
|
|
446
618
|
/** MF2 keys, in order of appearance; `*` is implied. */
|
|
447
619
|
keys: string[];
|
|
620
|
+
/** The function it was selected with, read from MF2; a bare `:number` where none is kept. */
|
|
621
|
+
fn?: Mf2Function;
|
|
448
622
|
}
|
|
449
623
|
|
|
450
624
|
/** A message tree as MF2 text. Throws, saying what has no MF2 equivalent, where it can't be written. */
|
|
@@ -458,7 +632,11 @@ export function toMf2(ast: Element[], locale: string): string {
|
|
|
458
632
|
const declarations: Model.Declaration[] = [];
|
|
459
633
|
for (const s of found) {
|
|
460
634
|
const fn: Model.FunctionRef =
|
|
461
|
-
s.kind === 'string'
|
|
635
|
+
s.kind === 'string'
|
|
636
|
+
? functionRef('string')
|
|
637
|
+
: s.fn !== undefined
|
|
638
|
+
? written(s.fn)
|
|
639
|
+
: functionRef('number', s.kind === 'ordinal' ? { select: 'ordinal' } : {});
|
|
462
640
|
declarations.push({ type: 'input', name: s.arg, value: { type: 'expression', arg: variable(s.arg), functionRef: fn } });
|
|
463
641
|
if (s.offset !== 0) {
|
|
464
642
|
declarations.push({
|
|
@@ -473,7 +651,8 @@ export function toMf2(ast: Element[], locale: string): string {
|
|
|
473
651
|
let combos: (string | null)[][] = [[]];
|
|
474
652
|
for (const s of found) combos = combos.flatMap((c) => [...s.keys, null].map((k) => [...c, k]));
|
|
475
653
|
const variables = new Map(found.map((s) => [s.arg, s.variable]));
|
|
476
|
-
|
|
654
|
+
// Under a selector declared with more than a bare `:number`, a placeholder spells out its own.
|
|
655
|
+
const numeric = new Set(found.filter((s) => s.kind !== 'string' && s.fn === undefined).map((s) => s.arg));
|
|
477
656
|
const render = (keys: (string | null)[]) =>
|
|
478
657
|
toPattern(ast, { choice: new Map(found.map((s, i) => [s.arg, keys[i]!])), variables, numeric, locale });
|
|
479
658
|
const variants = combos.map((keys) => ({ keys, pattern: render(keys), text: '' }));
|
|
@@ -506,7 +685,8 @@ function selectorsOf(ast: Element[]): Found[] {
|
|
|
506
685
|
checkName(el.value);
|
|
507
686
|
let local = `${el.value}_minus_${offset}`;
|
|
508
687
|
while (names.has(local)) local += '_';
|
|
509
|
-
|
|
688
|
+
const fn = selectionOf(el)?.fn;
|
|
689
|
+
s = { arg: el.value, variable: offset === 0 ? el.value : local, kind, offset, keys: [], ...(fn && { fn }) };
|
|
510
690
|
found.set(el.value, s);
|
|
511
691
|
} else if (s.kind !== kind || s.offset !== offset) {
|
|
512
692
|
throw new Error(
|
|
@@ -578,10 +758,16 @@ function toPattern(elements: Element[], w: Writing, pound?: string): Model.Patte
|
|
|
578
758
|
case TYPE.literal:
|
|
579
759
|
text(el.value);
|
|
580
760
|
break;
|
|
581
|
-
case TYPE.argument:
|
|
761
|
+
case TYPE.argument: {
|
|
582
762
|
checkName(el.value);
|
|
583
|
-
|
|
763
|
+
const dir = (el as { dir?: Mf2Function['dir'] }).dir;
|
|
764
|
+
out.push({
|
|
765
|
+
type: 'expression',
|
|
766
|
+
arg: variable(el.value),
|
|
767
|
+
...(dir && { functionRef: written({ name: 'string', options: {}, dir }) }),
|
|
768
|
+
});
|
|
584
769
|
break;
|
|
770
|
+
}
|
|
585
771
|
case TYPE.number: {
|
|
586
772
|
checkName(el.value);
|
|
587
773
|
const fn = numberFunction(el);
|
|
@@ -643,10 +829,17 @@ function variable(name: string): Model.VariableRef {
|
|
|
643
829
|
return { type: 'variable', name };
|
|
644
830
|
}
|
|
645
831
|
|
|
646
|
-
function functionRef(name: string, options: Record<string, string> = {}): Model.FunctionRef {
|
|
647
|
-
const entries =
|
|
832
|
+
function functionRef(name: string, options: Record<string, string> = {}, args: Record<string, string> = {}): Model.FunctionRef {
|
|
833
|
+
const entries: [string, Model.Literal | Model.VariableRef][] = [
|
|
834
|
+
...Object.entries(options).map(([k, v]): [string, Model.Literal] => [k, { type: 'literal', value: v }]),
|
|
835
|
+
...Object.entries(args).map(([k, v]): [string, Model.VariableRef] => [k, variable(v)]),
|
|
836
|
+
];
|
|
648
837
|
if (entries.length === 0) return { type: 'function', name };
|
|
649
|
-
return { type: 'function', name, options: Object.fromEntries(entries
|
|
838
|
+
return { type: 'function', name, options: Object.fromEntries(entries) };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function written(fn: Mf2Function): Model.FunctionRef {
|
|
842
|
+
return functionRef(fn.name, { ...fn.options, ...(fn.dir && { 'u:dir': fn.dir }) }, fn.args);
|
|
650
843
|
}
|
|
651
844
|
|
|
652
845
|
function numberFunction(el: NumberElement): Model.FunctionRef {
|
|
@@ -656,7 +849,7 @@ function numberFunction(el: NumberElement): Model.FunctionRef {
|
|
|
656
849
|
if (style === 'integer' || style === 'percent') return functionRef(style);
|
|
657
850
|
throw new Error(`{${el.value}, number, ${style}} has no MessageFormat 2 equivalent`);
|
|
658
851
|
}
|
|
659
|
-
if ('mf2' in style) return
|
|
852
|
+
if ('mf2' in style) return written((style as Mf2NumberSkeleton).mf2);
|
|
660
853
|
const {
|
|
661
854
|
style: kind,
|
|
662
855
|
currency,
|
|
@@ -690,8 +883,7 @@ function numberFunction(el: NumberElement): Model.FunctionRef {
|
|
|
690
883
|
*/
|
|
691
884
|
function dateFunction(el: DateElement | TimeElement): Model.FunctionRef {
|
|
692
885
|
const style = el.style;
|
|
693
|
-
if (typeof style === 'object' && style !== null && 'mf2' in style)
|
|
694
|
-
return functionRef((style as Mf2DateSkeleton).mf2.name, (style as Mf2DateSkeleton).mf2.options);
|
|
886
|
+
if (typeof style === 'object' && style !== null && 'mf2' in style) return written((style as Mf2DateSkeleton).mf2);
|
|
695
887
|
if (el.type === TYPE.time && (style === null || style === undefined || typeof style === 'string')) {
|
|
696
888
|
const time: Record<string, Record<string, string>> = {
|
|
697
889
|
short: {},
|
package/src/tools/migrate.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface MigratePlan {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): MigratePlan {
|
|
37
|
-
|
|
37
|
+
// Both syntaxes read without isolation marks, which MF1 never has, so their renders compare.
|
|
38
|
+
const catalog = loadCatalog({ ...config, isolate: false });
|
|
38
39
|
const plan: MigratePlan = { to, rewrites: [], blocked: [], changed: [] };
|
|
39
40
|
for (const m of catalog.messages) {
|
|
40
41
|
const versions = [
|
|
@@ -47,7 +48,7 @@ export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): M
|
|
|
47
48
|
let back: MessageFormatElement[];
|
|
48
49
|
try {
|
|
49
50
|
after = writeMessage(to, ast, locale);
|
|
50
|
-
back = readMessage(to, after, locale);
|
|
51
|
+
back = readMessage(to, after, locale, false);
|
|
51
52
|
} catch (e) {
|
|
52
53
|
plan.blocked.push({ ...at, reason: (e as Error).message });
|
|
53
54
|
continue;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every native writer shares: words to identifiers, numbers and strings to literals, and the
|
|
3
|
+
* names a file can't give two things.
|
|
4
|
+
*/
|
|
5
|
+
import type { Condition, Operand } from './plurals.ts';
|
|
6
|
+
|
|
7
|
+
/** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
|
|
8
|
+
export function wordsOf(name: string): string[] {
|
|
9
|
+
return name
|
|
10
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
11
|
+
.split(/[^A-Za-z0-9]+/)
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((w) => w.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const capital = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
17
|
+
|
|
18
|
+
export const snake = (words: string[]) => words.join('_');
|
|
19
|
+
export const pascal = (words: string[]) => words.map(capital).join('');
|
|
20
|
+
export const camel = (words: string[]) => words.map((w, i) => (i === 0 ? w : capital(w))).join('');
|
|
21
|
+
export const upper = (words: string[]) => words.join('_').toUpperCase();
|
|
22
|
+
|
|
23
|
+
/** An identifier can't start with a digit, so `404.title` is `_404Title`. */
|
|
24
|
+
export function leading(name: string): string {
|
|
25
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A double literal every target reads as one: `0.0`, `1.5`, `1e21`. */
|
|
29
|
+
export function float(value: number): string {
|
|
30
|
+
const s = String(value).replace('e+', 'e');
|
|
31
|
+
return /[.e]/.test(s) ? s : `${s}.0`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Characters that print nothing, or look like a space, written as escapes so the code shows them. */
|
|
35
|
+
const INVISIBLE = /[\u{a0}\u{61c}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{206f}\u{feff}]/gu;
|
|
36
|
+
|
|
37
|
+
/** Text for a doc comment, with what prints nothing written as `\u{…}`: Rust refuses direction marks there. */
|
|
38
|
+
export function docText(line: string): string {
|
|
39
|
+
return line.replace(INVISIBLE, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A string literal: JSON's escapes, `\u{…}` where the target writes them so (Rust, Swift), and `$` escaped in Kotlin. */
|
|
43
|
+
export function stringLiteral(value: string, language: 'rust' | 'swift' | 'kotlin'): string {
|
|
44
|
+
const json = JSON.stringify(value).replace(INVISIBLE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
45
|
+
if (language === 'kotlin') return json.replaceAll('$', '\\$');
|
|
46
|
+
return json.replace(/\\u([0-9a-f]{4})/g, (_, hex: string) => `\\u{${hex.replace(/^0+/, '')}}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A plural condition as an expression over operands `o`: `field(op)` reads one, `mod` takes a
|
|
51
|
+
* remainder, `within` tests a range as CLDR does (integers only). `&&`, `||` and `!` are every
|
|
52
|
+
* target's.
|
|
53
|
+
*/
|
|
54
|
+
export function conditionCode(
|
|
55
|
+
condition: Condition,
|
|
56
|
+
code: {
|
|
57
|
+
field: (op: Operand) => string;
|
|
58
|
+
mod: (value: string, by: string) => string;
|
|
59
|
+
within: (value: string, from: string, to: string) => string;
|
|
60
|
+
},
|
|
61
|
+
): string {
|
|
62
|
+
const ors = condition.map((and) =>
|
|
63
|
+
and
|
|
64
|
+
.map((r) => {
|
|
65
|
+
const value = r.mod === undefined ? code.field(r.operand) : code.mod(code.field(r.operand), float(r.mod));
|
|
66
|
+
if (r.ranges.length === 1 && r.ranges[0]![0] === r.ranges[0]![1])
|
|
67
|
+
return `${value} ${r.negated ? '!=' : '=='} ${float(r.ranges[0]![0])}`;
|
|
68
|
+
const any = r.ranges
|
|
69
|
+
.map(([from, to]) => (from === to ? `${value} == ${float(from)}` : code.within(value, float(from), float(to))))
|
|
70
|
+
.join(' || ');
|
|
71
|
+
return r.negated ? `!(${any})` : r.ranges.length > 1 ? `(${any})` : any;
|
|
72
|
+
})
|
|
73
|
+
.join(' && '),
|
|
74
|
+
);
|
|
75
|
+
return ors.length > 1 ? ors.map((o) => (o.includes(' && ') ? `(${o})` : o)).join(' || ') : ors[0]!;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fails with the keys whose names meet, or meet a name the file already has. */
|
|
79
|
+
export function checkNames(language: string, named: [key: string, name: string][], taken: string[]): void {
|
|
80
|
+
const seen = new Map<string, string>(taken.map((t) => [t, 'a name the file already has']));
|
|
81
|
+
for (const [key, name] of named) {
|
|
82
|
+
const other = seen.get(name);
|
|
83
|
+
if (other !== undefined)
|
|
84
|
+
throw new Error(`${language}: ${key} is named ${name}, as ${other} is: rename one, or change the target's keys`);
|
|
85
|
+
seen.set(name, key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native files `generate` writes beside the TypeScript, one per target in the config's `native`.
|
|
3
|
+
* A message a target can't render fails the build with the reason, as a translation that drifted does.
|
|
4
|
+
*/
|
|
5
|
+
import { CatalogError, type Catalog, type Problem } from '#src/tools/catalog.ts';
|
|
6
|
+
import type { Entry } from '#src/tools/compile.ts';
|
|
7
|
+
import type { ResolvedConfig } from '#src/tools/config.ts';
|
|
8
|
+
import { kotlinFile } from './kotlin.ts';
|
|
9
|
+
import { nativeModel } from './model.ts';
|
|
10
|
+
import { rustModule } from './rust.ts';
|
|
11
|
+
import { swiftFile } from './swift.ts';
|
|
12
|
+
|
|
13
|
+
const WRITERS = { rust: rustModule, swift: swiftFile, kotlin: kotlinFile };
|
|
14
|
+
|
|
15
|
+
export function nativeFiles(
|
|
16
|
+
config: ResolvedConfig,
|
|
17
|
+
catalog: Catalog,
|
|
18
|
+
locales: string[],
|
|
19
|
+
entries: Map<string, Entry[]>,
|
|
20
|
+
banner: string,
|
|
21
|
+
): Map<string, string> {
|
|
22
|
+
const files = new Map<string, string>();
|
|
23
|
+
const problems: Problem[] = [];
|
|
24
|
+
for (const target of config.native) {
|
|
25
|
+
const { model, problems: found } = nativeModel(target, catalog, locales, entries);
|
|
26
|
+
problems.push(...found);
|
|
27
|
+
if (found.length > 0) continue;
|
|
28
|
+
try {
|
|
29
|
+
files.set(target.out, WRITERS[target.language](model, target, banner));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
problems.push({ severity: 'error', message: (e as Error).message });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (problems.length > 0) throw new CatalogError(problems);
|
|
35
|
+
return files;
|
|
36
|
+
}
|