@k8ordo/i18n 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/locales.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import { inBrowser, localeStorage, register } from "./current.mjs";
1
+ import { parseAcceptLanguage } from "./accept-language.mjs";
2
+ import { readCookie } from "./cookie.mjs";
3
+ import { browserPathname, inBrowser, localeStorage, register } from "./current.mjs";
4
+ import { intlFormats } from "./format.mjs";
2
5
  //#region src/locales.ts
3
6
  /** `Intl.Locale` throws a RangeError on a tag that is not BCP 47; that is the only check a tag needs. */
4
7
  const parseTag = (tag) => {
@@ -14,12 +17,33 @@ const localize = (pathname, locale) => {
14
17
  if (rest !== "" && !rest.startsWith("/")) throw new TypeError(`localize: ${JSON.stringify(pathname)} is not a pathname — it does not start with "/"`);
15
18
  return `/${locale}${rest}`;
16
19
  };
20
+ /**
21
+ * Where a server keeps the locale. Without one this throws rather than
22
+ * falling back: the locale set would be ignored and the page rendered in the
23
+ * default.
24
+ */
25
+ const serverStorage = (caller) => {
26
+ const storage = localeStorage();
27
+ if (storage === null) throw new Error(`${caller}: this runtime has no AsyncLocalStorage to scope a locale to`);
28
+ return storage;
29
+ };
17
30
  /** Server only: the browser has no request, and its URL already is the locale. */
18
31
  const run = (locale, fn) => {
19
32
  if (inBrowser) throw new Error("locales.run: in the browser the URL is the locale — navigate instead");
20
- const storage = localeStorage();
21
- if (storage === null) throw new Error("locales.run: this runtime has no AsyncLocalStorage to scope a locale to");
22
- return storage.run(locale, fn);
33
+ return serverStorage("locales.run").run(locale, fn);
34
+ };
35
+ /**
36
+ * `Intl.DateTimeFormat` throws a RangeError on a time zone it does not know,
37
+ * and quietly takes the runtime's own for a missing one — the very value
38
+ * that differs between a server and a browser — so both are `null` here.
39
+ */
40
+ const resolveTimeZone = (value) => {
41
+ if (typeof value !== "string") return null;
42
+ try {
43
+ return new Intl.DateTimeFormat("en", { timeZone: value }).resolvedOptions().timeZone;
44
+ } catch {
45
+ return null;
46
+ }
23
47
  };
24
48
  /**
25
49
  * Declares the locale set once. Everything that needs to know which locales
@@ -28,18 +52,24 @@ const run = (locale, fn) => {
28
52
  * every message — reads this value, so the list is spelled in one place.
29
53
  *
30
54
  * ```ts
31
- * export const locales = defineLocales(['ja', 'en']);
32
- * export const locales = defineLocales(['en-US', 'en-GB', 'ja'], { default: 'ja' });
55
+ * export const locales = defineLocales({
56
+ * ja: { timeZone: 'Asia/Tokyo', dir: 'ltr' },
57
+ * en: { timeZone: 'America/New_York', dir: 'ltr' },
58
+ * });
33
59
  * ```
34
60
  */
35
- const defineLocales = (all, options) => {
36
- const list = all;
37
- if (new Set(list).size !== list.length) throw new TypeError(`defineLocales: a locale is listed twice in ${JSON.stringify(list)}`);
61
+ const defineLocales = (definitions, options) => {
62
+ const byTag = definitions;
63
+ const list = Object.keys(byTag);
64
+ if (list.length === 0) throw new TypeError("defineLocales: no locale is defined");
38
65
  const fallback = options?.default ?? list[0];
39
66
  if (!list.includes(fallback)) throw new TypeError(`defineLocales: the default ${JSON.stringify(fallback)} is not in ${JSON.stringify(list)}`);
40
67
  const supported = list.map((tag) => {
41
68
  const parsed = parseTag(tag);
42
69
  if (parsed === null) throw new TypeError(`defineLocales: ${JSON.stringify(tag)} is not a BCP 47 language tag`);
70
+ const { timeZone, dir } = byTag[tag];
71
+ if (resolveTimeZone(timeZone) === null) throw new TypeError(`defineLocales: the timeZone of ${JSON.stringify(tag)}, ${JSON.stringify(timeZone)}, is not a time zone`);
72
+ if (dir !== "ltr" && dir !== "rtl") throw new TypeError(`defineLocales: the dir of ${JSON.stringify(tag)}, ${JSON.stringify(dir)}, is neither "ltr" nor "rtl"`);
43
73
  return {
44
74
  tag,
45
75
  language: parsed.language
@@ -58,6 +88,15 @@ const defineLocales = (all, options) => {
58
88
  }
59
89
  return fallback;
60
90
  };
91
+ const negotiateRequest = (request, { cookie } = {}) => {
92
+ const chosen = cookie === void 0 ? null : readCookie(request.headers.get("cookie"), cookie);
93
+ return negotiate([...chosen === null ? [] : [chosen], ...parseAcceptLanguage(request.headers.get("accept-language"))]);
94
+ };
95
+ const paths = (patterns) => patterns.flatMap((pattern) => {
96
+ const segments = pattern.split("/");
97
+ if (!segments.includes(":locale")) return [pattern];
98
+ return list.map((locale) => segments.map((segment) => segment === ":locale" ? locale : segment).join("/"));
99
+ });
61
100
  const delocalize = (pathname) => {
62
101
  const first = pathname.split("/")[1] ?? "";
63
102
  if (!is(first)) return {
@@ -71,7 +110,7 @@ const defineLocales = (all, options) => {
71
110
  };
72
111
  };
73
112
  const getLocale = () => {
74
- if (inBrowser) return delocalize(location.pathname).locale ?? fallback;
113
+ if (inBrowser) return delocalize(browserPathname()).locale ?? fallback;
75
114
  const current = localeStorage()?.getStore();
76
115
  return is(current) ? current : fallback;
77
116
  };
@@ -84,7 +123,7 @@ const defineLocales = (all, options) => {
84
123
  message: `expected one of ${JSON.stringify(list)}, received ${JSON.stringify(locale)}`,
85
124
  path: ["locale"]
86
125
  }] };
87
- localeStorage()?.enterWith(locale);
126
+ if (!inBrowser) serverStorage("locales.paramsSchema").enterWith(locale);
88
127
  return { value: { locale } };
89
128
  }
90
129
  } };
@@ -94,14 +133,18 @@ const defineLocales = (all, options) => {
94
133
  });
