@k8ordo/i18n

Integrations

@k8ordo/i18n imports no other k8ordo package. The lines that combine them are the application's, and each takes a few lines. This page covers @k8ordo/ui, @k8ordo/form, @k8ordo/router, @k8ordo/static and @k8ordo/server, and how to test.

@k8ordo/ui

Wording the components render on their own — close button labels, the required marker, the loading announcement — comes from @k8ordo/ui's own dictionary. Pass it to UIProvider as messages, picked from dictionaries in @k8ordo/ui/i18n by the locale being rendered.

// src/routes/[locale]/layout.tsx
import { UIProvider } from '@k8ordo/ui';
import { dictionaries } from '@k8ordo/ui/i18n';
import type { ReactNode } from 'react';

import { locales } from '../../i18n';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return (
    <UIProvider messages={dictionaries[locales.getLocale()]}>
      {children}
    </UIProvider>
  );
}
  • A dictionary is an object of strings, so a Server Component layout can pass it straight to the client UIProvider. Only the one dictionary chosen travels in the RSC payload.
  • Under not-found.tsx nothing validates the catch-all's params, so getLocale() there is the default whatever the URL says, and a 404 at /en/… gets the default locale's dictionary too. To show a 404 in the visitor's locale, pick the dictionary in a Client Component from locales.delocalize(usePathname()).locale, as this site's LocaleShell does.
  • dictionaries holds ja and en. When the set has other locales (fr, or en-US), write those dictionaries yourself, annotated with the Messages type from @k8ordo/ui/i18n, and map locales to dictionaries with a Variants<Messages> so a missing one is a type error.
  • Text passed to a component's props is a string: call the message, as in <Button>{m.form.submit()}</Button>.

Read @k8ordo/ui's page on its wording

@k8ordo/form

The messages @k8ordo/form shows are zod's error messages: formFields turns them into strings by probing the schema when it derives the fields, and parseForm produces them when it validates. Both are made in the locale current at that moment, which leaves three things to get right.

  1. Give zod the message function as error, not a string. Zod calls it when it reports the issue, so the text is in the locale current then. Calling the message in the declaration, as in min(1, m.talk.titleRequired()), freezes the string of whatever locale was current then, usually the default.
  2. Call formFields(schema) inside the page's render, not at module scope. Module scope runs once, usually outside any request, so every locale's page would be handed the same messages, most likely the default locale's.
  3. A Server Action runs outside the [locale] render, so nothing names a locale there. Take the locale the page bound to the action and call parseForm inside locales.run. The value comes back from the client, so check it with locales.is before using it.
// src/messages/talk.ts
import { message } from '@k8ordo/i18n';

export const titleRequired = message({
  ja: 'タイトルを入力してください',
  en: 'Enter a title',
});

export const titleTooLong = message({
  ja: (max: number) => `${String(max)} 文字以内で入力してください`,
  en: (max) => `Use at most ${String(max)} characters`,
});
// src/routes/[locale]/talks/new/_parts/schema.ts
import * as z from 'zod';

import * as m from '../../../../../messages';

export const talkSchema = z.object({
  title: z
    .string()
    .min(1, { error: m.talk.titleRequired })
    .max(120, { error: () => m.talk.titleTooLong(120) }),
});
// src/routes/[locale]/talks/new/page.tsx
import { formFields } from '@k8ordo/form/server';

import { locales } from '../../../../i18n';
import { createTalk } from './_parts/actions';
import { talkSchema } from './_parts/schema';
import { TalkForm } from './_parts/talk-form';

export default function NewTalkPage() {
  return (
    <TalkForm
      action={createTalk.bind(null, locales.getLocale())}
      fields={formFields(talkSchema)}
    />
  );
}
// src/routes/[locale]/talks/new/_parts/actions.ts
'use server';

import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';

import { saveTalk } from '../../../../../db/talks';
import { locales } from '../../../../../i18n';
import { talkSchema } from './schema';

export async function createTalk(
  locale: string,
  _previous: FormState,
  formData: FormData,
): Promise<FormState> {
  const parsed = locales.run(
    locales.is(locale) ? locale : locales.default,
    () => parseForm(talkSchema, formData),
  );
  if (parsed.success) await saveTalk(parsed.data);
  return parsed.state;
}

Server Actions exist only under @k8ordo/server; on an @k8ordo/static site, only the first two points apply.

Read @k8ordo/form's guide

@k8ordo/router

The locale is the :locale parameter of every pattern. The router has no i18n settings.

  • Links and navigation use the href / navigateTo that bindParams(() => ({ locale: locales.getLocale() })) returns.
  • A language switcher reads the current pathname with usePathname() and builds the other locale's URL with delocalize and localize.
  • Section checks work on patterns that include the locale, as in useMatch('/:locale/docs/*'); :locale matches whatever the segment holds.

