myphp-runtime 0.0.0-stage → 1.28.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/src/i18n.ts ADDED
@@ -0,0 +1,738 @@
1
+ /**
2
+ * Formats the messages of a MyPHP app's catalogs in its pages: messages of
3
+ * ICU MessageFormat, as MyPHP\Message formats them in PHP, with the plural
4
+ * rules, numbers and dates of the browser's `Intl` (mygo-runtime/i18n).
5
+ * The module `myphp generate` writes next to the TypeScript client
6
+ * (`locales.ts`) is built on it:
7
+ *
8
+ * ```ts
9
+ * import { messages } from "./locales";
10
+ *
11
+ * status.textContent = messages.filesSelected(3); // "3 files selected"
12
+ * ```
13
+ *
14
+ * Use {@link formatMessage} directly to format a message of your own.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ /** A part of a parsed message. */
20
+ export type MessagePart =
21
+ | string
22
+ | { type: "pound" }
23
+ | { type: "arg"; name: string }
24
+ | { type: "number" | "date" | "time"; name: string; style: string }
25
+ | {
26
+ type: "plural" | "selectordinal" | "select";
27
+ name: string;
28
+ offset: number;
29
+ cases: { key: string; parts: MessagePart[] }[];
30
+ };
31
+
32
+ /** The error of a message that does not parse. */
33
+ export class MessageSyntaxError extends Error {
34
+ constructor(
35
+ /** The message. */
36
+ readonly source: string,
37
+ /** Where in it the error is, in UTF-16 code units. */
38
+ readonly offset: number,
39
+ reason: string,
40
+ ) {
41
+ super(`${reason} at offset ${offset} of ${JSON.stringify(source)}`);
42
+ this.name = "MessageSyntaxError";
43
+ }
44
+ }
45
+
46
+ const pluralKeywords = ["zero", "one", "two", "few", "many", "other"];
47
+
48
+ function isPatternSpace(c: string): boolean {
49
+ return /^[\t\n\v\f\r \u0085\u200e\u200f\u2028\u2029]$/.test(c);
50
+ }
51
+
52
+ function isPatternSyntax(c: string): boolean {
53
+ const r = c.codePointAt(0)!;
54
+ if (r < 0x80) {
55
+ return (r >= 0x21 && r <= 0x2f) || (r >= 0x3a && r <= 0x40) || (r >= 0x5b && r <= 0x5e) || r === 0x60 || (r >= 0x7b && r <= 0x7e);
56
+ }
57
+ return (
58
+ (r >= 0xa1 && r <= 0xa7) || r === 0xa9 || r === 0xab || r === 0xac || r === 0xae || r === 0xb0 || r === 0xb1 ||
59
+ r === 0xb6 || r === 0xbb || r === 0xbf || r === 0xd7 || r === 0xf7 ||
60
+ (r >= 0x2010 && r <= 0x2027) || (r >= 0x2030 && r <= 0x203e) || (r >= 0x2041 && r <= 0x2053) ||
61
+ (r >= 0x2055 && r <= 0x205e) || (r >= 0x2190 && r <= 0x245f) || (r >= 0x2500 && r <= 0x2775) ||
62
+ (r >= 0x2794 && r <= 0x2bff) || (r >= 0x2e00 && r <= 0x2e7f) || (r >= 0x3001 && r <= 0x3003) ||
63
+ (r >= 0x3008 && r <= 0x3020) || r === 0x3030 || r === 0xfd3e || r === 0xfd3f || r === 0xfe45 || r === 0xfe46
64
+ );
65
+ }
66
+
67
+ const decimalRe = /^-?\d+(\.\d+)?$/;
68
+
69
+ /**
70
+ * Parses a message of ICU MessageFormat, as MyPHP\Message::parse does:
71
+ * the same messages parse, and fail, in PHP and in pages.
72
+ */
73
+ export function parseMessage(source: string): MessagePart[] {
74
+ let i = 0;
75
+ const s = source;
76
+ const fail = (reason: string): never => {
77
+ throw new MessageSyntaxError(source, i, reason);
78
+ };
79
+ const space = () => {
80
+ while (i < s.length && isPatternSpace(s[i]!)) i++;
81
+ };
82
+ const word = (): string => {
83
+ const start = i;
84
+ while (i < s.length) {
85
+ const c = String.fromCodePoint(s.codePointAt(i)!);
86
+ if (isPatternSpace(c) || isPatternSyntax(c)) break;
87
+ i += c.length;
88
+ }
89
+ return s.slice(start, i);
90
+ };
91
+ const validName = (name: string): boolean =>
92
+ /^\d/.test(name) ? /^(0|[1-9]\d*)$/.test(name) : /^[\p{L}_]/u.test(name);
93
+
94
+ const message = (depth: number, inPlural: boolean): MessagePart[] => {
95
+ if (depth > 32) fail("arguments nest too deep");
96
+ const parts: MessagePart[] = [];
97
+ let text = "";
98
+ const flush = () => {
99
+ if (text !== "") parts.push(text);
100
+ text = "";
101
+ };
102
+ while (i < s.length) {
103
+ const c = s[i]!;
104
+ if (c === "'") {
105
+ i++;
106
+ if (s[i] === "'") {
107
+ text += "'";
108
+ i++;
109
+ } else if (i < s.length && (s[i] === "{" || s[i] === "}" || (s[i] === "#" && inPlural))) {
110
+ while (i < s.length) {
111
+ if (s[i] === "'") {
112
+ if (s[i + 1] === "'") {
113
+ text += "'";
114
+ i += 2;
115
+ continue;
116
+ }
117
+ i++;
118
+ break;
119
+ }
120
+ text += s[i];
121
+ i++;
122
+ }
123
+ } else {
124
+ text += "'";
125
+ }
126
+ } else if (c === "{") {
127
+ flush();
128
+ parts.push(argument(depth));
129
+ } else if (c === "}") {
130
+ break;
131
+ } else if (c === "#" && inPlural) {
132
+ flush();
133
+ parts.push({ type: "pound" });
134
+ i++;
135
+ } else {
136
+ text += c;
137
+ i++;
138
+ }
139
+ }
140
+ flush();
141
+ return parts;
142
+ };
143
+
144
+ const argument = (depth: number): MessagePart => {
145
+ i++; // {
146
+ space();
147
+ const name = word();
148
+ if (name === "") fail("an argument has no name");
149
+ if (!validName(name)) fail(`${JSON.stringify(name)} is not an argument name`);
150
+ space();
151
+ if (i >= s.length) fail(`unclosed argument {${name}`);
152
+ if (s[i] === "}") {
153
+ i++;
154
+ return { type: "arg", name };
155
+ }
156
+ if (s[i] !== ",") fail(`expected , or } after the argument name ${JSON.stringify(name)}`);
157
+ i++;
158
+ space();
159
+ const type = word();
160
+ space();
161
+ if (type === "number" || type === "date" || type === "time") {
162
+ let style = "";
163
+ if (s[i] === ",") {
164
+ i++;
165
+ const start = i;
166
+ let nest = 0;
167
+ for (;;) {
168
+ if (i >= s.length) fail("unclosed style");
169
+ const c = s[i];
170
+ if (c === "'") {
171
+ const end = s.indexOf("'", i + 1);
172
+ if (end < 0) fail("unclosed quote in a style");
173
+ i = end + 1;
174
+ continue;
175
+ }
176
+ if (c === "{") nest++;
177
+ if (c === "}") {
178
+ if (nest === 0) break;
179
+ nest--;
180
+ }
181
+ i++;
182
+ }
183
+ style = s.slice(start, i).replace(/^[\s\u0085\u200e\u200f]+|[\s\u0085\u200e\u200f]+$/g, "");
184
+ if (style === "") fail(`the ${type} argument ${JSON.stringify(name)} has an empty style`);
185
+ }
186
+ if (s[i] !== "}") fail(`unclosed argument {${name}, ${type}`);
187
+ i++;
188
+ return { type, name, style };
189
+ }
190
+ if (type === "") fail(`the argument ${JSON.stringify(name)} has no type`);
191
+ if (type !== "plural" && type !== "selectordinal" && type !== "select") {
192
+ fail(`unknown argument type ${JSON.stringify(type)} (want number, date, time, plural, selectordinal or select)`);
193
+ }
194
+ if (s[i] !== ",") fail(`expected , after ${type}`);
195
+ i++;
196
+ const plural = type !== "select";
197
+ let offset = 0;
198
+ space();
199
+ if (plural && s.startsWith("offset:", i)) {
200
+ i += "offset:".length;
201
+ space();
202
+ const start = i;
203
+ while (i < s.length && s[i]! >= "0" && s[i]! <= "9") i++;
204
+ if (start === i || i - start > 15) fail("offset: needs a number");
205
+ offset = Number(s.slice(start, i));
206
+ }
207
+ const cases: { key: string; parts: MessagePart[] }[] = [];
208
+ for (;;) {
209
+ space();
210
+ if (i >= s.length) fail(`unclosed ${type} argument ${JSON.stringify(name)}`);
211
+ if (s[i] === "}") {
212
+ i++;
213
+ break;
214
+ }
215
+ let key: string;
216
+ if (plural && s[i] === "=") {
217
+ i++;
218
+ const start = i;
219
+ while (i < s.length && /[0-9.]/.test(s[i]!)) i++;
220
+ const value = s.slice(start, i);
221
+ if (!/^\d+(\.\d+)?$/.test(value)) fail(`=${value} is not a number`);
222
+ key = "=" + value;
223
+ } else {
224
+ key = word();
225
+ if (key === "") fail(`expected a case of the ${type} argument ${JSON.stringify(name)}`);
226
+ if (plural && !pluralKeywords.includes(key)) {
227
+ fail(`${JSON.stringify(key)} is not a plural category (zero, one, two, few, many, other)`);
228
+ }
229
+ }
230
+ if (cases.some((c) => c.key === key)) fail(`the ${type} argument ${JSON.stringify(name)} has the case ${key} twice`);
231
+ space();
232
+ if (s[i] !== "{") fail(`expected { after the case ${key}`);
233
+ i++;
234
+ const parts = message(depth + 1, plural);
235
+ if (i >= s.length) fail(`unclosed case ${key}`);
236
+ i++; // }
237
+ cases.push({ key, parts });
238
+ }
239
+ if (!cases.some((c) => c.key === "other")) fail(`the ${type} argument ${JSON.stringify(name)} has no other case`);
240
+ return { type: type as "plural" | "selectordinal" | "select", name, offset, cases };
241
+ };
242
+
243
+ const parts = message(0, false);
244
+ if (i < s.length) fail("unmatched }");
245
+ return parts;
246
+ }
247
+
248
+ /** A number as a page formats it: its value, and its visible fraction digits. */
249
+ interface Num {
250
+ value: number;
251
+ /** The fraction digits a string of a decimal number shows, as 2 for "1.50". */
252
+ digits?: number;
253
+ /** The decimal number as written, for the cases of exact values. */
254
+ text: string;
255
+ }
256
+
257
+ function toNum(v: unknown): Num | undefined {
258
+ if (typeof v === "number") return Number.isFinite(v) ? { value: v, text: String(v) } : undefined;
259
+ if (typeof v === "bigint") return { value: Number(v), text: v.toString() };
260
+ if (typeof v === "string") {
261
+ const t = v.trim();
262
+ if (!decimalRe.test(t)) return undefined;
263
+ return { value: Number(t), digits: t.includes(".") ? t.split(".")[1]!.length : 0, text: t };
264
+ }
265
+ return undefined;
266
+ }
267
+
268
+ /** Writes n less offset, with the fraction digits of n. */
269
+ function minus(n: Num, offset: number): Num {
270
+ if (offset === 0) return n;
271
+ const value = n.value - offset;
272
+ if (n.digits !== undefined) return { value, digits: n.digits, text: value.toFixed(n.digits) };
273
+ return { value, text: String(value) };
274
+ }
275
+
276
+ /** Whether a number written as text equals an exact case, "=1" for 1.0. */
277
+ function exactEquals(text: string, exact: string): boolean {
278
+ if (text.startsWith("-")) return false;
279
+ const norm = (s: string) => {
280
+ let [i, f = ""] = s.split(".");
281
+ i = i!.replace(/^0+(?=\d)/, "");
282
+ f = f.replace(/0+$/, "");
283
+ return f === "" ? i : `${i}.${f}`;
284
+ };
285
+ return /^\d+(\.\d+)?$/.test(text) && norm(text) === norm(exact);
286
+ }
287
+
288
+ const caches = new Map<string, Intl.PluralRules | Intl.NumberFormat | Intl.DateTimeFormat>();
289
+
290
+ function cached<T extends Intl.PluralRules | Intl.NumberFormat | Intl.DateTimeFormat>(key: string, make: () => T): T {
291
+ let v = caches.get(key) as T | undefined;
292
+ if (!v) {
293
+ v = make();
294
+ if (caches.size > 256) caches.clear();
295
+ caches.set(key, v);
296
+ }
297
+ return v;
298
+ }
299
+
300
+ function safeLocale(locale: string): string | undefined {
301
+ try {
302
+ return Intl.getCanonicalLocales(locale)[0];
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ function pluralCategory(locale: string, n: Num, type: "cardinal" | "ordinal"): string {
309
+ const digits = n.digits;
310
+ const pr = cached(`p|${locale}|${type}|${digits ?? ""}`, () =>
311
+ new Intl.PluralRules(safeLocale(locale), digits === undefined ? { type } : { type, minimumFractionDigits: digits, maximumFractionDigits: digits }),
312
+ );
313
+ return (pr as Intl.PluralRules).select(Math.abs(n.value));
314
+ }
315
+
316
+ /** The hooks that write numbers and dates, which tests and apps may replace. */
317
+ export const formatters = {
318
+ /**
319
+ * Writes a number in an ICU number style: "" (the default), "integer",
320
+ * "percent", or a skeleton ("::percent .00"), of which this formatter
321
+ * reads percent, integer, precision-integer, the precision of fraction
322
+ * digits (.00, .0#, .##), group-off and currency/, and writes the rest
323
+ * as the default. MyPHP\Message reads the same, but currencies.
324
+ */
325
+ number(locale: string, n: number, style: string, digits?: number): string {
326
+ const options: Intl.NumberFormatOptions & { roundingMode?: string } = {};
327
+ const stems = style.startsWith("::") ? style.slice(2).trim().split(/\s+/) : [style];
328
+ let fraction = false;
329
+ for (const stem of stems) {
330
+ if (stem === "integer" || stem === "precision-integer") {
331
+ options.maximumFractionDigits = 0;
332
+ options.roundingMode = "halfEven";
333
+ } else if (stem === "percent") {
334
+ options.style = "percent";
335
+ options.roundingMode = "halfEven";
336
+ } else if (stem === "group-off") {
337
+ options.useGrouping = false;
338
+ } else if (/^\.[0#]+$/.test(stem)) {
339
+ options.minimumFractionDigits = stem.split("0").length - 1;
340
+ options.maximumFractionDigits = stem.length - 1;
341
+ fraction = true;
342
+ } else if (stem.startsWith("currency/")) {
343
+ options.style = "currency";
344
+ options.currency = stem.slice("currency/".length);
345
+ }
346
+ }
347
+ if (digits !== undefined && !fraction && options.style === undefined && options.maximumFractionDigits === undefined) {
348
+ options.minimumFractionDigits = options.maximumFractionDigits = Math.min(digits, 100);
349
+ }
350
+ const nf = cached(`n|${locale}|${JSON.stringify(options)}`, () => new Intl.NumberFormat(safeLocale(locale), options));
351
+ return (nf as Intl.NumberFormat).format(n);
352
+ },
353
+ /**
354
+ * Writes a time: its date, its time or both ("datetime"), in an ICU
355
+ * style, "short", "medium" (the default), "long" or "full", or a
356
+ * skeleton of CLDR ("::yMMMd"), which Intl finds the locale's pattern
357
+ * of as MyPHP\DateFormatter does.
358
+ */
359
+ date(locale: string, d: Date, kind: "date" | "time" | "datetime", style: string): string {
360
+ const s = (["short", "medium", "long", "full"].includes(style) ? style : "medium") as "short" | "medium" | "long" | "full";
361
+ const options: Intl.DateTimeFormatOptions = style.startsWith("::")
362
+ ? skeletonOptions(style.slice(2).trim())
363
+ : kind === "date"
364
+ ? { dateStyle: s }
365
+ : kind === "time"
366
+ ? { timeStyle: s }
367
+ : { dateStyle: "short", timeStyle: "short" };
368
+ const df = cached(`d|${locale}|${JSON.stringify(options)}`, () => new Intl.DateTimeFormat(safeLocale(locale), options));
369
+ return (df as Intl.DateTimeFormat).format(d);
370
+ },
371
+ };
372
+
373
+ /**
374
+ * The options of Intl.DateTimeFormat of a skeleton of CLDR, "yMMMd" or
375
+ * "jm": V8 makes the skeleton back of them, and finds its pattern with
376
+ * ICU's DateTimePatternGenerator. An hour letter other than j is an
377
+ * hourCycle, as in MyPHP\DateFormatter. EEEEEE, CLDR's short weekday
378
+ * ("Mo"), has no option of Intl: it is the abbreviated one ("Mon"), where
379
+ * MyPHP writes the short one.
380
+ */
381
+ export function skeletonOptions(skeleton: string): Intl.DateTimeFormatOptions {
382
+ const o: Intl.DateTimeFormatOptions = {};
383
+ for (const [field] of skeleton.matchAll(/([A-Za-z])\1*/g)) {
384
+ const n = field.length;
385
+ switch (field[0]) {
386
+ case "y":
387
+ o.year = n === 2 ? "2-digit" : "numeric";
388
+ break;
389
+ case "M":
390
+ case "L":
391
+ o.month = n === 1 ? "numeric" : n === 2 ? "2-digit" : n === 3 ? "short" : n === 4 ? "long" : "narrow";
392
+ break;
393
+ case "d":
394
+ o.day = n === 2 ? "2-digit" : "numeric";
395
+ break;
396
+ case "E":
397
+ case "c":
398
+ o.weekday = n === 4 ? "long" : n === 5 ? "narrow" : "short";
399
+ break;
400
+ case "j":
401
+ case "h":
402
+ case "H":
403
+ case "K":
404
+ case "k":
405
+ o.hour = n === 2 ? "2-digit" : "numeric";
406
+ if (field[0] === "h") o.hourCycle = "h12";
407
+ if (field[0] === "H") o.hourCycle = "h23";
408
+ if (field[0] === "K") o.hourCycle = "h11";
409
+ if (field[0] === "k") o.hourCycle = "h24";
410
+ break;
411
+ case "m":
412
+ o.minute = n === 2 ? "2-digit" : "numeric";
413
+ break;
414
+ case "s":
415
+ o.second = n === 2 ? "2-digit" : "numeric";
416
+ break;
417
+ case "z":
418
+ case "v":
419
+ o.timeZoneName = n >= 4 ? "long" : "short";
420
+ break;
421
+ }
422
+ }
423
+ return o;
424
+ }
425
+
426
+ function valueString(locale: string, v: unknown): string {
427
+ if (typeof v === "string") return v;
428
+ if (v instanceof Date) return formatters.date(locale, v, "datetime", "short");
429
+ if (v === null || v === undefined) return "";
430
+ if (typeof v === "number" || typeof v === "bigint") {
431
+ const n = toNum(v);
432
+ return n ? formatters.number(locale, n.value, "", n.digits) : String(v);
433
+ }
434
+ if (v instanceof Error) return v.message;
435
+ return String(v);
436
+ }
437
+
438
+ /** Transforms the text of a message for a pseudo locale. */
439
+ interface Pseudo {
440
+ text(s: string): string;
441
+ placeholder(s: string): string;
442
+ wrap(s: string): string;
443
+ }
444
+
445
+ const accented: Record<string, string> = {
446
+ a: "å", b: "ƀ", c: "ç", d: "ð", e: "é", f: "ƒ", g: "ĝ", h: "ĥ", i: "î", j: "ĵ", k: "ķ", l: "ļ", m: "ɱ",
447
+ n: "ñ", o: "ö", p: "þ", q: "ǫ", r: "ŕ", s: "š", t: "ţ", u: "û", v: "ṽ", w: "ŵ", x: "ẋ", y: "ý", z: "ž",
448
+ A: "Å", B: "Ɓ", C: "Ç", D: "Ð", E: "É", F: "Ƒ", G: "Ĝ", H: "Ĥ", I: "Î", J: "Ĵ", K: "Ķ", L: "Ļ", M: "Ṁ",
449
+ N: "Ñ", O: "Ö", P: "Þ", Q: "Ǫ", R: "Ŕ", S: "Š", T: "Ţ", U: "Û", V: "Ṽ", W: "Ŵ", X: "Ẋ", Y: "Ý", Z: "Ž",
450
+ };
451
+
452
+ const padWords = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty".split(" ");
453
+
454
+ const RLM = "\u200f";
455
+ const RLO = "\u202e";
456
+ const PDF = "\u202c";
457
+
458
+ /** The pseudo locales, which test layouts without translations. */
459
+ export const pseudoLocales = ["en-XA", "ar-XB"] as const;
460
+
461
+ function canonical(tag: string): string {
462
+ return safeLocale(tag.replace(/_/g, "-").split(/[.@]/)[0]!) ?? "";
463
+ }
464
+
465
+ /** Strips the extensions of a canonical tag, as "-u-ca-buddhist". */
466
+ function stripExtensions(tag: string): string {
467
+ const parts = tag.split("-");
468
+ const i = parts.findIndex((p, i) => i > 0 && p.length === 1);
469
+ return i < 0 ? tag : parts.slice(0, i).join("-");
470
+ }
471
+
472
+ /** Reports whether tag is a pseudo locale: en-XA or ar-XB. */
473
+ export function isPseudoLocale(tag: string): boolean {
474
+ return (pseudoLocales as readonly string[]).includes(stripExtensions(canonical(tag)));
475
+ }
476
+
477
+ function pseudoFor(locale: string): Pseudo | undefined {
478
+ switch (stripExtensions(canonical(locale))) {
479
+ case "en-XA": {
480
+ let length = 0;
481
+ return {
482
+ text(s) {
483
+ let out = "";
484
+ for (const c of s) {
485
+ out += accented[c] ?? c;
486
+ length++;
487
+ }
488
+ return out;
489
+ },
490
+ placeholder: (s) => s,
491
+ wrap(s) {
492
+ const pad = Math.floor((length * 2 + 4) / 5);
493
+ let out = "[" + s;
494
+ for (let i = 0, n = 0; n < pad; i++) {
495
+ const w = padWords[i % padWords.length]!;
496
+ out += " " + w;
497
+ n += 1 + w.length;
498
+ }
499
+ return out + "]";
500
+ },
501
+ };
502
+ }
503
+ case "ar-XB":
504
+ return {
505
+ text: (s) => s.replace(/[^\s]+/gu, (w) => RLM + RLO + w + PDF + RLM),
506
+ placeholder: (s) => RLM + s + RLM,
507
+ wrap: (s) => s,
508
+ };
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ function formatParts(
514
+ parts: MessagePart[],
515
+ locale: string,
516
+ pluralLocale: string,
517
+ args: Record<string, unknown>,
518
+ pseudo: Pseudo | undefined,
519
+ pound: { num?: Num; raw?: unknown } | undefined,
520
+ ): string {
521
+ let out = "";
522
+ const placeholder = (s: string) => (pseudo ? pseudo.placeholder(s) : s);
523
+ for (const part of parts) {
524
+ if (typeof part === "string") {
525
+ out += pseudo ? pseudo.text(part) : part;
526
+ continue;
527
+ }
528
+ if (part.type === "pound") {
529
+ if (!pound) {
530
+ out += "#";
531
+ } else if (pound.num) {
532
+ out += placeholder(formatters.number(locale, pound.num.value, "", pound.num.digits));
533
+ } else {
534
+ out += placeholder(valueString(locale, pound.raw));
535
+ }
536
+ continue;
537
+ }
538
+ if (!Object.prototype.hasOwnProperty.call(args, part.name)) {
539
+ out += `{${part.name}}`;
540
+ continue;
541
+ }
542
+ const v = args[part.name];
543
+ switch (part.type) {
544
+ case "arg":
545
+ out += placeholder(valueString(locale, v));
546
+ break;
547
+ case "number": {
548
+ const n = toNum(v);
549
+ out += placeholder(n ? formatters.number(locale, n.value, part.style, n.digits) : valueString(locale, v));
550
+ break;
551
+ }
552
+ case "date":
553
+ case "time":
554
+ out += placeholder(v instanceof Date ? formatters.date(locale, v, part.type, part.style) : valueString(locale, v));
555
+ break;
556
+ case "select": {
557
+ const key = typeof v === "string" ? v : typeof v === "number" ? (toNum(v)?.text ?? String(v)) : String(v);
558
+ const c = part.cases.find((c) => c.key === key) ?? part.cases.find((c) => c.key === "other")!;
559
+ out += formatParts(c.parts, locale, pluralLocale, args, pseudo, pound);
560
+ break;
561
+ }
562
+ case "plural":
563
+ case "selectordinal": {
564
+ const n = toNum(v);
565
+ const other = part.cases.find((c) => c.key === "other")!;
566
+ if (!n) {
567
+ out += formatParts(other.parts, locale, pluralLocale, args, pseudo, { raw: v });
568
+ break;
569
+ }
570
+ let chosen = part.cases.find((c) => c.key.startsWith("=") && exactEquals(n.text, c.key.slice(1)));
571
+ const shown = minus(n, part.offset);
572
+ if (!chosen) {
573
+ const cat = pluralCategory(pluralLocale, shown, part.type === "plural" ? "cardinal" : "ordinal");
574
+ chosen = part.cases.find((c) => c.key === cat) ?? other;
575
+ }
576
+ out += formatParts(chosen.parts, locale, pluralLocale, args, pseudo, { num: shown });
577
+ break;
578
+ }
579
+ }
580
+ }
581
+ return out;
582
+ }
583
+
584
+ const parsedCache = new Map<string, MessagePart[]>();
585
+
586
+ function parsed(message: string): MessagePart[] {
587
+ let parts = parsedCache.get(message);
588
+ if (!parts) {
589
+ parts = parseMessage(message);
590
+ parsedCache.set(message, parts);
591
+ }
592
+ return parts;
593
+ }
594
+
595
+ /**
596
+ * Formats a message of ICU MessageFormat with the values of its arguments
597
+ * for locale, as MyPHP\Message::format does: its plural rules
598
+ * pick the cases, its conventions write numbers and dates, and the pseudo
599
+ * locales en-XA and ar-XB transform the message. An argument args lacks is
600
+ * written as it is, `{name}`. Throws a {@link MessageSyntaxError} for a
601
+ * message that does not parse.
602
+ */
603
+ export function formatMessage(locale: string, message: string, args: Record<string, unknown> = {}): string {
604
+ const pseudo = pseudoFor(locale);
605
+ return format(parsed(message), pseudo ? "en" : locale, pseudo ? "en" : locale, args, pseudo);
606
+ }
607
+
608
+ function format(parts: MessagePart[], locale: string, pluralLocale: string, args: Record<string, unknown>, pseudo: Pseudo | undefined): string {
609
+ const out = formatParts(parts, locale, pluralLocale, args, pseudo, undefined);
610
+ return pseudo ? pseudo.wrap(out) : out;
611
+ }
612
+
613
+ /** The data of a catalog, as the module `myphp generate` writes has it. */
614
+ export interface CatalogData {
615
+ /** The source language, which every message falls back to. */
616
+ source: string;
617
+ /** The languages of the catalog: the source, then the others. */
618
+ languages: string[];
619
+ /** For each language, the languages it falls back along: itself, its parents, the source. */
620
+ fallbacks: Record<string, string[]>;
621
+ /** The names of the arguments of each key, in the order its function takes them. */
622
+ params: Record<string, string[]>;
623
+ /** The messages of each language by key. */
624
+ messages: Record<string, Record<string, string>>;
625
+ }
626
+
627
+ /** A catalog of messages in a page, which {@link createCatalog} returns. */
628
+ export interface Catalog<M> {
629
+ /** The languages of the catalog: the source, then the others. */
630
+ readonly languages: readonly string[];
631
+ /** Formats the message of key in locale, or returns key without one. */
632
+ format(locale: string, key: string, args?: Record<string, unknown>): string;
633
+ /** Returns the language of the catalog whose messages locale says. */
634
+ resolve(locale: string): string;
635
+ /** The messages in the language the page shows, read at each call. */
636
+ messages(): M;
637
+ /** The messages in the language of locale. */
638
+ messagesIn(locale: string): M;
639
+ }
640
+
641
+ /**
642
+ * Returns the language the page shows: the app's, which the MyPHP runtime
643
+ * holds (`window.myphp.locale`, set before the page's scripts run), else
644
+ * the browser's first preferred language.
645
+ */
646
+ export function currentLocale(): string {
647
+ const rt = (globalThis as { myphp?: { locale?: unknown } }).myphp;
648
+ const locale = typeof rt?.locale === "function" ? (rt.locale as () => unknown)() : rt?.locale;
649
+ if (typeof locale === "string" && locale !== "") return locale;
650
+ if (typeof navigator !== "undefined") return navigator.languages?.[0] ?? navigator.language ?? "";
651
+ return "";
652
+ }
653
+
654
+ /** Creates the catalog of a module `myphp generate` wrote. */
655
+ export function createCatalog<M>(data: CatalogData): Catalog<M> {
656
+ const resolved = new Map<string, string>();
657
+ const resolve = (locale: string): string => {
658
+ let r = resolved.get(locale);
659
+ if (r !== undefined) return r;
660
+ r = data.source;
661
+ const tag = stripExtensions(canonical(locale));
662
+ if (data.languages.includes(tag)) {
663
+ r = tag;
664
+ } else if (tag !== "" && !isPseudoLocale(tag)) {
665
+ // The tag without its last subtags, then a language written in the
666
+ // same script, as zh-Hant for zh-TW.
667
+ const found = truncations(tag).find((t) => data.languages.includes(t)) ?? sameScript(tag, data.languages);
668
+ if (found) r = found;
669
+ }
670
+ resolved.set(locale, r);
671
+ return r;
672
+ };
673
+ const has = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
674
+ const lookup = (locale: string, key: string): [string, string] | undefined => {
675
+ const lang = isPseudoLocale(locale) ? data.source : resolve(locale);
676
+ for (const t of data.fallbacks[lang] ?? [data.source]) {
677
+ // Own keys only: a key named as a property of every object, as
678
+ // "toString", is a message only where the catalog has it.
679
+ const messages = has(data.messages, t) ? data.messages[t] : undefined;
680
+ const m = messages && has(messages, key) ? messages[key] : undefined;
681
+ if (m !== undefined) return [m, t];
682
+ }
683
+ return undefined;
684
+ };
685
+ const formatKey = (locale: string, key: string, args: Record<string, unknown> = {}): string => {
686
+ const found = lookup(locale, key);
687
+ if (!found) return key;
688
+ const [message, lang] = found;
689
+ let parts: MessagePart[];
690
+ try {
691
+ parts = parsed(message);
692
+ } catch {
693
+ return key;
694
+ }
695
+ const pseudo = pseudoFor(locale);
696
+ const numbers = pseudo || locale === "" ? lang : locale;
697
+ return format(parts, numbers, lang, args, pseudo);
698
+ };
699
+ const bind = (locale: () => string): M => {
700
+ const out: Record<string, (...values: unknown[]) => string> = {};
701
+ for (const [key, names] of Object.entries(data.params)) {
702
+ out[key] = (...values: unknown[]) => {
703
+ const args: Record<string, unknown> = {};
704
+ names.forEach((name, i) => (args[name] = values[i]));
705
+ return formatKey(locale(), key, args);
706
+ };
707
+ }
708
+ return out as M;
709
+ };
710
+ return {
711
+ languages: data.languages,
712
+ format: formatKey,
713
+ resolve,
714
+ messages: () => bind(currentLocale),
715
+ messagesIn: (locale) => bind(() => locale),
716
+ };
717
+ }
718
+
719
+ function truncations(tag: string): string[] {
720
+ const parts = tag.split("-");
721
+ const out: string[] = [];
722
+ for (let n = parts.length; n > 0; n--) out.push(parts.slice(0, n).join("-"));
723
+ return out;
724
+ }
725
+
726
+ /** The first of langs in the language and script of tag. */
727
+ function sameScript(tag: string, langs: string[]): string | undefined {
728
+ try {
729
+ const want = new Intl.Locale(tag).maximize();
730
+ const same = langs.filter((l) => {
731
+ const have = new Intl.Locale(l).maximize();
732
+ return have.language === want.language && have.script === want.script;
733
+ });
734
+ return same.find((l) => new Intl.Locale(l).maximize().region === want.region) ?? same[0];
735
+ } catch {
736
+ return undefined;
737
+ }
738
+ }