Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-pans-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@saykit/carbon': minor
---

Replace `withSay` with `createWithSay`, so a command takes its properties mapping alone instead of a catalogue as well
5 changes: 5 additions & 0 deletions .changeset/witty-moons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@saykit/react': minor
---

Replace `<SayScope>` with `createWithSay` and `setSay` on the server, so a route segment establishes its own view instead of inheriting one from a parent that may not have rendered yet
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ a README explaining which parts of SayKit it exercises and why.
| --------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------- |
| [`vanilla`](./examples/vanilla) | TypeScript + Vite | The whole picture end to end, start here |
| [`react`](./examples/react) | React 19 SPA + Vite | Stores, rich-text messages, code-split catalogues |
| [`nextjs`](./examples/nextjs) | Next.js App Router + Babel | Server Components, `<SayScope>`, middleware locale detection |
| [`nextjs`](./examples/nextjs) | Next.js App Router + Babel | Server Components, `withSay`, middleware locale detection |
| [`tanstack-start`](./examples/tanstack-start) | TanStack Start + Vite | Fallback chains (`en-NZ → en-GB → en`), SSR locale negotiation |
| [`expo`](./examples/expo) | Expo / React Native + Metro | The `whitespace` prop, device locale |
| [`carbon`](./examples/carbon) | Carbon Discord bot on Cloudflare Workers | Per-locale command registration, `interaction.say` / `guild.say` |
Expand Down
8 changes: 1 addition & 7 deletions examples/browser-extension/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,4 @@ import fr from '../_locales/fr/messages.json';
export const locales = ['en', 'fr', 'de'] as const;
export type Locale = (typeof locales)[number];

const catalogue = createCatalogue({ en, fr, de });

export function uiSay() {
return catalogue.locale(catalogue.match(chrome.i18n.getUILanguage()));
}

export default catalogue;
export const catalogue = createCatalogue({ en, fr, de });
4 changes: 2 additions & 2 deletions examples/browser-extension/src/popup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { uiSay } from './i18n.js';
import { catalogue } from './i18n.js';
import { minutesFor, type PageStats } from './reading.js';

const say = uiSay();
const say = catalogue.locale(catalogue.match(chrome.i18n.getUILanguage()));
const root = document.querySelector<HTMLElement>('#popup')!;

function line(text: string, className?: string) {
Expand Down
23 changes: 12 additions & 11 deletions examples/carbon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,23 @@ This example shows both halves.

## What it demonstrates

| Concern | Where |
| ------------------------------------------------------------ | ----------------------------- |
| `SayPlugin`, installs `interaction.say` / `guild.say` | `src/index.ts` |
| `withSay(Command)`: `(say, properties)`, run once per locale | `src/commands/pick.ts` |
| `withSay(CommandWithSubcommands)` + localised options | `src/commands/leaderboard.ts` |
| `withSay(Button)` / `withSay(Modal)`: `(properties)` only | `pick.ts`, `join.ts` |
| `interaction.say`: the invoking user's locale | everywhere |
| `guild.say`: the **server's** `preferred_locale` | `src/commands/announce.ts` |
| `say.plural` / `say.ordinal` / `say.select` outside React | `leaderboard.ts` |
| Concern | Where |
| ----------------------------------------------------------- | ----------------------------- |
| `SayPlugin`, installs `interaction.say` / `guild.say` | `src/index.ts` |
| `createWithSay(catalogue)`, bound once beside the catalogue | `src/i18n.ts` |
| `withSay(Command)`: `(properties)`, run once per locale | `src/commands/pick.ts` |
| `withSay(CommandWithSubcommands)` + localised options | `src/commands/leaderboard.ts` |
| `withSay(Button)` / `withSay(Modal)`: `(properties)` only | `pick.ts`, `join.ts` |
| `interaction.say`: the invoking user's locale | everywhere |
| `guild.say`: the **server's** `preferred_locale` | `src/commands/announce.ts` |
| `say.plural` / `say.ordinal` / `say.select` outside React | `leaderboard.ts` |

## Two overloads, because there are two kinds of object

```ts
class PickCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({ name: say`pick`, description: say`…` }));
constructor() {
super((say) => ({ name: say`pick`, description: say`…` }));
}
}

Expand Down
7 changes: 3 additions & 4 deletions examples/carbon/src/commands/announce.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Command, type CommandInteraction } from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Catalogue } from 'saykit';
import { currentPick, members } from '../club.js';
import { withSay } from '../i18n.js';

export class AnnounceCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`announce`,
description: say`Post the next meeting in the server's language.`,
}));
Expand Down
8 changes: 4 additions & 4 deletions examples/carbon/src/commands/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
TextInput,
TextInputStyle,
} from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Catalogue, View } from 'saykit';
import type { View } from 'saykit';
import { currentPick } from '../club.js';
import { withSay } from '../i18n.js';