Read how links, the language switcher and the / page are written

@k8ordo/static

Two things matter for i18n in a static build.

  • framework({ paths: locales.paths }) expands the locale segment once per locale; any other parameter is expanded in the same function.
  • 404.html is rendered once under a sentinel segment, so render the text of not-found.tsx in a Client Component and let hydration bring it to the locale of the visitor's URL. This site also corrects <html lang> from an effect after hydration, through document.documentElement.lang.

@k8ordo/server

Rendering happens per request, so paramsSchema and messages work exactly as in a static build. What differs is that a page can read the request, and that there are Server Actions.

  • A page receives request, so / can negotiate from Accept-Language. With the answer in the HTML, a visitor without JavaScript also sees the link to follow.
  • The page cannot answer with a redirect itself: redirect() is for Server Actions, and a redirect.ts builds its target from the params, never from the request headers. To answer / with a 307 on the server, do it outside the application: a proxy in front of serve, or a host of your own around the built handler (dist/rsc/index.js), answers / before calling the handler. Otherwise the move happens on the client.
  • Wrap message calls in a Server Action with locales.run, as in the @k8ordo/form example above.
// src/routes/page.tsx
import { parseAcceptLanguage } from '@k8ordo/i18n';
import type { PageProps } from '@k8ordo/router';

import { locales } from '../i18n';
import { href } from '../links';
import { RedirectTo } from './_parts/redirect-to';

export default function RootPage({ request }: PageProps<'/'>) {
  const locale = locales.negotiate(
    parseAcceptLanguage(request.headers.get('accept-language')),
  );

  return <RedirectTo to={href('/:locale', { locale })} />;
}
// src/routes/_parts/redirect-to.tsx
'use client';

import { useEffect } from 'react';

export function RedirectTo({ to }: { to: string }) {
  useEffect(() => {
    navigation.navigate(to, { history: 'replace' });
  }, [to]);

  return <a href={to}>{to}</a>;
}

Read how @k8ordo/server's handler is run

Testing

How a test sets the locale depends on whether it runs on the server path or the browser path.

Tests that run under Node

With nothing named, messages return the default locale. Call them inside locales.run for another locale. An async function keeps the locale across its awaits, and concurrent run calls stay apart.

// src/messages/messages.test.ts
import { describe, expect, expectTypeOf, it } from 'vitest';

import { locales } from '../i18n';
import * as cart from './cart';
import * as nav from './nav';

describe('messages', () => {
  it('renders in the default locale unless run names another', () => {
    expect(nav.home()).toBe('ホーム');
    expect(locales.run('en', () => nav.home())).toBe('Home');
  });

  it('keeps the locale across awaits', async () => {
    const text = await locales.run('en', async () => {
      await Promise.resolve();
      return nav.home();
    });
    expect(text).toBe('Home');
  });

  it('types the arguments of a message', () => {
    expectTypeOf(cart.items).parameters.toEqualTypeOf<[count: number]>();
  });
});

Validating with paramsSchema sets the accepted locale for the rest of the async flow that called it. Wrap a test that validates in run so the locale does not leak into the tests after it.

// src/i18n.test.ts
import { expect, it } from 'vitest';

import { locales } from './i18n';

it('accepts only the listed locales', () => {
  const { validate } = locales.paramsSchema['~standard'];
  expect(locales.run('ja', () => validate({ locale: 'en' }))).toStrictEqual({
    value: { locale: 'en' },
  });
  expect(validate({ locale: 'fr' })).toMatchObject({
    issues: [{ path: ['locale'] }],
  });
});

Tests that run in a browser

In a real browser, such as Vitest's browser mode, the URL is the locale. Change the pathname with history.replaceState and put it back afterwards. run throws there.

// src/messages/messages.browser.test.ts
import { afterEach, expect, it } from 'vitest';

import { locales } from '../i18n';
import * as nav from './nav';

const initial = location.pathname;

afterEach(() => {
  history.replaceState(null, '', initial);
});

it('renders in the locale the URL spells', () => {
  history.replaceState(null, '', '/en/cart');
  expect(locales.getLocale()).toBe('en');
  expect(nav.home()).toBe('Home');
});

An environment that defines document, such as jsdom or happy-dom, is a browser as far as this package is concerned: run throws, and the locale is read from location.pathname.

The set, and types

  • Defining another set with defineLocales in a test makes the messages after it read that set. Import the application's locales, or, if some test defines its own set, define the application's set again in beforeEach.
  • Pin the type guarantees with type tests: expectTypeOf(cart.items).parameters.toEqualTypeOf<[count: number]>() for the arguments, and a // @ts-expect-error on a declaration missing a locale to prove that it does not compile.