@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/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).
@@ -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.