@saykit/react 0.10.0 → 0.11.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 +47 -11
- package/dist/client.d.mts +1 -1
- package/dist/client.server.d.mts +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +6 -3
- package/dist/index.server.mjs +6 -3
- package/dist/server.d.mts +36 -45
- package/dist/server.mjs +16 -10
- package/package.json +2 -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 `withSay` and `getSay()` that mirror them on the server.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -12,30 +12,66 @@ A `<Say>` component for rendering translated content in server and client compon
|
|
|
12
12
|
pnpm add @saykit/react saykit
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
You will also need a SayKit build-tool plugin and a `saykit.config.ts
|
|
15
|
+
You will also need a SayKit build-tool plugin ([`unplugin-saykit`](https://github.com/k0d13/saykit/tree/main/packages/plugin-unplugin) or [`babel-plugin-saykit`](https://github.com/k0d13/saykit/tree/main/packages/plugin-babel)) and a `saykit.config.ts` with `@saykit/transform-jsx` in the bucket. The lazy `() => import(...)` catalogues below need `unplugin-saykit` or `babel-plugin-saykit` with `catalogues: 'module'`; Babel's default inline mode only handles static imports.
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
|
-
```
|
|
20
|
-
import { Say } from '@saykit/react';
|
|
21
|
-
import { SayProvider } from '@saykit/react/client';
|
|
19
|
+
```ts title="src/i18n.ts"
|
|
22
20
|
import { createCatalogue, createStore } from 'saykit';
|
|
23
21
|
|
|
24
|
-
const
|
|
25
|
-
|
|
22
|
+
export const catalogue = createCatalogue({
|
|
23
|
+
en: () => import('./locales/en.po'),
|
|
24
|
+
fr: () => import('./locales/fr.po'),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const initial = catalogue.match(navigator.languages);
|
|
28
|
+
await catalogue.load(initial);
|
|
29
|
+
|
|
30
|
+
export const store = createStore(catalogue, initial);
|
|
31
|
+
export type Locale = (typeof catalogue.locales)[number];
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```tsx title="src/app.tsx"
|
|
35
|
+
import { Say } from '@saykit/react';
|
|
36
|
+
import { SayProvider, useSay } from '@saykit/react/client';
|
|
37
|
+
import { type Locale, store } from './i18n.js';
|
|
38
|
+
|
|
39
|
+
function Cart({ name, items }: { name: string; items: string[] }) {
|
|
40
|
+
return (
|
|
41
|
+
<p>
|
|
42
|
+
<Say>Hello, {name}!</Say>{' '}
|
|
43
|
+
<Say.Plural
|
|
44
|
+
_={items.length}
|
|
45
|
+
_0="Your cart is empty."
|
|
46
|
+
one="You have 1 item."
|
|
47
|
+
other={<>You have {items.length} items.</>}
|
|
48
|
+
/>
|
|
49
|
+
</p>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
26
52
|
|
|
27
|
-
|
|
53
|
+
function LocalePicker() {
|
|
54
|
+
const say = useSay();
|
|
55
|
+
return (
|
|
56
|
+
<select value={say.locale} onChange={(event) => store.set(event.target.value as Locale)}>
|
|
57
|
+
<option value="en">English</option>
|
|
58
|
+
<option value="fr">Français</option>
|
|
59
|
+
</select>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
28
62
|
|
|
29
|
-
function App() {
|
|
63
|
+
export function App() {
|
|
30
64
|
return (
|
|
31
65
|
<SayProvider store={store}>
|
|
32
|
-
<
|
|
33
|
-
<
|
|
66
|
+
<LocalePicker />
|
|
67
|
+
<Cart name="Ada" items={[]} />
|
|
34
68
|
</SayProvider>
|
|
35
69
|
);
|
|
36
70
|
}
|
|
37
71
|
```
|
|
38
72
|
|
|
73
|
+
Elements inside a message survive translation as numbered tags: `<Say>Read the <a href="/docs">docs</a></Say>` extracts as `Read the <0>docs</0>`.
|
|
74
|
+
|
|
39
75
|
## Documentation
|
|
40
76
|
|
|
41
77
|
[React integration guide](https://saykit.js.org/integrations/react) at [saykit.js.org](https://saykit.js.org).
|
package/dist/client.d.mts
CHANGED
|
@@ -37,7 +37,7 @@ type SayProviderProps = {
|
|
|
37
37
|
* @param props.messages The messages for that locale, which should be
|
|
38
38
|
* referentially stable rather than a fresh object literal per render
|
|
39
39
|
*/
|
|
40
|
-
declare function SayProvider({ store, locale, messages, children }: PropsWithChildren<SayProviderProps>): import("react").FunctionComponentElement<import("react").ProviderProps<
|
|
40
|
+
declare function SayProvider({ store, locale, messages, children }: PropsWithChildren<SayProviderProps>): import("react").FunctionComponentElement<import("react").ProviderProps<any>>;
|
|
41
41
|
/**
|
|
42
42
|
* Get the current {@link View}, on the client.
|
|
43
43
|
* Must be called within a {@link SayProvider}.
|
package/dist/client.server.d.mts
CHANGED
|
@@ -7,7 +7,7 @@ import "server-only";
|
|
|
7
7
|
* A store is a live object and cannot cross the server/client boundary, and a
|
|
8
8
|
* server component cannot hand its own scope to a client one either. What can
|
|
9
9
|
* cross is the locale and its messages, so this reads them off the view the
|
|
10
|
-
* enclosing {@link import('./server.js').
|
|
10
|
+
* the enclosing segment established with {@link import('./server.js').setSay}, and passes them
|
|
11
11
|
* to the real provider, which is why `<SayProvider>` written on the server
|
|
12
12
|
* takes no props.
|
|
13
13
|
*/
|
package/dist/index.d.mts
CHANGED
|
@@ -22,10 +22,11 @@ type PropsWithJSXSafeKeys<T> = { [K in keyof T as K extends number | `${number}$
|
|
|
22
22
|
* @returns The translation node for the descriptor
|
|
23
23
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
24
24
|
*/
|
|
25
|
-
declare function Say(props: PropsWithSayChildren<
|
|
25
|
+
declare function Say(props: PropsWithSayChildren<{
|
|
26
|
+
id?: string;
|
|
26
27
|
context?: string;
|
|
27
28
|
whitespace?: boolean;
|
|
28
|
-
}
|
|
29
|
+
}>): ReactElement;
|
|
29
30
|
declare namespace Say {
|
|
30
31
|
/**
|
|
31
32
|
* Define a pluralised message.
|
package/dist/index.mjs
CHANGED
|
@@ -56,14 +56,17 @@ function resolveValuePropKeys(props) {
|
|
|
56
56
|
//#endregion
|
|
57
57
|
//#region src/runtime/index.ts
|
|
58
58
|
function Say(props) {
|
|
59
|
-
if (!("id" in props)) throw new Error("'Say' is a macro and must be used with the relevant saykit plugin", { cause: /* @__PURE__ */ new Error("The 'id' property is required for a descriptor") });
|
|
59
|
+
if (!("id" in props) && !("message" in props)) throw new Error("'Say' is a macro and must be used with the relevant saykit plugin", { cause: /* @__PURE__ */ new Error("The 'id' property is required for a descriptor") });
|
|
60
60
|
const say = GET_SAY();
|
|
61
|
-
const { id, whitespace, ...rest } = props;
|
|
61
|
+
const { id, message, whitespace, ...rest } = props;
|
|
62
62
|
const values = resolveValuePropKeys(rest);
|
|
63
63
|
return createElement(Renderer, {
|
|
64
|
-
html: say.call({
|
|
64
|
+
html: say.call(message === void 0 ? {
|
|
65
65
|
...rest,
|
|
66
66
|
id
|
|
67
|
+
} : {
|
|
68
|
+
...rest,
|
|
69
|
+
message
|
|
67
70
|
}),
|
|
68
71
|
whitespace,
|
|
69
72
|
components(tag) {
|
package/dist/index.server.mjs
CHANGED
|
@@ -56,14 +56,17 @@ function resolveValuePropKeys(props) {
|
|
|
56
56
|
//#endregion
|
|
57
57
|
//#region src/runtime/index.ts
|
|
58
58
|
function Say(props) {
|
|
59
|
-
if (!("id" in props)) throw new Error("'Say' is a macro and must be used with the relevant saykit plugin", { cause: /* @__PURE__ */ new Error("The 'id' property is required for a descriptor") });
|
|
59
|
+
if (!("id" in props) && !("message" in props)) throw new Error("'Say' is a macro and must be used with the relevant saykit plugin", { cause: /* @__PURE__ */ new Error("The 'id' property is required for a descriptor") });
|
|
60
60
|
const say = GET_SAY();
|
|
61
|
-
const { id, whitespace, ...rest } = props;
|
|
61
|
+
const { id, message, whitespace, ...rest } = props;
|
|
62
62
|
const values = resolveValuePropKeys(rest);
|
|
63
63
|
return createElement(Renderer, {
|
|
64
|
-
html: say.call({
|
|
64
|
+
html: say.call(message === void 0 ? {
|
|
65
65
|
...rest,
|
|
66
66
|
id
|
|
67
|
+
} : {
|
|
68
|
+
...rest,
|
|
69
|
+
message
|
|
67
70
|
}),
|
|
68
71
|
whitespace,
|
|
69
72
|
components(tag) {
|
package/dist/server.d.mts
CHANGED
|
@@ -4,7 +4,8 @@ import "server-only";
|
|
|
4
4
|
//#region src/runtime/server.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* Get the current {@link View}, on the server.
|
|
7
|
-
* Must be called below a {@link
|
|
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
|
|
10
11
|
* *data*, to build an `Intl.NumberFormat` say, rather than as a rendered
|
|
@@ -17,61 +18,51 @@ import "server-only";
|
|
|
17
18
|
* ```
|
|
18
19
|
*
|
|
19
20
|
* @returns The current {@link View}
|
|
20
|
-
* @throws If no
|
|
21
|
+
* @throws If no view has been established for this request
|
|
21
22
|
*/
|
|
22
23
|
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
|
-
}
|
|
40
24
|
/**
|
|
41
|
-
* Establish the {@link View} for everything rendered
|
|
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.
|
|
42
28
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
47
33
|
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*
|
|
53
|
-
*
|
|
34
|
+
* @param view The view to establish
|
|
35
|
+
*/
|
|
36
|
+
declare function setSay(view: View): void;
|
|
37
|
+
/**
|
|
38
|
+
* Bind a `withSay` to a {@link Catalogue}, normally once beside the catalogue
|
|
39
|
+
* itself.
|
|
54
40
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
58
44
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
53
|
*
|
|
66
54
|
* @example
|
|
67
55
|
* ```tsx
|
|
68
|
-
*
|
|
56
|
+
* // i18n.ts
|
|
57
|
+
* export const withSay = createWithSay(catalogue);
|
|
58
|
+
*
|
|
59
|
+
* // app/[locale]/page.tsx
|
|
60
|
+
* export default withSay(Page, (props) => props.params.then((params) => params.locale));
|
|
69
61
|
* ```
|
|
70
62
|
*
|
|
71
|
-
* @param
|
|
72
|
-
* @
|
|
73
|
-
* @param props.view A view to establish as it is, instead of both of those
|
|
63
|
+
* @param catalogue The catalogue to take views from
|
|
64
|
+
* @returns A `withSay` bound to that catalogue
|
|
74
65
|
*/
|
|
75
|
-
declare function
|
|
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>;
|
|
76
67
|
//#endregion
|
|
77
|
-
export {
|
|
68
|
+
export { createWithSay, getSay, setSay };
|
package/dist/server.mjs
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
import { cache } from "react";
|
|
1
|
+
import { cache, createElement } from "react";
|
|
2
2
|
import "server-only";
|
|
3
3
|
//#region src/runtime/server.ts
|
|
4
4
|
const cell = cache(() => ({
|
|
5
5
|
view: void 0,
|
|
6
6
|
warned: false
|
|
7
7
|
}));
|
|
8
|
-
const NO_VIEW = "'getSay' must be called below a '
|
|
9
|
-
const
|
|
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.`;
|
|
10
10
|
function getSay() {
|
|
11
11
|
const view = cell().view;
|
|
12
12
|
if (!view) throw new Error(NO_VIEW);
|
|
13
13
|
return view;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
const resolved = view ?? await catalogue.load(catalogue.match(locale));
|
|
15
|
+
function setSay(view) {
|
|
17
16
|
const request = cell();
|
|
18
|
-
if (process.env.NODE_ENV !== "production" && !request.warned && request.view && request.view.locale !==
|
|
17
|
+
if (process.env.NODE_ENV !== "production" && !request.warned && request.view && request.view.locale !== view.locale) {
|
|
19
18
|
request.warned = true;
|
|
20
|
-
console.warn(
|
|
19
|
+
console.warn(SECOND_VIEW(request.view.locale, view.locale));
|
|
21
20
|
}
|
|
22
|
-
request.view =
|
|
23
|
-
|
|
21
|
+
request.view = view;
|
|
22
|
+
}
|
|
23
|
+
function createWithSay(catalogue) {
|
|
24
|
+
return function withSay(Component, locale) {
|
|
25
|
+
return async function WithSay(props) {
|
|
26
|
+
setSay(await catalogue.load(catalogue.match(await locale(props))));
|
|
27
|
+
return createElement(Component, props);
|
|
28
|
+
};
|
|
29
|
+
};
|
|
24
30
|
}
|
|
25
31
|
//#endregion
|
|
26
|
-
export {
|
|
32
|
+
export { createWithSay, getSay, setSay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saykit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "React integration for saykit, i18n hooks and components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"jsdom": "^29.1.1",
|
|
54
54
|
"react": "^19.2.8",
|
|
55
55
|
"react-dom": "^19.2.8",
|
|
56
|
-
"saykit": "^0.
|
|
56
|
+
"saykit": "^0.11.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"react": "*",
|