@zerolith/i18n 0.0.0-stage → 0.1.0-alpha.1

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/compile.ts ADDED
@@ -0,0 +1,949 @@
1
+ /**
2
+ * The message compiler: MessageFormat 2 catalogs in, one module per locale
3
+ * out, with a plain function per message whose arguments are typed from
4
+ * the placeholders. Formatting and plural selection call `Intl` in the
5
+ * generated module, so no message runtime is shipped. The subset
6
+ * implemented is the one catalogs use: text and placeholders, quoted and
7
+ * unquoted literals, `.input` and `.local` declarations, `.match` with
8
+ * numeric and string selectors, and the default functions `:string`,
9
+ * `:number`, `:integer`, `:percent`, `:currency`, `:offset`, `:date`,
10
+ * `:time`, and `:datetime`. Markup is accepted and formats to nothing.
11
+ */
12
+
13
+ /** A catalog: message key to MessageFormat 2 source. */
14
+ export type Catalog = Readonly<Record<string, string>>;
15
+
16
+ /** Options for `compileMessages`. */
17
+ export interface CompileOptions
18
+ {
19
+ /** `ts` emits TypeScript (the default); `js` emits JavaScript with JSDoc types for buildless projects. */
20
+ language?: "ts" | "js";
21
+ /** Named in the generated header. */
22
+ source?: string;
23
+ }
24
+
25
+ /** The result of compiling one catalog. */
26
+ export interface CompiledCatalog
27
+ {
28
+ code: string;
29
+ /** The message keys as function names, in source order. */
30
+ names: string[];
31
+ }
32
+
33
+ type ArgumentType = "number" | "date" | "string";
34
+
35
+ interface Literal
36
+ {
37
+ kind: "literal";
38
+ value: string;
39
+ }
40
+
41
+ interface Variable
42
+ {
43
+ kind: "variable";
44
+ name: string;
45
+ }
46
+
47
+ interface Expression
48
+ {
49
+ operand: Literal | Variable | undefined;
50
+ fn: string | undefined;
51
+ options: Record<string, Literal | Variable>;
52
+ }
53
+
54
+ type Part = string | Expression;
55
+
56
+ interface Variant
57
+ {
58
+ keys: string[];
59
+ pattern: Part[];
60
+ }
61
+
62
+ interface Message
63
+ {
64
+ declarations: Map<string, { expression: Expression; local: boolean }>;
65
+ pattern: Part[] | undefined;
66
+ selectors: Expression[];
67
+ variants: Variant[];
68
+ }
69
+
70
+ class Parser
71
+ {
72
+ private index = 0;
73
+ private readonly text: string;
74
+ private readonly key: string;
75
+
76
+ constructor(text: string, key: string)
77
+ {
78
+ this.text = text;
79
+ this.key = key;
80
+ }
81
+
82
+ private fail(message: string): never
83
+ {
84
+ throw new Error(`zerolith/i18n: message "${this.key}": ${message} at offset ${this.index}`);
85
+ }
86
+
87
+ private peek(offset = 0): string
88
+ {
89
+ return this.text.charAt(this.index + offset);
90
+ }
91
+
92
+ private skipSpace(): void
93
+ {
94
+ while (/[ \t\r\n ]/.test(this.peek()))
95
+ {
96
+ this.index++;
97
+ }
98
+ }
99
+
100
+ private expect(token: string): void
101
+ {
102
+ if (!this.text.startsWith(token, this.index))
103
+ {
104
+ this.fail(`expected ${JSON.stringify(token)}`);
105
+ }
106
+ this.index += token.length;
107
+ }
108
+
109
+ private name(): string
110
+ {
111
+ const match = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(this.text.slice(this.index));
112
+ if (match === null)
113
+ {
114
+ this.fail("expected a name");
115
+ }
116
+ this.index += match[0].length;
117
+ return match[0];
118
+ }
119
+
120
+ private literal(): Literal
121
+ {
122
+ if (this.peek() === "|")
123
+ {
124
+ this.index++;
125
+ let value = "";
126
+ while (this.index < this.text.length && this.peek() !== "|")
127
+ {
128
+ if (this.peek() === "\\")
129
+ {
130
+ value += this.peek(1);
131
+ this.index += 2;
132
+ }
133
+ else
134
+ {
135
+ value += this.peek();
136
+ this.index++;
137
+ }
138
+ }
139
+ this.expect("|");
140
+ return { kind: "literal", value };
141
+ }
142
+ const match = /^[A-Za-z0-9_.+:@%-]+/.exec(this.text.slice(this.index));
143
+ if (match === null)
144
+ {
145
+ this.fail("expected a literal");
146
+ }
147
+ this.index += match[0].length;
148
+ return { kind: "literal", value: match[0] };
149
+ }
150
+
151
+ private variable(): Variable
152
+ {
153
+ this.expect("$");
154
+ return { kind: "variable", name: this.name() };
155
+ }
156
+
157
+ /** Parses the inside of `{ ... }` after the opening brace. */
158
+ private expression(): Expression | undefined
159
+ {
160
+ this.skipSpace();
161
+ if (this.peek() === "#" || this.peek() === "/")
162
+ {
163
+ while (this.index < this.text.length && this.peek() !== "}")
164
+ {
165
+ this.index++;
166
+ }
167
+ this.expect("}");
168
+ return undefined;
169
+ }
170
+ let operand: Literal | Variable | undefined;
171
+ if (this.peek() === "$")
172
+ {
173
+ operand = this.variable();
174
+ }
175
+ else if (this.peek() !== ":")
176
+ {
177
+ operand = this.literal();
178
+ }
179
+ this.skipSpace();
180
+ let fn: string | undefined;
181
+ const options: Record<string, Literal | Variable> = {};
182
+ if (this.peek() === ":")
183
+ {
184
+ this.index++;
185
+ fn = this.name();
186
+ this.skipSpace();
187
+ while (this.peek() !== "}" && this.peek() !== "@" && this.index < this.text.length)
188
+ {
189
+ const option = this.name();
190
+ this.skipSpace();
191
+ this.expect("=");
192
+ this.skipSpace();
193
+ options[option] = this.peek() === "$" ? this.variable() : this.literal();
194
+ this.skipSpace();
195
+ }
196
+ }
197
+ while (this.peek() === "@")
198
+ {
199
+ this.index++;
200
+ this.name();
201
+ this.skipSpace();
202
+ if (this.peek() === "=")
203
+ {
204
+ this.index++;
205
+ this.skipSpace();
206
+ if (this.peek() === "$")
207
+ {
208
+ this.variable();
209
+ }
210
+ else
211
+ {
212
+ this.literal();
213
+ }
214
+ this.skipSpace();
215
+ }
216
+ }
217
+ this.expect("}");
218
+ if (operand === undefined && fn === undefined)
219
+ {
220
+ this.fail("an expression needs an operand or a function");
221
+ }
222
+ return { operand, fn, options };
223
+ }
224
+
225
+ /** Parses pattern text up to `end` (`}}` for quoted patterns, the end of input for simple ones). */
226
+ private pattern(quoted: boolean): Part[]
227
+ {
228
+ const parts: Part[] = [];
229
+ let text = "";
230
+ while (this.index < this.text.length)
231
+ {
232
+ const char = this.peek();
233
+ if (quoted && char === "}" && this.peek(1) === "}")
234
+ {
235
+ break;
236
+ }
237
+ if (char === "\\")
238
+ {
239
+ const next = this.peek(1);
240
+ if (next !== "{" && next !== "}" && next !== "\\" && next !== "|")
241
+ {
242
+ this.fail("only \\{ \\} \\\\ and \\| can be escaped");
243
+ }
244
+ text += next;
245
+ this.index += 2;
246
+ }
247
+ else if (char === "{")
248
+ {
249
+ if (text !== "")
250
+ {
251
+ parts.push(text);
252
+ text = "";
253
+ }
254
+ this.index++;
255
+ const expression = this.expression();
256
+ if (expression !== undefined)
257
+ {
258
+ parts.push(expression);
259
+ }
260
+ }
261
+ else if (char === "}")
262
+ {
263
+ this.fail("a lone } must be escaped as \\}");
264
+ }
265
+ else
266
+ {
267
+ text += char;
268
+ this.index++;
269
+ }
270
+ }
271
+ if (text !== "")
272
+ {
273
+ parts.push(text);
274
+ }
275
+ return parts;
276
+ }
277
+
278
+ private quotedPattern(): Part[]
279
+ {
280
+ this.expect("{{");
281
+ const parts = this.pattern(true);
282
+ this.expect("}}");
283
+ return parts;
284
+ }
285
+
286
+ parse(): Message
287
+ {
288
+ const message: Message = { declarations: new Map(), pattern: undefined, selectors: [], variants: [] };
289
+ const trimmed = this.text.trimStart();
290
+ if (!trimmed.startsWith(".") && !trimmed.startsWith("{{"))
291
+ {
292
+ message.pattern = this.pattern(false);
293
+ return message;
294
+ }
295
+ this.skipSpace();
296
+ while (this.text.startsWith(".input", this.index) || this.text.startsWith(".local", this.index))
297
+ {
298
+ const local = this.text.startsWith(".local", this.index);
299
+ this.index += 6;
300
+ this.skipSpace();
301
+ let name: string;
302
+ if (local)
303
+ {
304
+ name = this.variable().name;
305
+ this.skipSpace();
306
+ this.expect("=");
307
+ this.skipSpace();
308
+ }
309
+ else
310
+ {
311
+ name = "";
312
+ }
313
+ this.expect("{");
314
+ const expression = this.expression();
315
+ if (expression === undefined)
316
+ {
317
+ this.fail("a declaration cannot be markup");
318
+ }
319
+ if (!local)
320
+ {
321
+ if (expression.operand?.kind !== "variable")
322
+ {
323
+ this.fail(".input needs a variable operand");
324
+ }
325
+ name = expression.operand.name;
326
+ }
327
+ if (message.declarations.has(name))
328
+ {
329
+ this.fail(`$${name} is declared twice`);
330
+ }
331
+ message.declarations.set(name, { expression, local });
332
+ this.skipSpace();
333
+ }
334
+ if (this.text.startsWith(".match", this.index))
335
+ {
336
+ this.index += 6;
337
+ this.skipSpace();
338
+ while (this.peek() === "$" || this.peek() === "{")
339
+ {
340
+ if (this.peek() === "{")
341
+ {
342
+ this.index++;
343
+ const expression = this.expression();
344
+ if (expression === undefined)
345
+ {
346
+ this.fail("a selector cannot be markup");
347
+ }
348
+ message.selectors.push(expression);
349
+ }
350
+ else
351
+ {
352
+ message.selectors.push({ operand: this.variable(), fn: undefined, options: {} });
353
+ }
354
+ this.skipSpace();
355
+ }
356
+ if (message.selectors.length === 0)
357
+ {
358
+ this.fail(".match needs at least one selector");
359
+ }
360
+ while (this.index < this.text.length)
361
+ {
362
+ const keys: string[] = [];
363
+ while (this.peek() !== "{")
364
+ {
365
+ if (this.peek() === "*")
366
+ {
367
+ this.index++;
368
+ keys.push("*");
369
+ }
370
+ else
371
+ {
372
+ keys.push(this.literal().value);
373
+ }
374
+ this.skipSpace();
375
+ }
376
+ if (keys.length !== message.selectors.length)
377
+ {
378
+ this.fail(`a variant needs ${message.selectors.length} key(s), found ${keys.length}`);
379
+ }
380
+ message.variants.push({ keys, pattern: this.quotedPattern() });
381
+ this.skipSpace();
382
+ }
383
+ if (!message.variants.some((variant) => variant.keys.every((key) => key === "*")))
384
+ {
385
+ this.fail("a matcher needs a variant whose keys are all *");
386
+ }
387
+ return message;
388
+ }
389
+ message.pattern = this.quotedPattern();
390
+ this.skipSpace();
391
+ if (this.index < this.text.length)
392
+ {
393
+ this.fail("unexpected text after the pattern");
394
+ }
395
+ return message;
396
+ }
397
+ }
398
+
399
+ const NUMBER_FUNCTIONS = new Set(["number", "integer", "percent", "currency", "offset"]);
400
+ const DATE_FUNCTIONS = new Set(["date", "time", "datetime"]);
401
+ const NUMBER_OPTIONS: Record<string, string> = {
402
+ minimumFractionDigits: "minimumFractionDigits",
403
+ maximumFractionDigits: "maximumFractionDigits",
404
+ minimumIntegerDigits: "minimumIntegerDigits",
405
+ minimumSignificantDigits: "minimumSignificantDigits",
406
+ maximumSignificantDigits: "maximumSignificantDigits",
407
+ signDisplay: "signDisplay",
408
+ trailingZeroDisplay: "trailingZeroDisplay",
409
+ roundingMode: "roundingMode",
410
+ roundingIncrement: "roundingIncrement",
411
+ roundingPriority: "roundingPriority",
412
+ currencyDisplay: "currencyDisplay",
413
+ currencySign: "currencySign",
414
+ numberingSystem: "numberingSystem"
415
+ };
416
+
417
+ function identifier(key: string): string
418
+ {
419
+ const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
420
+ const name = words.map((word, index) => (index === 0 ? word.charAt(0).toLowerCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1))).join("");
421
+ return /^[0-9]/.test(name) ? `_${name}` : name === "" ? "_" : name;
422
+ }
423
+
424
+ class Emitter
425
+ {
426
+ readonly arguments: Map<string, ArgumentType> = new Map();
427
+ readonly locals: Map<string, string> = new Map();
428
+ readonly prelude: string[] = [];
429
+ private readonly key: string;
430
+ private readonly message: Message;
431
+
432
+ constructor(key: string, message: Message)
433
+ {
434
+ this.key = key;
435
+ this.message = message;
436
+ }
437
+
438
+ private fail(text: string): never
439
+ {
440
+ throw new Error(`zerolith/i18n: message "${this.key}": ${text}`);
441
+ }
442
+
443
+ private argument(name: string, type: ArgumentType): string
444
+ {
445
+ const local = this.locals.get(name);
446
+ if (local !== undefined)
447
+ {
448
+ return local;
449
+ }
450
+ const existing = this.arguments.get(name);
451
+ if (existing === undefined || existing === "string")
452
+ {
453
+ this.arguments.set(name, type);
454
+ }
455
+ else if (existing !== type && type !== "string")
456
+ {
457
+ this.fail(`$${name} is used both as ${existing} and as ${type}`);
458
+ }
459
+ return `args.${name}`;
460
+ }
461
+
462
+ private optionValue(value: Literal | Variable | undefined): string | undefined
463
+ {
464
+ if (value === undefined)
465
+ {
466
+ return undefined;
467
+ }
468
+ return value.kind === "literal" ? JSON.stringify(value.value) : `String(${this.argument(value.name, "string")})`;
469
+ }
470
+
471
+ private numberOptions(expression: Expression): string
472
+ {
473
+ const entries: string[] = [];
474
+ const fn = expression.fn ?? "number";
475
+ if (fn === "integer")
476
+ {
477
+ entries.push("maximumFractionDigits: 0");
478
+ }
479
+ else if (fn === "percent")
480
+ {
481
+ entries.push("style: \"percent\"");
482
+ }
483
+ else if (fn === "currency")
484
+ {
485
+ const currency = this.optionValue(expression.options["currency"]);
486
+ if (currency === undefined)
487
+ {
488
+ this.fail(":currency needs a currency option");
489
+ }
490
+ entries.push("style: \"currency\"", `currency: ${currency}`);
491
+ }
492
+ for (const [option, value] of Object.entries(expression.options))
493
+ {
494
+ const mapped = NUMBER_OPTIONS[option];
495
+ if (mapped !== undefined)
496
+ {
497
+ const literal = value.kind === "literal" ? value.value : undefined;
498
+ const numeric = literal !== undefined && /^[0-9]+$/.test(literal) ? literal : undefined;
499
+ entries.push(`${mapped}: ${numeric ?? this.optionValue(value) ?? "undefined"}`);
500
+ }
501
+ else if (option === "useGrouping" && value.kind === "literal")
502
+ {
503
+ entries.push(`useGrouping: ${value.value === "never" ? "false" : value.value === "always" ? "true" : JSON.stringify(value.value)}`);
504
+ }
505
+ else if (option === "fractionDigits" && value.kind === "literal" && /^[0-9]+$/.test(value.value))
506
+ {
507
+ entries.push(`minimumFractionDigits: ${value.value}`, `maximumFractionDigits: ${value.value}`);
508
+ }
509
+ }
510
+ return `{ ${entries.join(", ")} }`;
511
+ }
512
+
513
+ private dateOptions(expression: Expression): string
514
+ {
515
+ const entries: string[] = [];
516
+ const fn = expression.fn ?? "date";
517
+ const literal = (name: string): string | undefined =>
518
+ {
519
+ const value = expression.options[name];
520
+ return value?.kind === "literal" ? value.value : undefined;
521
+ };
522
+ const length = literal(fn === "datetime" ? "dateLength" : "length") ?? (fn === "time" ? undefined : "medium");
523
+ if (length !== undefined && fn !== "time")
524
+ {
525
+ entries.push(`dateStyle: ${JSON.stringify(length)}`);
526
+ }
527
+ const precision = literal(fn === "datetime" ? "timePrecision" : "precision") ?? (fn === "date" ? undefined : "minute");
528
+ if (precision !== undefined && fn !== "date")
529
+ {
530
+ entries.push(`timeStyle: ${JSON.stringify(precision === "second" ? "medium" : "short")}`);
531
+ }
532
+ const zone = literal("timeZone");
533
+ if (zone !== undefined && zone !== "input")
534
+ {
535
+ entries.push(`timeZone: ${JSON.stringify(zone)}`);
536
+ }
537
+ const hour12 = literal("hour12");
538
+ if (hour12 !== undefined)
539
+ {
540
+ entries.push(`hour12: ${hour12 === "true"}`);
541
+ }
542
+ const style = literal("timeZoneStyle");
543
+ if (style !== undefined)
544
+ {
545
+ entries.push(`timeZoneName: ${JSON.stringify(style)}`);
546
+ }
547
+ return `{ ${entries.join(", ")} }`;
548
+ }
549
+
550
+ /** The JavaScript for an expression's numeric value, before formatting. */
551
+ private numeric(expression: Expression): string
552
+ {
553
+ const operand = expression.operand;
554
+ let value: string;
555
+ if (operand === undefined)
556
+ {
557
+ this.fail(`:${expression.fn ?? ""} needs an operand`);
558
+ }
559
+ else if (operand.kind === "literal")
560
+ {
561
+ value = Number.isFinite(Number(operand.value)) ? String(Number(operand.value)) : this.fail(`${JSON.stringify(operand.value)} is not a number`);
562
+ }
563
+ else
564
+ {
565
+ value = this.argument(operand.name, "number");
566
+ }
567
+ if (expression.fn === "offset")
568
+ {
569
+ const add = this.optionValue(expression.options["add"]);
570
+ const subtract = this.optionValue(expression.options["subtract"]);
571
+ if ((add === undefined) === (subtract === undefined))
572
+ {
573
+ this.fail(":offset needs exactly one of add or subtract");
574
+ }
575
+ value = `(${value} ${add === undefined ? "-" : "+"} Number(${add ?? subtract ?? "0"}))`;
576
+ }
577
+ return value;
578
+ }
579
+
580
+ /** Resolves a plain `{$x}` to its declaration when one exists. */
581
+ private resolve(expression: Expression): Expression
582
+ {
583
+ if (expression.fn === undefined && expression.operand?.kind === "variable")
584
+ {
585
+ const declared = this.message.declarations.get(expression.operand.name);
586
+ if (declared !== undefined && !declared.local)
587
+ {
588
+ return declared.expression;
589
+ }
590
+ }
591
+ return expression;
592
+ }
593
+
594
+ /** The JavaScript producing the formatted string of an expression. */
595
+ format(raw: Expression): string
596
+ {
597
+ const expression = this.resolve(raw);
598
+ const fn = expression.fn;
599
+ if (fn === undefined || fn === "string")
600
+ {
601
+ const operand = expression.operand;
602
+ if (operand === undefined)
603
+ {
604
+ this.fail(":string needs an operand");
605
+ }
606
+ return operand.kind === "literal" ? JSON.stringify(operand.value) : `String(${this.argument(operand.name, "string")})`;
607
+ }
608
+ if (NUMBER_FUNCTIONS.has(fn))
609
+ {
610
+ return `number(${this.numeric(expression)}, ${this.numberOptions(expression)})`;
611
+ }
612
+ if (DATE_FUNCTIONS.has(fn))
613
+ {
614
+ const operand = expression.operand;
615
+ if (operand === undefined)
616
+ {
617
+ this.fail(`:${fn} needs an operand`);
618
+ }
619
+ const value = operand.kind === "literal" ? `new Date(${JSON.stringify(operand.value)})` : this.argument(operand.name, "date");
620
+ return `date(${value}, ${this.dateOptions(expression)})`;
621
+ }
622
+ this.fail(`unknown function :${fn}`);
623
+ }
624
+
625
+ /** The JavaScript for a selector: its comparable value and the categories it matches. */
626
+ selector(raw: Expression): string
627
+ {
628
+ const expression = this.resolve(raw);
629
+ const fn = expression.fn ?? "string";
630
+ if (NUMBER_FUNCTIONS.has(fn))
631
+ {
632
+ const select = expression.options["select"];
633
+ const mode = select?.kind === "literal" ? select.value : "plural";
634
+ const value = fn === "percent" ? `(${this.numeric(expression)}) * 100` : this.numeric(expression);
635
+ if (mode === "exact")
636
+ {
637
+ return `{ value: String(${value}), categories: [] }`;
638
+ }
639
+ return `{ value: String(${value}), categories: [plural(${value}, ${JSON.stringify(mode === "ordinal" ? "ordinal" : "cardinal")})] }`;
640
+ }
641
+ if (fn === "string")
642
+ {
643
+ const operand = expression.operand;
644
+ if (operand === undefined)
645
+ {
646
+ this.fail("a selector needs an operand");
647
+ }
648
+ const value = operand.kind === "literal" ? JSON.stringify(operand.value) : `String(${this.argument(operand.name, "string")})`;
649
+ return `{ value: ${value}, categories: [] }`;
650
+ }
651
+ this.fail(`:${fn} cannot select`);
652
+ }
653
+
654
+ pattern(parts: Part[]): string
655
+ {
656
+ if (parts.length === 0)
657
+ {
658
+ return "\"\"";
659
+ }
660
+ return parts.map((part) => (typeof part === "string" ? JSON.stringify(part) : this.format(part))).join(" + ");
661
+ }
662
+
663
+ body(): string
664
+ {
665
+ for (const [name, declaration] of this.message.declarations)
666
+ {
667
+ if (declaration.local)
668
+ {
669
+ const value = this.format(declaration.expression);
670
+ this.locals.set(name, `local_${name}`);
671
+ this.prelude.push(`const local_${name} = ${value};`);
672
+ }
673
+ }
674
+ if (this.message.pattern !== undefined)
675
+ {
676
+ return this.pattern(this.message.pattern);
677
+ }
678
+ const selectors = this.message.selectors.map((selector) => this.selector(selector));
679
+ const variants = this.message.variants.map((variant) => `{ keys: ${JSON.stringify(variant.keys)}, pattern: () => ${this.pattern(variant.pattern)} }`);
680
+ return `select([${selectors.join(", ")}], [${variants.join(", ")}])`;
681
+ }
682
+ }
683
+
684
+ const HELPERS = `const formats = new Map();
685
+ function cached(kind, options, create)
686
+ {
687
+ const key = kind + JSON.stringify(options);
688
+ let instance = formats.get(key);
689
+ if (instance === undefined)
690
+ {
691
+ instance = create();
692
+ formats.set(key, instance);
693
+ }
694
+ return instance;
695
+ }
696
+ function number(value, options)
697
+ {
698
+ return cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
699
+ }
700
+ function date(value, options)
701
+ {
702
+ return cached("d", options, () => new Intl.DateTimeFormat(locale, options)).format(value);
703
+ }
704
+ function plural(value, type)
705
+ {
706
+ return cached("p", { type }, () => new Intl.PluralRules(locale, { type })).select(value);
707
+ }
708
+ function select(selectors, variants)
709
+ {
710
+ let best;
711
+ let bestRank;
712
+ for (const variant of variants)
713
+ {
714
+ const rank = [];
715
+ let matches = true;
716
+ for (const [index, key] of variant.keys.entries())
717
+ {
718
+ const selector = selectors[index];
719
+ if (key === "*")
720
+ {
721
+ rank.push(0);
722
+ }
723
+ else if (key === selector.value)
724
+ {
725
+ rank.push(2);
726
+ }
727
+ else if (selector.categories.includes(key))
728
+ {
729
+ rank.push(1);
730
+ }
731
+ else
732
+ {
733
+ matches = false;
734
+ break;
735
+ }
736
+ }
737
+ if (!matches)
738
+ {
739
+ continue;
740
+ }
741
+ if (matches && better(rank, bestRank))
742
+ {
743
+ best = variant;
744
+ bestRank = rank;
745
+ }
746
+ }
747
+ return best === undefined ? "" : best.pattern();
748
+ }
749
+ function better(rank, current)
750
+ {
751
+ if (current === undefined)
752
+ {
753
+ return true;
754
+ }
755
+ for (const [index, score] of rank.entries())
756
+ {
757
+ const other = current[index] ?? 0;
758
+ if (score !== other)
759
+ {
760
+ return score > other;
761
+ }
762
+ }
763
+ return false;
764
+ }`;
765
+
766
+ const HELPERS_TS = `interface Selector
767
+ {
768
+ value: string;
769
+ categories: string[];
770
+ }
771
+ interface Variant
772
+ {
773
+ keys: string[];
774
+ pattern: () => string;
775
+ }
776
+ const formats: Map<string, unknown> = new Map();
777
+ function cached<T>(kind: string, options: object, create: () => T): T
778
+ {
779
+ const key = kind + JSON.stringify(options);
780
+ let instance = formats.get(key) as T | undefined;
781
+ if (instance === undefined)
782
+ {
783
+ instance = create();
784
+ formats.set(key, instance);
785
+ }
786
+ return instance;
787
+ }
788
+ function number(value: number, options: Intl.NumberFormatOptions): string
789
+ {
790
+ return cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
791
+ }
792
+ function date(value: Date | number, options: Intl.DateTimeFormatOptions): string
793
+ {
794
+ return cached("d", options, () => new Intl.DateTimeFormat(locale, options)).format(value);
795
+ }
796
+ function plural(value: number, type: "cardinal" | "ordinal"): string
797
+ {
798
+ return cached("p", { type }, () => new Intl.PluralRules(locale, { type })).select(value);
799
+ }
800
+ function better(rank: number[], current: number[] | undefined): boolean
801
+ {
802
+ if (current === undefined)
803
+ {
804
+ return true;
805
+ }
806
+ for (const [index, score] of rank.entries())
807
+ {
808
+ const other = current[index] ?? 0;
809
+ if (score !== other)
810
+ {
811
+ return score > other;
812
+ }
813
+ }
814
+ return false;
815
+ }
816
+ function select(selectors: Selector[], variants: Variant[]): string
817
+ {
818
+ let best: Variant | undefined;
819
+ let bestRank: number[] | undefined;
820
+ for (const variant of variants)
821
+ {
822
+ const rank: number[] = [];
823
+ let matches = true;
824
+ for (const [index, key] of variant.keys.entries())
825
+ {
826
+ const selector = selectors[index];
827
+ if (key === "*")
828
+ {
829
+ rank.push(0);
830
+ }
831
+ else if (selector !== undefined && key === selector.value)
832
+ {
833
+ rank.push(2);
834
+ }
835
+ else if (selector !== undefined && selector.categories.includes(key))
836
+ {
837
+ rank.push(1);
838
+ }
839
+ else
840
+ {
841
+ matches = false;
842
+ break;
843
+ }
844
+ }
845
+ if (matches && better(rank, bestRank))
846
+ {
847
+ best = variant;
848
+ bestRank = rank;
849
+ }
850
+ }
851
+ return best === undefined ? "" : best.pattern();
852
+ }`;
853
+
854
+ function typeOf(type: ArgumentType): string
855
+ {
856
+ return type === "number" ? "number" : type === "date" ? "Date | number" : "string | number";
857
+ }
858
+
859
+ /**
860
+ * Compiles one locale's catalog into a module: `locale`, one exported
861
+ * function per message named from its key, and the `Intl` helpers they
862
+ * share. Each function takes an `args` object typed from the message's
863
+ * placeholders, or nothing when the message has none.
864
+ */
865
+ export function compileMessages(locale: string, catalog: Catalog, options: CompileOptions = {}): CompiledCatalog
866
+ {
867
+ const javascript = options.language === "js";
868
+ const out: string[] = [];
869
+ out.push(`// Generated by @zerolith/i18n from ${options.source ?? `the ${locale} catalog`}. Do not edit; regenerate instead.`);
870
+ if (javascript)
871
+ {
872
+ out.push("// @ts-check");
873
+ }
874
+ out.push(`export const locale${javascript ? "" : ": string"} = ${JSON.stringify(locale)};`);
875
+ out.push("");
876
+ out.push(javascript ? HELPERS : HELPERS_TS);
877
+ out.push("");
878
+ const names: string[] = [];
879
+ const seen = new Set<string>();
880
+ for (const [key, source] of Object.entries(catalog))
881
+ {
882
+ const name = identifier(key);
883
+ if (seen.has(name))
884
+ {
885
+ throw new Error(`zerolith/i18n: keys "${key}" and another one both become ${name}`);
886
+ }
887
+ seen.add(name);
888
+ names.push(name);
889
+ const message = new Parser(source, key).parse();
890
+ const emitter = new Emitter(key, message);
891
+ const body = emitter.body();
892
+ const args = [...emitter.arguments.entries()].toSorted(([a], [b]) => a.localeCompare(b));
893
+ const doc = source.replace(/\*\//g, "* /").split(/\r?\n/).map((line) => ` * ${line}`.trimEnd()).join("\n");
894
+ const argumentType = `{ ${args.map(([argument, type]) => `${argument}: ${typeOf(type)}`).join("; ")} }`;
895
+ if (javascript)
896
+ {
897
+ out.push(`/**\n${doc}${args.length > 0 ? `\n * @param {${argumentType}} args` : ""}\n * @returns {string}\n */`);
898
+ out.push(`export function ${name}(${args.length > 0 ? "args" : ""})`);
899
+ }
900
+ else
901
+ {
902
+ out.push(`/**\n${doc}\n */`);
903
+ out.push(`export function ${name}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
904
+ }
905
+ out.push("{");
906
+ for (const line of emitter.prelude)
907
+ {
908
+ out.push(` ${line}`);
909
+ }
910
+ out.push(` return ${body};`);
911
+ out.push("}");
912
+ out.push("");
913
+ }
914
+ return { code: out.join("\n"), names };
915
+ }
916
+
917
+ /** The keys of a catalog as function names, for comparing locales. */
918
+ export function messageNames(catalog: Catalog): string[]
919
+ {
920
+ return Object.keys(catalog).map(identifier);
921
+ }
922
+
923
+ /**
924
+ * The index module for a set of locales: the list of locales and one lazy
925
+ * loader per locale, so each locale is its own chunk.
926
+ */
927
+ export function compileIndex(locales: readonly string[], options: CompileOptions = {}): string
928
+ {
929
+ const javascript = options.language === "js";
930
+ const extension = javascript ? "js" : "ts";
931
+ const lines = [`// Generated by @zerolith/i18n. Do not edit; regenerate instead.`];
932
+ if (javascript)
933
+ {
934
+ lines.push("// @ts-check");
935
+ }
936
+ if (!javascript)
937
+ {
938
+ lines.push(`import type * as first from "./${locales[0] ?? "en"}.${extension}";`);
939
+ lines.push("");
940
+ }
941
+ lines.push(`export const locales${javascript ? "" : ": readonly string[]"} = ${JSON.stringify(locales)};`);
942
+ lines.push("");
943
+ lines.push(`/** Loads one locale's messages; each locale is its own module. */`);
944
+ lines.push(javascript ? "export const loaders = {" : "export const loaders: Readonly<Record<string, () => Promise<typeof first>>> = {");
945
+ lines.push(locales.map((locale) => ` ${JSON.stringify(locale)}: () => import("./${locale}.${extension}")`).join(",\n"));
946
+ lines.push("};");
947
+ lines.push("");
948
+ return lines.join("\n");
949
+ }