@pithy-sh/i18n 0.12.0 → 0.14.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.
Files changed (45) hide show
  1. package/dist/capability.d.ts +1 -1
  2. package/dist/capability.d.ts.map +1 -1
  3. package/dist/capability.js +1 -1
  4. package/dist/catalogs/es/errors.d.ts.map +1 -1
  5. package/dist/catalogs/es/errors.js +18 -0
  6. package/dist/catalogs/es/screens.d.ts.map +1 -1
  7. package/dist/catalogs/es/screens.js +79 -0
  8. package/dist/config/config.d.ts.map +1 -1
  9. package/dist/config/config.js +0 -1
  10. package/dist/native/environment.d.ts +16 -0
  11. package/dist/native/environment.d.ts.map +1 -0
  12. package/dist/native/environment.js +56 -0
  13. package/dist/native/useNativeDirection.d.ts +13 -0
  14. package/dist/native/useNativeDirection.d.ts.map +1 -0
  15. package/dist/native/useNativeDirection.js +25 -0
  16. package/dist/react/browserLocaleEnvironment.d.ts +6 -0
  17. package/dist/react/browserLocaleEnvironment.d.ts.map +1 -0
  18. package/dist/react/browserLocaleEnvironment.js +28 -0
  19. package/dist/react/localeEnvironment.d.ts +29 -0
  20. package/dist/react/localeEnvironment.d.ts.map +1 -0
  21. package/dist/react/localeEnvironment.js +2 -0
  22. package/dist/react/useDocumentLocale.d.ts +10 -0
  23. package/dist/react/useDocumentLocale.d.ts.map +1 -0
  24. package/dist/react/useDocumentLocale.js +23 -0
  25. package/dist/react/useNegotiatedLocale.d.ts +16 -3
  26. package/dist/react/useNegotiatedLocale.d.ts.map +1 -1
  27. package/dist/react/useNegotiatedLocale.js +30 -37
  28. package/dist/resolve/browser.d.ts +2 -1
  29. package/dist/resolve/browser.d.ts.map +1 -1
  30. package/dist/resolve/browser.js +2 -1
  31. package/dist/version.generated.d.ts +1 -1
  32. package/dist/version.generated.js +1 -1
  33. package/package.json +20 -3
  34. package/src/capability.ts +5 -9
  35. package/src/catalogs/es/errors.ts +20 -0
  36. package/src/catalogs/es/screens.ts +94 -1
  37. package/src/config/config.ts +0 -1
  38. package/src/native/environment.ts +57 -0
  39. package/src/native/useNativeDirection.ts +24 -0
  40. package/src/react/browserLocaleEnvironment.ts +32 -0
  41. package/src/react/localeEnvironment.ts +31 -0
  42. package/src/react/useDocumentLocale.ts +18 -0
  43. package/src/react/useNegotiatedLocale.ts +52 -51
  44. package/src/resolve/browser.ts +2 -1
  45. package/src/version.generated.ts +1 -1
@@ -3,11 +3,13 @@
3
3
 
4
4
  import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
5
5
  import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
6
- import { useCallback, useEffect, useMemo, useState } from "react";
7
- import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals";
6
+ import { useRead } from "@pithy-sh/ui-core/src/read";
7
+ import { useCallback, useMemo, useState } from "react";
8
8
  import { loadKitCatalog } from "../catalogs/browser";
9
9
  import type { I18nClientProjection } from "../client/projection";
10
10
  import { resolveBrowserLocale } from "../resolve/browser";
11
+ import { browserLocaleEnvironment } from "./browserLocaleEnvironment";
12
+ import type { LocaleEnvironment } from "./localeEnvironment";
11
13
  import type { TranslatorSource } from "./translator";
12
14
 
13
15
  /** What the hook hands back: what to mount, what was chosen, and how to choose again. */
@@ -20,8 +22,14 @@ export interface NegotiatedLocale {
20
22
  readonly source: TranslatorSource | null;
21
23
  /** The catalog locale in force — the words the app has. */
22
24
  readonly locale: string;
23
- /** Choose a language. Remembers it on this device and puts `lang`/`dir` on the document. */
25
+ /** Choose a language. Remembers it on this device and applies it through the environment. */
24
26
  readonly choose: (locale: string) => void;
27
+ /**
28
+ * `true` when the locale in force lays out in the other direction from the one this launch started with, and the
29
+ * new direction takes effect only after the app reloads. React Native's rule; a browser never answers `true`. A
30
+ * screen says so rather than flip half a layout.
31
+ */
32
+ readonly reloadRequired: boolean;
25
33
  }