export class JoinCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`join`,
description: say`Sign up for this month's book.`,
}));
Expand Down
17 changes: 8 additions & 9 deletions examples/carbon/src/commands/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import {
type CommandInteraction,
CommandWithSubcommands,
} from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Catalogue } from 'saykit';
import { findMember, leaderboard, members } from '../club.js';
import { withSay } from '../i18n.js';

class BooksCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`books`,
description: say`Rank everyone by books finished this year.`,
}));
Expand Down Expand Up @@ -43,8 +42,8 @@ class BooksCommand extends withSay(Command) {
}

class PagesCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`pages`,
description: say`See pages read this week.`,
options: [
Expand Down Expand Up @@ -103,11 +102,11 @@ class PagesCommand extends withSay(Command) {
}

export class LeaderboardCommand extends withSay(CommandWithSubcommands) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`leaderboard`,
description: say`Reading stats for the club.`,
subcommands: [new BooksCommand(catalogue), new PagesCommand(catalogue)],
subcommands: [new BooksCommand(), new PagesCommand()],
}));
}
}
8 changes: 4 additions & 4 deletions examples/carbon/src/commands/pick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {
type CommandInteraction,
Row,
} from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Catalogue, View } from 'saykit';
import type { View } from 'saykit';
import { currentPick } from '../club.js';
import { withSay } from '../i18n.js';

