@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/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
- return `number(${this.numeric(expression)}, ${this.numberOptions(expression)})`;
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 `date(${value}, ${this.dateOptions(expression)})`;
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
- return `{ value: String(${value}), categories: [plural(${value}, ${JSON.stringify(mode === "ordinal" ? "ordinal" : "cardinal")})] }`;
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
- this.locals.set(name, `local_${name}`);
671
- this.prelude.push(`const local_${name} = ${value};`);
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
- return `select([${selectors.join(", ")}], [${variants.join(", ")}])`;
729
+ this.used.add("select");
730
+ return `$select([${selectors.join(", ")}], [${variants.join(", ")}])`;
681
731
  }
682
732
  }
683
733
 
684
- const HELPERS = `const formats = new Map();
685
- function cached(kind, options, create)
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
- function number(value, options)
751
+ }`,
752
+ ts: `const $formats: Map<string, unknown> = new Map();
753
+ function $cached<T>(kind: string, options: object, create: () => T): T
697
754
  {
698
- return cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
699
- }
700
- function date(value, options)
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
- return cached("d", options, () => new Intl.DateTimeFormat(locale, options)).format(value);
703
- }
704
- function plural(value, type)
768
+ let instance;
769
+ return () => (instance ??= create());
770
+ }`,
771
+ ts: `function $once<T>(create: () => T): () => T
705
772
  {
706
- return cached("p", { type }, () => new Intl.PluralRules(locale, { type })).select(value);
707
- }
708
- function select(selectors, variants)
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
- 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
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
- out.push(javascript ? HELPERS : HELPERS_TS);
877
- out.push("");
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(`export function ${name}(${args.length > 0 ? "args" : ""})`);
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(`export function ${name}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
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. `*` and no match at all give `fallback`.
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.find((candidate) => candidate.lower.startsWith(`${language}-`));
93
- if (related !== undefined)
96
+ const related = candidates.filter((candidate) => candidate.lower.startsWith(`${language}-`));
97
+ if (related.length > 0)
94
98
  {
95
- return related.tag;
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
- const RTL_LANGUAGES = new Set(["ar", "arc", "ckb", "dv", "fa", "he", "iw", "ks", "ku", "nqo", "pa-arab", "ps", "sd", "syr", "ug", "ur", "yi"]);
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 writing direction of a locale, from `Intl.Locale` where the engine provides it and from the script or language otherwise. */
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
- const language = parsed.language.toLowerCase();
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. Defaults to the first supported one. */
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
- const fallback = options.fallback ?? supported[0] ?? "en";
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