@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 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 small server runtime (`setSay`, `getSay`, `unstable_createWithSay`).
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, SayProvider } from '@saykit/react/client';
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 locale="fr" messages={fr}>
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 { ReadonlySay, Say } from "saykit";
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
- * Provide a localised {@link runtime.Say} instance to descendant **client** components via context.
9
- * Must wrap any component tree using {@link useSay} or {@link Say}.
5
+ * Where a provider takes its view from.
10
6
  *
11
- * The instance is rebuilt whenever `locale` or `messages` changes, so keep `messages`
12
- * referentially stable (module scope, or memoised) rather than passing a fresh object
13
- * literal on every render.
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
- * @param props.locale The current locale
16
- * @param props.messages The current messages for the locale
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
- declare function SayProvider({ locale, messages, children }: PropsWithChildren<{
16
+ type SayProviderProps = {
17
+ store: Store;
18
+ locale?: never;
19
+ messages?: never;
20
+ } | {
21
+ store?: never;
19
22
  locale: string;
20
- messages: Say.Messages;
21
- }>): import("react").FunctionComponentElement<import("react").ProviderProps<SayRef>>;
23
+ messages: View.Messages;
24
+ } | {
25
+ store?: never;
26
+ locale?: never;
27
+ messages?: never;
28
+ };
22
29
  /**
23
- * Get the current {@link Say} **client** instance.
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
- * @returns The current {@link Say} instance
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(): any;
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 { Say } from "saykit";
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({ current: null });
5
+ const SayContext = createContext(null);
6
6
  SayContext.displayName = "SayContext";
7
- function SayProvider({ locale, messages, children }) {
8
- const ref = useMemo(() => {
9
- const instance = new Say({
10
- locales: [locale],
11
- loader: () => messages
12
- });
13
- instance.load(locale);
14
- instance.activate(locale);
15
- return { current: instance.freeze() };
16
- }, [locale, messages]);
17
- return createElement(SayContext.Provider, { value: ref }, children);
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 ref = useContext(SayContext);
21
- if (!ref.current) throw new Error("'useSay' must be used within a 'SayProvider'");
22
- return ref.current;
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 — `{{ name: value }}` — reaches the type checker as a plain
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 (e.g. "1st", "2nd", "3rd").
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, useful for handling gender, status, or other categories.
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`, and `Say.Select`, this is a fragment
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 Formatting style: a named style, an ICU skeleton such as
108
- * `::currency/EUR`, or a literal number pattern such as `#,##0.00`
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 Formatting style, either a named style or an ICU
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 Formatting style, either a named style or an ICU
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 { ReadonlySay, Say } from "saykit";
2
+ import { Catalogue, View } from "saykit";
3
3
  import "server-only";
4
4
  //#region src/runtime/server.d.ts
5
5
  /**
6
- * Set the current {@link Say} **server** instance.
7
- * Must be called before any {@link getSay} calls.
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
- * @param say The current {@link Say} instance
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 setSay(say: Say | ReadonlySay | (() => Say | ReadonlySay)): void;
23
+ declare function getSay(): View;
12
24
  /**
13
- * Get the current {@link Say} **server** instance.
14
- * Must only be called after any {@link setSay} calls.
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
- * @returns The current {@link Say} instance
17
- * @throws If no {@link Say} instance has been set
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 getSay(): ReadonlySay;
36
+ declare function setSay(view: View): void;
20
37
  /**
21
- * Create a {@link withSay} higher-order component factory bound to a specific {@link Say} instance.
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
- * @param say The {@link Say} instance to bind into the server context
59
+ * // app/[locale]/page.tsx
60
+ * export default withSay(Page, (props) => props.params.then((params) => params.locale));
61
+ * ```
24
62
  *
25
- * @returns A {@link withSay} higher-order component factory
63
+ * @param catalogue The catalogue to take views from
64
+ * @returns A `withSay` bound to that catalogue
26
65
  */
27
- declare function unstable_createWithSay(say: Say): <P = unknown>(Component: (props: PropsWithSay<P>) => ReactNode, getLocale: (props: P) => string | Promise<string>) => (props: P) => Promise<import("react").FunctionComponentElement<PropsWithSay<P>>>;
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 { PropsWithSay, getSay, setSay, unstable_createWithSay };
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 serverContext = cache(() => ({ current: null }));
6
- function setSay(say) {
7
- const ref = serverContext();
8
- if (say instanceof Say) ref.current = say.clone().freeze();
9
- else ref.current = say().clone().freeze();
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 ref = serverContext();
13
- if (!ref.current) throw new Error("Attempt to access the server-only Say instance before initialisation", { cause: /* @__PURE__ */ new Error("'getSay' must be called after 'setSay'") });
14
- return ref.current;
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 unstable_createWithSay(say) {
17
- return function withSay(Component, getLocale) {
23
+ function createWithSay(catalogue) {
24
+ return function withSay(Component, locale) {
18
25
  return async function WithSay(props) {
19
- const guess = await getLocale(props);
20
- const locale = say.match(guess);
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, unstable_createWithSay };
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-20260810145057",
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-20260810145057"
56
+ "saykit": "^0.0.0-beta-20260906064714"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "react": "*",
59
- "saykit": "0.0.0-beta-20260810145057"
60
+ "saykit": "0.0.0-beta-20260906064714"
60
61
  },
61
62
  "scripts": {
62
63
  "check": "tsc --noEmit",