26
34
 
27
35
  /** What the caller knows that the browser cannot work out for itself. */
@@ -50,6 +58,12 @@ export interface NegotiatedLocaleOptions {
50
58
  * catch it here where you know what your API meant.
51
59
  */
52
60
  readonly persist?: (locale: string) => void | Promise<void>;
61
+ /**
62
+ * Where the starting signals come from, where a choice is remembered and how the locale is applied. The browser's
63
+ * by default; pass `nativeLocaleEnvironment` from `@pithy-sh/i18n/src/native/environment` on React Native. Keep it
64
+ * the same object for the life of the component.
65
+ */
66
+ readonly environment?: LocaleEnvironment;
53
67
  }
54
68
 
55
69
  /**
@@ -62,8 +76,8 @@ export interface NegotiatedLocaleOptions {
62
76
  const UNNEGOTIATED_LOCALE = "en";
63
77
 
64
78
  /**
65
- * Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
66
- * step.
79
+ * Negotiate the reader's locale, load that locale's catalog, and keep the platform in step: the document in a
80
+ * browser, `I18nManager` on React Native, through `options.environment`.
67
81
  *
68
82
  * **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
69
83
  * else — so a screen reads the chain order **this project** configured rather than one the front end
@@ -91,24 +105,23 @@ export function useNegotiatedLocale(
91
105
  // and every hook below runs either way and answers for it rather than being skipped — a hook behind a
92
106
  // condition is the one thing React does not allow.
93
107
  const config = projection.enabled ? projection : null;
94
- // The same fact as a boolean, and the two effects below depend on **this** rather than on `config`.
95
- // Composed, `config` is `projection` under another name, so its identity is the caller's — and a
96
- // caller is free to hand the hook a fresh object every render (`{ ...i18nConfig }`, or a parse in a
97
- // component body). An effect keyed on that identity re-runs on every render; the catalog effect sets
98
- // state, so it re-renders itself, forever, with no error anywhere to read. Both effects want nothing
99
- // from `config` but whether it is there, and a boolean is stable by value however the caller spells
100
- // its projection.
108
+ // The same fact as a boolean, which the catalog read and the document hook key on rather than `config`. A caller
109
+ // is free to hand the hook a fresh projection every render, and a boolean is stable by value however it is spelled.
101
110
  const enabled = projection.enabled;
111
+ const environment = options.environment ?? browserLocaleEnvironment;
102
112
 
113
+ const signals = environment.useSignals(config);
114
+ // A signal still being read, such as storage on React Native: nothing is negotiated, fetched or applied until it
115
+ // answers, so a remembered choice is not preceded by a flash of another language.
116
+ const pending = enabled && signals === undefined;
103
117
  const resolved = useMemo(
104
118
  () =>
105
- config === null
119
+ config === null || signals === undefined
106
120
  ? null
107
- : resolveBrowserLocale({ ...readBrowserSignals(config), account: options.account }, config),
108
- [config, options.account],
121
+ : resolveBrowserLocale({ ...signals, account: options.account }, config),
122
+ [config, signals, options.account],
109
123
  );
110
124
  const [chosen, setChosen] = useState<string | null>(null);
111
- const [kit, setKit] = useState<{ locale: string; catalog: MessageCatalog } | null>(null);
112
125
 
113
126
  const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
114
127
  // A chosen locale is a catalog locale, so it carries no region; the negotiated one may be more
@@ -119,28 +132,18 @@ export function useNegotiatedLocale(
119
132
  // project without the capability could mean.
120
133
  const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
121
134
 
122
- useEffect(() => {
123
- // No chunk is asked for at all with the capability uncomposed, and that is the whole of what keeps
124
- // `source` null there: `kit` never arrives, so the memo below has nothing to mount and the caller
125
- // renders through the same null it already renders through while a catalog is in flight.
126
- if (!enabled) return;
127
- let live = true;
128
- loadKitCatalog(locale)
129
- .then((catalog) => {
130
- if (live) setKit({ locale, catalog });
131
- })
132
- // A per-locale chunk that will not load — a 404 after a deploy, an offline tab — must not leave
133
- // the provider unmounted forever with `lang` already changed on the document. That is the
134
- // "declares a language it does not speak" failure by another road: a screen reader believes the
135
- // attribute and mispronounces English. An empty catalog mounts the provider, so every screen
136
- // falls through to the English it was scaffolded with, which is what is actually on screen.
137
- .catch(() => {
138
- if (live) setKit({ locale, catalog: {} });
139
- });
140
- return () => {
141
- live = false;
142
- };
143
- }, [enabled, locale]);
135
+ // No chunk is asked for with the capability uncomposed, which is what keeps `source` null there. A per-locale chunk
136
+ // that will not load — a 404 after a deploy, an offline tab — mounts an empty catalog rather than leaving the
137
+ // provider unmounted under a `lang` already changed on the document: every screen then falls through to the
138
+ // English it was scaffolded with, which is what is actually on screen.
139
+ const kit = useRead(
140
+ `catalog:${locale}`,
141
+ async (): Promise<{ locale: string; catalog: MessageCatalog }> => ({
142
+ locale,
143
+ catalog: await loadKitCatalog(locale).catch((): MessageCatalog => ({})),
144
+ }),
145
+ { skip: !enabled || pending },
146
+ ).value;
144
147
 
145
148
  // Derived from the locale in force, never taken off `resolved`. `resolveBrowserLocale` answers for
146
149
  // what the *chain* negotiated, and `choose` moves past it: a reader who picks Arabic from a language
@@ -150,13 +153,7 @@ export function useNegotiatedLocale(
150
153
  // byte-identical to what it answered before.
151
154
  const direction = localeDirection(locale);
152
155
 
153
- useEffect(() => {
154
- // Nothing negotiated it, so nothing declares it. A project without the capability keeps the `lang`
155
- // its `index.html` shipped — restamping the document with a locale no chain chose would be the
156
- // "declares a language it does not speak" failure with the negotiation removed rather than added.
157
- if (!enabled) return;
158
- applyDocumentLocale(locale, direction);
159
- }, [enabled, locale, direction]);
156
+ const reloadRequired = environment.useApply(enabled && !pending, locale, direction);
160
157
 
161
158
  const persist = options.persist;
162
159
  const choose = useCallback(
@@ -164,9 +161,13 @@ export function useNegotiatedLocale(
164
161
  // No languages to choose between, and no `storageKey` to remember one under.
165
162
  if (config === null) return;
166
163
  if (!config.supportedLocales.includes(next)) return;
167
- // The device first, always: it is synchronous, it cannot fail in a way worth waiting for, and it
168
- // is what answers on the next visit if the account write does not land.
169
- rememberBrowserLocale(next, config);
164
+ // The device first, always: it is what answers on the next visit if the account write does not land. Not
165
+ // awaited, and a failure costs only the memory of the choice, never the choice.
166
+ try {
167
+ Promise.resolve(environment.remember(next, config)).catch(() => undefined);
168
+ } catch {
169
+ // A store that throws before it answers. The reader still gets the language.
170
+ }
170
171
  setChosen(next);
171
172
  // Then the account, when the app knows who is reading. Not awaited — a language switch is a
172
173
  // render, not a round trip, and nothing on screen should wait for a preference.
@@ -178,13 +179,13 @@ export function useNegotiatedLocale(
178
179
  // reporting is a question about the adopter's own API, so it is answered inside `persist`.
179
180
  Promise.resolve(persist?.(next)).catch(() => undefined);
180
181
  },
181
- [config, persist],
182
+ [config, persist, environment],
182
183
  );
183
184
 
184
185
  const source = useMemo<TranslatorSource | null>(() => {
185
186
  // Only once the catalog for *this* locale has landed. Mounting the previous locale's catalog under
186
187
  // the new locale's tag is a page that says it is Spanish and reads English. This is also the one
187
- // test the uncomposed case has to pass: nothing ever loads a catalog there, so `kit` stays null
188
+ // test the uncomposed case has to pass: nothing ever loads a catalog there, so `kit` stays undefined
188
189
  // and no provider is ever mounted over screens already rendering their baked English.
189
190
  if (kit?.locale !== locale) return null;
190
191
  return {
@@ -194,5 +195,5 @@ export function useNegotiatedLocale(
194
195
  };
195
196
  }, [kit, locale, formattingLocale, options.messages, defaultLocale]);
196
197
 
197
- return { source, locale, choose };
198
+ return { source, locale, choose, reloadRequired };
198
199
  }
@@ -88,7 +88,8 @@ export interface BrowserLocaleSignals {
88
88
  /**
89
89
  * The reader's locale, by the configured browser chain.
90
90
  *
91
- * Default order: `?lang=`, the account, local storage, the server's answer, the project default.
91
+ * Default order: `?lang=`, the account, local storage, the browser's own languages, the server's answer,
92
+ * the project default.
92
93
  *
93
94
  * **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
94
95
  * signed-in reader who picks a language on one device must not silently be reading another language on
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/i18n";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.12.0";
16
+ export const PACKAGE_VERSION = "0.14.0";