@k8ordo/i18n 0.1.0 → 1.1.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/docs/GUIDE.md CHANGED
@@ -22,7 +22,8 @@ legacy fallbacks.
22
22
  locale segment in front of them and nothing after it.
23
23
  - **A message grammar.** No placeholder syntax, no ICU. A message with
24
24
  values is a function of those values: interpolation is a template
25
- literal, plurals are `Intl.PluralRules`, dates and numbers are `Intl`.
25
+ literal, and plurals, dates and numbers are `Intl` — the set only draws
26
+ the `Intl` object for the current locale ([Formatting](#formatting)).
26
27
  TypeScript checks the arguments because they are arguments.
27
28
  - **Loading.** Messages are ordinary exports. Which ones reach the browser is
28
29
  decided by the bundler from what each client module imports, not by a
@@ -31,21 +32,17 @@ legacy fallbacks.
31
32
  ## The shape of it
32
33
 
33
34
  ```ts
34
- // i18n.ts — the one place the list is spelled
35
+ // src/i18n.ts — the one place the list is spelled
35
36
  import { defineLocales } from '@k8ordo/i18n';
36
- import type { LocaleOf } from '@k8ordo/i18n';
37
-
38
- export const locales = defineLocales(['ja', 'en']);
39
37
 
40
- declare module '@k8ordo/i18n' {
41
- interface Register {
42
- locale: LocaleOf<typeof locales>;
43
- }
44
- }
38
+ export const locales = defineLocales({
39
+ ja: { timeZone: 'Asia/Tokyo', dir: 'ltr' },
40
+ en: { timeZone: 'UTC', dir: 'ltr' },
41
+ });
45
42
  ```
46
43
 
47
44
  ```ts
48
- // messages/nav.ts — each message is one export
45
+ // src/messages/nav.ts — each message is one export
49
46
  import { message } from '@k8ordo/i18n';
50
47
 
51
48
  export const home = message({ ja: 'ホーム', en: 'Home' });
@@ -56,51 +53,98 @@ export const greeting = message({
56
53
  ```
57
54
 
58
55
  ```tsx
59
- // routes/[locale]/layout.tsx — a Server Component
56
+ // src/routes/[locale]/layout.tsx — a Server Component
60
57
  import { locales } from '../../i18n';
61
58
 
62
- export const paramsSchema = locales.paramsSchema;
59
+ export const { paramsSchema } = locales;
63
60
  ```
64
61
 
65
- Spell it as that assignment: the framework finds a route's schema by
66
- reading the file for `export const paramsSchema`, so a destructuring export
67
- (`export const { paramsSchema } = locales`) would go unnoticed and `/fr/…`
68
- would render in the default locale. A linter's `prefer-destructuring`
69
- autofix rewrites it into exactly that, so disable the rule on the line.
70
-
71
62
  ```tsx
72
- // anywhere — a Server Component or a Client Component, the same line
73
- import * as nav from '../messages/nav';
63
+ // src/components/header.tsx — a Server or a Client Component, the same lines
64
+ import { greeting, home } from '../messages/nav';
74
65
 
75
- <h1>{nav.home()}</h1>
76
- <p>{nav.greeting(name)}</p>
66
+ <h1>{home()}</h1>
67
+ <p>{greeting(name)}</p>
68
+ ```
69
+
70
+ That is all a working setup needs under `@k8ordo/framework`:
71
+ `defineLocales`, `message`, and `paramsSchema` on the `[locale]` segment.
72
+
73
+ `Register` is what holds every message to the set. Under the framework it is
74
+ generated: when the application's `package.json` lists `@k8ordo/i18n` and
75
+ `src/i18n.ts` exports the set as `locales`, the framework writes the
76
+ augmentation into `.k8ordo/register.gen.ts` beside the route table,
77
+ rewrites it as that file changes under `vite dev`, and the application
78
+ writes none. It reads the export from the file's syntax, so the set has to
79
+ be exported by that name from that file — `export const locales` or
80
+ `export { locales } from './locales'`, not a type-only export and not
81
+ `export *`. A `src/i18n.ts` without it gets a warning from the build and no
82
+ `Register`.
83
+
84
+ Anywhere else — Next.js, a framework of your own — write it once, beside the
85
+ set:
86
+
87
+ ```ts
88
+ // src/i18n.ts, outside @k8ordo/framework
89
+ import type { LocaleOf } from '@k8ordo/i18n';
90
+
91
+ declare module '@k8ordo/i18n' {
92
+ interface Register {
93
+ locale: LocaleOf<typeof locales>;
94
+ }
95
+ }
77
96
  ```
78
97
 
79
- That is the whole surface: `defineLocales`, `Register`, `message`, and
80
- `paramsSchema` on the `[locale]` segment.
98
+ A hand-written one left in a framework application still compiles, since
99
+ both declare the same type, and can be deleted.
81
100
 
82
101
  ## The locale set
83
102
 
84
- `defineLocales(all, { default? })` returns the set. The first entry is the
85
- default unless told otherwise; every tag must be BCP 47 (checked with
86
- `Intl.Locale`); a repeated tag or a default outside the list throws at the
87
- definition, not later.
88
-
89
- | Member | What it is |
90
- | ----------------- | --------------------------------------------------------------------------------- |
91
- | `all` | The tags, in order. |
92
- | `default` | The tag used when nothing names one. |
93
- | `is(value)` | Membership as a type guard. |
94
- | `negotiate(…)` | The best supported tag for a preference list. |
95
- | `localize` | `'/ui'` → `'/en/ui'`; `'/'` → `'/en'`. |
96
- | `delocalize` | `'/en/ui'` → `{ locale: 'en', pathname: '/ui' }`; `'/x'` → `{ locale: null, … }`. |
97
- | `paramsSchema` | The `[locale]` segment's schema (Standard Schema; no schema library). |
98
- | `getLocale()` | The locale of the render in progress. Not a hook. |
99
- | `run(locale, fn)` | Server only: runs `fn` with `locale` current. |
100
-
101
- `LocaleOf<typeof locales>` is the tag union. `delocalize` says `null` for a
102
- first segment that is not a locale rather than guessing the default, so the
103
- root layout and the 404 page choose the fallback visibly.
103
+ `defineLocales(definitions, { default? })` returns the set. `definitions` is
104
+ keyed by locale tag, and each locale states its `timeZone` and its `dir`. The
105
+ first entry is the default unless told otherwise (without `default`,
106
+ `locales.default` is typed as the whole union); every tag must be BCP 47
107
+ (checked with `Intl.Locale`); an empty set, a default outside the list, a time
108
+ zone the runtime does not know, and a `dir` other than `ltr` / `rtl` throw at
109
+ the definition, not later.
110
+
111
+ | Member | What it is |
112
+ | --------------------- | --------------------------------------------------------------------------------- |
113
+ | `all` | The tags, in order. |
114
+ | `definitions` | Each locale's `{ timeZone, dir }`, as given. |
115
+ | `default` | The tag used when nothing names one. |
116
+ | `is(value)` | Membership as a type guard. |
117
+ | `negotiate(…)` | The best supported tag for a preference list. |
118
+ | `negotiateRequest(…)` | The best supported tag for a `Request`: a cookie, then `Accept-Language`. |
119
+ | `localize` | `'/ui'` → `'/en/ui'`; `'/'` → `'/en'`. |
120
+ | `delocalize` | `'/en/ui'` → `{ locale: 'en', pathname: '/ui' }`; `'/x'` → `{ locale: null, … }`. |
121
+ | `paths` | The static build's `paths` option: `/:locale` expanded to every locale. |
122
+ | `paramsSchema` | The `[locale]` segment's schema (Standard Schema; no schema library). |
123
+ | `getLocale()` | The locale of the render in progress. Not a hook. |
124
+ | `run(locale, fn)` | Server only: runs `fn` with `locale` current. |
125
+ | `dateTimeFormat`… | `Intl` for the current locale ([Formatting](#formatting)). |
126
+
127
+ `LocaleOf<typeof locales>` is the tag union and `LocaleDefinition` one
128
+ locale's `{ timeZone, dir }`. `delocalize` says `null` for a first segment
129
+ that is not a locale rather than guessing the default, so the root layout and
130
+ the 404 page choose the fallback visibly.
131
+
132
+ ### `timeZone` and `dir`
133
+
134
+ Neither can be derived from the runtime, so each locale declares both.
135
+
136
+ `timeZone` is the IANA time zone the locale's dates are shown in. The
137
+ runtime's own zone is the server's on the server and the visitor's in the
138
+ browser, so a date left to it renders one way in the HTML and another while
139
+ hydrating — a different day, near midnight. One zone per locale gives both
140
+ sides the same value. Which zone is a product decision: a site about events in
141
+ Tokyo may show its English pages in `Asia/Tokyo` too; a display that should
142
+ follow each visitor's own zone belongs in a part that renders in the browser
143
+ only.
144
+
145
+ `dir` is the direction the locale's text runs in, for `<html dir>`.
146
+ `Intl.Locale`'s `getTextInfo()` has not reached every browser, so it is
147
+ declared rather than derived.
104
148
 
105
149
  ### Negotiation
106
150
 
@@ -112,12 +156,39 @@ skipped, because the list is user input.
112
156
 
113
157
  ```ts
114
158
  locales.negotiate(navigator.languages); // in the browser
115
- locales.negotiate(parseAcceptLanguage(request.headers.get('accept-language')));
159
+ locales.negotiateRequest(request, { cookie: 'locale' }); // on a server
160
+ ```
161
+
162
+ `negotiateRequest(request, { cookie? })` is the server's form. The cookie
163
+ named by `cookie` comes first when the request carries it — the locale the
164
+ visitor chose before, written where they switched language — then the
165
+ `Accept-Language` header in its order of preference. Both go through
166
+ `negotiate`, so a cookie still holding a locale the set no longer lists falls
167
+ through to the header. The name is the application's: a language switcher
168
+ that writes it and the server that reads it agree on one. Without `cookie`,
169
+ only the header is read.
170
+
171
+ ```ts
172
+ // the language switcher, in the browser
173
+ await cookieStore.set({
174
+ name: 'locale',
175
+ value: next,
176
+ sameSite: 'lax',
177
+ expires: Date.now() + 400 * 24 * 60 * 60 * 1000,
178
+ });
116
179
  ```
117
180
 
118
- `parseAcceptLanguage(header)` turns an `Accept-Language` header into that
119
- list: `q` weights decide the order, ties keep the header's order, `q=0` and
120
- `*` are dropped, and a missing header is an empty list.
181
+ Left to the Cookie Store API's defaults, the switcher would write a session
182
+ cookie under `SameSite=Strict`: the choice is gone once the browser closes,
183
+ and the first request arriving from a link on another site goes without it,
184
+ so that page is chosen by the header instead. `sameSite: 'lax'` and a far
185
+ `expires` — 400 days is the longest a browser keeps a cookie — are what make
186
+ the choice stick.
187
+
188
+ `parseAcceptLanguage(header)` is the header half on its own: it turns an
189
+ `Accept-Language` header into a preference list for `negotiate`: `q` weights decide the order (a `q` that is not a number is ignored),
190
+ ties keep the header's order, a weight of 0 or less (an empty `q=` included)
191
+ and `*` are dropped, and a missing header is an empty list.
121
192
 
122
193
  ## Messages
123
194
 
@@ -128,14 +199,16 @@ key does not compile" guarantee, per message, with no key list to maintain.
128
199
 
129
200
  A message is either text in every locale or a function in every locale;
130
201
  the two are not mixed within one message. For a function message the
131
- arguments are typed by the variant you annotate, and every other variant
132
- is held to the same parameters:
202
+ argument types come from the variant you annotate, and every other variant
203
+ is held to them. Declare every parameter in every variant, an unused one as
204
+ `_count`: whether a variant that declares fewer compiles depends on which
205
+ locale holds it, and with no annotation at all the arguments are `unknown`.
133
206
 
134
207
  ```ts
135
208
  export const items = message({
136
209
  ja: (count: number) => `${String(count)} 件`,
137
210
  en: (count) =>
138
- `${String(count)} ${new Intl.PluralRules('en').select(count) === 'one' ? 'item' : 'items'}`,
211
+ `${String(count)} ${locales.pluralRules().select(count) === 'one' ? 'item' : 'items'}`,
139
212
  });
140
213
  ```
141
214
 
@@ -144,8 +217,13 @@ string` for a function. That type is what a component that takes a message
144
217
  as a prop should accept —
145
218
 
146
219
  ```tsx
220
+ // src/components/nav-list.tsx
221
+ import type { Message } from '@k8ordo/i18n';
222
+
223
+ import { home } from '../messages/nav';
224
+
147
225
  type NavItem = { path: string; label: Message };
148
- const items: NavItem[] = [{ path: '/ui', label: nav.ui }];
226
+ const items: NavItem[] = [{ path: '/', label: home }];
149
227
  …
150
228
  <a href={item.path}>{item.label()}</a>
151
229
  ```
@@ -155,34 +233,41 @@ component translating on another's behalf.
155
233
 
156
234
  ### Where messages live
157
235
 
158
- Anywhere. A file per area (`messages/nav.ts`, `messages/form.ts`) with a
159
- barrel that re-exports each as a namespace reads well at the call site:
160
-
161
- ```ts
162
- // messages/index.ts
163
- export * as nav from './nav';
164
- export * as form from './form';
165
- ```
236
+ Anywhere. A file per area in `src/messages/` (`nav.ts`, `cart.ts`) reads
237
+ well: a module imports the messages it uses by name, and renames one on import
238
+ where two files export the same name:
166
239
 
167
240
  ```tsx
168
- import * as m from '../messages';
169
- <h1>{m.nav.home()}</h1>;
241
+ // src/components/cart-summary.tsx
242
+ import { title as cartTitle } from '../messages/cart';
243
+ import { home } from '../messages/nav';
244
+
245
+ <a href="/">{home()}</a>
246
+ <h2>{cartTitle()}</h2>
170
247
  ```
171
248
 
172
- A message that belongs to one component can sit next to that component.
173
- Grouping related messages in an object (`export const button = { label:
174
- message(…), hint: message(…) }`) is fine too; the bundler then keeps the
175
- group together.
249
+ A message that belongs to one component can be declared in that
250
+ component's own module. Grouping related messages in an object (`export const
251
+ button = { label: message(…), hint: message(…) }`) is fine too; the bundler
252
+ then keeps the group together.
176
253
 
177
254
  ### Across the Server Component boundary
178
255
 
179
256
  A message is a function, and a function does not cross from a Server
180
257
  Component to a Client Component as a prop. Where a Server Component hands
181
258
  text to a `'use client'` component, it calls the message and passes the
182
- string — `<Dialog title={m.dialog.title()} />` — which is the rule the rest
183
- of k8ordo already follows: only input crosses the boundary. Nothing further
184
- down needs the string threaded through it, because any component, on either
185
- side, can import a message and call it itself.
259
+ string:
260
+
261
+ ```tsx
262
+ // src/components/settings.tsx — a Server Component
263
+ import { title } from '../messages/dialog';
264
+
265
+ <Dialog title={title()} />;
266
+ ```
267
+
268
+ That is the rule the rest of k8ordo already follows: only input crosses the
269
+ boundary. Nothing further down needs the string threaded through it, because
270
+ any component, on either side, can import a message and call it itself.
186
271
 
187
272
  A component without a directive is shared: rendered by a Server Component it
188
273
  runs on the server, rendered by a Client Component it runs in the browser,
@@ -203,99 +288,295 @@ Keep the rule of thumb in mind: text that a Server Component renders costs
203
288
  the client nothing, whichever module declares it. A client component that
204
289
  needs text names it and pays for that message alone.
205
290
 
291
+ ## Formatting
292
+
293
+ Plurals, dates, numbers and lists are `Intl` itself; there is no format
294
+ syntax here. The set draws the `Intl` object for the current locale:
295
+
296
+ | Member | Returns |
297
+ | ------------------------------ | --------------------------------------------------------------- |
298
+ | `dateTimeFormat(options?)` | `Intl.DateTimeFormat` in the current locale and its `timeZone`. |
299
+ | `numberFormat(options?)` | `Intl.NumberFormat` in the current locale. |
300
+ | `relativeTimeFormat(options?)` | `Intl.RelativeTimeFormat` in the current locale. |
301
+ | `pluralRules(options?)` | `Intl.PluralRules` in the current locale. |
302
+ | `listFormat(options?)` | `Intl.ListFormat` in the current locale. |
303
+
304
+ ```tsx
305
+ <time dateTime={date.toISOString()}>
306
+ {locales.dateTimeFormat({ dateStyle: 'medium' }).format(date)}
307
+ </time>
308
+ ```
309
+
310
+ What comes back is the `Intl` object, so `format`, `formatToParts`,
311
+ `formatRange`, `select` and the options are `Intl`'s own. One is made per
312
+ locale and options and returned again after that, so calling it where a
313
+ component or a message renders costs a lookup. Options are keyed by their
314
+ JSON: the same options in another order make a second object, never a
315
+ different answer. None of them is a hook, and each reads the current locale
316
+ the way a message does, so the same call works in a Server Component, a
317
+ Client Component, and inside a message's function.
318
+
319
+ `dateTimeFormat` writes a date only in the locale's `timeZone`. Its options
320
+ type refuses a `timeZone`, and one forced through with `as` is overridden. The
321
+ runtime's own zone is the server's on one side and the visitor's on the other,
322
+ so a date left to it reads differently in the HTML and while hydrating; the
323
+ locale's zone is the same on both. A display that should follow the visitor's
324
+ own zone — a local clock, a time relative to now — differs between the two by
325
+ nature: use `Intl` directly for it, in a part that renders in the browser
326
+ only. An `Intl` API not listed here (`Intl.Collator`, `Intl.DisplayNames`)
327
+ takes the tag from `locales.getLocale()`.
328
+
206
329
  ## Where the locale comes from
207
330
 
208
331
  **On the server**, `paramsSchema` accepting a locale makes it the current
209
- one for the rest of that request's render — the Server Components, the
332
+ one for the render of the page that accepted it — the Server Components, the
210
333
  HTML they become, and the client components that run on the server for
211
- that HTML. Concurrent renders stay apart (`AsyncLocalStorage`). Outside a
212
- `[locale]` route — a test, code that runs before the route matched — use
334
+ that HTML — and for nothing else. Concurrent renders stay apart
335
+ (`AsyncLocalStorage`). An acceptance belongs to the pattern that answered,
336
+ not to the request: when a schema further down the same stack refuses
337
+ (`/en/blog/nope`, where the page's slug schema says no), the pattern does
338
+ not answer and the locale goes with it. The 404 that answers instead runs
339
+ the schemas above its `not-found.tsx` on its own, so it is in the locale its
340
+ URL names — `/en/blog/nope` as `/en/nothing` is — and in the default under a
341
+ segment the set refuses (`/fr/nothing`). Outside a `[locale]` route — a
342
+ test, code that runs before the route matched — use
213
343
  `locales.run(locale, fn)`; and when nothing names a locale, messages render
214
344
  in the default.
215
345
 
346
+ The server keeps the locale in `AsyncLocalStorage`, reached through
347
+ `process.getBuiltinModule`. A runtime without them throws from
348
+ `paramsSchema` and from `run`, rather than accepting a locale and rendering
349
+ the page in the default.
350
+
216
351
  **In the browser**, the URL is the locale: the first segment of
217
352
  `location.pathname`, read when a message is called. Changing locale is a
218
353
  navigation to the same pathname under the other segment
219
354
  (`locales.localize(locales.delocalize(pathname).pathname, 'en')`), which
220
355
  re-renders the page; there is no state to keep in sync.
221
356
 
357
+ Under Vite's `base` (`base: '/docs/'`), the segment read is the first one
358
+ below it — `/docs/en/ui` is in `en` — as the route table's `[locale]` sits
359
+ below it too. `localize` and `delocalize` work on pathnames in the table's
360
+ terms, which is what `usePathname` returns; the one you navigate to gets the
361
+ base back from the router's `withBase` (imported from `@k8ordo/framework` in
362
+ a framework application).
363
+
222
364
  A first segment that is not one of the set's locales is no locale, and the
223
365
  default applies; before the set has been defined in that environment, a
224
366
  segment no message has text for is read the same way, so a 404 page never
225
367
  throws on `/fr/…`.
226
368
 
227
369
  `locales.getLocale()` reads the same source for code that needs the tag
228
- itself: `<html lang>`, a language switcher, `Intl` formatters.
370
+ itself: `<html lang>`, a language switcher, an `Intl` API the set does not
371
+ draw.
229
372
 
230
373
  ### The `/` page
231
374
 
232
- `/` is the one URL without a locale. Render nothing there and redirect from
233
- an effect:
375
+ `/` is the one URL without a locale; all it does is send the visitor to one.
376
+
377
+ In server mode a `guard.ts` answers it before anything renders. It
378
+ chooses from the request — the cookie holding the locale the visitor chose
379
+ before, then `Accept-Language` — and ends the request with a `307`:
380
+
381
+ ```ts
382
+ // src/routes/(home)/guard.ts
383
+ import { withBase } from '@k8ordo/framework';
384
+ import type { Guard } from '@k8ordo/framework/server';
385
+
386
+ import { locales } from '../../i18n';
387
+
388
+ const guard: Guard<'/'> = ({ request }) => {
389
+ const locale = locales.negotiateRequest(request, { cookie: 'locale' });
390
+ return new Response(null, {
391
+ status: 307,
392
+ headers: { location: withBase(locales.localize('/', locale)) },
393
+ });
394
+ };
395
+
396
+ export default guard;
397
+ ```
398
+
399
+ ```
400
+ src/routes/
401
+ layout.tsx
402
+ (home)/
403
+ page.tsx declares /; never renders, since the guard always answers
404
+ guard.ts
405
+ [locale]/
406
+ ```
407
+
408
+ The route group is what keeps the guard to `/`: a `guard.ts` runs before
409
+ every URL below its directory, so one directly in `src/routes/` would send
410
+ `/en/…` away as well. The page still has to exist — a guard runs before a
411
+ URL a page declares, and without one `/` is a 404 — and it can return
412
+ `null`. The status is `307`, not `308`, because the answer depends on who
413
+ asks; `withBase` puts back the Vite `base` that `localize` leaves out. A
414
+ visitor without JavaScript is redirected all the same, and so is a client
415
+ navigation to `/`, whose payload request the guard answers too.
416
+
417
+ Static mode refuses a `guard.ts` — a file has no request to guard — so
418
+ there the page renders nothing and redirects from an effect: through the
419
+ bound `navigateTo` where the router is `@k8ordo/router`'s (below), or
420
+ `locales.localize` otherwise:
234
421
 
235
422
  ```tsx
236
423
  'use client';
237
424
  useEffect(() => {
238
- navigation.navigate(
239
- locales.localize('/', locales.negotiate(navigator.languages)),
425
+ navigateTo(
426
+ '/:locale',
427
+ { locale: locales.negotiate(navigator.languages) },
240
428
  { history: 'replace' },
241
429
  );
242
430
  }, []);
243
431
  ```
244
432
 
245
- Under `@k8ordo/server` the same decision can be made on the server with
246
- `parseAcceptLanguage` and a redirect.
247
-
248
- ### `<html lang>`
433
+ ### `<html lang>` and `dir`
249
434
 
250
- The root layout sits above `[locale]` and receives `pathname`; the schema
251
- has already run for that request, so `locales.getLocale()` is right, and
435
+ The root layout sits above `[locale]` and receives `pathname`. On a page the
436
+ schema has already run for that page, so `locales.getLocale()` is right, and
252
437
  `locales.delocalize(pathname).locale ?? locales.default` says the same thing
253
- in terms of the URL alone.
438
+ in terms of the URL alone. On a 404 they agree too: the schema runs over the
439
+ catch-all's params, so `getLocale()` is the URL's locale where it names one
440
+ and the default where it does not, as `delocalize` reads it.
441
+
442
+ ```tsx
443
+ const locale = locales.delocalize(pathname).locale ?? locales.default;
444
+
445
+ <html dir={locales.definitions[locale].dir} lang={locale}>
446
+ ```
254
447
 
255
448
  ## Static builds
256
449
 
257
- `@k8ordo/static` asks for the pathnames of every pattern with a parameter.
258
- Expand `[locale]` from the set — the list is spelled once:
450
+ Static mode asks for the pathnames of every pattern with a parameter.
451
+ The set answers for its own segment:
259
452
 
260
453
  ```ts
261
- framework({
262
- paths: (patterns) =>
263
- patterns.flatMap((pattern) =>
264
- locales.all.map((locale) => pattern.replace(':locale', locale)),
265
- ),
266
- });
454
+ framework({ mode: 'static', paths: locales.paths });
455
+ ```
456
+
457
+ `/:locale` in every pattern becomes one pathname per locale. A pattern with
458
+ another parameter comes back still holding it (`/ja/blog/:slug`). Only a
459
+ page with a `fallback.tsx` beside it takes such a pathname, as the location
460
+ of its shell: one per locale, answering in the browser the slugs the build
461
+ did not write. For any other route the build stops:
462
+
463
+ ```
464
+ the "paths" option supplied pathnames that still hold a parameter, and only a page with a fallback.tsx beside it takes one: /ja/blog/:slug ([locale]/blog/[slug]/page.tsx has none)
267
465
  ```
268
466
 
467
+ A site with a second parameter expands the rest in the same function:
468
+ `paths: (patterns) => locales.paths(patterns).flatMap(expandSlug)`, where
469
+ `expandSlug` keeps `/ja/blog/:slug` beside the slugs for a page that has a
470
+ `fallback.tsx`.
471
+
472
+ A shell runs the schemas of the layouts above it, never its page's: there
473
+ is no value for the page's to parse. With the schema on
474
+ `[locale]/layout.tsx`, as in [The shape of it](#the-shape-of-it), the
475
+ locale is set for every page below it, shells included, and a shell's
476
+ messages come out in the locale its location fills.
477
+
269
478
  Each path is rendered as its own request, so the schema names the locale
270
- for each and the messages come out in that locale. The `404.html` a static
271
- host serves for everything else is rendered once, under the build's
272
- sentinel locale; a client component on it reads the visitor's URL and
273
- re-renders in theirs after hydration.
479
+ for each and the messages come out in that locale; the build renders several
480
+ at once, and none lends its locale to another. The `404.html` a static host
481
+ serves for everything else is rendered once, by the catch-all under the
482
+ build's sentinel segment, which the schema refuses — so it is in the default
483
+ locale whatever the build rendered before it, and cannot follow the
484
+ visitor's. The browser does not hydrate it: it was rendered for another URL,
485
+ and a message read while hydrating would disagree with the text written
486
+ there. It renders the file afresh where the visitor is instead, so its client
487
+ components come out in their locale once the script runs, and a visitor
488
+ without JavaScript keeps the default.
274
489
 
275
490
  ## Alongside the rest of k8ordo
276
491
 
277
- - **`@k8ordo/router`**: `locales.localize` / `delocalize` are the only
278
- things here that touch a pathname. A link component that prefixes the
279
- current locale is `href(locales.localize(path, locales.getLocale()))`.
280
- - **`@k8ordo/ui`**: its own built-in strings take a dictionary prop on
281
- `UIProvider`; pass `en` on `/en/…` from `locales.getLocale()`.
282
- - **`@k8ordo/form`**: constraint messages are messages — `message({ ja:
283
- (max: number) => …, en: … })` — called where the constraint is declared.
492
+ - **`@k8ordo/router`**: the locale is a param of every pattern
493
+ (`/:locale/products/:id`), and the router's `bindParams` supplies it from
494
+ this package once, so links stay typed against the table and never spell
495
+ the locale:
496
+
497
+ ```ts
498
+ // src/lib/links.ts
499
+ import { bindParams } from '@k8ordo/framework'; // '@k8ordo/router' without the framework
500
+
501
+ export const { href, navigateTo } = bindParams(() => ({
502
+ locale: locales.getLocale(),
503
+ }));
504
+ ```
505
+
506
+ `href('/:locale/products/:id', { id })` then reads the current locale;
507
+ `navigateTo('/:locale', { locale: 'en' }, { history: 'replace' })`
508
+ overrides it. Neither package imports the other: the line above is the
509
+ application's. `localize` / `delocalize` remain for a pathname in hand —
510
+ the language switcher, which takes the page it is on to another locale.
511
+
512
+ - **`@k8ordo/ui`**: its built-in strings read `currentLocale()` from this
513
+ package — the locale messages render in, or `null` (English there) when no
514
+ set is defined in that environment — so they follow the app's locale with
515
+ nothing passed. `ja` and `en` ship with it; another locale is registered
516
+ next to the set with `registerMessages` from `@k8ordo/ui/i18n`. The module
517
+ that defines the set has to be loaded in the browser too, or the components
518
+ there speak English.
519
+ - **`@k8ordo/form`**: constraint messages are messages, and a message called
520
+ where the constraint is declared keeps the text of whatever locale was
521
+ current then. Hand zod the message instead, so it is called when zod
522
+ reports the issue — and a `defineForm` rule the same way, which calls it
523
+ when the rule is reported:
524
+
525
+ ```ts
526
+ // src/lib/talk-form.ts
527
+ import {
528
+ reasonRequired,
529
+ titleRequired,
530
+ titleTooLong,
531
+ } from '../messages/talk';
532
+
533
+ export const talkForm = defineForm(
534
+ z.object({
535
+ title: z
536
+ .string()
537
+ .min(1, { error: titleRequired })
538
+ .max(120, { error: () => titleTooLong(120) }),
539
+ status: z.enum(['draft', 'rejected']),
540
+ reason: z.string(),
541
+ }),
542
+ [requiredWhen('reason', 'status', 'rejected', reasonRequired)],
543
+ );
544
+ ```
545
+
546
+ The definition stays at module scope; what has to happen per request is
547
+ calling it. Call `formFields` during the page's render, not at module
548
+ scope. A Server Action posted from a `[locale]` page runs in that page's
549
+ locale in server mode — the framework runs the page's params
550
+ schemas for the action's request too — so `parseForm` in it needs nothing
551
+ more. Anywhere else — an action run outside the framework, a job — call it
552
+ inside `locales.run`.
284
553
 
285
554
  ## What it guarantees
286
555
 
287
556
  - A locale outside the list never reaches a page: the schema refuses it.
288
- - A message missing a locale does not compile once `Register` is merged.
557
+ - A message missing a locale does not compile once `Register` is merged —
558
+ by `@k8ordo/framework`'s generated files, or by hand.
289
559
  From JavaScript, or through `as`, it throws where it is read, naming the
290
560
  locale and the variants present — never `undefined`.
291
561
  - Arguments to a function message are typed by the function.
292
- - No render is ever in a locale the URL does not spell (browser) or the
293
- request did not accept (server).
562
+ - A page under `[locale]` never renders in a locale its URL does not spell:
563
+ on the server its schema accepted that locale, and in the browser the URL
564
+ is what is read. A locale accepted for one page reaches no other page, no
565
+ 404, and nothing rendered after it; a 404 is in the locale its own URL
566
+ names, or the default.
567
+ - On a server runtime without `AsyncLocalStorage`, accepting a locale throws
568
+ rather than rendering in the default.
569
+ - A date written through `dateTimeFormat` is in its locale's time zone on the
570
+ server and in every browser; the type refuses any other.
294
571
 
295
572
  ## Testing
296
573
 
297
- Under Node, `locales.run('en', () => nav.home())` renders in `en`; without
298
- it, the default. The `paramsSchema` sets the locale for the rest of its
299
- scope too, so wrap a test that validates in `run` to keep tests apart. In a
300
- browser environment, `history.replaceState(null, '', '/en/…')` is the
301
- locale.
574
+ Under Node, `locales.run('en', () => home())` renders in `en`; without
575
+ it, the default. Called directly, `paramsSchema` sets the locale for the
576
+ rest of the caller's async context — the framework gives each pattern's
577
+ schemas a context of their own, a test does not — so wrap a test that
578
+ validates in `run` to keep tests apart. A test that calls `defineLocales`
579
+ itself replaces the set messages read: the last one defined wins. In a
580
+ browser environment, `history.replaceState(null, '', '/en/…')` is the locale
581
+ and `run` throws; an environment that defines `document`, such as jsdom or
582
+ happy-dom, counts as one.