export class PickCommand extends withSay(Command) {
constructor(catalogue: Catalogue) {
super(catalogue, (say) => ({
constructor() {
super((say) => ({
name: say`pick`,
description: say`See what the club is reading right now.`,
}));
Expand Down
10 changes: 8 additions & 2 deletions examples/carbon/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createWithSay } from '@saykit/carbon';
import { createCatalogue } from 'saykit';
import de from './locales/de.json';
import en from './locales/en-US.json';
Expand All @@ -7,6 +8,11 @@ import ja from './locales/ja.json';
export const locales = ['en-US', 'fr', 'de', 'ja'] as const;
export type Locale = (typeof locales)[number];

const catalogue = createCatalogue({ 'en-US': en, fr, de, ja });
export const catalogue = createCatalogue({ 'en-US': en, fr, de, ja });

export default catalogue;
/**
* Wraps a Carbon base class so a command's name, description and options are
* built once per locale in the catalogue. Bound here so commands do not have
* to take the catalogue themselves.
*/
export const withSay = createWithSay(catalogue);
16 changes: 8 additions & 8 deletions examples/carbon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { AnnounceCommand } from './commands/announce.js';
import { JoinCommand, JoinModal } from './commands/join.js';
import { LeaderboardCommand } from './commands/leaderboard.js';
import { PickCommand, RemindMeButton } from './commands/pick.js';
import catalogue from './i18n.js';
import { catalogue } from './i18n.js';

const say = catalogue.locale(catalogue.locales[0]);
const view = catalogue.locale(catalogue.locales[0]);

const client = new Client(
{
Expand All @@ -20,17 +20,17 @@ const client = new Client(
},
{
commands: [
new PickCommand(catalogue),
new JoinCommand(catalogue),
new LeaderboardCommand(catalogue),
new AnnounceCommand(catalogue),
new PickCommand(),
new JoinCommand(),
new LeaderboardCommand(),
new AnnounceCommand(),
],
components: [new RemindMeButton(say)],
components: [new RemindMeButton(view)],
},
[new SayPlugin(catalogue), new CommandDataPlugin()],
);

for (const modal of [new JoinModal(say)]) client.modalHandler.registerModal(modal);
for (const modal of [new JoinModal(view)]) client.modalHandler.registerModal(modal);

const handler = createHandler(client);
export default { fetch: handler };
Expand Down
4 changes: 1 addition & 3 deletions examples/custom-formatter/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import fr from './locales/fr.yml';
export const locales = ['en', 'fr', 'de'] as const;
export type Locale = (typeof locales)[number];

const catalogue = createCatalogue({ en, fr, de });
export const catalogue = createCatalogue({ en, fr, de });

export function environmentLocale() {
const raw = process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG ?? '';
const tag = raw.split('.')[0]?.replace('_', '-');

return catalogue.match(tag ? [tag] : []);
}

export default catalogue;
2 changes: 1 addition & 1 deletion examples/custom-formatter/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lastDeployment } from './deploy.js';
import catalogue, { environmentLocale } from './i18n.js';
import { catalogue, environmentLocale } from './i18n.js';
import summary from './templates/summary.email';

const say = catalogue.locale(environmentLocale());
Expand Down
4 changes: 1 addition & 3 deletions examples/expo/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ja from './locales/ja.json';
export const locales = ['en', 'fr', 'ja'] as const;
export type Locale = (typeof locales)[number];

const catalogue = createCatalogue({ en, fr, ja });
export const catalogue = createCatalogue({ en, fr, ja });

export function deviceLocale() {
const tags = getLocales()
Expand All @@ -18,5 +18,3 @@ export function deviceLocale() {
}

export const store = createStore(catalogue, deviceLocale());

export default catalogue;
6 changes: 3 additions & 3 deletions examples/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ once.

| Concern | Where |
| ------------------------------------------------------------------------------------------ | ---------------------------------------- |
| `<SayScope>`, resolving the view for the request | `src/app/[locale]/layout.tsx` |
| `withSay`, establishing the view for a route segment | `layout.tsx`, `page.tsx` |
| `getSay()` inside a plain server component | `src/app/[locale]/product-card.tsx` |
| `SayProvider` at the root, taking its props from the scope | `src/app/[locale]/layout.tsx` |
| `<Say>` in a **client** component | `add-to-cart.tsx`, `locale-switcher.tsx` |
Expand All @@ -25,7 +25,7 @@ once.
`@saykit/react` publishes its `.` entry twice and lets the bundler pick:

- In a **server** environment the `react-server` export condition resolves to a build where `<Say>`
reads from `getSay()`, the view the enclosing `<SayScope>` put in React's request cache.
reads from `getSay()`, the view `withSay` put in React's request cache.
- In a **client** environment it resolves to the `"use client"` build, where `<Say>` reads from
`useSay()`, the nearest `SayProvider`.

Expand All @@ -37,7 +37,7 @@ The component you write is identical in both cases:
</Say>
```

Because both halves are fed from the same `<SayScope>` in the root layout, with the client provider
Because both halves are fed from the same view, with the client provider
reading its locale and messages straight off it, server output and client hydration cannot disagree
about which locale is active.

Expand Down
52 changes: 23 additions & 29 deletions examples/nextjs/src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,39 @@
import { Say } from '@saykit/react';
import { SayProvider } from '@saykit/react/client';
import { SayScope } from '@saykit/react/server';
import type { ReactNode } from 'react';
import catalogue from '../../i18n';
import { getSay } from '@saykit/react/server';
import { catalogue, withSay } from '../../i18n';
import './styles.css';
import { LocaleSwitcher } from './locale-switcher';

export function generateStaticParams() {
return catalogue.locales.map((locale) => ({ locale }));
}

type RootLayoutProps = {
params: Promise<{ locale: string }>;
children: ReactNode;
};

export default async function RootLayout({ params, children }: RootLayoutProps) {
async function RootLayout({ params, children }: LayoutProps<'/[locale]'>) {
const { locale } = await params;

return (
<SayScope catalogue={catalogue} locale={locale}>
<html lang={catalogue.match(locale)}>
<body>
<SayProvider>
<header className="masthead">
<a className="masthead__brand" href={`/${locale}`}>
<Say>Harbour Coffee</Say>
</a>
<LocaleSwitcher current={locale} />
</header>
<html lang={getSay().locale}>
<body>
<SayProvider>
<header className="masthead">
<a className="masthead__brand" href={`/${locale}`}>
<Say>Harbour Coffee</Say>
</a>
<LocaleSwitcher current={locale} />
</header>

<main>{children}</main>
<main>{children}</main>

<footer className="footer">
<Say>
Prices include VAT. See our <a href="#returns">returns policy</a>.
</Say>
</footer>
</SayProvider>
</body>
</html>
</SayScope>
<footer className="footer">
<Say>
Prices include VAT. See our <a href="#returns">returns policy</a>.
</Say>
</footer>
</SayProvider>
</body>
</html>
);
}

export default withSay(RootLayout, (props) => props.params.then((params) => params.locale));
20 changes: 8 additions & 12 deletions examples/nextjs/src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { Say } from '@saykit/react';
import { getSay } from '@saykit/react/server';
import { currency, freeShippingThresholdInCents, products } from '../../catalogue';
import { freeShippingThresholdInCents, products } from '../../catalogue';
import { withSay } from '../../i18n';
import { ProductCard } from './product-card';

type StorefrontPageProps = { params: Promise<{ locale: string }> };

function StorefrontPage(_: StorefrontPageProps) {
const say = getSay();

const threshold = new Intl.NumberFormat(say.locale, { style: 'currency', currency }).format(
freeShippingThresholdInCents / 100,
);
function StorefrontPage(_: PageProps<'/[locale]'>) {
const threshold = freeShippingThresholdInCents / 100;

return (
<>
Expand All @@ -19,7 +13,9 @@ function StorefrontPage(_: StorefrontPageProps) {
<Say>Freshly roasted, shipped Thursdays</Say>
</h1>
<p>
<Say>Free delivery on orders over {threshold}.</Say>
<Say>
Free delivery on orders over <Say.Number _={{ threshold }} style="::currency/EUR" />.
</Say>
</p>
</section>

Expand All @@ -32,4 +28,4 @@ function StorefrontPage(_: StorefrontPageProps) {
);
}

export default StorefrontPage;
export default withSay(StorefrontPage, (props) => props.params.then((params) => params.locale));
Loading
Loading