@uniflowed/i18n 0.0.0-alpha.18

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/format.js ADDED
@@ -0,0 +1,824 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/i18n/format`: a parsed message, a locale and some arguments,
4
+ // into a string.
5
+ //
6
+ // # Why uf formats MF2 itself, and does not format numbers or dates
7
+ //
8
+ // `Intl.MessageFormat` is the obvious thing to build on and it does not exist.
9
+ // It is a TC39 proposal with no implementation in any shipping browser or
10
+ // runtime, so a package that used it would be a package that never ran, and
11
+ // one that fell back to its own formatter when the constructor was missing
12
+ // would be worse: two code paths, one of which nobody has executed, deciding
13
+ // what a user reads. The same catalogue would render differently in Safari and
14
+ // in Node, and nothing in a test suite would notice until a screenshot in
15
+ // another browser did.
16
+ //
17
+ // So the *algorithm* is here — parsing, declarations, selection, the variant
18
+ // sort — and none of the *data* is. Plural categories come from
19
+ // `Intl.PluralRules`, numerals from `Intl.NumberFormat`, dates from
20
+ // `Intl.DateTimeFormat`. That split is the whole design, and the reason is
21
+ // weight: the selection algorithm below is a few hundred lines, and CLDR's
22
+ // plural rules and number formats for the locales a browser already carries
23
+ // are megabytes. A library that shipped its own would be shipping a second,
24
+ // staler copy of something in every runtime uf targets, and the day the two
25
+ // disagreed the user would see a number formatted one way in a message and
26
+ // another way beside it.
27
+ //
28
+ // When `Intl.MessageFormat` does ship, the seam is `formatMessage` in this
29
+ // module rather than anything a caller wrote — which is the point of the
30
+ // catalogue being an API rather than a string table.
31
+ //
32
+ // `Intl.PluralRules` is optional in Flow's own library definition, and it is
33
+ // genuinely absent from a small-icu Node build. A `.match` on `:number` in
34
+ // that runtime raises rather than guessing English, because a message that
35
+ // silently pluralises Polish as if it were English is a bug that reaches
36
+ // production looking like a translation mistake.
37
+ //
38
+ // # Why the three constructors are declared here
39
+ //
40
+ // The same reason `@uniflowed/hooks`'s `timing.js` declares
41
+ // `Intl.RelativeTimeFormat`: the vendored `intl.js` has not caught up. Its
42
+ // `Intl$DateTimeFormatOptions` predates `dateStyle` and `timeStyle`, which are
43
+ // exactly what MF2 defines `:date` and `:time` in terms of — and its option
44
+ // types have invariant optional properties, so an options object built at run
45
+ // time from a message cannot be passed to them at all without listing every
46
+ // property ECMA-402 has ever had.
47
+ //
48
+ // So the three constructors this module calls are declared narrowly, with the
49
+ // options MF2 actually names. That is not a way around the checker: it is a
50
+ // *stricter* type than the libdef's, because MF2's `:number` takes
51
+ // `style=decimal` or `style=percent` and nothing else, and a message asking
52
+ // for `style=currency` should be refused rather than passed through. The
53
+ // declarations are as wide as what is called and no wider, and
54
+ // `Intl.PluralRules` stays optional so the small-icu branch above survives.
55
+ //
56
+ // # Why the Intl objects are cached, and on the catalogue
57
+ //
58
+ // Constructing an `Intl.NumberFormat` is the expensive part of formatting a
59
+ // number — enough that building one per call is the standard way an
60
+ // application's first render becomes slow — so they are memoised by locale and
61
+ // options.
62
+ //
63
+ // The cache lives on the catalogue rather than in a module-level `Map`,
64
+ // because a module-level cache is a leak with no owner: it is keyed by strings
65
+ // nobody frees, it is shared between a server's requests for different
66
+ // locales, and it makes two tests in one process affect each other. A
67
+ // catalogue is already the thing whose lifetime this data has.
68
+ //
69
+ // # Why the arguments arrive as `mixed` and are read through a `Map`
70
+ //
71
+ // Two reasons, and the second is the one that matters.
72
+ //
73
+ // Inside `defineCatalogue`'s generic body, `ArgsOf<TMessages[TKey]>` is a
74
+ // conditional type Flow has nothing to resolve it against yet, so it is
75
+ // `mixed` there. A dictionary parameter here would make the one place this
76
+ // package has to be generic the one place it cannot compile — and the
77
+ // checking it looks like it is buying already happened at the call, where Flow
78
+ // does know the message.
79
+ //
80
+ // And an argument object is data from outside. Copying it into a `Map` once
81
+ // means `{"__proto__": …}` and `{"hasOwnProperty": …}` are ordinary keys
82
+ // rather than a lookup that walks a prototype, which is the same rule
83
+ // `@uniflowed/validator`'s `plain-object.js` follows for the same reason.
84
+ //
85
+ // # What happens when formatting fails anyway
86
+ //
87
+ // Almost nothing should reach here: the argument type is checked by Flow at
88
+ // the call, and the message is checked against its parameters when it is
89
+ // declared. What is left is the case where a value arrived from outside the
90
+ // type system — a JSON response, an `any`, a server prop — and is not what the
91
+ // message needs.
92
+ //
93
+ // MF2 says to emit a fallback (`{$name}`) and signal an error. uf does both,
94
+ // and lets the catalogue decide what "signal" means: `onError` defaults to
95
+ // throwing, because a formatting error here means the types were bypassed and
96
+ // that is worth finding, and an application that would rather ship a fallback
97
+ // than a blank page passes an `onError` that records instead.
98
+
99
+ import type {
100
+ MessageAnnotation,
101
+ MessageExpression,
102
+ MessageNode,
103
+ MessageOperand,
104
+ MessagePattern,
105
+ MessageVariant,
106
+ } from "./syntax.js";
107
+
108
+ /** The four widths MF2 gives `:date` and `:time`. */
109
+ type DateWidth = "full" | "long" | "medium" | "short";
110
+
111
+ /** What MF2's `:date`, `:time` and `:datetime` may ask `Intl` for. */
112
+ type DateTimeOptions = {
113
+ dateStyle?: DateWidth,
114
+ timeStyle?: DateWidth,
115
+ weekday?: "narrow" | "short" | "long",
116
+ era?: "narrow" | "short" | "long",
117
+ year?: "numeric" | "2-digit",
118
+ month?: "numeric" | "2-digit" | "narrow" | "short" | "long",
119
+ day?: "numeric" | "2-digit",
120
+ hour?: "numeric" | "2-digit",
121
+ minute?: "numeric" | "2-digit",
122
+ second?: "numeric" | "2-digit",
123
+ fractionalSecondDigits?: number,
124
+ timeZoneName?: "short" | "long",
125
+ hour12?: boolean,
126
+ timeZone?: string,
127
+ calendar?: string,
128
+ numberingSystem?: string,
129
+ };
130
+
131
+ /**
132
+ * What MF2's `:number` and `:integer` may ask `Intl` for.
133
+ *
134
+ * Narrower than ECMA-402 on purpose: `style` is `decimal` or `percent`,
135
+ * because those are the two MF2's default registry defines. `currency` and
136
+ * `unit` are in the *draft* registry and need an operand this package does not
137
+ * accept, so a message asking for one is refused rather than handed to `Intl`
138
+ * without the currency code it would then need.
139
+ */
140
+ type NumberOptions = {
141
+ style?: "decimal" | "percent",
142
+ minimumIntegerDigits?: number,
143
+ minimumFractionDigits?: number,
144
+ maximumFractionDigits?: number,
145
+ minimumSignificantDigits?: number,
146
+ maximumSignificantDigits?: number,
147
+ useGrouping?: boolean,
148
+ signDisplay?: "auto" | "always" | "exceptZero" | "never",
149
+ notation?: "standard" | "scientific" | "engineering" | "compact",
150
+ compactDisplay?: "short" | "long",
151
+ numberingSystem?: string,
152
+ };
153
+
154
+ declare class MessageNumberFormat {
155
+ constructor(locale: string, options: NumberOptions): void;
156
+ format(value: number): string;
157
+ }
158
+
159
+ declare class MessageDateTimeFormat {
160
+ constructor(locale: string, options: DateTimeOptions): void;
161
+ format(value: Date): string;
162
+ }
163
+
164
+ declare class MessagePluralRules {
165
+ constructor(locale: string, options: { type: "cardinal" | "ordinal" }): void;
166
+ select(value: number): string;
167
+ }
168
+
169
+ /** See the module header on why these are declared rather than imported. */
170
+ declare var Intl: {
171
+ NumberFormat: Class<MessageNumberFormat>,
172
+ DateTimeFormat: Class<MessageDateTimeFormat>,
173
+ PluralRules?: Class<MessagePluralRules>,
174
+ ...
175
+ };
176
+
177
+ /**
178
+ * A value that could not be formatted, and the fallback that was used instead.
179
+ *
180
+ * Not thrown from this module directly: the catalogue's `onError` decides
181
+ * whether it is thrown at all.
182
+ */
183
+ export class MessageFormatError extends Error {
184
+ /** The MF2 fallback for the expression that failed, such as `{$count}`. */
185
+ fallback: string;
186
+
187
+ constructor(message: string, fallback: string) {
188
+ super(`@uniflowed/i18n: ${message}`);
189
+ this.name = "MessageFormatError";
190
+ this.fallback = fallback;
191
+ }
192
+ }
193
+
194
+ /** What a catalogue hands `formatMessage` so that two calls can share work. */
195
+ export type FormatContext = {
196
+ readonly locale: string,
197
+ /**
198
+ * U+2068/U+2069 around every placeholder, per MF2's default bidi strategy.
199
+ *
200
+ * Off here, and the specification allows that — `bidiIsolation` is a
201
+ * formatting option with a `none` value for exactly this. The reason to
202
+ * default the other way from the specification is that uf's output goes into
203
+ * React children, where the DOM already isolates by direction from `dir` and
204
+ * the Unicode algorithm, and two invisible code points per placeholder would
205
+ * turn every `expect(t(…)).toBe("…")` in every application into a puzzle.
206
+ *
207
+ * A page that concatenates message output into a single text node with
208
+ * right-to-left content in it should turn this on.
209
+ */
210
+ readonly bidiIsolation: boolean,
211
+ readonly onError: (error: MessageFormatError) => void,
212
+ readonly numberFormats: Map<string, MessageNumberFormat>,
213
+ readonly dateFormats: Map<string, MessageDateTimeFormat>,
214
+ readonly pluralRules: Map<string, MessagePluralRules>,
215
+ };
216
+
217
+ /** The options one annotation was given, already resolved to values. */
218
+ type OptionBag = Map<string, mixed>;
219
+
220
+ /** A value with the annotation that was applied to it, if any. */
221
+ type Resolved = {
222
+ readonly value: mixed,
223
+ readonly functionName: string | null,
224
+ readonly options: OptionBag,
225
+ /** The MF2 fallback text for the expression this came from. */
226
+ readonly fallback: string,
227
+ };
228
+
229
+ type Scope = Map<string, Resolved>;
230
+
231
+ /** The six functions of the MF2 default registry. Nothing else is accepted. */
232
+ const KNOWN_FUNCTIONS: $ReadOnlyArray<string> = [
233
+ "string",
234
+ "number",
235
+ "integer",
236
+ "date",
237
+ "time",
238
+ "datetime",
239
+ ];
240
+
241
+ /** The three of those that MF2 allows in a `.match`. */
242
+ const SELECTOR_FUNCTIONS: $ReadOnlyArray<string> = ["string", "number", "integer"];
243
+
244
+ export function isKnownFunction(name: string): boolean {
245
+ return KNOWN_FUNCTIONS.includes(name);
246
+ }
247
+
248
+ /** MF2's fallback representation of an expression that could not be resolved. */
249
+ function fallbackFor(expression: MessageExpression): string {
250
+ const operand = expression.operand;
251
+ if (operand != null && operand.kind === "variable") return `{$${operand.name}}`;
252
+ if (operand != null) return `{|${operand.value}|}`;
253
+ const annotation = expression.annotation;
254
+ return annotation == null ? "{}" : `{:${annotation.name}}`;
255
+ }
256
+
257
+ /**
258
+ * A cache key for one formatter.
259
+ *
260
+ * Built from the option bag rather than from the object handed to `Intl`,
261
+ * because the bag is what differs: `:date` and `:time` produce different
262
+ * objects from the same bag, which is what `prefix` is for.
263
+ */
264
+ function formatterKey(prefix: string, locale: string, options: OptionBag): string {
265
+ const names = Array.from(options.keys()).sort();
266
+ let key = `${prefix}${locale}`;
267
+ for (const name of names) {
268
+ key += `${name}${String(options.get(name))}`;
269
+ }
270
+ return key;
271
+ }
272
+
273
+ /**
274
+ * A whole number an option was given, or `null` if it was not a number at all.
275
+ *
276
+ * Option values arrive as literals — always strings, because that is what MF2
277
+ * source is — or as variables, which may already be numbers. Both spellings
278
+ * have to reach `Intl` as a number, and neither may reach it as `NaN`.
279
+ */
280
+ function asInteger(value: mixed): number | null {
281
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
282
+ if (typeof value !== "string") return null;
283
+ const parsed = Number(value);
284
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
285
+ }
286
+
287
+ /** A number, from the three spellings MF2 says `:number` accepts. */
288
+ function asNumber(value: mixed): number | null {
289
+ if (typeof value === "number") return Number.isNaN(value) ? null : value;
290
+ if (typeof value === "bigint") return Number(value);
291
+ if (typeof value === "string" && value.trim() !== "") {
292
+ const parsed = Number(value);
293
+ return Number.isNaN(parsed) ? null : parsed;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /** An instant, from a `Date`, epoch milliseconds, or an ISO 8601 string. */
299
+ function asDate(value: mixed): Date | null {
300
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
301
+ if (typeof value === "number") return Number.isNaN(value) ? null : new Date(value);
302
+ if (typeof value === "string") {
303
+ const parsed = new Date(value);
304
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
305
+ }
306
+ return null;
307
+ }
308
+
309
+ function asWidth(value: mixed): DateWidth | null {
310
+ if (value === "full" || value === "long" || value === "medium" || value === "short") return value;
311
+ return null;
312
+ }
313
+
314
+ /** The value of an operand, looked up in the scope if it is a variable. */
315
+ function resolveOperand(
316
+ operand: MessageOperand,
317
+ scope: Scope,
318
+ args: Map<string, mixed>,
319
+ ): { readonly value: mixed, readonly known: boolean } {
320
+ if (operand.kind === "literal") return { value: operand.value, known: true };
321
+ const declared = scope.get(operand.name);
322
+ if (declared != null) return { value: declared.value, known: true };
323
+ if (args.has(operand.name)) return { value: args.get(operand.name), known: true };
324
+ return { value: undefined, known: false };
325
+ }
326
+
327
+ function resolveOptions(
328
+ annotation: MessageAnnotation,
329
+ scope: Scope,
330
+ args: Map<string, mixed>,
331
+ ): OptionBag {
332
+ const resolved: OptionBag = new Map();
333
+ for (const option of annotation.options) {
334
+ resolved.set(option.name, resolveOperand(option.value, scope, args).value);
335
+ }
336
+ return resolved;
337
+ }
338
+
339
+ /**
340
+ * One `{…}`, resolved but not yet turned into text.
341
+ *
342
+ * Split from formatting because a `.match` selector needs the resolved value
343
+ * and its annotation without ever formatting it, and because an `.input`
344
+ * declaration stores exactly this.
345
+ */
346
+ function resolveExpression(
347
+ expression: MessageExpression,
348
+ scope: Scope,
349
+ args: Map<string, mixed>,
350
+ context: FormatContext,
351
+ ): Resolved {
352
+ const fallback = fallbackFor(expression);
353
+ const annotation = expression.annotation;
354
+ const operand = expression.operand;
355
+
356
+ let value: mixed = undefined;
357
+ let inherited: string | null = null;
358
+ let inheritedOptions: OptionBag = new Map();
359
+ if (operand != null) {
360
+ const found = resolveOperand(operand, scope, args);
361
+ if (!found.known) {
362
+ const named = operand.kind === "variable" ? `$${operand.name}` : "a value";
363
+ context.onError(
364
+ new MessageFormatError(`the message reads ${named}, which was not given to it`, fallback),
365
+ );
366
+ return { value: undefined, functionName: null, options: new Map(), fallback };
367
+ }
368
+ value = found.value;
369
+ if (operand.kind === "variable") {
370
+ // `.input {$n :number}` then `{$n}` formats as a number: an annotation
371
+ // put on a name by a declaration travels with the name. This is what
372
+ // makes `.input` worth having over repeating the annotation.
373
+ const declared = scope.get(operand.name);
374
+ if (declared != null) {
375
+ inherited = declared.functionName;
376
+ inheritedOptions = declared.options;
377
+ }
378
+ }
379
+ }
380
+
381
+ if (annotation == null) {
382
+ return { value, functionName: inherited, options: inheritedOptions, fallback };
383
+ }
384
+ const options: OptionBag = new Map(inheritedOptions);
385
+ for (const [name, given] of resolveOptions(annotation, scope, args)) {
386
+ options.set(name, given);
387
+ }
388
+ return { value, functionName: annotation.name, options, fallback };
389
+ }
390
+
391
+ /**
392
+ * MF2's `:number` options translated into `Intl.NumberFormat`'s.
393
+ *
394
+ * Written out one property at a time rather than copied across, and that is
395
+ * what makes the narrow `NumberOptions` above real: an option MF2 does not
396
+ * define, or a value outside the set it allows, is dropped here rather than
397
+ * reaching `Intl` — where it would either throw a `RangeError` at a user or,
398
+ * worse, be silently ignored.
399
+ *
400
+ * `:integer` is `:number` with the fraction digits pinned, which is how the
401
+ * specification defines it rather than as a function of its own.
402
+ */
403
+ function numberOptions(options: OptionBag, integer: boolean): NumberOptions {
404
+ const out: NumberOptions = {};
405
+
406
+ const style = options.get("style");
407
+ if (style === "decimal" || style === "percent") out.style = style;
408
+
409
+ const signDisplay = options.get("signDisplay");
410
+ if (
411
+ signDisplay === "auto" ||
412
+ signDisplay === "always" ||
413
+ signDisplay === "exceptZero" ||
414
+ signDisplay === "never"
415
+ ) {
416
+ out.signDisplay = signDisplay;
417
+ }
418
+
419
+ const notation = options.get("notation");
420
+ if (
421
+ notation === "standard" ||
422
+ notation === "scientific" ||
423
+ notation === "engineering" ||
424
+ notation === "compact"
425
+ ) {
426
+ out.notation = notation;
427
+ }
428
+
429
+ const compactDisplay = options.get("compactDisplay");
430
+ if (compactDisplay === "short" || compactDisplay === "long") out.compactDisplay = compactDisplay;
431
+
432
+ const numberingSystem = options.get("numberingSystem");
433
+ if (typeof numberingSystem === "string") out.numberingSystem = numberingSystem;
434
+
435
+ const grouping = options.get("useGrouping");
436
+ if (grouping != null) out.useGrouping = grouping !== "false" && grouping !== false;
437
+
438
+ const minimumIntegerDigits = asInteger(options.get("minimumIntegerDigits"));
439
+ if (minimumIntegerDigits != null) out.minimumIntegerDigits = minimumIntegerDigits;
440
+
441
+ const minimumSignificantDigits = asInteger(options.get("minimumSignificantDigits"));
442
+ if (minimumSignificantDigits != null) out.minimumSignificantDigits = minimumSignificantDigits;
443
+
444
+ const maximumSignificantDigits = asInteger(options.get("maximumSignificantDigits"));
445
+ if (maximumSignificantDigits != null) out.maximumSignificantDigits = maximumSignificantDigits;
446
+
447
+ if (integer) {
448
+ out.minimumFractionDigits = 0;
449
+ out.maximumFractionDigits = 0;
450
+ return out;
451
+ }
452
+
453
+ const minimumFractionDigits = asInteger(options.get("minimumFractionDigits"));
454
+ if (minimumFractionDigits != null) out.minimumFractionDigits = minimumFractionDigits;
455
+
456
+ const maximumFractionDigits = asInteger(options.get("maximumFractionDigits"));
457
+ if (maximumFractionDigits != null) out.maximumFractionDigits = maximumFractionDigits;
458
+
459
+ return out;
460
+ }
461
+
462
+ /** MF2's `:date`, `:time` and `:datetime` options, the same way. */
463
+ function dateTimeOptions(functionName: string, options: OptionBag): DateTimeOptions {
464
+ const out: DateTimeOptions = {};
465
+
466
+ const timeZone = options.get("timeZone");
467
+ if (typeof timeZone === "string") out.timeZone = timeZone;
468
+ const calendar = options.get("calendar");
469
+ if (typeof calendar === "string") out.calendar = calendar;
470
+ const numberingSystem = options.get("numberingSystem");
471
+ if (typeof numberingSystem === "string") out.numberingSystem = numberingSystem;
472
+ const hour12 = options.get("hour12");
473
+ if (hour12 != null) out.hour12 = hour12 !== "false" && hour12 !== false;
474
+
475
+ if (functionName === "date") {
476
+ out.dateStyle = asWidth(options.get("style")) ?? "medium";
477
+ return out;
478
+ }
479
+ if (functionName === "time") {
480
+ out.timeStyle = asWidth(options.get("style")) ?? "short";
481
+ return out;
482
+ }
483
+
484
+ // `:datetime` takes the field options directly. It only defaults when it was
485
+ // given none of them, because `{$at :datetime year=numeric}` gaining a month
486
+ // and a day the author did not ask for is a worse surprise than a bare
487
+ // `{$at :datetime}` having to pick something.
488
+ const dateStyle = asWidth(options.get("dateStyle"));
489
+ if (dateStyle != null) out.dateStyle = dateStyle;
490
+ const timeStyle = asWidth(options.get("timeStyle"));
491
+ if (timeStyle != null) out.timeStyle = timeStyle;
492
+
493
+ const weekday = options.get("weekday");
494
+ if (weekday === "narrow" || weekday === "short" || weekday === "long") out.weekday = weekday;
495
+ const era = options.get("era");
496
+ if (era === "narrow" || era === "short" || era === "long") out.era = era;
497
+ const year = options.get("year");
498
+ if (year === "numeric" || year === "2-digit") out.year = year;
499
+ const month = options.get("month");
500
+ if (
501
+ month === "numeric" ||
502
+ month === "2-digit" ||
503
+ month === "narrow" ||
504
+ month === "short" ||
505
+ month === "long"
506
+ ) {
507
+ out.month = month;
508
+ }
509
+ const day = options.get("day");
510
+ if (day === "numeric" || day === "2-digit") out.day = day;
511
+ const hour = options.get("hour");
512
+ if (hour === "numeric" || hour === "2-digit") out.hour = hour;
513
+ const minute = options.get("minute");
514
+ if (minute === "numeric" || minute === "2-digit") out.minute = minute;
515
+ const second = options.get("second");
516
+ if (second === "numeric" || second === "2-digit") out.second = second;
517
+ const fractionalSecondDigits = asInteger(options.get("fractionalSecondDigits"));
518
+ if (fractionalSecondDigits != null) out.fractionalSecondDigits = fractionalSecondDigits;
519
+ const timeZoneName = options.get("timeZoneName");
520
+ if (timeZoneName === "short" || timeZoneName === "long") out.timeZoneName = timeZoneName;
521
+
522
+ if (
523
+ out.dateStyle == null &&
524
+ out.timeStyle == null &&
525
+ out.weekday == null &&
526
+ out.era == null &&
527
+ out.year == null &&
528
+ out.month == null &&
529
+ out.day == null &&
530
+ out.hour == null &&
531
+ out.minute == null &&
532
+ out.second == null
533
+ ) {
534
+ out.dateStyle = "medium";
535
+ out.timeStyle = "short";
536
+ }
537
+ return out;
538
+ }
539
+
540
+ function numberFormat(context: FormatContext, options: NumberOptions, key: string) {
541
+ const cached = context.numberFormats.get(key);
542
+ if (cached != null) return cached;
543
+ const made = new Intl.NumberFormat(context.locale, options);
544
+ context.numberFormats.set(key, made);
545
+ return made;
546
+ }
547
+
548
+ function dateFormat(context: FormatContext, options: DateTimeOptions, key: string) {
549
+ const cached = context.dateFormats.get(key);
550
+ if (cached != null) return cached;
551
+ const made = new Intl.DateTimeFormat(context.locale, options);
552
+ context.dateFormats.set(key, made);
553
+ return made;
554
+ }
555
+
556
+ function pluralRules(context: FormatContext, type: "cardinal" | "ordinal") {
557
+ const cached = context.pluralRules.get(type);
558
+ if (cached != null) return cached;
559
+ const constructor = Intl.PluralRules;
560
+ if (constructor == null) {
561
+ // See the module header: guessing English here would be a translation bug
562
+ // that only shows up in the languages uf is least able to check.
563
+ throw new MessageFormatError(
564
+ "this runtime has no Intl.PluralRules, so a .match on a number cannot choose a variant; " +
565
+ "a small-icu Node build is the usual cause",
566
+ "",
567
+ );
568
+ }
569
+ const made = new constructor(context.locale, { type });
570
+ context.pluralRules.set(type, made);
571
+ return made;
572
+ }
573
+
574
+ /**
575
+ * The function that applies when none was written.
576
+ *
577
+ * MF2 leaves an unannotated placeholder to the implementation, and the choice
578
+ * here is the one that makes `Hello, {$name}!` and `You have {$count}` both do
579
+ * what their author meant: format by the runtime type of the value. The
580
+ * alternative — everything is a string — renders 1234567 identically in every
581
+ * locale, which is the bug this package exists to prevent.
582
+ */
583
+ function impliedFunction(value: mixed): string {
584
+ if (typeof value === "number" || typeof value === "bigint") return "number";
585
+ if (value instanceof Date) return "datetime";
586
+ return "string";
587
+ }
588
+
589
+ /** A value named the way an error message should name it. */
590
+ function describe(value: mixed): string {
591
+ if (value === null) return "null";
592
+ if (value === undefined) return "undefined";
593
+ if (typeof value === "string") return `the string ${JSON.stringify(value)}`;
594
+ return `a ${typeof value}`;
595
+ }
596
+
597
+ function formatValue(resolved: Resolved, context: FormatContext): string {
598
+ const name = resolved.functionName ?? impliedFunction(resolved.value);
599
+ const value = resolved.value;
600
+
601
+ if (name === "string") {
602
+ if (value == null) {
603
+ context.onError(
604
+ new MessageFormatError("a placeholder was given null or undefined", resolved.fallback),
605
+ );
606
+ return resolved.fallback;
607
+ }
608
+ return String(value);
609
+ }
610
+
611
+ if (name === "number" || name === "integer") {
612
+ const numeric = asNumber(value);
613
+ if (numeric == null) {
614
+ context.onError(
615
+ new MessageFormatError(
616
+ `:${name} was given ${describe(value)}, which is not a number`,
617
+ resolved.fallback,
618
+ ),
619
+ );
620
+ return resolved.fallback;
621
+ }
622
+ const integer = name === "integer";
623
+ const key = formatterKey(integer ? "integer" : "number", context.locale, resolved.options);
624
+ return numberFormat(context, numberOptions(resolved.options, integer), key).format(numeric);
625
+ }
626
+
627
+ const instant = asDate(value);
628
+ if (instant == null) {
629
+ context.onError(
630
+ new MessageFormatError(
631
+ `:${name} was given ${describe(value)}, which is not a date`,
632
+ resolved.fallback,
633
+ ),
634
+ );
635
+ return resolved.fallback;
636
+ }
637
+ const key = formatterKey(name, context.locale, resolved.options);
638
+ return dateFormat(context, dateTimeOptions(name, resolved.options), key).format(instant);
639
+ }
640
+
641
+ /**
642
+ * The keys of `keys` this value selects, best first.
643
+ *
644
+ * MF2 gives each selector function its own `selectKey`, and the two that exist
645
+ * here differ in one thing: `:number` tries the literal value before it tries
646
+ * the plural category, so `0 {{No messages}}` beats `other` for zero in a
647
+ * language where zero is `other`. That ordering is what makes a special case
648
+ * for one number expressible without a second message.
649
+ */
650
+ function selectKeys(
651
+ resolved: Resolved,
652
+ keys: $ReadOnlyArray<string>,
653
+ context: FormatContext,
654
+ ): $ReadOnlyArray<string> {
655
+ const name = resolved.functionName ?? impliedFunction(resolved.value);
656
+ if (!SELECTOR_FUNCTIONS.includes(name)) {
657
+ throw new MessageFormatError(
658
+ `:${name} cannot be a .match selector; only :string, :number and :integer can`,
659
+ resolved.fallback,
660
+ );
661
+ }
662
+
663
+ if (name === "string") {
664
+ const exact = String(resolved.value);
665
+ return keys.filter((key) => key === exact);
666
+ }
667
+
668
+ const numeric = asNumber(resolved.value);
669
+ if (numeric == null) {
670
+ context.onError(
671
+ new MessageFormatError(
672
+ `a .match on :${name} was given ${describe(resolved.value)}`,
673
+ resolved.fallback,
674
+ ),
675
+ );
676
+ return [];
677
+ }
678
+
679
+ const matched: Array<string> = [];
680
+ for (const key of keys) {
681
+ const asValue = Number(key);
682
+ if (key.trim() !== "" && !Number.isNaN(asValue) && asValue === numeric) matched.push(key);
683
+ }
684
+
685
+ const select = resolved.options.get("select");
686
+ if (select === "exact") return matched;
687
+
688
+ const category = pluralRules(context, select === "ordinal" ? "ordinal" : "cardinal").select(
689
+ numeric,
690
+ );
691
+ for (const key of keys) {
692
+ if (key === category && !matched.includes(key)) matched.push(key);
693
+ }
694
+ return matched;
695
+ }
696
+
697
+ /**
698
+ * MF2's variant selection, in the order the specification gives it.
699
+ *
700
+ * Written as three passes — resolve the preferences, filter, sort — rather
701
+ * than the obvious single scan for the best row, because with two selectors
702
+ * "best" is not a property of a row on its own. `.match $count $gender` with
703
+ * `one female`, `one *`, `* female` and `* *` has to prefer `one female`, then
704
+ * `one *`, then `* female`: the *later* selector breaks ties within the
705
+ * earlier one, which is why the sort runs from the last selector to the first.
706
+ * A single scan gets that wrong in a way that only shows up in the messages
707
+ * with two selectors, which are the ones nobody writes a test for.
708
+ */
709
+ function selectVariant(
710
+ selectors: $ReadOnlyArray<Resolved>,
711
+ variants: $ReadOnlyArray<MessageVariant>,
712
+ context: FormatContext,
713
+ ): MessageVariant {
714
+ const preferences: Array<$ReadOnlyArray<string>> = [];
715
+ for (let index = 0; index < selectors.length; index += 1) {
716
+ const keys: Array<string> = [];
717
+ for (const variant of variants) {
718
+ const key = variant.keys[index];
719
+ if (key != null && key.kind === "literal" && !keys.includes(key.value)) keys.push(key.value);
720
+ }
721
+ preferences.push(selectKeys(selectors[index], keys, context));
722
+ }
723
+
724
+ let remaining: $ReadOnlyArray<MessageVariant> = variants;
725
+ for (let index = selectors.length - 1; index >= 0; index -= 1) {
726
+ const matched = preferences[index];
727
+ remaining = remaining.filter((variant) => {
728
+ const key = variant.keys[index];
729
+ return key == null || key.kind === "catch-all" || matched.includes(key.value);
730
+ });
731
+ }
732
+
733
+ let sorted: Array<MessageVariant> = remaining.slice();
734
+ for (let index = selectors.length - 1; index >= 0; index -= 1) {
735
+ const matched = preferences[index];
736
+ // A catch-all sorts after every literal that matched, which is what makes
737
+ // `*` the last resort rather than a tie with the worst real match.
738
+ const rank = (variant: MessageVariant): number => {
739
+ const key = variant.keys[index];
740
+ if (key == null || key.kind === "catch-all") return matched.length;
741
+ const found = matched.indexOf(key.value);
742
+ return found < 0 ? matched.length : found;
743
+ };
744
+ // `sort` is stable in every runtime uf targets, which is load-bearing:
745
+ // the pass for selector *i* must not disturb the order the pass for
746
+ // selector *i + 1* established.
747
+ sorted = sorted.sort((left, right) => rank(left) - rank(right));
748
+ }
749
+
750
+ const winner = sorted[0];
751
+ if (winner == null) {
752
+ // `syntax.js` refuses a matcher with no catch-all, so this is unreachable
753
+ // from a parsed message and is kept as an assertion rather than removed:
754
+ // the day a caller builds a `MessageNode` by hand, it should say so.
755
+ throw new MessageFormatError("no variant matched and the message has no * variant", "");
756
+ }
757
+ return winner;
758
+ }
759
+
760
+ const ISOLATE_FIRST = "⁨";
761
+ const ISOLATE_POP = "⁩";
762
+
763
+ function formatPattern(
764
+ pattern: MessagePattern,
765
+ scope: Scope,
766
+ args: Map<string, mixed>,
767
+ context: FormatContext,
768
+ ): string {
769
+ let out = "";
770
+ for (const part of pattern) {
771
+ if (part.kind === "text") {
772
+ out += part.value;
773
+ continue;
774
+ }
775
+ const text = formatValue(resolveExpression(part, scope, args, context), context);
776
+ out += context.bidiIsolation ? ISOLATE_FIRST + text + ISOLATE_POP : text;
777
+ }
778
+ return out;
779
+ }
780
+
781
+ /** The argument object as a `Map`. See the module header on why. */
782
+ function argumentsOf(args: mixed): Map<string, mixed> {
783
+ if (args == null || typeof args !== "object") return new Map();
784
+ return new Map(Object.entries(args));
785
+ }
786
+
787
+ /**
788
+ * Format one message.
789
+ *
790
+ * The declarations run first and in order, because a `.local` may read what an
791
+ * earlier one produced; the body then sees a scope in which every declared
792
+ * name is already annotated.
793
+ */
794
+ export function formatMessage(node: MessageNode, args: mixed, context: FormatContext): string {
795
+ const bag = argumentsOf(args);
796
+ const scope: Scope = new Map();
797
+ for (const declaration of node.declarations) {
798
+ scope.set(declaration.name, resolveExpression(declaration.expression, scope, bag, context));
799
+ }
800
+
801
+ const body = node.body;
802
+ if (body.kind === "pattern") return formatPattern(body.pattern, scope, bag, context);
803
+
804
+ const selectors = body.selectors.map((selector) => {
805
+ const declared = scope.get(selector.name);
806
+ if (declared != null) return declared;
807
+ // A selector that no declaration annotated is still resolvable — `.match
808
+ // $count` with a plain number argument is the common shape — so it is
809
+ // resolved here as a bare variable expression rather than refused.
810
+ return resolveExpression(
811
+ { kind: "expression", operand: selector, annotation: null, at: 0 },
812
+ scope,
813
+ bag,
814
+ context,
815
+ );
816
+ });
817
+
818
+ return formatPattern(
819
+ selectVariant(selectors, body.variants, context).pattern,
820
+ scope,
821
+ bag,
822
+ context,
823
+ );
824
+ }