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.tsxnothing validates the catch-all's params, sogetLocale()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 fromlocales.delocalize(usePathname()).locale, as this site'sLocaleShelldoes. dictionariesholdsjaanden. When the set has other locales (fr, oren-US), write those dictionaries yourself, annotated with theMessagestype from@k8ordo/ui/i18n, and map locales to dictionaries with aVariants<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>.
@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.
- 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 inmin(1, m.talk.titleRequired()), freezes the string of whatever locale was current then, usually the default. - 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. - A Server Action runs outside the
[locale]render, so nothing names a locale there. Take the locale the page bound to the action and callparseForminsidelocales.run. The value comes back from the client, so check it withlocales.isbefore 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.
@k8ordo/router
The locale is the :locale parameter of every pattern. The router has no i18n settings.
- Links and navigation use the
href/navigateTothatbindParams(() => ({ locale: locales.getLocale() }))returns. - A language switcher reads the current pathname with
usePathname()and builds the other locale's URL withdelocalizeandlocalize. - Section checks work on patterns that include the locale, as in
useMatch('/:locale/docs/*');:localematches 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.htmlis rendered once under a sentinel segment, so render the text ofnot-found.tsxin 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, throughdocument.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 fromAccept-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 aredirect.tsbuilds its target from the params, never from the request headers. To answer/with a307on the server, do it outside the application: a proxy in front ofserve, 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/formexample 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>;
}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
defineLocalesin a test makes the messages after it read that set. Import the application'slocales, or, if some test defines its own set, define the application's set again inbeforeEach. - Pin the type guarantees with type tests:
expectTypeOf(cart.items).parameters.toEqualTypeOf<[count: number]>()for the arguments, and a// @ts-expect-erroron a declaration missing a locale to prove that it does not compile.