@zerolith/i18n 0.1.0-alpha.5 → 0.1.0-alpha.6
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +53 -7
- package/dist/cli.js.map +1 -1
- package/dist/compile.d.ts +6 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +146 -68
- package/dist/compile.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -14
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +70 -7
- package/src/compile.ts +164 -69
- package/src/index.ts +43 -16
package/src/compile.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface CompileOptions
|
|
|
25
25
|
/** The result of compiling one catalog. */
|
|
26
26
|
export interface CompiledCatalog
|
|
27
27
|
{
|
|
28
|
+
/** The generated module's source, TypeScript or JavaScript as the `language` option chose; the `zerolith-i18n` CLI writes it to `<locale>.ts` or `<locale>.js`. */
|
|
28
29
|
code: string;
|
|
29
30
|
/** The message keys as function names, in source order. */
|
|
30
31
|
names: string[];
|
|
@@ -414,6 +415,18 @@ const NUMBER_OPTIONS: Record<string, string> = {
|
|
|
414
415
|
numberingSystem: "numberingSystem"
|
|
415
416
|
};
|
|
416
417
|
|
|
418
|
+
/** Names a module cannot bind; a message named so is exported under its name from a binding of its own. */
|
|
419
|
+
const RESERVED: ReadonlySet<string> = new Set(["arguments", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "eval", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield"]);
|
|
420
|
+
|
|
421
|
+
/** An argument or local name JavaScript can write as it is; MessageFormat also allows `-` and `.`, as in `$first-name`. */
|
|
422
|
+
const PLAIN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
423
|
+
|
|
424
|
+
/** A property name as code: as it is when plain, quoted otherwise. */
|
|
425
|
+
function property(name: string): string
|
|
426
|
+
{
|
|
427
|
+
return PLAIN_NAME.test(name) ? name : JSON.stringify(name);
|
|
428
|
+
}
|
|
429
|
+
|
|
417
430
|
function identifier(key: string): string
|
|
418
431
|
{
|
|
419
432
|
const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
|
|
@@ -428,11 +441,29 @@ class Emitter
|
|
|
428
441
|
readonly prelude: string[] = [];
|
|
429
442
|
private readonly key: string;
|
|
430
443
|
private readonly message: Message;
|
|
444
|
+
private readonly formatters: Map<string, string>;
|
|
445
|
+
private readonly used: Set<Helper>;
|
|
446
|
+
private reads = 0;
|
|
431
447
|
|
|
432
|
-
constructor(key: string, message: Message)
|
|
448
|
+
constructor(key: string, message: Message, formatters: Map<string, string>, used: Set<Helper>)
|
|
433
449
|
{
|
|
434
450
|
this.key = key;
|
|
435
451
|
this.message = message;
|
|
452
|
+
this.formatters = formatters;
|
|
453
|
+
this.used = used;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The module-level binding of a formatter whose options are known at compile time, created on first use and shared by every message that creates it the same way. */
|
|
457
|
+
private shared(create: string): string
|
|
458
|
+
{
|
|
459
|
+
this.used.add("once");
|
|
460
|
+
let binding = this.formatters.get(create);
|
|
461
|
+
if (binding === undefined)
|
|
462
|
+
{
|
|
463
|
+
binding = `$f${this.formatters.size}`;
|
|
464
|
+
this.formatters.set(create, binding);
|
|
465
|
+
}
|
|
466
|
+
return binding;
|
|
436
467
|
}
|
|
437
468
|
|
|
438
469
|
private fail(text: string): never
|
|
@@ -442,6 +473,7 @@ class Emitter
|
|
|
442
473
|
|
|
443
474
|
private argument(name: string, type: ArgumentType): string
|
|
444
475
|
{
|
|
476
|
+
this.reads++;
|
|
445
477
|
const local = this.locals.get(name);
|
|
446
478
|
if (local !== undefined)
|
|
447
479
|
{
|
|
@@ -456,7 +488,7 @@ class Emitter
|
|
|
456
488
|
{
|
|
457
489
|
this.fail(`$${name} is used both as ${existing} and as ${type}`);
|
|
458
490
|
}
|
|
459
|
-
return `args.${name}`;
|
|
491
|
+
return PLAIN_NAME.test(name) ? `args.${name}` : `args[${JSON.stringify(name)}]`;
|
|
460
492
|
}
|
|
461
493
|
|
|
462
494
|
private optionValue(value: Literal | Variable | undefined): string | undefined
|
|
@@ -507,7 +539,7 @@ class Emitter
|
|
|
507
539
|
entries.push(`minimumFractionDigits: ${value.value}`, `maximumFractionDigits: ${value.value}`);
|
|
508
540
|
}
|
|
509
541
|
}
|
|
510
|
-
return `{ ${entries.join(", ")} }`;
|
|
542
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
511
543
|
}
|
|
512
544
|
|
|
513
545
|
private dateOptions(expression: Expression): string
|
|
@@ -544,7 +576,7 @@ class Emitter
|
|
|
544
576
|
{
|
|
545
577
|
entries.push(`timeZoneName: ${JSON.stringify(style)}`);
|
|
546
578
|
}
|
|
547
|
-
return `{ ${entries.join(", ")} }`;
|
|
579
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
548
580
|
}
|
|
549
581
|
|
|
550
582
|
/** The JavaScript for an expression's numeric value, before formatting. */
|
|
@@ -577,13 +609,13 @@ class Emitter
|
|
|
577
609
|
return value;
|
|
578
610
|
}
|
|
579
611
|
|
|
580
|
-
/** Resolves a plain `{$x}` to its declaration when one exists. */
|
|
581
|
-
private resolve(expression: Expression): Expression
|
|
612
|
+
/** Resolves a plain `{$x}` to its `.input` declaration when one exists, or with `locals` to its `.local` one too, so a selector on a local selects as its expression does. */
|
|
613
|
+
private resolve(expression: Expression, locals = false): Expression
|
|
582
614
|
{
|
|
583
615
|
if (expression.fn === undefined && expression.operand?.kind === "variable")
|
|
584
616
|
{
|
|
585
617
|
const declared = this.message.declarations.get(expression.operand.name);
|
|
586
|
-
if (declared !== undefined && !declared.local)
|
|
618
|
+
if (declared !== undefined && (locals || !declared.local))
|
|
587
619
|
{
|
|
588
620
|
return declared.expression;
|
|
589
621
|
}
|
|
@@ -607,7 +639,15 @@ class Emitter
|
|
|
607
639
|
}
|
|
608
640
|
if (NUMBER_FUNCTIONS.has(fn))
|
|
609
641
|
{
|
|
610
|
-
|
|
642
|
+
const value = this.numeric(expression);
|
|
643
|
+
const reads = this.reads;
|
|
644
|
+
const options = this.numberOptions(expression);
|
|
645
|
+
if (this.reads === reads)
|
|
646
|
+
{
|
|
647
|
+
return `${this.shared(`new Intl.NumberFormat(locale, ${options})`)}().format(${value})`;
|
|
648
|
+
}
|
|
649
|
+
this.used.add("number");
|
|
650
|
+
return `$number(${value}, ${options})`;
|
|
611
651
|
}
|
|
612
652
|
if (DATE_FUNCTIONS.has(fn))
|
|
613
653
|
{
|
|
@@ -617,7 +657,7 @@ class Emitter
|
|
|
617
657
|
this.fail(`:${fn} needs an operand`);
|
|
618
658
|
}
|
|
619
659
|
const value = operand.kind === "literal" ? `new Date(${JSON.stringify(operand.value)})` : this.argument(operand.name, "date");
|
|
620
|
-
return `
|
|
660
|
+
return `${this.shared(`new Intl.DateTimeFormat(locale, ${this.dateOptions(expression)})`)}().format(${value})`;
|
|
621
661
|
}
|
|
622
662
|
this.fail(`unknown function :${fn}`);
|
|
623
663
|
}
|
|
@@ -625,7 +665,7 @@ class Emitter
|
|
|
625
665
|
/** The JavaScript for a selector: its comparable value and the categories it matches. */
|
|
626
666
|
selector(raw: Expression): string
|
|
627
667
|
{
|
|
628
|
-
const expression = this.resolve(raw);
|
|
668
|
+
const expression = this.resolve(raw, true);
|
|
629
669
|
const fn = expression.fn ?? "string";
|
|
630
670
|
if (NUMBER_FUNCTIONS.has(fn))
|
|
631
671
|
{
|
|
@@ -636,7 +676,15 @@ class Emitter
|
|
|
636
676
|
{
|
|
637
677
|
return `{ value: String(${value}), categories: [] }`;
|
|
638
678
|
}
|
|
639
|
-
|
|
679
|
+
const type = JSON.stringify(mode === "ordinal" ? "ordinal" : "cardinal");
|
|
680
|
+
const reads = this.reads;
|
|
681
|
+
const options = this.numberOptions(expression);
|
|
682
|
+
if (this.reads === reads)
|
|
683
|
+
{
|
|
684
|
+
return `{ value: String(${value}), categories: [${this.shared(`new Intl.PluralRules(locale, { ...new Intl.NumberFormat(locale, ${options}).resolvedOptions(), type: ${type} })`)}().select(${value})] }`;
|
|
685
|
+
}
|
|
686
|
+
this.used.add("plural");
|
|
687
|
+
return `{ value: String(${value}), categories: [$plural(${value}, ${type}, ${options})] }`;
|
|
640
688
|
}
|
|
641
689
|
if (fn === "string")
|
|
642
690
|
{
|
|
@@ -667,8 +715,9 @@ class Emitter
|
|
|
667
715
|
if (declaration.local)
|
|
668
716
|
{
|
|
669
717
|
const value = this.format(declaration.expression);
|
|
670
|
-
|
|
671
|
-
this.
|
|
718
|
+
const binding = PLAIN_NAME.test(name) ? `local_${name}` : `local_${this.locals.size}`;
|
|
719
|
+
this.locals.set(name, binding);
|
|
720
|
+
this.prelude.push(`const ${binding} = ${value};`);
|
|
672
721
|
}
|
|
673
722
|
}
|
|
674
723
|
if (this.message.pattern !== undefined)
|
|
@@ -677,35 +726,76 @@ class Emitter
|
|
|
677
726
|
}
|
|
678
727
|
const selectors = this.message.selectors.map((selector) => this.selector(selector));
|
|
679
728
|
const variants = this.message.variants.map((variant) => `{ keys: ${JSON.stringify(variant.keys)}, pattern: () => ${this.pattern(variant.pattern)} }`);
|
|
680
|
-
|
|
729
|
+
this.used.add("select");
|
|
730
|
+
return `$select([${selectors.join(", ")}], [${variants.join(", ")}])`;
|
|
681
731
|
}
|
|
682
732
|
}
|
|
683
733
|
|
|
684
|
-
|
|
685
|
-
|
|
734
|
+
/** A group of helpers a compiled module shares; each is written only when one of the module's messages uses it. */
|
|
735
|
+
type Helper = "cache" | "once" | "number" | "plural" | "select";
|
|
736
|
+
|
|
737
|
+
/** The helpers in JavaScript and in TypeScript, in the order a module writes them. */
|
|
738
|
+
const HELPERS: Record<Helper, { js: string; ts: string }> = {
|
|
739
|
+
cache: {
|
|
740
|
+
js: `const $formats = new Map();
|
|
741
|
+
function $cached(kind, options, create)
|
|
686
742
|
{
|
|
687
743
|
const key = kind + JSON.stringify(options);
|
|
688
|
-
let instance = formats.get(key);
|
|
744
|
+
let instance = $formats.get(key);
|
|
689
745
|
if (instance === undefined)
|
|
690
746
|
{
|
|
691
747
|
instance = create();
|
|
692
|
-
formats.set(key, instance);
|
|
748
|
+
$formats.set(key, instance);
|
|
693
749
|
}
|
|
694
750
|
return instance;
|
|
695
|
-
}
|
|
696
|
-
|
|
751
|
+
}`,
|
|
752
|
+
ts: `const $formats: Map<string, unknown> = new Map();
|
|
753
|
+
function $cached<T>(kind: string, options: object, create: () => T): T
|
|
697
754
|
{
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
755
|
+
const key = kind + JSON.stringify(options);
|
|
756
|
+
let instance = $formats.get(key) as T | undefined;
|
|
757
|
+
if (instance === undefined)
|
|
758
|
+
{
|
|
759
|
+
instance = create();
|
|
760
|
+
$formats.set(key, instance);
|
|
761
|
+
}
|
|
762
|
+
return instance;
|
|
763
|
+
}`
|
|
764
|
+
},
|
|
765
|
+
once: {
|
|
766
|
+
js: `function $once(create)
|
|
701
767
|
{
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
768
|
+
let instance;
|
|
769
|
+
return () => (instance ??= create());
|
|
770
|
+
}`,
|
|
771
|
+
ts: `function $once<T>(create: () => T): () => T
|
|
705
772
|
{
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
773
|
+
let instance: T | undefined;
|
|
774
|
+
return () => (instance ??= create());
|
|
775
|
+
}`
|
|
776
|
+
},
|
|
777
|
+
number: {
|
|
778
|
+
js: `function $number(value, options)
|
|
779
|
+
{
|
|
780
|
+
return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
|
|
781
|
+
}`,
|
|
782
|
+
ts: `function $number(value: number, options: Intl.NumberFormatOptions): string
|
|
783
|
+
{
|
|
784
|
+
return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
|
|
785
|
+
}`
|
|
786
|
+
},
|
|
787
|
+
plural: {
|
|
788
|
+
js: `function $plural(value, type, options)
|
|
789
|
+
{
|
|
790
|
+
return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
|
|
791
|
+
}`,
|
|
792
|
+
ts: `function $plural(value: number, type: "cardinal" | "ordinal", options: Intl.NumberFormatOptions): string
|
|
793
|
+
{
|
|
794
|
+
return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
|
|
795
|
+
}`
|
|
796
|
+
},
|
|
797
|
+
select: {
|
|
798
|
+
js: `function $select(selectors, variants)
|
|
709
799
|
{
|
|
710
800
|
let best;
|
|
711
801
|
let bestRank;
|
|
@@ -738,7 +828,7 @@ function select(selectors, variants)
|
|
|
738
828
|
{
|
|
739
829
|
continue;
|
|
740
830
|
}
|
|
741
|
-
if (matches && better(rank, bestRank))
|
|
831
|
+
if (matches && $better(rank, bestRank))
|
|
742
832
|
{
|
|
743
833
|
best = variant;
|
|
744
834
|
bestRank = rank;
|
|
@@ -746,7 +836,7 @@ function select(selectors, variants)
|
|
|
746
836
|
}
|
|
747
837
|
return best === undefined ? "" : best.pattern();
|
|
748
838
|
}
|
|
749
|
-
function better(rank, current)
|
|
839
|
+
function $better(rank, current)
|
|
750
840
|
{
|
|
751
841
|
if (current === undefined)
|
|
752
842
|
{
|
|
@@ -761,9 +851,8 @@ function better(rank, current)
|
|
|
761
851
|
}
|
|
762
852
|
}
|
|
763
853
|
return false;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
const HELPERS_TS = `interface Selector
|
|
854
|
+
}`,
|
|
855
|
+
ts: `interface Selector
|
|
767
856
|
{
|
|
768
857
|
value: string;
|
|
769
858
|
categories: string[];
|
|
@@ -773,31 +862,7 @@ interface Variant
|
|
|
773
862
|
keys: string[];
|
|
774
863
|
pattern: () => string;
|
|
775
864
|
}
|
|
776
|
-
|
|
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
|
|
865
|
+
function $better(rank: number[], current: number[] | undefined): boolean
|
|
801
866
|
{
|
|
802
867
|
if (current === undefined)
|
|
803
868
|
{
|
|
@@ -813,7 +878,7 @@ function better(rank: number[], current: number[] | undefined): boolean
|
|
|
813
878
|
}
|
|
814
879
|
return false;
|
|
815
880
|
}
|
|
816
|
-
function select(selectors: Selector[], variants: Variant[]): string
|
|
881
|
+
function $select(selectors: Selector[], variants: Variant[]): string
|
|
817
882
|
{
|
|
818
883
|
let best: Variant | undefined;
|
|
819
884
|
let bestRank: number[] | undefined;
|
|
@@ -842,14 +907,27 @@ function select(selectors: Selector[], variants: Variant[]): string
|
|
|
842
907
|
break;
|
|
843
908
|
}
|
|
844
909
|
}
|
|
845
|
-
if (matches && better(rank, bestRank))
|
|
910
|
+
if (matches && $better(rank, bestRank))
|
|
846
911
|
{
|
|
847
912
|
best = variant;
|
|
848
913
|
bestRank = rank;
|
|
849
914
|
}
|
|
850
915
|
}
|
|
851
916
|
return best === undefined ? "" : best.pattern();
|
|
852
|
-
}
|
|
917
|
+
}`
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
/** The helpers a module's messages use, with the cache that `$number` and `$plural` share. */
|
|
922
|
+
function helpersFor(used: ReadonlySet<Helper>, javascript: boolean): string
|
|
923
|
+
{
|
|
924
|
+
const needed = new Set(used);
|
|
925
|
+
if (needed.has("number") || needed.has("plural"))
|
|
926
|
+
{
|
|
927
|
+
needed.add("cache");
|
|
928
|
+
}
|
|
929
|
+
return (Object.keys(HELPERS) as Helper[]).filter((name) => needed.has(name)).map((name) => HELPERS[name][javascript ? "js" : "ts"]).join("\n");
|
|
930
|
+
}
|
|
853
931
|
|
|
854
932
|
function typeOf(type: ArgumentType): string
|
|
855
933
|
{
|
|
@@ -860,7 +938,11 @@ function typeOf(type: ArgumentType): string
|
|
|
860
938
|
* Compiles one locale's catalog into a module: `locale`, one exported
|
|
861
939
|
* function per message named from its key, and the `Intl` helpers they
|
|
862
940
|
* share. Each function takes an `args` object typed from the message's
|
|
863
|
-
* placeholders, or nothing when the message has none.
|
|
941
|
+
* placeholders, or nothing when the message has none. A formatter whose
|
|
942
|
+
* options are all literals is a module-level binding created on first use
|
|
943
|
+
* and shared by every message that formats the same way; options that read
|
|
944
|
+
* an argument find their formatter in a cache keyed by the options. Only
|
|
945
|
+
* the helpers the messages use are written.
|
|
864
946
|
*/
|
|
865
947
|
export function compileMessages(locale: string, catalog: Catalog, options: CompileOptions = {}): CompiledCatalog
|
|
866
948
|
{
|
|
@@ -873,8 +955,9 @@ export function compileMessages(locale: string, catalog: Catalog, options: Compi
|
|
|
873
955
|
}
|
|
874
956
|
out.push(`export const locale${javascript ? "" : ": string"} = ${JSON.stringify(locale)};`);
|
|
875
957
|
out.push("");
|
|
876
|
-
|
|
877
|
-
|
|
958
|
+
const head = out.length;
|
|
959
|
+
const formatters = new Map<string, string>();
|
|
960
|
+
const used = new Set<Helper>();
|
|
878
961
|
const names: string[] = [];
|
|
879
962
|
const seen = new Set<string>();
|
|
880
963
|
for (const [key, source] of Object.entries(catalog))
|
|
@@ -884,23 +967,28 @@ export function compileMessages(locale: string, catalog: Catalog, options: Compi
|
|
|
884
967
|
{
|
|
885
968
|
throw new Error(`zerolith/i18n: keys "${key}" and another one both become ${name}`);
|
|
886
969
|
}
|
|
970
|
+
if (name === "locale")
|
|
971
|
+
{
|
|
972
|
+
throw new Error(`zerolith/i18n: key "${key}" becomes locale, which every compiled module exports; rename the key`);
|
|
973
|
+
}
|
|
974
|
+
const binding = RESERVED.has(name) ? `message_${name}` : name;
|
|
887
975
|
seen.add(name);
|
|
888
976
|
names.push(name);
|
|
889
977
|
const message = new Parser(source, key).parse();
|
|
890
|
-
const emitter = new Emitter(key, message);
|
|
978
|
+
const emitter = new Emitter(key, message, formatters, used);
|
|
891
979
|
const body = emitter.body();
|
|
892
980
|
const args = [...emitter.arguments.entries()].toSorted(([a], [b]) => a.localeCompare(b));
|
|
893
981
|
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("; ")} }`;
|
|
982
|
+
const argumentType = `{ ${args.map(([argument, type]) => `${property(argument)}: ${typeOf(type)}`).join("; ")} }`;
|
|
895
983
|
if (javascript)
|
|
896
984
|
{
|
|
897
985
|
out.push(`/**\n${doc}${args.length > 0 ? `\n * @param {${argumentType}} args` : ""}\n * @returns {string}\n */`);
|
|
898
|
-
out.push(
|
|
986
|
+
out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? "args" : ""})`);
|
|
899
987
|
}
|
|
900
988
|
else
|
|
901
989
|
{
|
|
902
990
|
out.push(`/**\n${doc}\n */`);
|
|
903
|
-
out.push(
|
|
991
|
+
out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
|
|
904
992
|
}
|
|
905
993
|
out.push("{");
|
|
906
994
|
for (const line of emitter.prelude)
|
|
@@ -909,8 +997,15 @@ export function compileMessages(locale: string, catalog: Catalog, options: Compi
|
|
|
909
997
|
}
|
|
910
998
|
out.push(` return ${body};`);
|
|
911
999
|
out.push("}");
|
|
1000
|
+
if (binding !== name)
|
|
1001
|
+
{
|
|
1002
|
+
out.push(`export { ${binding} as ${name} };`);
|
|
1003
|
+
}
|
|
912
1004
|
out.push("");
|
|
913
1005
|
}
|
|
1006
|
+
const helpers = helpersFor(used, javascript);
|
|
1007
|
+
const shared = Array.from(formatters, ([create, binding]) => `const ${binding} = $once(() => ${create});`);
|
|
1008
|
+
out.splice(head, 0, ...(helpers === "" ? [] : [helpers, ""]), ...(shared.length === 0 ? [] : [...shared, ""]));
|
|
914
1009
|
return { code: out.join("\n"), names };
|
|
915
1010
|
}
|
|
916
1011
|
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { type ReadableSignal, type Signal, computed, effect, signal } from "zero
|
|
|
12
12
|
/** One entry of an `Accept-Language` header or of `navigator.languages`. */
|
|
13
13
|
export interface LanguageRange
|
|
14
14
|
{
|
|
15
|
+
/** The language tag, or `*` for any language, as written before any `;` parameters. */
|
|
15
16
|
readonly range: string;
|
|
17
|
+
/** The entry's `q` weight between 0 and 1, rounded to three decimals; 1 when the entry gives none. */
|
|
16
18
|
readonly quality: number;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -67,7 +69,9 @@ function canonical(tag: string): string
|
|
|
67
69
|
* `Accept-Language` header, `navigator.languages`, or one tag. Each range
|
|
68
70
|
* is tried in order of quality, first as an exact match, then by dropping
|
|
69
71
|
* subtags from the end as RFC 4647 lookup does, then as the language of a
|
|
70
|
-
* more specific supported locale
|
|
72
|
+
* more specific supported locale, preferring one written in the same
|
|
73
|
+
* script, so `zh-HK` finds `zh-TW` before `zh-CN`. `*` and no match at all
|
|
74
|
+
* give `fallback`.
|
|
71
75
|
*/
|
|
72
76
|
export function negotiateLocale(requested: string | readonly string[] | null | undefined, supported: readonly string[], fallback: string): string
|
|
73
77
|
{
|
|
@@ -89,18 +93,33 @@ export function negotiateLocale(requested: string | readonly string[] | null | u
|
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
const language = wanted.split("-")[0] ?? wanted;
|
|
92
|
-
const related = candidates.
|
|
93
|
-
if (related
|
|
96
|
+
const related = candidates.filter((candidate) => candidate.lower.startsWith(`${language}-`));
|
|
97
|
+
if (related.length > 0)
|
|
94
98
|
{
|
|
95
|
-
|
|
99
|
+
const script = scriptOf(range);
|
|
100
|
+
return (related.find((candidate) => scriptOf(candidate.tag) === script) ?? related[0])?.tag ?? fallback;
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
return fallback;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
|
|
106
|
+
/** The script a locale is written in, with the likely script filled in, as `Hant` for `zh-TW`; undefined for a tag that does not parse. */
|
|
107
|
+
function scriptOf(tag: string): string | undefined
|
|
108
|
+
{
|
|
109
|
+
try
|
|
110
|
+
{
|
|
111
|
+
return new Intl.Locale(tag).maximize().script;
|
|
112
|
+
}
|
|
113
|
+
catch
|
|
114
|
+
{
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
102
118
|
|
|
103
|
-
/** The
|
|
119
|
+
/** The scripts written right to left, by their ISO 15924 codes in lowercase. */
|
|
120
|
+
const RTL_SCRIPTS: ReadonlySet<string> = new Set(["adlm", "arab", "hebr", "mand", "mend", "nkoo", "rohg", "samr", "syrc", "thaa", "yezi"]);
|
|
121
|
+
|
|
122
|
+
/** The writing direction of a locale, from `Intl.Locale` where the engine provides it and otherwise from its script, the likely one when the tag names none, as engines decide. */
|
|
104
123
|
export function localeDirection(locale: string): "ltr" | "rtl"
|
|
105
124
|
{
|
|
106
125
|
try
|
|
@@ -112,13 +131,7 @@ export function localeDirection(locale: string): "ltr" | "rtl"
|
|
|
112
131
|
{
|
|
113
132
|
return info.direction === "rtl" ? "rtl" : "ltr";
|
|
114
133
|
}
|
|
115
|
-
|
|
116
|
-
const script = parsed.script?.toLowerCase();
|
|
117
|
-
if (script === "arab" || script === "hebr" || script === "thaa" || script === "syrc" || script === "nkoo")
|
|
118
|
-
{
|
|
119
|
-
return "rtl";
|
|
120
|
-
}
|
|
121
|
-
return RTL_LANGUAGES.has(language) || RTL_LANGUAGES.has(`${language}-${script ?? ""}`) ? "rtl" : "ltr";
|
|
134
|
+
return RTL_SCRIPTS.has((parsed.maximize().script ?? "").toLowerCase()) ? "rtl" : "ltr";
|
|
122
135
|
}
|
|
123
136
|
catch
|
|
124
137
|
{
|
|
@@ -131,7 +144,7 @@ export interface LocaleOptions
|
|
|
131
144
|
{
|
|
132
145
|
/** The locales the application ships, most preferred first. */
|
|
133
146
|
supported: readonly string[];
|
|
134
|
-
/** The locale used when nothing matches
|
|
147
|
+
/** The locale used when nothing matches, one of `supported`. Defaults to the first supported one. */
|
|
135
148
|
fallback?: string;
|
|
136
149
|
/** What to negotiate from at first: an `Accept-Language` header, `navigator.languages`, or a tag. Defaults to the fallback. */
|
|
137
150
|
initial?: string | readonly string[] | null;
|
|
@@ -144,16 +157,25 @@ export interface LocaleState
|
|
|
144
157
|
readonly locale: ReadableSignal<string>;
|
|
145
158
|
/** The writing direction of the current locale. */
|
|
146
159
|
readonly direction: ReadableSignal<"ltr" | "rtl">;
|
|
160
|
+
/** A copy of the `supported` option, in the same order; `set` negotiates against it. */
|
|
147
161
|
readonly supported: readonly string[];
|
|
148
162
|
/** Switches locales after negotiating the request against `supported`. */
|
|
149
163
|
set(requested: string | readonly string[]): void;
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
/** Creates the locale state for an application. */
|
|
166
|
+
/** Creates the locale state for an application. Throws a `TypeError` when `supported` is empty or `fallback` is not one of its locales, since the locale must always be one of them. */
|
|
153
167
|
export function createLocale(options: LocaleOptions): LocaleState
|
|
154
168
|
{
|
|
155
169
|
const supported = [...options.supported];
|
|
156
|
-
|
|
170
|
+
if (supported.length === 0)
|
|
171
|
+
{
|
|
172
|
+
throw new TypeError("zerolith/i18n: createLocale needs at least one supported locale");
|
|
173
|
+
}
|
|
174
|
+
const fallback = options.fallback === undefined ? (supported[0] ?? "") : supported.find((tag) => canonical(tag).toLowerCase() === canonical(options.fallback ?? "").toLowerCase());
|
|
175
|
+
if (fallback === undefined)
|
|
176
|
+
{
|
|
177
|
+
throw new TypeError(`zerolith/i18n: the fallback ${options.fallback ?? ""} is not one of the supported locales, ${supported.join(", ")}`);
|
|
178
|
+
}
|
|
157
179
|
const locale: Signal<string> = signal(negotiateLocale(options.initial ?? fallback, supported, fallback), { name: "locale" });
|
|
158
180
|
return {
|
|
159
181
|
locale,
|
|
@@ -179,10 +201,15 @@ export function applyLocale(state: LocaleState, doc: Document = document): () =>
|
|
|
179
201
|
/** Formatters for the current locale; each reads the locale signal when called, so a computed value or template re-renders on a locale change. */
|
|
180
202
|
export interface Formatters
|
|
181
203
|
{
|
|
204
|
+
/** Formats a number with `Intl.NumberFormat`. */
|
|
182
205
|
number(value: number, options?: Intl.NumberFormatOptions): string;
|
|
206
|
+
/** Formats a `Date`, or a timestamp in milliseconds since the epoch, with `Intl.DateTimeFormat`. */
|
|
183
207
|
date(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
|
|
208
|
+
/** Formats a time `value` units of `unit` away from now with `Intl.RelativeTimeFormat`, such as `in 2 hours`; a negative value lies in the past. */
|
|
184
209
|
relative(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
|
|
210
|
+
/** Joins strings into a list with `Intl.ListFormat`, such as `a, b, and c` in English. */
|
|
185
211
|
list(values: Iterable<string>, options?: Intl.ListFormatOptions): string;
|
|
212
|
+
/** Selects the plural category of a number with `Intl.PluralRules`, such as `one` or `other`, for choosing a message variant. */
|
|
186
213
|
plural(value: number, options?: Intl.PluralRulesOptions): Intl.LDMLPluralRule;
|
|
187
214
|
}
|
|
188
215
|
|