@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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Coverage](https://codecov.io/gh/k0d13/saykit/graph/badge.svg?flag=integration-react)](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 `<SayScope>` and `getSay()` that mirror them on the server.
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
- ```tsx
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 en = { greeting: 'Hello, {name}!' };
25
- const fr = { greeting: 'Bonjour, {name} !' };
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
- const store = createStore(createCatalogue({ en, fr }), 'fr');
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
- <Say>Hello, {name}!</Say>
33
- <Say.Plural _={count} one={<>{count} item</>} other={<>{count} items</>} />
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<Store<string> | null>>;
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}.
@@ -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').SayScope} established and passes them
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<Disallow<{
25
+ declare function Say(props: PropsWithSayChildren<{
26
+ id?: string;
26
27
  context?: string;
27
28
  whitespace?: boolean;
28
- }, 'id'>>): ReactElement;
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) {
@@ -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 SayScope}.
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 {@link SayScope} is above the caller
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 inside it, on the server.
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
- * 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.
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
- * 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.
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
- * 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.
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
- * @example
60
- * ```tsx
61
- * <SayScope catalogue={catalogue} locale={locale}>
62
- * <SayProvider>{children}</SayProvider>
63
- * </SayScope>
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
- * <SayScope view={await catalogue.load('fr')}>{children}</SayScope>
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 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
63
+ * @param catalogue The catalogue to take views from
64
+ * @returns A `withSay` bound to that catalogue
74
65
  */
75
- declare function SayScope<Locale extends string>({ catalogue, locale, view, children }: SayScope.Props<Locale>): Promise<ReactNode>;
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 { SayScope, getSay };
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 '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.`;
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
- async function SayScope({ catalogue, locale, view, children }) {
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 !== resolved.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(NESTED_SCOPE(request.view.locale, resolved.locale));
19
+ console.warn(SECOND_VIEW(request.view.locale, view.locale));
21
20
  }
22
- request.view = resolved;
23
- return children;
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 { SayScope, getSay };
32
+ export { createWithSay, getSay, setSay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saykit/react",
3
- "version": "0.10.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.10.0"
56
+ "saykit": "^0.11.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "react": "*",