@saykit/react 0.0.0-beta-20260810145057 → 0.0.0-beta-20260906064714
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 +54 -19
- package/dist/server.mjs +22 -23
- package/package.json +4 -3
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 `withSay` 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<any>>;
|
|
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
|
+
* the enclosing segment established with {@link import('./server.js').setSay}, 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,68 @@
|
|
|
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 setSay}, which {@link createWithSay} does for
|
|
8
|
+
* you.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* The server counterpart of `useSay`. Reach for it when you need the locale as
|
|
11
|
+
* *data*, to build an `Intl.NumberFormat` say, rather than as a rendered
|
|
12
|
+
* message, which is what `<Say>` is for.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const say = getSay();
|
|
17
|
+
* const price = new Intl.NumberFormat(say.locale, { style: 'currency', currency }).format(total);
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @returns The current {@link View}
|
|
21
|
+
* @throws If no view has been established for this request
|
|
10
22
|
*/
|
|
11
|
-
declare function
|
|
23
|
+
declare function getSay(): View;
|
|
12
24
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
25
|
+
* Establish the {@link View} for everything rendered after this point, on the
|
|
26
|
+
* server. Reach for it when you already have a view; {@link createWithSay}
|
|
27
|
+
* negotiates and loads one for you.
|
|
15
28
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
29
|
+
* Per request is the limit. React renders a server component's children after
|
|
30
|
+
* it returns, so a view does not end where a subtree does: a second view takes
|
|
31
|
+
* over for everything rendered after it, including the messages
|
|
32
|
+
* `<SayProvider>` serialises. Development warns when that happens.
|
|
33
|
+
*
|
|
34
|
+
* @param view The view to establish
|
|
18
35
|
*/
|
|
19
|
-
declare function
|
|
36
|
+
declare function setSay(view: View): void;
|
|
20
37
|
/**
|
|
21
|
-
*
|
|
38
|
+
* Bind a `withSay` to a {@link Catalogue}, normally once beside the catalogue
|
|
39
|
+
* itself.
|
|
40
|
+
*
|
|
41
|
+
* `withSay` wraps a server component so its view is negotiated, loaded and
|
|
42
|
+
* established before the component renders, which `<Say>` and {@link getSay}
|
|
43
|
+
* then read at any depth below.
|
|
44
|
+
*
|
|
45
|
+
* Every route segment that renders messages wraps itself, rather than
|
|
46
|
+
* inheriting from a parent. A framework is free to render a page before the
|
|
47
|
+
* layout above it - Next.js does - and a parent that has not run yet has
|
|
48
|
+
* established nothing.
|
|
49
|
+
*
|
|
50
|
+
* A `<SayProvider>` written inside a wrapped component takes no props of its
|
|
51
|
+
* own: the server build of `@saykit/react/client` reads the established view
|
|
52
|
+
* and serialises the locale and its messages across the boundary.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```tsx
|
|
56
|
+
* // i18n.ts
|
|
57
|
+
* export const withSay = createWithSay(catalogue);
|
|
22
58
|
*
|
|
23
|
-
*
|
|
59
|
+
* // app/[locale]/page.tsx
|
|
60
|
+
* export default withSay(Page, (props) => props.params.then((params) => params.locale));
|
|
61
|
+
* ```
|
|
24
62
|
*
|
|
25
|
-
* @
|
|
63
|
+
* @param catalogue The catalogue to take views from
|
|
64
|
+
* @returns A `withSay` bound to that catalogue
|
|
26
65
|
*/
|
|
27
|
-
declare function
|
|
28
|
-
type PropsWithSay<P = unknown> = P & {
|
|
29
|
-
locale: string;
|
|
30
|
-
messages: Say.Messages;
|
|
31
|
-
};
|
|
66
|
+
declare function createWithSay<Locale extends string>(catalogue: Catalogue<Locale>): <P>(Component: (props: P) => ReactNode, locale: (props: P) => Catalogue.Guess | Promise<Catalogue.Guess>) => (props: P) => Promise<ReactNode>;
|
|
32
67
|
//#endregion
|
|
33
|
-
export {
|
|
68
|
+
export { createWithSay, getSay, setSay };
|
package/dist/server.mjs
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
import { cache, createElement } from "react";
|
|
2
|
-
import { Say } from "saykit";
|
|
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 'withSay'. Wrap the component in 'withSay(Component, (props) => props.params.then((params) => params.locale))'.";
|
|
9
|
+
const SECOND_VIEW = (established, next) => `A view for '${next}' was established while '${established}' was already established for this request. A view 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 one that established it, 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;
|
|
14
|
+
}
|
|
15
|
+
function setSay(view) {
|
|
16
|
+
const request = cell();
|
|
17
|
+
if (process.env.NODE_ENV !== "production" && !request.warned && request.view && request.view.locale !== view.locale) {
|
|
18
|
+
request.warned = true;
|
|
19
|
+
console.warn(SECOND_VIEW(request.view.locale, view.locale));
|
|
20
|
+
}
|
|
21
|
+
request.view = view;
|
|
15
22
|
}
|
|
16
|
-
function
|
|
17
|
-
return function withSay(Component,
|
|
23
|
+
function createWithSay(catalogue) {
|
|
24
|
+
return function withSay(Component, locale) {
|
|
18
25
|
return async function WithSay(props) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
await say.load(locale);
|
|
22
|
-
say.activate(locale);
|
|
23
|
-
setSay(say);
|
|
24
|
-
return createElement(Component, {
|
|
25
|
-
...props,
|
|
26
|
-
locale: say.locale,
|
|
27
|
-
messages: say.messages
|
|
28
|
-
});
|
|
26
|
+
setSay(await catalogue.load(catalogue.match(await locale(props))));
|
|
27
|
+
return createElement(Component, props);
|
|
29
28
|
};
|
|
30
29
|
};
|
|
31
30
|
}
|
|
32
31
|
//#endregion
|
|
33
|
-
export { getSay, setSay
|
|
32
|
+
export { createWithSay, getSay, setSay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saykit/react",
|
|
3
|
-
"version": "0.0.0-beta-
|
|
3
|
+
"version": "0.0.0-beta-20260906064714",
|
|
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,11 +53,11 @@
|
|
|
52
53
|
"jsdom": "^29.1.1",
|
|
53
54
|
"react": "^19.2.8",
|
|
54
55
|
"react-dom": "^19.2.8",
|
|
55
|
-
"saykit": "^0.0.0-beta-
|
|
56
|
+
"saykit": "^0.0.0-beta-20260906064714"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
59
|
"react": "*",
|
|
59
|
-
"saykit": "0.0.0-beta-
|
|
60
|
+
"saykit": "0.0.0-beta-20260906064714"
|
|
60
61
|
},
|
|
61
62
|
"scripts": {
|
|
62
63
|
"check": "tsc --noEmit",
|