@saykit/react 0.9.1 → 0.10.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/README.md +10 -3
- package/dist/client.d.mts +45 -18
- package/dist/client.mjs +17 -17
- package/dist/client.server.d.mts +18 -0
- package/dist/client.server.mjs +14 -0
- package/dist/index.d.mts +8 -10
- package/dist/server.d.mts +65 -21
- package/dist/server.mjs +20 -27
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=integration-react)
|
|
6
6
|
|
|
7
|
-
A `<Say>` component for rendering translated content in server and client components, a `<SayProvider>` and `useSay()` for client trees, and a
|
|
7
|
+
A `<Say>` component for rendering translated content in server and client components, a `<SayProvider>` and `useSay()` for client trees, and a `<SayScope>` and `getSay()` that mirror them on the server.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -17,11 +17,18 @@ You will also need a SayKit build-tool plugin and a `saykit.config.ts`.
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
19
|
```tsx
|
|
20
|
-
import { Say
|
|
20
|
+
import { Say } from '@saykit/react';
|
|
21
|
+
import { SayProvider } from '@saykit/react/client';
|
|
22
|
+
import { createCatalogue, createStore } from 'saykit';
|
|
23
|
+
|
|
24
|
+
const en = { greeting: 'Hello, {name}!' };
|
|
25
|
+
const fr = { greeting: 'Bonjour, {name} !' };
|
|
26
|
+
|
|
27
|
+
const store = createStore(createCatalogue({ en, fr }), 'fr');
|
|
21
28
|
|
|
22
29
|
function App() {
|
|
23
30
|
return (
|
|
24
|
-
<SayProvider
|
|
31
|
+
<SayProvider store={store}>
|
|
25
32
|
<Say>Hello, {name}!</Say>
|
|
26
33
|
<Say.Plural _={count} one={<>{count} item</>} other={<>{count} items</>} />
|
|
27
34
|
</SayProvider>
|
package/dist/client.d.mts
CHANGED
|
@@ -1,31 +1,58 @@
|
|
|
1
1
|
import { PropsWithChildren } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Store, View } from "saykit";
|
|
3
3
|
//#region src/runtime/client.d.ts
|
|
4
|
-
type SayRef = {
|
|
5
|
-
current: ReadonlySay | null;
|
|
6
|
-
};
|
|
7
4
|
/**
|
|
8
|
-
*
|
|
9
|
-
* Must wrap any component tree using {@link useSay} or {@link Say}.
|
|
5
|
+
* Where a provider takes its view from.
|
|
10
6
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* A store is the reactive form: it owns a catalogue, so it can switch locale,
|
|
8
|
+
* and every consumer re-renders when it does. Being a live object, it cannot
|
|
9
|
+
* cross the server/client boundary.
|
|
14
10
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* A locale and its messages are the serialisable form a server can hand across
|
|
12
|
+
* that boundary. Only that one locale comes over, so the provider built from
|
|
13
|
+
* it has nothing to switch to: switching is the server's to do, normally
|
|
14
|
+
* through navigation.
|
|
17
15
|
*/
|
|
18
|
-
|
|
16
|
+
type SayProviderProps = {
|
|
17
|
+
store: Store;
|
|
18
|
+
locale?: never;
|
|
19
|
+
messages?: never;
|
|
20
|
+
} | {
|
|
21
|
+
store?: never;
|
|
19
22
|
locale: string;
|
|
20
|
-
messages:
|
|
21
|
-
}
|
|
23
|
+
messages: View.Messages;
|
|
24
|
+
} | {
|
|
25
|
+
store?: never;
|
|
26
|
+
locale?: never;
|
|
27
|
+
messages?: never;
|
|
28
|
+
};
|
|
22
29
|
/**
|
|
23
|
-
*
|
|
30
|
+
* Provide a {@link View} to descendant **client** components via context.
|
|
31
|
+
* Must wrap any component tree using {@link useSay} or {@link Say}.
|
|
32
|
+
*
|
|
33
|
+
* @param props.store The store to follow, for an application that switches
|
|
34
|
+
* locale on the client
|
|
35
|
+
* @param props.locale The current locale, for one that was given a single
|
|
36
|
+
* locale by the server
|
|
37
|
+
* @param props.messages The messages for that locale, which should be
|
|
38
|
+
* referentially stable rather than a fresh object literal per render
|
|
39
|
+
*/
|
|
40
|
+
declare function SayProvider({ store, locale, messages, children }: PropsWithChildren<SayProviderProps>): import("react").FunctionComponentElement<import("react").ProviderProps<Store<string> | null>>;
|
|
41
|
+
/**
|
|
42
|
+
* Get the current {@link View}, on the client.
|
|
24
43
|
* Must be called within a {@link SayProvider}.
|
|
25
44
|
*
|
|
26
|
-
*
|
|
45
|
+
* The component re-renders when the store switches locale, so the view this
|
|
46
|
+
* returns is the current one rather than the one the tree first mounted with.
|
|
47
|
+
*
|
|
48
|
+
* There is no hook for the store behind it: a store is a module-scope value,
|
|
49
|
+
* so a locale picker imports the one it built and calls {@link Store.set} on
|
|
50
|
+
* it. A provider given a locale and its messages has no catalogue to switch
|
|
51
|
+
* through anyway.
|
|
52
|
+
*
|
|
53
|
+
* @returns The current {@link View}
|
|
27
54
|
* @throws If no provider is in the component tree
|
|
28
55
|
*/
|
|
29
|
-
declare function useSay():
|
|
56
|
+
declare function useSay(): View;
|
|
30
57
|
//#endregion
|
|
31
|
-
export { SayProvider, useSay };
|
|
58
|
+
export { SayProvider, SayProviderProps, useSay };
|
package/dist/client.mjs
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { createContext, createElement, useContext, useMemo } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
import { createCatalogue, createStore } from "saykit";
|
|
4
4
|
//#region src/runtime/client.ts
|
|
5
|
-
const SayContext = createContext(
|
|
5
|
+
const SayContext = createContext(null);
|
|
6
6
|
SayContext.displayName = "SayContext";
|
|
7
|
-
function SayProvider({ locale, messages, children }) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return createElement(SayContext.Provider, { value:
|
|
7
|
+
function SayProvider({ store, locale, messages, children }) {
|
|
8
|
+
const held = useMemo(() => {
|
|
9
|
+
if (store) return store;
|
|
10
|
+
if (locale === void 0 || !messages) throw new Error("'SayProvider' must be given a store, or a locale and its messages");
|
|
11
|
+
return createStore(createCatalogue({ [locale]: messages }), locale);
|
|
12
|
+
}, [
|
|
13
|
+
store,
|
|
14
|
+
locale,
|
|
15
|
+
messages
|
|
16
|
+
]);
|
|
17
|
+
return createElement(SayContext.Provider, { value: held }, children);
|
|
18
18
|
}
|
|
19
19
|
function useSay() {
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
return
|
|
20
|
+
const store = useContext(SayContext);
|
|
21
|
+
if (!store) throw new Error("'useSay' must be used within a 'SayProvider'");
|
|
22
|
+
return useSyncExternalStore(useMemo(() => (listener) => store.subscribe(listener), [store]), () => store.say, () => store.say);
|
|
23
23
|
}
|
|
24
24
|
//#endregion
|
|
25
25
|
export { SayProvider, useSay };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import "server-only";
|
|
3
|
+
//#region src/runtime/client.server.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The server build of `@saykit/react/client`.
|
|
6
|
+
*
|
|
7
|
+
* A store is a live object and cannot cross the server/client boundary, and a
|
|
8
|
+
* server component cannot hand its own scope to a client one either. What can
|
|
9
|
+
* cross is the locale and its messages, so this reads them off the view the
|
|
10
|
+
* enclosing {@link import('./server.js').SayScope} established and passes them
|
|
11
|
+
* to the real provider, which is why `<SayProvider>` written on the server
|
|
12
|
+
* takes no props.
|
|
13
|
+
*/
|
|
14
|
+
declare function SayProvider({ children }: {
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}): import("react").FunctionComponentElement<import("react").PropsWithChildren<import("./client.js").SayProviderProps>>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { SayProvider };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import "server-only";
|
|
3
|
+
import { SayProvider as SayProvider$1 } from "./client.mjs";
|
|
4
|
+
import { getSay } from "./server.mjs";
|
|
5
|
+
//#region src/runtime/client.server.ts
|
|
6
|
+
function SayProvider({ children }) {
|
|
7
|
+
const say = getSay();
|
|
8
|
+
return createElement(SayProvider$1, {
|
|
9
|
+
locale: say.locale,
|
|
10
|
+
messages: say.messages
|
|
11
|
+
}, children);
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { SayProvider };
|
package/dist/index.d.mts
CHANGED
|
@@ -3,7 +3,7 @@ import { DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, Select
|
|
|
3
3
|
//#region src/types.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* What a message is allowed to contain. On top of everything React renders, a
|
|
6
|
-
* named placeholder
|
|
6
|
+
* named placeholder, `{{ name: value }}`, reaches the type checker as a plain
|
|
7
7
|
* object child, so the object form has to be part of the contract even though
|
|
8
8
|
* nothing ever renders it: the transform reads the name off it and compiles the
|
|
9
9
|
* child away before React sees the tree.
|
|
@@ -48,7 +48,7 @@ declare namespace Say {
|
|
|
48
48
|
_: number | Named<number>;
|
|
49
49
|
} & PropsWithJSXSafeKeys<Disallow<NumeralOptions<ReactNode>, 'id' | 'context'>>): ReactNode;
|
|
50
50
|
/**
|
|
51
|
-
* Define an ordinal message (
|
|
51
|
+
* Define an ordinal message ("1st", "2nd", "3rd").
|
|
52
52
|
*
|
|
53
53
|
* @example
|
|
54
54
|
* ```tsx
|
|
@@ -70,7 +70,7 @@ declare namespace Say {
|
|
|
70
70
|
_: number | Named<number>;
|
|
71
71
|
} & PropsWithJSXSafeKeys<Disallow<NumeralOptions<ReactNode>, 'id' | 'context'>>): ReactNode;
|
|
72
72
|
/**
|
|
73
|
-
* Define a select message,
|
|
73
|
+
* Define a select message, for gender, status, or other categories.
|
|
74
74
|
*
|
|
75
75
|
* @example
|
|
76
76
|
* ```tsx
|
|
@@ -93,7 +93,7 @@ declare namespace Say {
|
|
|
93
93
|
/**
|
|
94
94
|
* Format a number the way the active locale writes one.
|
|
95
95
|
*
|
|
96
|
-
* Unlike `Say.Plural`, `Say.Ordinal
|
|
96
|
+
* Unlike `Say.Plural`, `Say.Ordinal` and `Say.Select`, this is a fragment
|
|
97
97
|
* rather than a whole message, and is normally written inside one.
|
|
98
98
|
*
|
|
99
99
|
* @example
|
|
@@ -104,8 +104,8 @@ declare namespace Say {
|
|
|
104
104
|
* ```
|
|
105
105
|
*
|
|
106
106
|
* @param props._ Number to format
|
|
107
|
-
* @param props.style
|
|
108
|
-
* `::currency/EUR`, or a
|
|
107
|
+
* @param props.style A named style, an ICU skeleton such as
|
|
108
|
+
* `::currency/EUR`, or a pattern such as `#,##0.00`
|
|
109
109
|
* @returns The formatted number, as a React node
|
|
110
110
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
111
111
|
*/
|
|
@@ -122,8 +122,7 @@ declare namespace Say {
|
|
|
122
122
|
* ```
|
|
123
123
|
*
|
|
124
124
|
* @param props._ Date to format
|
|
125
|
-
* @param props.style
|
|
126
|
-
* skeleton such as `::yyyyMMdd`
|
|
125
|
+
* @param props.style A named style or an ICU skeleton such as `::yyyyMMdd`
|
|
127
126
|
* @returns The formatted date, as a React node
|
|
128
127
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
129
128
|
*/
|
|
@@ -140,8 +139,7 @@ declare namespace Say {
|
|
|
140
139
|
* ```
|
|
141
140
|
*
|
|
142
141
|
* @param props._ Date to format
|
|
143
|
-
* @param props.style
|
|
144
|
-
* skeleton such as `::Hm`
|
|
142
|
+
* @param props.style A named style or an ICU skeleton such as `::Hm`
|
|
145
143
|
* @returns The formatted time, as a React node
|
|
146
144
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
147
145
|
*/
|
package/dist/server.d.mts
CHANGED
|
@@ -1,33 +1,77 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Catalogue, View } from "saykit";
|
|
3
3
|
import "server-only";
|
|
4
4
|
//#region src/runtime/server.d.ts
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* Must be called
|
|
6
|
+
* Get the current {@link View}, on the server.
|
|
7
|
+
* Must be called below a {@link SayScope}.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Get the current {@link Say} **server** instance.
|
|
14
|
-
* Must only be called after any {@link setSay} calls.
|
|
9
|
+
* The server counterpart of `useSay`. Reach for it when you need the locale as
|
|
10
|
+
* *data*, to build an `Intl.NumberFormat` say, rather than as a rendered
|
|
11
|
+
* message, which is what `<Say>` is for.
|
|
15
12
|
*
|
|
16
|
-
* @
|
|
17
|
-
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const say = getSay();
|
|
16
|
+
* const price = new Intl.NumberFormat(say.locale, { style: 'currency', currency }).format(total);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @returns The current {@link View}
|
|
20
|
+
* @throws If no {@link SayScope} is above the caller
|
|
18
21
|
*/
|
|
19
|
-
declare function getSay():
|
|
22
|
+
declare function getSay(): View;
|
|
23
|
+
declare namespace SayScope {
|
|
24
|
+
/**
|
|
25
|
+
* Which view a scope establishes: a catalogue and a locale to negotiate
|
|
26
|
+
* against it, or a view already resolved.
|
|
27
|
+
*/
|
|
28
|
+
type Props<Locale extends string = string> = {
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
} & ({
|
|
31
|
+
catalogue: Catalogue<Locale>;
|
|
32
|
+
locale: Catalogue.Guess;
|
|
33
|
+
view?: never;
|
|
34
|
+
} | {
|
|
35
|
+
view: View<Locale>;
|
|
36
|
+
catalogue?: never;
|
|
37
|
+
locale?: never;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
20
40
|
/**
|
|
21
|
-
*
|
|
41
|
+
* Establish the {@link View} for everything rendered inside it, on the server.
|
|
42
|
+
*
|
|
43
|
+
* Given a catalogue and a locale, the locale is negotiated against the
|
|
44
|
+
* catalogue and its messages are loaded before the children render. Given a
|
|
45
|
+
* view, that view is established as it is. Either way `<Say>` and
|
|
46
|
+
* {@link getSay} resolve at any depth below, and the scope is per request.
|
|
47
|
+
*
|
|
48
|
+
* Per request is also the limit. React renders a server component's children
|
|
49
|
+
* after it returns, so a scope does not end where its children do: a second
|
|
50
|
+
* scope takes over for everything rendered after it, including components
|
|
51
|
+
* outside it and the messages `<SayProvider>` serialises. Development warns
|
|
52
|
+
* when that happens. Render another locale in its own request, or resolve its
|
|
53
|
+
* view yourself and pass it to the components that need it.
|
|
54
|
+
*
|
|
55
|
+
* A `<SayProvider>` written inside one takes no props of its own: the server
|
|
56
|
+
* build of `@saykit/react/client` reads the established view and serialises
|
|
57
|
+
* the locale and its messages across the boundary.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```tsx
|
|
61
|
+
* <SayScope catalogue={catalogue} locale={locale}>
|
|
62
|
+
* <SayProvider>{children}</SayProvider>
|
|
63
|
+
* </SayScope>
|
|
64
|
+
* ```
|
|
22
65
|
*
|
|
23
|
-
* @
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* <SayScope view={await catalogue.load('fr')}>{children}</SayScope>
|
|
69
|
+
* ```
|
|
24
70
|
*
|
|
25
|
-
* @
|
|
71
|
+
* @param props.catalogue The catalogue to take the view from
|
|
72
|
+
* @param props.locale The locale to negotiate against it
|
|
73
|
+
* @param props.view A view to establish as it is, instead of both of those
|
|
26
74
|
*/
|
|
27
|
-
declare function
|
|
28
|
-
type PropsWithSay<P = unknown> = P & {
|
|
29
|
-
locale: string;
|
|
30
|
-
messages: Say.Messages;
|
|
31
|
-
};
|
|
75
|
+
declare function SayScope<Locale extends string>({ catalogue, locale, view, children }: SayScope.Props<Locale>): Promise<ReactNode>;
|
|
32
76
|
//#endregion
|
|
33
|
-
export {
|
|
77
|
+
export { SayScope, getSay };
|
package/dist/server.mjs
CHANGED
|
@@ -1,33 +1,26 @@
|
|
|
1
|
-
import { cache
|
|
2
|
-
import { Say } from "saykit";
|
|
1
|
+
import { cache } from "react";
|
|
3
2
|
import "server-only";
|
|
4
3
|
//#region src/runtime/server.ts
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
4
|
+
const cell = cache(() => ({
|
|
5
|
+
view: void 0,
|
|
6
|
+
warned: false
|
|
7
|
+
}));
|
|
8
|
+
const NO_VIEW = "'getSay' must be called below a 'SayScope'. Wrap the tree in '<SayScope catalogue={catalogue} locale={locale}>'.";
|
|
9
|
+
const NESTED_SCOPE = (established, next) => `A 'SayScope' established '${next}' while '${established}' was already established for this request. A scope is per request rather than per subtree: React renders a server component's children after it returns, so there is nowhere to put the previous view back, and everything rendered after this point reads '${next}' - including components outside the inner scope, and the messages 'SayProvider' serialises to the client. Render the other locale in its own request, or resolve its view yourself and pass it to the components that need it.`;
|
|
11
10
|
function getSay() {
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
return
|
|
11
|
+
const view = cell().view;
|
|
12
|
+
if (!view) throw new Error(NO_VIEW);
|
|
13
|
+
return view;
|
|
15
14
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
...props,
|
|
26
|
-
locale: say.locale,
|
|
27
|
-
messages: say.messages
|
|
28
|
-
});
|
|
29
|
-
};
|
|
30
|
-
};
|
|
15
|
+
async function SayScope({ catalogue, locale, view, children }) {
|
|
16
|
+
const resolved = view ?? await catalogue.load(catalogue.match(locale));
|
|
17
|
+
const request = cell();
|
|
18
|
+
if (process.env.NODE_ENV !== "production" && !request.warned && request.view && request.view.locale !== resolved.locale) {
|
|
19
|
+
request.warned = true;
|
|
20
|
+
console.warn(NESTED_SCOPE(request.view.locale, resolved.locale));
|
|
21
|
+
}
|
|
22
|
+
request.view = resolved;
|
|
23
|
+
return children;
|
|
31
24
|
}
|
|
32
25
|
//#endregion
|
|
33
|
-
export {
|
|
26
|
+
export { SayScope, getSay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saykit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "React integration for saykit, i18n hooks and components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"./client": {
|
|
33
33
|
"types": "./dist/client.d.mts",
|
|
34
|
+
"react-server": "./dist/client.server.mjs",
|
|
34
35
|
"default": "./dist/client.mjs"
|
|
35
36
|
},
|
|
36
37
|
"./server": {
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"jsdom": "^29.1.1",
|
|
53
54
|
"react": "^19.2.8",
|
|
54
55
|
"react-dom": "^19.2.8",
|
|
55
|
-
"saykit": "^0.
|
|
56
|
+
"saykit": "^0.10.0"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
59
|
"react": "*",
|