95
134
  return {
96
135
  all: list,
136
+ definitions: byTag,
97
137
  default: fallback,
98
138
  is,
99
139
  negotiate,
140
+ negotiateRequest,
100
141
  localize,
101
142
  delocalize,
143
+ paths,
102
144
  paramsSchema,
103
145
  getLocale,
104
- run
146
+ run,
147
+ ...intlFormats(getLocale, byTag)
105
148
  };
106
149
  };
107
150
  //#endregion
@@ -5,9 +5,9 @@ import { RegisteredLocale } from "./register.mjs";
5
5
  * string), returning the text in the current locale. What a component that
6
6
  * takes a message as a prop should accept.
7
7
  */
8
- type Message<A extends readonly unknown[] = []> = (...args: A) => string;
8
+ export type Message<A extends readonly unknown[] = []> = (...args: A) => string;
9
9
  /** One value per locale — every locale, no locale twice. */
10
- type Variants<V> = Readonly<Record<RegisteredLocale, V>>;
10
+ export type Variants<V> = Readonly<Record<RegisteredLocale, V>>;
11
11
  /**
12
12
  * Declares one message with its text in every locale. The result reads the
13
13
  * locale where it is called — the request on a server, the URL in the
@@ -25,7 +25,6 @@ type Variants<V> = Readonly<Record<RegisteredLocale, V>>;
25
25
  * When nothing names a locale, the text is the default locale's — the one
26
26
  * `defineLocales` registered, else the first variant written.
27
27
  */
28
- declare function message(variants: Variants<string>): Message;
29
- declare function message<A extends readonly unknown[]>(variants: Variants<(...args: A) => string>): Message<A>;
30
- //#endregion
31
- export { Message, Variants, message };
28
+ export declare function message(variants: Variants<string>): Message;
29
+ export declare function message<A extends readonly unknown[]>(variants: Variants<(...args: A) => string>): Message<A>;
30
+ //#endregion
package/dist/message.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { currentLocale, registeredDefault, setRegistered } from "./current.mjs";
1
+ import { namedLocale, registeredDefault, setRegistered } from "./current.mjs";
2
2
  //#region src/message.ts
3
3
  function message(variants) {
4
4
  return (...args) => {
5
- const named = currentLocale();
5
+ const named = namedLocale();
6
6
  const locale = named !== null && (setRegistered() || Object.hasOwn(variants, named)) ? named : registeredDefault();
7
7
  const variant = locale === null ? variants[Object.keys(variants)[0] ?? ""] : variants[locale];
8
8
  if (variant === void 0) throw new TypeError(`message: no text for ${JSON.stringify(locale)} in ${JSON.stringify(Object.keys(variants))}`);
@@ -1,7 +1,8 @@
1
1
  //#region src/register.d.ts
2
2
  /**
3
- * Merged by the application once, so `message()` knows which locales every
4
- * message must carry — without each message naming the set:
3
+ * Merged once, so `message()` knows which locales every message must carry —
4
+ * without each message naming the set. `@k8ordo/framework` generates it from
5
+ * the `locales` export of `src/i18n.ts`; any other application writes it:
5
6
  *
6
7
  * ```ts
7
8
  * declare module '@k8ordo/i18n' {
@@ -14,10 +15,9 @@
14
15
  * The one `interface` in this package, kept so because it exists to be
15
16
  * merged (see the repository's conventions).
16
17
  */
17
- interface Register {}
18
+ export interface Register {}
18
19
  /** The application's locale union once `Register` is merged; `string` before. */
19
- type RegisteredLocale = Register extends {
20
+ export type RegisteredLocale = Register extends {
20
21
  locale: infer L extends string;
21
22
  } ? L : string;
22
- //#endregion
23
- export { Register, RegisteredLocale };
23
+ //#endregion