@alxia/i18n 0.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/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/i18n.d.ts +56 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +10 -0
- package/docs/README.md +13 -0
- package/docs/guide.md +592 -0
- package/docs/roadmap.md +50 -0
- package/docs/troubleshooting.md +472 -0
- package/package.json +55 -0
package/docs/guide.md
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
This page covers `createI18n()`: the catalogues and the fallback, the
|
|
4
|
+
`@alxia/language` options it passes through, what the routes behind it
|
|
5
|
+
read, how messages are formatted and keys typed, where its own `t()` knows
|
|
6
|
+
the request's language and where it does not, and how `@nxgt/i18n`'s
|
|
7
|
+
`translate` follows it.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { alxia } from '@alxia/core';
|
|
11
|
+
import { createI18n } from '@alxia/i18n';
|
|
12
|
+
|
|
13
|
+
const en = { home: { title: 'Welcome', greeting: 'Hello {name}' } };
|
|
14
|
+
const fr = { home: { title: 'Bienvenue', greeting: 'Bonjour {name}' } };
|
|
15
|
+
|
|
16
|
+
const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
17
|
+
|
|
18
|
+
const app = alxia()
|
|
19
|
+
.use(i18n)
|
|
20
|
+
.get('/', ({ t, reply }) => reply(200, { title: t('home.title'), greeting: t('home.greeting', { name: 'Ada' }) }));
|
|
21
|
+
|
|
22
|
+
app.listen(3000);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A browser set to French gets `{"title":"Bienvenue","greeting":"Bonjour Ada"}`,
|
|
26
|
+
`/?lang=en` gets English whatever the browser says, and a client that names
|
|
27
|
+
no language the catalogues have gets the fallback's, English.
|
|
28
|
+
`t('home.titel')` does not compile.
|
|
29
|
+
|
|
30
|
+
## The signature
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
function createI18n<
|
|
34
|
+
const C extends Catalogues,
|
|
35
|
+
const Fallback extends keyof C & string,
|
|
36
|
+
Ctx extends object = BaseContext,
|
|
37
|
+
>(
|
|
38
|
+
options: I18nOptions<C, Fallback, Ctx>,
|
|
39
|
+
): Alxia<RequiresOf<Ctx, 'resolve'> & Empty & LanguageContext<keyof C & string> & I18nContext<KeyOf<C[Fallback]>>, Empty, '', never> &
|
|
40
|
+
Requiring<RequiresOf<Ctx, 'resolve'>> & {
|
|
41
|
+
t: Translate<KeyOf<C[Fallback]>>;
|
|
42
|
+
language: () => keyof C & string;
|
|
43
|
+
supported: (keyof C & string)[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface I18nOptions<C extends Catalogues, Fallback extends keyof C & string, Ctx extends object = BaseContext>
|
|
47
|
+
extends Omit<LanguageOptions<keyof C & string, Ctx>, 'supported' | 'fallback'> {
|
|
48
|
+
readonly resources: C;
|
|
49
|
+
readonly fallback: Fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Catalogues = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
53
|
+
type KeyOf<Catalogue> = Path<Catalogue> & string; // every key, dotted: 'home.title'
|
|
54
|
+
type Translate<Key extends string> = (key: Key, context?: TranslationContext) => string;
|
|
55
|
+
interface I18nContext<Key extends string> {
|
|
56
|
+
readonly t: Translate<Key>;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`C` and `Fallback` are inferred from `resources` and `fallback`, and `Ctx`
|
|
61
|
+
from the type `resolve`'s parameter is annotated with: `RequiresOf<Ctx, 'resolve'>`,
|
|
62
|
+
`@alxia/core`'s, is what that annotation adds to `BaseContext`, and `Empty`
|
|
63
|
+
when `resolve` is absent or not annotated. See
|
|
64
|
+
[Reading the app's context](#reading-the-apps-context).
|
|
65
|
+
|
|
66
|
+
`createI18n()` returns two things in one value:
|
|
67
|
+
|
|
68
|
+
- **an app plugin**: pass it to `use`. It applies to the routes declared
|
|
69
|
+
**after** it, in the same app or [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md),
|
|
70
|
+
and adds `t`, `language` and `languageSource` to their context;
|
|
71
|
+
- **`t()`, `language()` and `supported`**, to call where no context is at
|
|
72
|
+
hand: a service, a model, a job. See [Outside a route](#outside-a-route).
|
|
73
|
+
|
|
74
|
+
It also registers the request's language with `@nxgt/i18n` — one source,
|
|
75
|
+
however many times `createI18n()` is called, so building an app per test
|
|
76
|
+
does not grow `@nxgt/i18n`'s list; see [`@nxgt/i18n`'s own `translate`](#nxgti18ns-own-translate).
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
| Option | Type | Default | Effect |
|
|
81
|
+
| --- | --- | --- | --- |
|
|
82
|
+
| `resources` | `Catalogues` | required | one catalogue per language; its keys are the languages the app supports |
|
|
83
|
+
| `fallback` | one of `resources`' keys | required | the language when no source names a supported one, and the catalogue whose keys type `t` |
|
|
84
|
+
| `order` | `readonly LanguageSource[]` | `['query', 'cookie', 'header']` | the sources read, in this order |
|
|
85
|
+
| `query` | `string` | `'lang'` | the query parameter: `?lang=fr` |
|
|
86
|
+
| `cookie` | `string` | `'language'` | the cookie read, and written by `persist` |
|
|
87
|
+
| `pathIndex` | `number` | `0` | the path segment the `path` source reads |
|
|
88
|
+
| `persist` | `boolean \| { maxAge?, secure? }` | `false` | a language the query named is kept in the cookie |
|
|
89
|
+
| `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the plugin runs for |
|
|
90
|
+
| `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; annotate `ctx` to read what an earlier plugin adds |
|
|
91
|
+
|
|
92
|
+
Every option but `resources` and `fallback` is `@alxia/language`'s, passed
|
|
93
|
+
through as it is; its [guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md)
|
|
94
|
+
details each one, how `Accept-Language` is negotiated, and the `Vary` the
|
|
95
|
+
plugin adds. `supported` is not an option here: it is `resources`' keys.
|
|
96
|
+
|
|
97
|
+
### `resources`
|
|
98
|
+
|
|
99
|
+
One catalogue per language, nested as deep as you like. A leaf is an
|
|
100
|
+
[ICU message](#messages); a key is its dotted path, `cart.items`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createI18n } from '@alxia/i18n';
|
|
104
|
+
|
|
105
|
+
const en = {
|
|
106
|
+
cart: {
|
|
107
|
+
items: '{count, plural, =0 {No items} one {One item} other {# items}}',
|
|
108
|
+
total: 'Total: {amount, number, ::currency/EUR}',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const fr = {
|
|
112
|
+
cart: {
|
|
113
|
+
items: '{count, plural, =0 {Aucun article} one {Un article} other {# articles}}',
|
|
114
|
+
total: 'Total : {amount, number, ::currency/EUR}',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Catalogues kept in JSON files work as they are, typed keys included, with
|
|
122
|
+
`resolveJsonModule` in your `tsconfig.json`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { createI18n } from '@alxia/i18n';
|
|
126
|
+
import en from './locales/en.json';
|
|
127
|
+
import fr from './locales/fr.json';
|
|
128
|
+
|
|
129
|
+
export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`@nxgt/i18n` ships catalogues of its own, in English and French: error
|
|
133
|
+
messages (`errors.not-found`, `errors.unauthenticated`, …), validation
|
|
134
|
+
messages, and `zod.*`. Spread its `resources` into yours to translate them
|
|
135
|
+
with the same `t`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createI18n } from '@alxia/i18n';
|
|
139
|
+
import { resources as shared } from '@nxgt/i18n';
|
|
140
|
+
|
|
141
|
+
const en = { ...shared.en, home: { title: 'Welcome' } };
|
|
142
|
+
const fr = { ...shared.fr, home: { title: 'Bienvenue' } };
|
|
143
|
+
|
|
144
|
+
export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
145
|
+
|
|
146
|
+
i18n.t('errors.not-found'); // 'Could not find the requested resource.'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `fallback`
|
|
150
|
+
|
|
151
|
+
The language of every request that names none the catalogues have, of
|
|
152
|
+
`i18n.t()` outside a request — and the catalogue that types the keys. It
|
|
153
|
+
must be one of `resources`' keys; anything else is a compile error:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
error TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
and, when a cast hides it from the compiler, an error when `createI18n()`
|
|
160
|
+
is called:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
TypeError: language(): the fallback "de" is not supported
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### The language options
|
|
167
|
+
|
|
168
|
+
To read the language from the path rather than the query:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { alxia } from '@alxia/core';
|
|
172
|
+
import { createI18n } from '@alxia/i18n';
|
|
173
|
+
|
|
174
|
+
const i18n = createI18n({
|
|
175
|
+
resources: { en: { home: { title: 'Welcome' } }, fr: { home: { title: 'Bienvenue' } } },
|
|
176
|
+
fallback: 'en',
|
|
177
|
+
order: ['path', 'header'],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const app = alxia()
|
|
181
|
+
.use(i18n)
|
|
182
|
+
.get('/:lang/home', ({ t, reply }) => reply(200, t('home.title'))); // /fr/home → 'Bienvenue'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The plugin reads the segment; it does not route on it, so the routes
|
|
186
|
+
declare it.
|
|
187
|
+
|
|
188
|
+
### Reading the app's context
|
|
189
|
+
|
|
190
|
+
To speak the language a signed-in user saved, annotate `resolve`'s
|
|
191
|
+
parameter with what an earlier plugin added. `createI18n()` infers it from
|
|
192
|
+
the annotation — the languages and the keys are still inferred from
|
|
193
|
+
`resources` and `fallback` — and the plugin then requires it: an app that
|
|
194
|
+
does not give `user` before it cannot use it.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { alxia, type BaseContext } from '@alxia/core';
|
|
198
|
+
import { createI18n } from '@alxia/i18n';
|
|
199
|
+
|
|
200
|
+
interface User {
|
|
201
|
+
readonly language: string | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const i18n = createI18n({
|
|
205
|
+
resources: { en, fr },
|
|
206
|
+
fallback: 'en',
|
|
207
|
+
order: ['query'], // the query decides first, then the user's saved language
|
|
208
|
+
resolve: ({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export const app = alxia()
|
|
212
|
+
.use(auth) // derives user: User | null
|
|
213
|
+
.use(i18n)
|
|
214
|
+
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
215
|
+
|
|
216
|
+
alxia().use(i18n);
|
|
217
|
+
// error: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
This is `@alxia/language`'s check, carried through; its
|
|
221
|
+
[guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md#reading-the-apps-context)
|
|
222
|
+
details it. A `resolve` left unannotated reads `BaseContext` only, and the
|
|
223
|
+
plugin requires nothing. Annotated `any`, the plugin is refused on every
|
|
224
|
+
app:
|
|
225
|
+
[`the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`](troubleshooting.md#the-plugins-resolve-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
|
|
226
|
+
|
|
227
|
+
## What the routes read
|
|
228
|
+
|
|
229
|
+
| Property | Type | Is |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| `t` | `Translate<KeyOf<C[Fallback]>>` | translates into the request's language |
|
|
232
|
+
| `language` | `keyof C & string` | the request's language: `'en' \| 'fr'` |
|
|
233
|
+
| `languageSource` | `LanguageSource \| 'resolve' \| 'fallback'` | what decided it, from `@alxia/language` |
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { alxia } from '@alxia/core';
|
|
237
|
+
import { createI18n } from '@alxia/i18n';
|
|
238
|
+
|
|
239
|
+
const i18n = createI18n({
|
|
240
|
+
resources: { en: { home: { title: 'Welcome' } }, fr: { home: { title: 'Bienvenue' } } },
|
|
241
|
+
fallback: 'en',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
export const app = alxia()
|
|
245
|
+
.use(i18n)
|
|
246
|
+
.get('/', ({ t, language, languageSource, reply }) =>
|
|
247
|
+
reply(200, { title: t('home.title'), language, languageSource }),
|
|
248
|
+
);
|
|
249
|
+
// GET /?lang=fr → {"title":"Bienvenue","language":"fr","languageSource":"query"}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A route declared before `.use(i18n)` reads none of them:
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Messages
|
|
259
|
+
|
|
260
|
+
Messages are ICU, formatted by `@nxgt/i18n` with `intl-messageformat` in
|
|
261
|
+
the request's language. The second argument of `t` holds the values:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { createI18n } from '@alxia/i18n';
|
|
265
|
+
|
|
266
|
+
const en = {
|
|
267
|
+
greeting: 'Hello {name}',
|
|
268
|
+
items: '{count, plural, =0 {No items} one {One item} other {# items}}',
|
|
269
|
+
role: '{role, select, admin {an administrator} other {a member}}',
|
|
270
|
+
total: 'Total: {amount, number, ::currency/EUR}',
|
|
271
|
+
};
|
|
272
|
+
const i18n = createI18n({ resources: { en }, fallback: 'en' });
|
|
273
|
+
|
|
274
|
+
i18n.t('greeting', { name: 'Ada' }); // 'Hello Ada'
|
|
275
|
+
i18n.t('items', { count: 3 }); // '3 items'
|
|
276
|
+
i18n.t('role', { role: 'admin' }); // 'an administrator'
|
|
277
|
+
i18n.t('total', { amount: 12.5 }); // 'Total: €12.50'
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
What `t` answers when it cannot do better — it never throws:
|
|
281
|
+
|
|
282
|
+
| Case | Answer |
|
|
283
|
+
| --- | --- |
|
|
284
|
+
| the key is in the request's catalogue | the message, formatted |
|
|
285
|
+
| the request's catalogue lacks the key | **the key itself**, `'home.title'` — not the fallback's message |
|
|
286
|
+
| a value the message names is missing, or the message is not valid ICU | the message unformatted, `'Hello {name}'`, and the error logged with `console.error` |
|
|
287
|
+
|
|
288
|
+
The values are not typed by the message: `t('greeting')` compiles, and
|
|
289
|
+
answers `'Hello {name}'` with this line in the log:
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
The intl string context variable "name" was not provided to the string "Hello {name}"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A language whose catalogue is partial shows keys, not the fallback's
|
|
296
|
+
messages. Fill it from the fallback before passing it — a nested merge, as
|
|
297
|
+
a spread only replaces whole branches:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { createI18n } from '@alxia/i18n';
|
|
301
|
+
|
|
302
|
+
type Tree = { readonly [key: string]: unknown };
|
|
303
|
+
|
|
304
|
+
const fill = (from: Tree, into: Tree): Tree =>
|
|
305
|
+
Object.fromEntries(
|
|
306
|
+
Object.entries(from).map(([key, value]) => {
|
|
307
|
+
const own = into[key];
|
|
308
|
+
return [
|
|
309
|
+
key,
|
|
310
|
+
typeof value === 'object' && value !== null && typeof own === 'object' && own !== null
|
|
311
|
+
? fill(value as Tree, own as Tree)
|
|
312
|
+
: (own ?? value),
|
|
313
|
+
];
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const en = { home: { title: 'Welcome', subtitle: 'Good to see you' } };
|
|
318
|
+
const de = { home: { title: 'Willkommen' } }; // no subtitle yet
|
|
319
|
+
|
|
320
|
+
export const i18n = createI18n({ resources: { en, de: fill(en, de) }, fallback: 'en' });
|
|
321
|
+
// in German: t('home.subtitle') → 'Good to see you', not 'home.subtitle'
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Typed keys
|
|
325
|
+
|
|
326
|
+
`t` takes the dotted keys of the **fallback's** catalogue, and nothing
|
|
327
|
+
else:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { createI18n } from '@alxia/i18n';
|
|
331
|
+
|
|
332
|
+
const en = { cart: { items: '{count, plural, one {One item} other {# items}}' } };
|
|
333
|
+
const fr = { cart: { items: '{count, plural, one {Un article} other {# articles}}', extra: 'Rien' } };
|
|
334
|
+
|
|
335
|
+
const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
336
|
+
|
|
337
|
+
i18n.t('cart.items', { count: 2 });
|
|
338
|
+
// @ts-expect-error: a typo
|
|
339
|
+
i18n.t('cart.itmes');
|
|
340
|
+
// @ts-expect-error: only in French
|
|
341
|
+
i18n.t('cart.extra');
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```text
|
|
345
|
+
error TS2345: Argument of type '"cart.itmes"' is not assignable to parameter of type '"cart.items"'.
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The keys are read from the catalogue's type. A catalogue typed
|
|
349
|
+
`Record<string, …>` — built at runtime, or annotated so — has no keys to
|
|
350
|
+
read, and `t` then takes any string. Declare catalogues as literals, or
|
|
351
|
+
import them from JSON.
|
|
352
|
+
|
|
353
|
+
To type a function that receives `t`, or a key it is given, name the types
|
|
354
|
+
from the catalogue:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
import { createI18n, type KeyOf, type Translate } from '@alxia/i18n';
|
|
358
|
+
|
|
359
|
+
const en = { cart: { items: '{count, plural, one {One item} other {# items}}' } };
|
|
360
|
+
export const i18n = createI18n({ resources: { en }, fallback: 'en' });
|
|
361
|
+
|
|
362
|
+
type Key = KeyOf<typeof en>; // 'cart.items'
|
|
363
|
+
|
|
364
|
+
export const describeCart = (t: Translate<Key>, count: number) => t('cart.items', { count });
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Outside a route
|
|
368
|
+
|
|
369
|
+
`i18n.t()` translates in the language of the request it runs in, and
|
|
370
|
+
`i18n.language()` says which; outside a request, both are the fallback's.
|
|
371
|
+
Nothing is passed down: the request's language follows every `await` and
|
|
372
|
+
every function the route calls, an event stream's generator included.
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { alxia } from '@alxia/core';
|
|
376
|
+
import { createI18n } from '@alxia/i18n';
|
|
377
|
+
|
|
378
|
+
const i18n = createI18n({
|
|
379
|
+
resources: {
|
|
380
|
+
en: { cart: { items: '{count, plural, one {One item} other {# items}}' } },
|
|
381
|
+
fr: { cart: { items: '{count, plural, one {Un article} other {# articles}}' } },
|
|
382
|
+
},
|
|
383
|
+
fallback: 'en',
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// a service, deep down: no language passed
|
|
387
|
+
async function describeCart(count: number) {
|
|
388
|
+
await Bun.sleep(1);
|
|
389
|
+
return i18n.t('cart.items', { count });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export const app = alxia()
|
|
393
|
+
.use(i18n)
|
|
394
|
+
.get('/cart', async ({ reply }) => reply(200, await describeCart(3))); // ?lang=fr → '3 articles'
|
|
395
|
+
|
|
396
|
+
i18n.language(); // 'en': no request here
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The plugin reads the request's language in a route hook; from then on,
|
|
400
|
+
everything the request runs knows it. Before that, `i18n.t()` and
|
|
401
|
+
`i18n.language()` answer in the fallback:
|
|
402
|
+
|
|
403
|
+
| Where | `i18n.t()` answers in |
|
|
404
|
+
| --- | --- |
|
|
405
|
+
| a route, `derive` or `wrap` declared after `.use(i18n)`, and what they call | the request's language |
|
|
406
|
+
| an `onError` hook, for what a route after the plugin threw | the request's language |
|
|
407
|
+
| an `onResponse` hook, for a request the plugin ran for | the request's language |
|
|
408
|
+
| an `around` hook declared after `.use(i18n)`, once `next()` has resolved | the request's language |
|
|
409
|
+
| an `onRequest` hook; an `around` hook declared before `.use(i18n)`; one declared after it, before `next()` | the fallback: the language is not read yet |
|
|
410
|
+
| a route or route hook declared before `.use(i18n)`, or a `404` no route matched | the fallback: the plugin does not run for it |
|
|
411
|
+
| code outside any request: start-up, a timer, a queue consumer | the fallback: pass the language, see below |
|
|
412
|
+
|
|
413
|
+
An `onError` hook translates an error's message with `i18n.t()`, or with
|
|
414
|
+
`t` from its context:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { alxia, HttpError } from '@alxia/core';
|
|
418
|
+
import { createI18n } from '@alxia/i18n';
|
|
419
|
+
import { resources as shared } from '@nxgt/i18n';
|
|
420
|
+
|
|
421
|
+
const i18n = createI18n({ resources: { en: shared.en, fr: shared.fr }, fallback: 'en' });
|
|
422
|
+
|
|
423
|
+
export const app = alxia()
|
|
424
|
+
.use(i18n)
|
|
425
|
+
.onError((error, { reply }) =>
|
|
426
|
+
error instanceof HttpError && error.status === 404
|
|
427
|
+
? reply(404, { error: i18n.t('errors.not-found') })
|
|
428
|
+
: undefined,
|
|
429
|
+
)
|
|
430
|
+
.get('/users/:id', () => {
|
|
431
|
+
throw new HttpError(404, {});
|
|
432
|
+
});
|
|
433
|
+
// GET /users/1?lang=fr → 404 {"error":"Impossible de trouver la ressource demandée."}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Outside any request, a job that knows its user's language uses
|
|
437
|
+
`@nxgt/i18n`'s `createTranslator` on the same catalogues, with that
|
|
438
|
+
language:
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
import { createI18n, type KeyOf } from '@alxia/i18n';
|
|
442
|
+
import { createTranslator } from '@nxgt/i18n';
|
|
443
|
+
|
|
444
|
+
const en = { mail: { subject: 'Your order {id}' } };
|
|
445
|
+
const fr = { mail: { subject: 'Votre commande {id}' } };
|
|
446
|
+
const resources = { en, fr };
|
|
447
|
+
|
|
448
|
+
export const i18n = createI18n({ resources, fallback: 'en' });
|
|
449
|
+
|
|
450
|
+
const translator = createTranslator<KeyOf<typeof en>>(resources);
|
|
451
|
+
translator('mail.subject', { id: 42 }, 'fr'); // 'Votre commande 42'
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`createTranslator`'s third argument is typed with `@nxgt/i18n`'s own
|
|
455
|
+
languages, `'en' | 'fr'`.
|
|
456
|
+
|
|
457
|
+
## `@nxgt/i18n`'s own `translate`
|
|
458
|
+
|
|
459
|
+
`createI18n()` registers the request's language as a language source of
|
|
460
|
+
`@nxgt/i18n`. Its `getLanguage()` and `translate` — and every package that
|
|
461
|
+
translates through them — answer in the alxia request's language, wherever
|
|
462
|
+
`i18n.t()` would:
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
import { alxia } from '@alxia/core';
|
|
466
|
+
import { createI18n } from '@alxia/i18n';
|
|
467
|
+
import { getLanguage, translate, resources as shared } from '@nxgt/i18n';
|
|
468
|
+
|
|
469
|
+
const i18n = createI18n({ resources: { en: shared.en, fr: shared.fr }, fallback: 'en' });
|
|
470
|
+
|
|
471
|
+
export const app = alxia()
|
|
472
|
+
.use(i18n)
|
|
473
|
+
.get('/', ({ reply }) => reply(200, { language: getLanguage(), message: translate('errors.not-found') }));
|
|
474
|
+
// GET /?lang=fr → {"language":"fr","message":"Impossible de trouver la ressource demandée."}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Its limits are `@nxgt/i18n`'s:
|
|
478
|
+
|
|
479
|
+
- **It speaks `en` and `fr` only.** A request in a language your
|
|
480
|
+
catalogues add — `de` — is not one of them, and `getLanguage()` answers
|
|
481
|
+
as if no source had: `'en'`.
|
|
482
|
+
- **Its fallback is `'en'`, not yours.** Outside a request, or before the
|
|
483
|
+
language is read, `getLanguage()` answers `'en'` even when your
|
|
484
|
+
`fallback` is `'fr'`.
|
|
485
|
+
- **It hears one plugin.** With two `createI18n()` on one app, it follows
|
|
486
|
+
the language the first one read — its fallback included, when the
|
|
487
|
+
request named a language only the second supports.
|
|
488
|
+
|
|
489
|
+
## Caching
|
|
490
|
+
|
|
491
|
+
A response in the request's language varies by what decided it. The plugin
|
|
492
|
+
says so in `Vary` — `Accept-Language, Cookie` with the default `order` —
|
|
493
|
+
and a cache in front of it needs the same headers:
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
import { alxia } from '@alxia/core';
|
|
497
|
+
import { cache } from '@alxia/cache';
|
|
498
|
+
import { createI18n } from '@alxia/i18n';
|
|
499
|
+
|
|
500
|
+
const i18n = createI18n({
|
|
501
|
+
resources: { en: { home: { title: 'Welcome' } }, fr: { home: { title: 'Bienvenue' } } },
|
|
502
|
+
fallback: 'en',
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
export const app = alxia()
|
|
506
|
+
.use(i18n)
|
|
507
|
+
.use(cache({ ttl: 60, vary: ['accept-language', 'cookie'] }))
|
|
508
|
+
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
## Testing
|
|
512
|
+
|
|
513
|
+
`app.request()` takes the header a browser sends, or the query:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
import { expect, test } from 'bun:test';
|
|
517
|
+
import { alxia } from '@alxia/core';
|
|
518
|
+
import { createI18n } from '@alxia/i18n';
|
|
519
|
+
|
|
520
|
+
const i18n = createI18n({
|
|
521
|
+
resources: { en: { home: { title: 'Welcome' } }, fr: { home: { title: 'Bienvenue' } } },
|
|
522
|
+
fallback: 'en',
|
|
523
|
+
});
|
|
524
|
+
const app = alxia()
|
|
525
|
+
.use(i18n)
|
|
526
|
+
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
527
|
+
|
|
528
|
+
test("answers in the browser's language", async () => {
|
|
529
|
+
const response = await app.request('/', { headers: { 'accept-language': 'fr-FR,fr;q=0.9' } });
|
|
530
|
+
expect(await response.text()).toBe('Bienvenue');
|
|
531
|
+
expect(response.headers.get('content-language')).toBe('fr');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('answers in the fallback when nothing names a language', async () => {
|
|
535
|
+
expect(await (await app.request('/')).text()).toBe('Welcome');
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## A realistic setup
|
|
540
|
+
|
|
541
|
+
Catalogues in JSON, `@nxgt/i18n`'s shared messages spread in, a service
|
|
542
|
+
that translates without being passed a language, errors rendered in the
|
|
543
|
+
request's language, and a language switcher that sticks:
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
// src/i18n.ts
|
|
547
|
+
import { createI18n } from '@alxia/i18n';
|
|
548
|
+
import { resources as shared } from '@nxgt/i18n';
|
|
549
|
+
import ownEn from './locales/en.json';
|
|
550
|
+
import ownFr from './locales/fr.json';
|
|
551
|
+
|
|
552
|
+
export const i18n = createI18n({
|
|
553
|
+
resources: { en: { ...shared.en, ...ownEn }, fr: { ...shared.fr, ...ownFr } },
|
|
554
|
+
fallback: 'en',
|
|
555
|
+
persist: { secure: process.env['NODE_ENV'] === 'production' },
|
|
556
|
+
});
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
// src/app.ts
|
|
561
|
+
import { alxia, HttpError } from '@alxia/core';
|
|
562
|
+
import { i18n } from './i18n';
|
|
563
|
+
|
|
564
|
+
const carts = new Map<string, string[]>([['c1', ['book', 'pen']]]);
|
|
565
|
+
|
|
566
|
+
// a service: no language passed
|
|
567
|
+
const describeCart = (items: readonly string[]) => i18n.t('cart.items', { count: items.length });
|
|
568
|
+
|
|
569
|
+
export const app = alxia()
|
|
570
|
+
.use(i18n)
|
|
571
|
+
.onError((error, { t, reply }) =>
|
|
572
|
+
error instanceof HttpError && error.status === 404
|
|
573
|
+
? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
|
|
574
|
+
: undefined,
|
|
575
|
+
)
|
|
576
|
+
.get('/carts/:id', ({ params, reply }) => {
|
|
577
|
+
const items = carts.get(params.id);
|
|
578
|
+
if (items === undefined) throw new HttpError(404, {});
|
|
579
|
+
return reply(200, { items, summary: describeCart(items) });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
app.listen(3000);
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
With `locales/en.json` holding
|
|
586
|
+
`{ "cart": { "items": "{count, plural, =0 {No items} one {One item} other {# items}}" } }`,
|
|
587
|
+
`/carts/c1?lang=fr` answers `{"items":["book","pen"],"summary":"2 articles"}`
|
|
588
|
+
and sets the `language` cookie, so `/carts/c2` — without the query —
|
|
589
|
+
answers `404 {"error":"Impossible de trouver la ressource demandée."}`.
|
|
590
|
+
|
|
591
|
+
When something does not answer in the language you expected, see
|
|
592
|
+
[Troubleshooting](troubleshooting.md).
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/i18n` gives an app, and what is coming. This page is a
|
|
4
|
+
direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/i18n/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A runtime dependency.** `@alxia/i18n` declares no dependency, only
|
|
23
|
+
peers — `@alxia/core`, `@alxia/language`, `@nxgt/i18n` and `typescript`:
|
|
24
|
+
it is built on their public APIs and Node's `AsyncLocalStorage`.
|
|
25
|
+
|
|
26
|
+
## Shipped
|
|
27
|
+
|
|
28
|
+
### 0.1.0
|
|
29
|
+
|
|
30
|
+
- **Translations as one plugin.** `alxia().use(createI18n({ resources, fallback }))`
|
|
31
|
+
gives the routes after it `t`, bound to the request's language, and
|
|
32
|
+
`language`, typed as one of the catalogues' languages.
|
|
33
|
+
- **The request's language, found by `@alxia/language`.** The query, a
|
|
34
|
+
cookie, `Accept-Language`, then `fallback`; its options pass through.
|
|
35
|
+
- **A preference an earlier plugin knows.** Annotate `resolve`'s parameter —
|
|
36
|
+
`({ user }: BaseContext & { user: User }) => user.language` — and it reads
|
|
37
|
+
what an earlier plugin added; an app that does not give it cannot use the
|
|
38
|
+
plugin, and one annotated `any` is refused. The languages and keys stay
|
|
39
|
+
inferred from `resources` and `fallback`.
|
|
40
|
+
- **Keys typed by your catalogue.** `t` takes the dotted keys of the
|
|
41
|
+
fallback's catalogue, from a literal or a JSON file, and refuses a typo.
|
|
42
|
+
- **ICU messages.** Plurals, selects and numbers, formatted by `@nxgt/i18n`
|
|
43
|
+
in the request's language; a missing key answers itself, a message that
|
|
44
|
+
cannot be formatted is logged and answered as it is.
|
|
45
|
+
- **`t()` anywhere a request runs.** The plugin's own `t()` and
|
|
46
|
+
`language()` follow the request through every `await` — a service, a
|
|
47
|
+
model — and answer in the fallback outside one.
|
|
48
|
+
- **`@nxgt/i18n` speaks it too.** The request's language is registered as
|
|
49
|
+
one of `@nxgt/i18n`'s sources, so its `getLanguage()` and `translate`
|
|
50
|
+
follow the alxia request.
|