@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/catalogue.js +501 -0
- package/format.js +824 -0
- package/index.js +271 -0
- package/negotiate.js +162 -0
- package/package.json +28 -0
- package/syntax.js +717 -0
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
|
+
}
|