prosebook 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +121 -0
- package/src/tools/config.ts +211 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
package/src/tools/mf2.ts
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unicode MessageFormat 2, read into the element tree MF1 messages parse to, so the compiler, the
|
|
3
|
+
* types, the lint and the pseudo-locale treat both syntaxes alike; and written back from that tree,
|
|
4
|
+
* for `extract` and `migrate`.
|
|
5
|
+
*
|
|
6
|
+
* The tree holds what a message does, not how it was written. `.input {$n :number}` becomes a number
|
|
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`). Option
|
|
9
|
+
* values are literals, resolved here into `Intl` options, so the runtime is the same for both.
|
|
10
|
+
*/
|
|
11
|
+
import { SKELETON_TYPE, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
12
|
+
import type {
|
|
13
|
+
DateElement,
|
|
14
|
+
DateTimeSkeleton,
|
|
15
|
+
NumberElement,
|
|
16
|
+
NumberSkeleton,
|
|
17
|
+
PluralElement,
|
|
18
|
+
PluralOrSelectOption,
|
|
19
|
+
SelectElement,
|
|
20
|
+
TimeElement,
|
|
21
|
+
} from '@formatjs/icu-messageformat-parser';
|
|
22
|
+
import { MessageSyntaxError, parseMessage, stringifyMessage, validate, type Model } from 'messageformat';
|
|
23
|
+
|
|
24
|
+
import { walk, type MessageFormatElement } from './icu.ts';
|
|
25
|
+
|
|
26
|
+
type Element = MessageFormatElement;
|
|
27
|
+
|
|
28
|
+
/** A function as written: `:number minimumFractionDigits=2`. Kept on the styles it produces, to write back. */
|
|
29
|
+
export interface Mf2Function {
|
|
30
|
+
name: string;
|
|
31
|
+
options: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Value = { kind: 'literal'; value: string; fn?: Mf2Function } | { kind: 'variable'; name: string; fn?: Mf2Function; offset: number };
|
|
35
|
+
|
|
36
|
+
type Scope = Map<string, Value>;
|
|
37
|
+
|
|
38
|
+
interface Selector {
|
|
39
|
+
name: string;
|
|
40
|
+
kind: 'plural' | 'ordinal' | 'exact' | 'string';
|
|
41
|
+
offset: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NUMBER_LITERAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
45
|
+
const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
46
|
+
const NAME = /^[\p{L}_][\p{L}\p{N}_.-]*$/u;
|
|
47
|
+
|
|
48
|
+
const DIGITS = [
|
|
49
|
+
'minimumIntegerDigits',
|
|
50
|
+
'minimumFractionDigits',
|
|
51
|
+
'maximumFractionDigits',
|
|
52
|
+
'minimumSignificantDigits',
|
|
53
|
+
'maximumSignificantDigits',
|
|
54
|
+
'roundingIncrement',
|
|
55
|
+
];
|
|
56
|
+
const ROUNDING = ['trailingZeroDisplay', 'roundingPriority', 'roundingMode'];
|
|
57
|
+
const SIGNIFICANT = ['minimumSignificantDigits', 'maximumSignificantDigits'];
|
|
58
|
+
|
|
59
|
+
/** The options each default function takes, from the MF2 spec. */
|
|
60
|
+
const OPTIONS: Record<string, readonly string[]> = {
|
|
61
|
+
string: [],
|
|
62
|
+
number: ['select', 'signDisplay', 'useGrouping', ...DIGITS, ...ROUNDING],
|
|
63
|
+
integer: ['select', 'signDisplay', 'useGrouping', 'minimumIntegerDigits', 'maximumSignificantDigits'],
|
|
64
|
+
currency: [
|
|
65
|
+
'currency',
|
|
66
|
+
'currencySign',
|
|
67
|
+
'currencyDisplay',
|
|
68
|
+
'useGrouping',
|
|
69
|
+
'minimumIntegerDigits',
|
|
70
|
+
'fractionDigits',
|
|
71
|
+
...SIGNIFICANT,
|
|
72
|
+
...ROUNDING,
|
|
73
|
+
'roundingIncrement',
|
|
74
|
+
],
|
|
75
|
+
percent: ['signDisplay', 'useGrouping', 'minimumFractionDigits', 'maximumFractionDigits', ...SIGNIFICANT, ...ROUNDING],
|
|
76
|
+
unit: ['unit', 'unitDisplay', 'signDisplay', 'useGrouping', ...DIGITS, ...ROUNDING],
|
|
77
|
+
offset: ['add', 'subtract'],
|
|
78
|
+
date: ['fields', 'length', 'calendar', 'timeZone'],
|
|
79
|
+
time: ['precision', 'timeZoneStyle', 'hour12', 'calendar', 'timeZone'],
|
|
80
|
+
datetime: ['dateFields', 'dateLength', 'timePrecision', 'timeZoneStyle', 'hour12', 'calendar', 'timeZone'],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Options whose values are a fixed set, checked here because MF2's names differ from `Intl`'s. */
|
|
84
|
+
const DATE_FIELDS = ['weekday', 'day-weekday', 'month-day', 'month-day-weekday', 'year-month-day', 'year-month-day-weekday'];
|
|
85
|
+
const ENUMS: Record<string, readonly string[]> = {
|
|
86
|
+
select: ['plural', 'ordinal', 'exact'],
|
|
87
|
+
useGrouping: ['auto', 'always', 'never', 'min2'],
|
|
88
|
+
currencyDisplay: ['narrowSymbol', 'symbol', 'name', 'code'],
|
|
89
|
+
fields: DATE_FIELDS,
|
|
90
|
+
dateFields: DATE_FIELDS,
|
|
91
|
+
length: ['long', 'medium', 'short'],
|
|
92
|
+
dateLength: ['long', 'medium', 'short'],
|
|
93
|
+
precision: ['hour', 'minute', 'second'],
|
|
94
|
+
timePrecision: ['hour', 'minute', 'second'],
|
|
95
|
+
timeZoneStyle: ['long', 'short'],
|
|
96
|
+
hour12: ['true', 'false'],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Options an operand's function doesn't pass on to these, per the spec. */
|
|
100
|
+
const DISCARDED: Record<string, readonly string[]> = {
|
|
101
|
+
integer: ['minimumFractionDigits', 'maximumFractionDigits', 'minimumSignificantDigits'],
|
|
102
|
+
percent: ['minimumIntegerDigits', 'roundingIncrement', 'select'],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const NUMERIC = new Set(['number', 'integer', 'currency', 'percent', 'unit']);
|
|
106
|
+
const TEMPORAL = new Set(['date', 'time', 'datetime']);
|
|
107
|
+
|
|
108
|
+
const family = (name: string) => (NUMERIC.has(name) ? 'number' : TEMPORAL.has(name) ? 'date' : name);
|
|
109
|
+
|
|
110
|
+
const HINTS: Record<string, string> = {
|
|
111
|
+
'missing-selector-annotation': 'a .match variable needs a function that selects, declared first: .input {$count :number}',
|
|
112
|
+
'missing-fallback': 'a .match needs a variant whose keys are all *',
|
|
113
|
+
'key-mismatch': 'each variant has one key per .match variable',
|
|
114
|
+
'duplicate-variant': 'two variants have the same keys',
|
|
115
|
+
'duplicate-declaration': 'a variable is declared twice, or declared after a use',
|
|
116
|
+
'duplicate-option-name': 'an option is given twice',
|
|
117
|
+
'duplicate-attribute': 'an attribute is given twice',
|
|
118
|
+
'bad-escape': 'only \\\\, \\{, \\} and \\| are escapes',
|
|
119
|
+
'bad-input-expression': '.input takes a variable: .input {$count :number}',
|
|
120
|
+
'extra-content': 'something follows the end of the message: a message with .match ends with its last variant',
|
|
121
|
+
'empty-token': 'a name or value is missing',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Thrown from `validate`'s callback, which reports a data-model error without throwing. */
|
|
125
|
+
class ModelError extends Error {
|
|
126
|
+
readonly type: string;
|
|
127
|
+
constructor(type: string) {
|
|
128
|
+
super(type);
|
|
129
|
+
this.type = type;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function syntaxProblem(text: string, e: unknown): string {
|
|
134
|
+
const type = e instanceof MessageSyntaxError || e instanceof ModelError ? e.type : undefined;
|
|
135
|
+
const hint = type === undefined ? undefined : HINTS[type];
|
|
136
|
+
let where = '';
|
|
137
|
+
if (e instanceof MessageSyntaxError) {
|
|
138
|
+
const before = text.slice(0, e.start).split('\n');
|
|
139
|
+
const near = text.slice(e.start, e.start + 16).split('\n')[0];
|
|
140
|
+
where = ` at line ${before.length}, column ${before.at(-1)!.length + 1}${near ? ` (near "${near}")` : ''}`;
|
|
141
|
+
}
|
|
142
|
+
const reason = hint ?? (e instanceof Error ? e.message.replace(/ at \d+$/, '') : String(e));
|
|
143
|
+
return `is not valid MessageFormat 2${where}: ${reason}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 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[] {
|
|
148
|
+
let message: Model.Message;
|
|
149
|
+
try {
|
|
150
|
+
message = parseMessage(text);
|
|
151
|
+
validate(message, (type) => {
|
|
152
|
+
throw new ModelError(type);
|
|
153
|
+
});
|
|
154
|
+
} catch (e) {
|
|
155
|
+
throw new Error(syntaxProblem(text, e), { cause: e });
|
|
156
|
+
}
|
|
157
|
+
const scope: Scope = new Map();
|
|
158
|
+
for (const declaration of message.declarations)
|
|
159
|
+
// `.input` reads the argument itself; a `.local` can build on what is declared before it.
|
|
160
|
+
scope.set(declaration.name, resolve(declaration.value, declaration.type === 'input' ? new Map() : scope));
|
|
161
|
+
|
|
162
|
+
if (message.type === 'message') return pattern(message.pattern, scope, locale);
|
|
163
|
+
const selectors = message.selectors.map((s) => selectorOf(s.name, scope));
|
|
164
|
+
const candidates = message.variants.map((variant) => {
|
|
165
|
+
const keys = variant.keys.map((k) => (k.type === '*' ? null : k.value));
|
|
166
|
+
keys.forEach((key, i) => checkKey(selectors[i]!, key, message.selectors[i]!.name));
|
|
167
|
+
return { keys, prefs: [], value: pattern(variant.value, scope, locale) };
|
|
168
|
+
});
|
|
169
|
+
return matchTree(selectors, candidates, 0, locale);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function functionOf(ref: Model.FunctionRef): Mf2Function {
|
|
173
|
+
const allowed = OPTIONS[ref.name];
|
|
174
|
+
if (allowed === undefined) {
|
|
175
|
+
const known = Object.keys(OPTIONS).map((n) => `:${n}`);
|
|
176
|
+
throw new Error(`:${ref.name} is not a function prosebook knows: ${known.join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
const options: Record<string, string> = {};
|
|
179
|
+
for (const [key, value] of Object.entries(ref.options ?? {})) {
|
|
180
|
+
// `u:id` and `u:dir` say nothing about formatting here: there is no bidi isolation to steer.
|
|
181
|
+
if (key === 'u:id' || key === 'u:dir') continue;
|
|
182
|
+
if (value.type === 'variable')
|
|
183
|
+
throw new Error(`:${ref.name} ${key}=$${value.name}: option values are literals here, since formatting is fixed at build time`);
|
|
184
|
+
if (!allowed.includes(key))
|
|
185
|
+
throw new Error(`:${ref.name} has no option "${key}"${allowed.length > 0 ? `: ${allowed.join(', ')}` : ''}`);
|
|
186
|
+
const values = ENUMS[key];
|
|
187
|
+
if (values !== undefined && !values.includes(value.value))
|
|
188
|
+
throw new Error(`:${ref.name} ${key}=${value.value} is not a value it takes: ${values.join(', ')}`);
|
|
189
|
+
if (DIGITS.includes(key) && !/^\d+$/.test(value.value)) throw new Error(`:${ref.name} ${key}=${value.value} is not a whole number`);
|
|
190
|
+
options[key] = value.value;
|
|
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}');
|
|
195
|
+
if (ref.name === 'unit' && ref.options?.usage !== undefined)
|
|
196
|
+
throw new Error(":unit usage converts between units, which `Intl` can't do: convert the value before passing it");
|
|
197
|
+
if (options.timeZone === 'input') throw new Error(`:${ref.name} timeZone=input needs a zoned value, which a JavaScript Date isn't`);
|
|
198
|
+
if (ref.name === 'offset') {
|
|
199
|
+
const given = ['add', 'subtract'].filter((k) => options[k] !== undefined);
|
|
200
|
+
if (given.length !== 1 || !/^\d+$/.test(options[given[0]!]!))
|
|
201
|
+
throw new Error(':offset takes one of add or subtract, as a whole number: {$count :offset subtract=1}');
|
|
202
|
+
}
|
|
203
|
+
return { name: ref.name, options };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolve(expr: Model.Expression, scope: Scope): Value {
|
|
207
|
+
const fn = expr.functionRef === undefined ? undefined : functionOf(expr.functionRef);
|
|
208
|
+
if (expr.arg === undefined) throw new Error(`{:${fn!.name}} has nothing to format: give it a value, {$value :${fn!.name}}`);
|
|
209
|
+
if (expr.arg.type === 'literal') return apply({ kind: 'literal', value: expr.arg.value }, fn);
|
|
210
|
+
return apply(scope.get(expr.arg.name) ?? { kind: 'variable', name: expr.arg.name, offset: 0 }, fn);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `fn` applied to a value that may already carry one: options carry over within a family, as the spec has it. */
|
|
214
|
+
function apply(base: Value, fn: Mf2Function | undefined): Value {
|
|
215
|
+
if (fn === undefined) return base;
|
|
216
|
+
if (fn.name === 'offset') {
|
|
217
|
+
const by = Number(fn.options.subtract ?? 0) - Number(fn.options.add ?? 0);
|
|
218
|
+
const numeric = base.fn ?? { name: 'number', options: {} };
|
|
219
|
+
if (base.kind === 'literal') return { ...base, value: String(Number(literalNumber(base.value)) - by), fn: numeric };
|
|
220
|
+
return { ...base, offset: base.offset + by, fn: numeric };
|
|
221
|
+
}
|
|
222
|
+
if (base.fn === undefined || family(base.fn.name) !== family(fn.name)) return { ...base, fn };
|
|
223
|
+
const inherited = { ...base.fn.options };
|
|
224
|
+
for (const key of DISCARDED[fn.name] ?? []) delete inherited[key];
|
|
225
|
+
return { ...base, fn: { name: fn.name, options: { ...inherited, ...fn.options } } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function literalNumber(value: string): string {
|
|
229
|
+
if (!NUMBER_LITERAL.test(value)) throw new Error(`|${value}| is not a number: write it as one, like 42 or 1.5`);
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function selectorOf(name: string, scope: Scope): Selector {
|
|
234
|
+
const value = scope.get(name);
|
|
235
|
+
// `validate` has refused a selector declared without a function.
|
|
236
|
+
if (value === undefined || value.fn === undefined) throw new Error(`.match $${name} needs a function: .input {$${name} :number}`);
|
|
237
|
+
if (value.kind === 'literal')
|
|
238
|
+
throw new Error(`.match $${name} selects on a literal, which always picks the same variant: use that variant's text`);
|
|
239
|
+
const { fn } = value;
|
|
240
|
+
if (fn.name === 'string') return { name: value.name, kind: 'string', offset: 0 };
|
|
241
|
+
if (fn.name === 'number' || fn.name === 'integer') {
|
|
242
|
+
const kind = (fn.options.select ?? 'plural') as Selector['kind'];
|
|
243
|
+
return { name: value.name, kind, offset: value.offset };
|
|
244
|
+
}
|
|
245
|
+
throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer or :string`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function checkKey(selector: Selector, key: string | null, name: string): void {
|
|
249
|
+
if (key === null) return;
|
|
250
|
+
if (selector.kind === 'string') {
|
|
251
|
+
// The runtime's select falls back to its `other` arm, so a key named `other` would catch everything.
|
|
252
|
+
if (key === 'other') throw new Error(`.match $${name} has a key "other", which can't be told from *: name it something else`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (NUMBER_LITERAL.test(key)) {
|
|
256
|
+
if (String(Number(key)) !== key) throw new Error(`.match $${name} has the key ${key}: write it as ${Number(key)}, as values print`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (selector.kind === 'exact') throw new Error(`.match $${name} selects exactly, so its keys are numbers, not "${key}"`);
|
|
260
|
+
if (!CATEGORIES.has(key))
|
|
261
|
+
throw new Error(`.match $${name} has the key "${key}": a number key is a number or one of ${[...CATEGORIES].join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface Candidate {
|
|
265
|
+
keys: (string | null)[];
|
|
266
|
+
/** How well each key so far matched: lower is better, as the spec ranks them. */
|
|
267
|
+
prefs: number[];
|
|
268
|
+
value: Element[];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `.match` as nested plural and select elements. MF2 picks, among the variants whose every key
|
|
273
|
+
* matches, the one whose keys match best, the first selector deciding first; an exact number beats
|
|
274
|
+
* a plural category, which beats `*`. The runtime picks an arm per selector instead, so each arm
|
|
275
|
+
* here holds every variant that can still match a value reaching it, and the leaf takes the best.
|
|
276
|
+
* An exact arm `=1` holds the variants keyed `one` as well, since 1 is also in that category: which
|
|
277
|
+
* category is the locale's, which is why this runs per locale.
|
|
278
|
+
*/
|
|
279
|
+
function matchTree(selectors: Selector[], candidates: Candidate[], i: number, locale: string): Element[] {
|
|
280
|
+
// Never empty: `validate` requires a variant whose keys are all `*`.
|
|
281
|
+
if (i === selectors.length) return candidates.reduce((a, b) => (compare(b.prefs, a.prefs) < 0 ? b : a)).value;
|
|
282
|
+
const s = selectors[i]!;
|
|
283
|
+
const keys = [...new Set(candidates.map((c) => c.keys[i]).filter((k): k is string => k !== null && k !== undefined))];
|
|
284
|
+
const options: Record<string, PluralOrSelectOption> = {};
|
|
285
|
+
const arm = (name: string, preference: (key: string | null) => number | undefined) => {
|
|
286
|
+
const next = candidates.flatMap((c) => {
|
|
287
|
+
const p = preference(c.keys[i]!);
|
|
288
|
+
return p === undefined ? [] : [{ ...c, prefs: [...c.prefs, p] }];
|
|
289
|
+
});
|
|
290
|
+
options[name] = { value: matchTree(selectors, next, i + 1, locale) };
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
if (s.kind === 'string') {
|
|
294
|
+
for (const k of keys) arm(k, (key) => (key === k ? 0 : key === null ? 1 : undefined));
|
|
295
|
+
arm('other', (key) => (key === null ? 1 : undefined));
|
|
296
|
+
return [{ type: TYPE.select, value: s.name, options }];
|
|
297
|
+
}
|
|
298
|
+
const rules = new Intl.PluralRules(locale, { type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' });
|
|
299
|
+
for (const k of keys.filter((key) => NUMBER_LITERAL.test(key))) {
|
|
300
|
+
const category = s.kind === 'exact' ? undefined : rules.select(Number(k));
|
|
301
|
+
// The runtime compares the value before its offset; the key is the value after it.
|
|
302
|
+
arm(`=${Number(k) + s.offset}`, (key) => (key === k ? 0 : key === category ? 1 : key === null ? 2 : undefined));
|
|
303
|
+
}
|
|
304
|
+
if (s.kind !== 'exact') {
|
|
305
|
+
// With a key named `other`, an arm for every category, so that the catch-all arm is reached only
|
|
306
|
+
// by values whose category is `other`.
|
|
307
|
+
const categories: string[] = keys.includes('other') ? rules.resolvedOptions().pluralCategories : keys.filter((k) => CATEGORIES.has(k));
|
|
308
|
+
for (const c of categories) arm(c, (key) => (key === c ? 1 : key === null ? 2 : undefined));
|
|
309
|
+
}
|
|
310
|
+
if (!('other' in options)) arm('other', (key) => (key === null ? 2 : undefined));
|
|
311
|
+
return [{ type: TYPE.plural, value: s.name, offset: s.offset, pluralType: s.kind === 'ordinal' ? 'ordinal' : 'cardinal', options }];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function compare(a: number[], b: number[]): number {
|
|
315
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i]! - b[i]!;
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[] {
|
|
320
|
+
const root: Element[] = [];
|
|
321
|
+
const open: { name: string; children: Element[] }[] = [];
|
|
322
|
+
const push = (el: Element) => {
|
|
323
|
+
const into = open.at(-1)?.children ?? root;
|
|
324
|
+
const last = into.at(-1);
|
|
325
|
+
if (el.type === TYPE.literal && last?.type === TYPE.literal) last.value += el.value;
|
|
326
|
+
else into.push(el);
|
|
327
|
+
};
|
|
328
|
+
for (const part of parts) {
|
|
329
|
+
if (typeof part === 'string') {
|
|
330
|
+
push({ type: TYPE.literal, value: part });
|
|
331
|
+
} else if (part.type === 'expression') {
|
|
332
|
+
push(element(resolve(part, scope), locale));
|
|
333
|
+
} else if (part.kind === 'close') {
|
|
334
|
+
const top = open.pop();
|
|
335
|
+
if (top?.name !== part.name) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
top
|
|
338
|
+
? `{/${part.name}} closes {#${top.name}}: close tags in the order they opened`
|
|
339
|
+
: `{/${part.name}} closes nothing: open it with {#${part.name}}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
push({ type: TYPE.tag, value: top.name, children: top.children });
|
|
343
|
+
} else {
|
|
344
|
+
const options = Object.keys(part.options ?? {}).filter((k) => k !== 'u:id');
|
|
345
|
+
if (options.length > 0)
|
|
346
|
+
throw new Error(`{#${part.name}} has options (${options.join(', ')}): tags take none here, so give them to its function`);
|
|
347
|
+
if (part.kind === 'open') open.push({ name: part.name, children: [] });
|
|
348
|
+
else push({ type: TYPE.tag, value: part.name, children: [] });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (open.length > 0) throw new Error(`{#${open.at(-1)!.name}} is never closed: end it with {/${open.at(-1)!.name}}`);
|
|
352
|
+
return root;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function element(value: Value, locale: string): Element {
|
|
356
|
+
if (value.kind === 'literal') return { type: TYPE.literal, value: formatLiteral(value, locale) };
|
|
357
|
+
const { name, fn, offset } = value;
|
|
358
|
+
// A value less its offset is what `#` prints inside a plural, so a plural with only `other` prints it.
|
|
359
|
+
if (offset !== 0)
|
|
360
|
+
return { type: TYPE.plural, value: name, offset, pluralType: 'cardinal', options: { other: { value: [{ type: TYPE.pound }] } } };
|
|
361
|
+
if (fn === undefined || fn.name === 'string') return { type: TYPE.argument, value: name };
|
|
362
|
+
if (NUMERIC.has(fn.name)) return { type: TYPE.number, value: name, style: numberStyle(fn) };
|
|
363
|
+
return { type: TYPE.date, value: name, style: dateStyle(fn) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A literal with a function is the same in every render, so it is formatted now. */
|
|
367
|
+
function formatLiteral(value: Extract<Value, { kind: 'literal' }>, locale: string): string {
|
|
368
|
+
const { fn } = value;
|
|
369
|
+
if (fn === undefined || fn.name === 'string') return value.value;
|
|
370
|
+
if (NUMERIC.has(fn.name)) return new Intl.NumberFormat(locale, intlNumber(fn)).format(Number(literalNumber(value.value)));
|
|
371
|
+
const date = new Date(value.value);
|
|
372
|
+
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
|
+
return new Intl.DateTimeFormat(locale, intlDate(fn)).format(date);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export type Mf2NumberSkeleton = NumberSkeleton & { mf2: Mf2Function };
|
|
377
|
+
export type Mf2DateSkeleton = DateTimeSkeleton & { mf2: Mf2Function };
|
|
378
|
+
|
|
379
|
+
function numberStyle(fn: Mf2Function): Mf2NumberSkeleton {
|
|
380
|
+
return { type: SKELETON_TYPE.number, tokens: [], parsedOptions: intlNumber(fn) as NumberSkeleton['parsedOptions'], mf2: fn };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function dateStyle(fn: Mf2Function): Mf2DateSkeleton {
|
|
384
|
+
return { type: SKELETON_TYPE.dateTime, pattern: '', parsedOptions: intlDate(fn), mf2: fn };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The `Intl.NumberFormat` options a numeric function formats with. */
|
|
388
|
+
export function intlNumber(fn: Mf2Function): Intl.NumberFormatOptions {
|
|
389
|
+
const allowed = OPTIONS[fn.name] ?? [];
|
|
390
|
+
const out: Record<string, unknown> = {};
|
|
391
|
+
for (const [key, value] of Object.entries(fn.options)) {
|
|
392
|
+
if (!allowed.includes(key) || key === 'select') continue;
|
|
393
|
+
if (key === 'useGrouping') {
|
|
394
|
+
out.useGrouping = value === 'never' ? false : value;
|
|
395
|
+
} else if (key === 'fractionDigits') {
|
|
396
|
+
if (value !== 'auto') out.minimumFractionDigits = out.maximumFractionDigits = Number(value);
|
|
397
|
+
} else if (DIGITS.includes(key)) {
|
|
398
|
+
out[key] = Number(value);
|
|
399
|
+
} else {
|
|
400
|
+
out[key] = value;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (fn.name === 'integer') out.maximumFractionDigits = 0;
|
|
404
|
+
if (fn.name === 'percent' || fn.name === 'currency' || fn.name === 'unit') out.style = fn.name;
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The `Intl.DateTimeFormat` options a date or time function formats with. MF2's date and time
|
|
410
|
+
* functions are still a draft; this follows the spec's semantic options as the `messageformat`
|
|
411
|
+
* package does, so a message reads the same in both.
|
|
412
|
+
*/
|
|
413
|
+
export function intlDate(fn: Mf2Function): Intl.DateTimeFormatOptions {
|
|
414
|
+
const o = fn.options;
|
|
415
|
+
const out: Intl.DateTimeFormatOptions = {};
|
|
416
|
+
if (o.calendar !== undefined) out.calendar = o.calendar;
|
|
417
|
+
if (fn.name !== 'date' && o.hour12 !== undefined) out.hour12 = o.hour12 === 'true';
|
|
418
|
+
if (o.timeZone !== undefined) out.timeZone = o.timeZone;
|
|
419
|
+
if (fn.name !== 'time') {
|
|
420
|
+
const fields = new Set((o[fn.name === 'date' ? 'fields' : 'dateFields'] ?? 'year-month-day').split('-'));
|
|
421
|
+
const length = o[fn.name === 'date' ? 'length' : 'dateLength'];
|
|
422
|
+
if (fields.has('year')) out.year = 'numeric';
|
|
423
|
+
if (fields.has('month')) out.month = length === 'long' ? 'long' : length === 'short' ? 'numeric' : 'short';
|
|
424
|
+
if (fields.has('day')) out.day = 'numeric';
|
|
425
|
+
if (fields.has('weekday')) out.weekday = length === 'long' ? 'long' : 'short';
|
|
426
|
+
}
|
|
427
|
+
if (fn.name !== 'date') {
|
|
428
|
+
const precision = o[fn.name === 'time' ? 'precision' : 'timePrecision'];
|
|
429
|
+
out.hour = 'numeric';
|
|
430
|
+
if (precision !== 'hour') out.minute = 'numeric';
|
|
431
|
+
if (precision === 'second') out.second = 'numeric';
|
|
432
|
+
if (o.timeZoneStyle !== undefined) out.timeZoneName = o.timeZoneStyle as 'long' | 'short';
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Writing MF2 ////////////////////////////////////////////////////////////////////////////////////////
|
|
438
|
+
|
|
439
|
+
interface Found {
|
|
440
|
+
/** The argument a plural or select reads. */
|
|
441
|
+
arg: string;
|
|
442
|
+
/** The variable `.match` names: the argument, or a local less the offset. */
|
|
443
|
+
variable: string;
|
|
444
|
+
kind: 'plural' | 'ordinal' | 'string';
|
|
445
|
+
offset: number;
|
|
446
|
+
/** MF2 keys, in order of appearance; `*` is implied. */
|
|
447
|
+
keys: string[];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** A message tree as MF2 text. Throws, saying what has no MF2 equivalent, where it can't be written. */
|
|
451
|
+
export function toMf2(ast: Element[], locale: string): string {
|
|
452
|
+
const found = selectorsOf(ast);
|
|
453
|
+
if (found.length === 0) {
|
|
454
|
+
const pattern = toPattern(ast, { choice: new Map(), variables: new Map(), numeric: new Set(), locale });
|
|
455
|
+
return stringifyMessage({ type: 'message', declarations: [], pattern });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const declarations: Model.Declaration[] = [];
|
|
459
|
+
for (const s of found) {
|
|
460
|
+
const fn: Model.FunctionRef =
|
|
461
|
+
s.kind === 'string' ? functionRef('string') : functionRef('number', s.kind === 'ordinal' ? { select: 'ordinal' } : {});
|
|
462
|
+
declarations.push({ type: 'input', name: s.arg, value: { type: 'expression', arg: variable(s.arg), functionRef: fn } });
|
|
463
|
+
if (s.offset !== 0) {
|
|
464
|
+
declarations.push({
|
|
465
|
+
type: 'local',
|
|
466
|
+
name: s.variable,
|
|
467
|
+
value: { type: 'expression', arg: variable(s.arg), functionRef: functionRef('offset', { subtract: String(s.offset) }) },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Every combination of keys, `*` last, rendered by picking each plural's and select's arm.
|
|
473
|
+
let combos: (string | null)[][] = [[]];
|
|
474
|
+
for (const s of found) combos = combos.flatMap((c) => [...s.keys, null].map((k) => [...c, k]));
|
|
475
|
+
const variables = new Map(found.map((s) => [s.arg, s.variable]));
|
|
476
|
+
const numeric = new Set(found.filter((s) => s.kind !== 'string').map((s) => s.arg));
|
|
477
|
+
const render = (keys: (string | null)[]) =>
|
|
478
|
+
toPattern(ast, { choice: new Map(found.map((s, i) => [s.arg, keys[i]!])), variables, numeric, locale });
|
|
479
|
+
const variants = combos.map((keys) => ({ keys, pattern: render(keys), text: '' }));
|
|
480
|
+
for (const v of variants) v.text = JSON.stringify(v.pattern);
|
|
481
|
+
const kept = prune(variants, found, locale);
|
|
482
|
+
|
|
483
|
+
return stringifyMessage({
|
|
484
|
+
type: 'select',
|
|
485
|
+
declarations,
|
|
486
|
+
selectors: found.map((s) => variable(s.variable)),
|
|
487
|
+
variants: kept.map((v) => ({
|
|
488
|
+
keys: v.keys.map((k): Model.Literal | Model.CatchallKey => (k === null ? { type: '*' } : { type: 'literal', value: k })),
|
|
489
|
+
value: v.pattern,
|
|
490
|
+
})),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function selectorsOf(ast: Element[]): Found[] {
|
|
495
|
+
const found = new Map<string, Found>();
|
|
496
|
+
const names = new Set<string>();
|
|
497
|
+
walk(ast, (el) => {
|
|
498
|
+
if ('value' in el && typeof el.value === 'string' && el.type !== TYPE.literal) names.add(el.value);
|
|
499
|
+
});
|
|
500
|
+
walk(ast, (el) => {
|
|
501
|
+
if (el.type !== TYPE.plural && el.type !== TYPE.select) return;
|
|
502
|
+
const kind = el.type === TYPE.select ? 'string' : el.pluralType === 'ordinal' ? 'ordinal' : 'plural';
|
|
503
|
+
const offset = el.type === TYPE.plural ? el.offset : 0;
|
|
504
|
+
let s = found.get(el.value);
|
|
505
|
+
if (s === undefined) {
|
|
506
|
+
checkName(el.value);
|
|
507
|
+
let local = `${el.value}_minus_${offset}`;
|
|
508
|
+
while (names.has(local)) local += '_';
|
|
509
|
+
s = { arg: el.value, variable: offset === 0 ? el.value : local, kind, offset, keys: [] };
|
|
510
|
+
found.set(el.value, s);
|
|
511
|
+
} else if (s.kind !== kind || s.offset !== offset) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`{${el.value}} is selected on in two ways (${s.kind}, ${kind}${offset !== s.offset ? ', with different offsets' : ''}), which one .match can't say: make them the same`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
for (const arm of Object.keys(el.options)) {
|
|
517
|
+
if (arm === 'other') continue;
|
|
518
|
+
const key = el.type === TYPE.plural && arm.startsWith('=') ? String(Number(arm.slice(1)) - offset) : arm;
|
|
519
|
+
if (!s.keys.includes(key)) s.keys.push(key);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
return [...found.values()];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The variants a reader needs: one that renders like the variant it would fall back to is dropped,
|
|
527
|
+
* as long as every combination of values still lands on a variant rendering what it should.
|
|
528
|
+
*/
|
|
529
|
+
function prune<V extends { keys: (string | null)[]; text: string }>(variants: V[], found: Found[], locale: string): V[] {
|
|
530
|
+
const rules = found.map((s) =>
|
|
531
|
+
s.kind === 'string' ? undefined : new Intl.PluralRules(locale, { type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' }),
|
|
532
|
+
);
|
|
533
|
+
const preference = (i: number, key: string | null, value: string | null): number | undefined => {
|
|
534
|
+
if (key === null) return 2;
|
|
535
|
+
if (value === null) return undefined;
|
|
536
|
+
if (key === value) return NUMBER_LITERAL.test(key) || rules[i] === undefined ? 0 : 1;
|
|
537
|
+
if (rules[i] !== undefined && NUMBER_LITERAL.test(value) && rules[i].select(Number(value)) === key) return 1;
|
|
538
|
+
return undefined;
|
|
539
|
+
};
|
|
540
|
+
const pick = (kept: V[], values: (string | null)[]) => {
|
|
541
|
+
let best: { v: V; prefs: number[] } | undefined;
|
|
542
|
+
for (const v of kept) {
|
|
543
|
+
const prefs = values.map((value, i) => preference(i, v.keys[i]!, value));
|
|
544
|
+
if (prefs.some((p) => p === undefined)) continue;
|
|
545
|
+
if (best === undefined || compare(prefs as number[], best.prefs) < 0) best = { v, prefs: prefs as number[] };
|
|
546
|
+
}
|
|
547
|
+
return best?.v;
|
|
548
|
+
};
|
|
549
|
+
let kept = [...variants];
|
|
550
|
+
const specificFirst = [...variants].sort((a, b) => b.keys.filter((k) => k !== null).length - a.keys.filter((k) => k !== null).length);
|
|
551
|
+
for (const candidate of specificFirst) {
|
|
552
|
+
if (candidate.keys.every((k) => k === null)) continue;
|
|
553
|
+
const without = kept.filter((v) => v !== candidate);
|
|
554
|
+
if (variants.every((v) => pick(without, v.keys)?.text === v.text)) kept = without;
|
|
555
|
+
}
|
|
556
|
+
return kept;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
interface Writing {
|
|
560
|
+
/** The key each selected argument takes in the variant being written. */
|
|
561
|
+
choice: Map<string, string | null>;
|
|
562
|
+
/** The variable `.match` names for each selected argument. */
|
|
563
|
+
variables: Map<string, string>;
|
|
564
|
+
/** Arguments declared `.input {$n :number}`, whose plain `{$n}` already formats as a number. */
|
|
565
|
+
numeric: Set<string>;
|
|
566
|
+
locale: string;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function toPattern(elements: Element[], w: Writing, pound?: string): Model.Pattern {
|
|
570
|
+
const out: Model.Pattern = [];
|
|
571
|
+
const text = (value: string) => {
|
|
572
|
+
const last = out.at(-1);
|
|
573
|
+
if (typeof last === 'string') out[out.length - 1] = last + value;
|
|
574
|
+
else if (value !== '') out.push(value);
|
|
575
|
+
};
|
|
576
|
+
for (const el of elements) {
|
|
577
|
+
switch (el.type) {
|
|
578
|
+
case TYPE.literal:
|
|
579
|
+
text(el.value);
|
|
580
|
+
break;
|
|
581
|
+
case TYPE.argument:
|
|
582
|
+
checkName(el.value);
|
|
583
|
+
out.push({ type: 'expression', arg: variable(el.value) });
|
|
584
|
+
break;
|
|
585
|
+
case TYPE.number: {
|
|
586
|
+
checkName(el.value);
|
|
587
|
+
const fn = numberFunction(el);
|
|
588
|
+
const declared = w.numeric.has(el.value) && fn.name === 'number' && fn.options === undefined;
|
|
589
|
+
out.push(
|
|
590
|
+
declared ? { type: 'expression', arg: variable(el.value) } : { type: 'expression', arg: variable(el.value), functionRef: fn },
|
|
591
|
+
);
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
case TYPE.date:
|
|
595
|
+
case TYPE.time:
|
|
596
|
+
checkName(el.value);
|
|
597
|
+
out.push({ type: 'expression', arg: variable(el.value), functionRef: dateFunction(el) });
|
|
598
|
+
break;
|
|
599
|
+
case TYPE.pound:
|
|
600
|
+
out.push({ type: 'expression', arg: variable(pound ?? '') });
|
|
601
|
+
break;
|
|
602
|
+
case TYPE.plural:
|
|
603
|
+
case TYPE.select: {
|
|
604
|
+
const arm = armFor(el, w.choice.get(el.value) ?? null, w.locale);
|
|
605
|
+
const own = w.variables.get(el.value) ?? el.value;
|
|
606
|
+
for (const part of toPattern(arm, w, el.type === TYPE.plural ? own : pound)) {
|
|
607
|
+
if (typeof part === 'string') text(part);
|
|
608
|
+
else out.push(part);
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case TYPE.tag:
|
|
613
|
+
checkName(el.value);
|
|
614
|
+
out.push({ type: 'markup', kind: 'open', name: el.value });
|
|
615
|
+
for (const part of toPattern(el.children, w, pound)) {
|
|
616
|
+
if (typeof part === 'string') text(part);
|
|
617
|
+
else out.push(part);
|
|
618
|
+
}
|
|
619
|
+
out.push({ type: 'markup', kind: 'close', name: el.value });
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function armFor(el: PluralElement | SelectElement, key: string | null, locale: string): Element[] {
|
|
627
|
+
const other = el.options.other!.value;
|
|
628
|
+
if (key === null) return other;
|
|
629
|
+
if (el.type === TYPE.plural && NUMBER_LITERAL.test(key)) {
|
|
630
|
+
const exact = el.options[`=${Number(key) + el.offset}`];
|
|
631
|
+
if (exact !== undefined) return exact.value;
|
|
632
|
+
const category = new Intl.PluralRules(locale, { type: el.pluralType }).select(Number(key));
|
|
633
|
+
return el.options[category]?.value ?? other;
|
|
634
|
+
}
|
|
635
|
+
return el.options[key]?.value ?? other;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function checkName(name: string): void {
|
|
639
|
+
if (!NAME.test(name)) throw new Error(`"${name}" can't be a MessageFormat 2 name: start it with a letter`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function variable(name: string): Model.VariableRef {
|
|
643
|
+
return { type: 'variable', name };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function functionRef(name: string, options: Record<string, string> = {}): Model.FunctionRef {
|
|
647
|
+
const entries = Object.entries(options);
|
|
648
|
+
if (entries.length === 0) return { type: 'function', name };
|
|
649
|
+
return { type: 'function', name, options: Object.fromEntries(entries.map(([k, v]) => [k, { type: 'literal', value: v }])) };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function numberFunction(el: NumberElement): Model.FunctionRef {
|
|
653
|
+
const style = el.style;
|
|
654
|
+
if (style === null || style === undefined) return functionRef('number');
|
|
655
|
+
if (typeof style === 'string') {
|
|
656
|
+
if (style === 'integer' || style === 'percent') return functionRef(style);
|
|
657
|
+
throw new Error(`{${el.value}, number, ${style}} has no MessageFormat 2 equivalent`);
|
|
658
|
+
}
|
|
659
|
+
if ('mf2' in style) return functionRef((style as Mf2NumberSkeleton).mf2.name, (style as Mf2NumberSkeleton).mf2.options);
|
|
660
|
+
const {
|
|
661
|
+
style: kind,
|
|
662
|
+
currency,
|
|
663
|
+
currencyDisplay,
|
|
664
|
+
currencySign,
|
|
665
|
+
unit,
|
|
666
|
+
unitDisplay,
|
|
667
|
+
...rest
|
|
668
|
+
} = style.parsedOptions as Record<string, string | number | boolean | undefined>;
|
|
669
|
+
const name = kind === 'percent' || kind === 'currency' || kind === 'unit' ? kind : 'number';
|
|
670
|
+
const options: Record<string, string> = {};
|
|
671
|
+
const own = name === 'currency' ? { currency, currencyDisplay, currencySign } : name === 'unit' ? { unit, unitDisplay } : {};
|
|
672
|
+
for (const [key, value] of Object.entries(own)) if (value !== undefined) options[key] = String(value);
|
|
673
|
+
if (name === 'currency' && rest.minimumFractionDigits !== undefined && rest.minimumFractionDigits === rest.maximumFractionDigits) {
|
|
674
|
+
options.fractionDigits = String(rest.minimumFractionDigits);
|
|
675
|
+
delete rest.minimumFractionDigits;
|
|
676
|
+
delete rest.maximumFractionDigits;
|
|
677
|
+
}
|
|
678
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
679
|
+
if (value === undefined) continue;
|
|
680
|
+
if (key === 'useGrouping') options.useGrouping = value === false ? 'never' : String(value);
|
|
681
|
+
else if (OPTIONS[name]!.includes(key) && key !== 'select') options[key] = String(value);
|
|
682
|
+
else throw new Error(`{${el.value}}'s ${key}: ${String(value)} has no MessageFormat 2 equivalent`);
|
|
683
|
+
}
|
|
684
|
+
return functionRef(name, options);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The MF2 function for a date or time element. MF1's named styles have no exact counterpart, so they
|
|
689
|
+
* map to the nearest; `migrate` renders both and reports any that come out different.
|
|
690
|
+
*/
|
|
691
|
+
function dateFunction(el: DateElement | TimeElement): Model.FunctionRef {
|
|
692
|
+
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);
|
|
695
|
+
if (el.type === TYPE.time && (style === null || style === undefined || typeof style === 'string')) {
|
|
696
|
+
const time: Record<string, Record<string, string>> = {
|
|
697
|
+
short: {},
|
|
698
|
+
medium: { precision: 'second' },
|
|
699
|
+
long: { precision: 'second', timeZoneStyle: 'short' },
|
|
700
|
+
full: { precision: 'second', timeZoneStyle: 'long' },
|
|
701
|
+
};
|
|
702
|
+
return functionRef('time', time[style ?? 'short'] ?? {});
|
|
703
|
+
}
|
|
704
|
+
if (style === null || style === undefined || typeof style === 'string') {
|
|
705
|
+
const date: Record<string, Record<string, string>> = {
|
|
706
|
+
// MF1's default is `Intl`'s: numeric year, month and day.
|
|
707
|
+
default: { length: 'short' },
|
|
708
|
+
short: { length: 'short' },
|
|
709
|
+
medium: {},
|
|
710
|
+
long: { length: 'long' },
|
|
711
|
+
full: { fields: 'year-month-day-weekday', length: 'long' },
|
|
712
|
+
};
|
|
713
|
+
return functionRef('date', date[style ?? 'default'] ?? {});
|
|
714
|
+
}
|
|
715
|
+
const { calendar, timeZone, hour12, ...fields } = style.parsedOptions;
|
|
716
|
+
const overrides: Record<string, string> = {};
|
|
717
|
+
if (calendar !== undefined) overrides.calendar = calendar;
|
|
718
|
+
if (timeZone !== undefined) overrides.timeZone = timeZone;
|
|
719
|
+
if (hour12 !== undefined) overrides.hour12 = String(hour12);
|
|
720
|
+
const wanted = JSON.stringify(sorted(fields));
|
|
721
|
+
for (const candidate of dateCandidates()) {
|
|
722
|
+
const { calendar: _c, timeZone: _t, hour12: _h, ...got } = intlDate(candidate);
|
|
723
|
+
if (JSON.stringify(sorted(got)) === wanted) return functionRef(candidate.name, { ...candidate.options, ...overrides });
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`{${el.value}}'s date skeleton "${style.pattern}" has no MessageFormat 2 equivalent: use a named style`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function sorted(o: object): [string, unknown][] {
|
|
729
|
+
return Object.entries(o)
|
|
730
|
+
.filter(([, v]) => v !== undefined)
|
|
731
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function* dateCandidates(): Generator<Mf2Function> {
|
|
735
|
+
const lengths = [undefined, 'long', 'short'];
|
|
736
|
+
const precisions = [undefined, 'hour', 'second'];
|
|
737
|
+
const zones = [undefined, 'long', 'short'];
|
|
738
|
+
const defined = (o: Record<string, string | undefined>) =>
|
|
739
|
+
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Record<string, string>;
|
|
740
|
+
// Defaults first, so the shortest spelling wins.
|
|
741
|
+
const fieldsList = [undefined, ...DATE_FIELDS.filter((f) => f !== 'year-month-day')];
|
|
742
|
+
const dates = fieldsList.flatMap((fields) => lengths.map((length) => ({ fields, length })));
|
|
743
|
+
const times = precisions.flatMap((precision) => zones.map((timeZoneStyle) => ({ precision, timeZoneStyle })));
|
|
744
|
+
for (const d of dates) yield { name: 'date', options: defined(d) };
|
|
745
|
+
for (const t of times) yield { name: 'time', options: defined(t) };
|
|
746
|
+
for (const d of dates) {
|
|
747
|
+
for (const t of times) {
|
|
748
|
+
const options = { dateFields: d.fields, dateLength: d.length, timePrecision: t.precision, timeZoneStyle: t.timeZoneStyle };
|
|
749
|
+
yield { name: 'datetime', options: defined(options) };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|