@k8ordo/i18n 0.1.0 → 1.0.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/README.md +73 -28
- package/dist/accept-language.d.mts +7 -7
- package/dist/accept-language.mjs +5 -4
- package/dist/cookie.d.mts +10 -0
- package/dist/cookie.mjs +20 -0
- package/dist/current.d.mts +26 -23
- package/dist/current.mjs +31 -5
- package/dist/format.d.mts +32 -0
- package/dist/format.mjs +31 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +2 -1
- package/dist/locales.d.mts +76 -19
- package/dist/locales.mjs +55 -12
- package/dist/message.d.mts +5 -6
- package/dist/message.mjs +2 -2
- package/dist/register.d.mts +6 -6
- package/docs/GUIDE.md +394 -113
- package/docs/llms.txt +37 -14
- package/package.json +7 -7
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
//
|
|
73
|
-
import
|
|
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>{
|
|
76
|
-
<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
|
-
|
|
80
|
-
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
|
|
101
|
-
`
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
132
|
-
is held to
|
|
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)} ${
|
|
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: '/
|
|
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 (`
|
|
159
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
173
|
-
Grouping related messages in an object (`export const
|
|
174
|
-
message(…), hint: message(…) }`) is fine too; the bundler
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
|
212
|
-
`
|
|
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`
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
239
|
-
|
|
425
|
+
navigateTo(
|
|
426
|
+
'/:locale',
|
|
427
|
+
{ locale: locales.negotiate(navigator.languages) },
|
|
240
428
|
{ history: 'replace' },
|
|
241
429
|
);
|
|
242
430
|
}, []);
|
|
243
431
|
```
|
|
244
432
|
|
|
245
|
-
|
|
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
|
|
251
|
-
has already run for that
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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`**:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
-
|
|
293
|
-
|
|
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', () =>
|
|
298
|
-
it, the default.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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.
|