@zerolith/i18n 0.1.0-alpha.1 → 0.1.0-alpha.10

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/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