@alxia/language 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,553 @@
1
+ # Guide
2
+
3
+ This page covers how `language()` decides a request's language: each
4
+ option and its default, the sources it reads and in which order, how an
5
+ `Accept-Language` header is negotiated, what the routes behind it read, and
6
+ what it adds to the response.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { language } from '@alxia/language';
11
+
12
+ const greetings = { en: 'Hello', fr: 'Bonjour', 'pt-BR': 'Olá' };
13
+
14
+ const app = alxia()
15
+ .use(language({ supported: ['en', 'fr', 'pt-BR'], fallback: 'en' }))
16
+ .get('/', ({ language: current, reply }) => reply(200, greetings[current]));
17
+
18
+ app.listen(3000);
19
+ ```
20
+
21
+ A browser set to French gets `Bonjour`, one set to Brazilian Portuguese
22
+ gets `Olá`, `/?lang=fr` gets `Bonjour` whatever the browser says, and a
23
+ client that names no language it supports gets `Hello`. `current` is typed
24
+ `'en' | 'fr' | 'pt-BR'`, so `greetings[current]` needs no check.
25
+
26
+ ## The signature
27
+
28
+ ```ts
29
+ function language<const L extends string, Ctx extends object = BaseContext>(
30
+ options: LanguageOptions<L, Ctx>,
31
+ ): Alxia<RequiresOf<Ctx> & LanguageContext<L>, Empty, '', never> &
32
+ Requiring<RequiresOf<Ctx>>;
33
+
34
+ interface LanguageOptions<L extends string, Ctx extends object = BaseContext> {
35
+ readonly supported: readonly L[];
36
+ readonly fallback: NoInfer<L>;
37
+ readonly order?: readonly LanguageSource[];
38
+ readonly query?: string;
39
+ readonly cookie?: string;
40
+ readonly pathIndex?: number;
41
+ readonly persist?: boolean | { readonly maxAge?: number; readonly secure?: boolean };
42
+ readonly contentLanguage?: boolean;
43
+ readonly resolve?: (ctx: BaseContext & Ctx) => string | undefined;
44
+ readonly vary?: readonly string[];
45
+ }
46
+
47
+ type LanguageSource = 'query' | 'cookie' | 'path' | 'header';
48
+ ```
49
+
50
+ Both type parameters are inferred: `L` from `supported`, and `Ctx` from the
51
+ type `resolve`'s parameter is annotated with. `RequiresOf<Ctx>`,
52
+ `@alxia/core`'s, is what that annotation adds to `BaseContext` —
53
+ `{ user: User }` — and `Empty` when `resolve` is absent or not annotated;
54
+ see
55
+ [Reading the app's context](#reading-the-apps-context).
56
+
57
+ `language()` returns an app plugin: pass it to `use`, called. It is a
58
+ `derive`, so it applies to the routes declared **after** it — in the same
59
+ app, or inside the [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
60
+ it is used in — and adds `language` and `languageSource` to their context.
61
+ A route declared before it neither runs it nor reads them.
62
+
63
+ It throws once, when it is created, if `fallback` is not one of
64
+ `supported`:
65
+
66
+ ```text
67
+ TypeError: language(): the fallback "de" is not supported
68
+ ```
69
+
70
+ The types refuse that already when `supported` is a literal list; see
71
+ [Troubleshooting](troubleshooting.md#typeerror-language-the-fallback-de-is-not-supported)
72
+ for when they cannot.
73
+
74
+ ## Options
75
+
76
+ | Option | Type | Default | Effect |
77
+ | --- | --- | --- | --- |
78
+ | `supported` | `readonly L[]` | required | the languages the app speaks, and the type of `language` |
79
+ | `fallback` | `L` | required | the language when no source names a supported one |
80
+ | `order` | `readonly LanguageSource[]` | `['query', 'cookie', 'header']` | the sources read, in this order; a source not listed is never read |
81
+ | `query` | `string` | `'lang'` | the query parameter: `?lang=fr` |
82
+ | `cookie` | `string` | `'language'` | the cookie read, and written by `persist` |
83
+ | `pathIndex` | `number` | `0` | the path segment read by the `path` source: `/fr/products` is 0 |
84
+ | `persist` | `boolean \| { maxAge?, secure? }` | `false` | a language the query named is written to the cookie |
85
+ | `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the plugin runs for |
86
+ | `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; its annotated parameter types what it reads |
87
+ | `vary` | `readonly string[]` | none | the request headers `resolve` reads, added to `Vary` |
88
+
89
+ ### `supported` and `fallback`
90
+
91
+ `supported` is the list of languages, written as the tags your
92
+ translations use: `'en'`, `'pt-BR'`. Whatever a client sends, `language` is
93
+ one of these strings, spelled as you spelled it — `PT-br` from a client is
94
+ `'pt-BR'` on the context.
95
+
96
+ The type of `language` is read from the list, so write the list where
97
+ TypeScript can see its strings: inline, or `as const`. A list typed
98
+ `string[]` makes `language` a plain `string`:
99
+
100
+ ```ts
101
+ export const supported = ['en', 'fr', 'pt-BR'] as const;
102
+ export type Language = (typeof supported)[number]; // 'en' | 'fr' | 'pt-BR'
103
+
104
+ language({ supported, fallback: 'en' });
105
+ ```
106
+
107
+ `fallback` must be one of them; the types refuse another (`fallback: 'de'`
108
+ is a compile error), and so does the plugin, at start-up.
109
+
110
+ ### `order`
111
+
112
+ The sources, read one after the other; the first that names a supported
113
+ language decides, and the others are not read.
114
+
115
+ ```ts
116
+ language({ supported: ['en', 'fr'], fallback: 'en' }); // query, cookie, header
117
+ language({ supported: ['en', 'fr'], fallback: 'en', order: ['path', 'header'] }); // /fr/… first, then the browser
118
+ language({ supported: ['en', 'fr'], fallback: 'en', order: ['header'] }); // the browser only
119
+ ```
120
+
121
+ A value that names no supported language is skipped, not an error: with
122
+ `?lang=klingon` and `Accept-Language: fr`, the language is `fr`, from the
123
+ header. See [how the language is resolved](#how-the-language-is-resolved).
124
+
125
+ ### `query` and `cookie`
126
+
127
+ The names of the query parameter and of the cookie:
128
+
129
+ ```ts
130
+ language({ supported: ['en', 'fr'], fallback: 'en', query: 'locale', cookie: 'locale' });
131
+ // /?locale=fr, Cookie: locale=fr
132
+ ```
133
+
134
+ ### `pathIndex`
135
+
136
+ Which path segment the `path` source reads, counting from 0 and ignoring
137
+ empty segments. It is only read when `order` lists `'path'`:
138
+
139
+ ```ts
140
+ language({ supported: ['en', 'fr'], fallback: 'en', order: ['path'], pathIndex: 1 });
141
+ // /shop/fr/products → 'fr'
142
+ ```
143
+
144
+ The plugin reads the segment; it does not route on it or remove it. The
145
+ routes still declare it, as a parameter:
146
+
147
+ ```ts
148
+ const app = alxia()
149
+ .use(language({ supported: ['en', 'fr'], fallback: 'en', order: ['path', 'header'] }))
150
+ .get('/:lang/products', ({ language: current, reply }) => reply(200, current));
151
+
152
+ await app.request('/fr/products'); // 'fr'
153
+ await app.request('/products'); // 404: no route matches; the plugin never runs
154
+ ```
155
+
156
+ A segment is matched like any other tag, so `/fr-ca/products` is `fr` too.
157
+ A segment that names no supported language — `/de/products` — is skipped,
158
+ and the next source decides.
159
+
160
+ ### `persist`
161
+
162
+ With `persist`, a language that came from the query is written to the
163
+ cookie, so a link to `?lang=fr` switches the next requests too:
164
+
165
+ ```ts
166
+ language({ supported: ['en', 'fr'], fallback: 'en', persist: true });
167
+ // GET /?lang=fr → Set-Cookie: language=fr; Path=/; Max-Age=31536000; Secure; SameSite=Lax
168
+
169
+ language({ supported: ['en', 'fr'], fallback: 'en', persist: { maxAge: 3600, secure: false } });
170
+ // GET /?lang=fr → Set-Cookie: language=fr; Path=/; Max-Age=3600; SameSite=Lax
171
+ ```
172
+
173
+ | `persist` field | Default | Effect |
174
+ | --- | --- | --- |
175
+ | `maxAge` | `31536000` (a year) | the cookie's lifetime, in seconds |
176
+ | `secure` | `true` | the `Secure` attribute: a browser drops the cookie over plain `http` |
177
+
178
+ The cookie is written with `Path=/` and `SameSite=Lax`, and without
179
+ `HttpOnly`, so a page's script can read the language too. It holds the
180
+ supported language the query matched — `?lang=fr-CA` writes `fr` — and is
181
+ only written when the query decided: a request whose language came from
182
+ the cookie or the header sets nothing. For the cookie to be read back,
183
+ `order` must list `'cookie'`, as the default does.
184
+
185
+ ### `contentLanguage`
186
+
187
+ On by default: every response of a route behind the plugin says
188
+ `Content-Language: <language>`. A route that sets its own wins over it:
189
+
190
+ ```ts
191
+ .get('/legal', ({ reply }) => reply(200, legalText, { headers: { 'content-language': 'fr' } }));
192
+ ```
193
+
194
+ Turn it off for an app that sets it itself, or for responses that are not
195
+ in a language:
196
+
197
+ ```ts
198
+ language({ supported: ['en', 'fr'], fallback: 'en', contentLanguage: false });
199
+ ```
200
+
201
+ ### `resolve`
202
+
203
+ A last word, after every source in `order` and before `fallback`: typically
204
+ a preference the user saved, read from what the request carries.
205
+
206
+ ```ts
207
+ language({
208
+ supported: ['en', 'fr'],
209
+ fallback: 'en',
210
+ order: ['query', 'cookie'],
211
+ resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
212
+ vary: ['X-Preferred-Language'],
213
+ });
214
+ ```
215
+
216
+ `vary` names the request headers `resolve` reads, so a shared cache keeps
217
+ one response per value; the plugin cannot see what a function reads.
218
+
219
+ It receives the request's `BaseContext` — `request`, `url`, `ip`,
220
+ `pathParams`, `set` — and, when its parameter is annotated, what an earlier
221
+ plugin added: see [Reading the app's context](#reading-the-apps-context). It
222
+ returns a tag, or `undefined` for none. The tag is matched against
223
+ `supported` like any other: a tag it does not support is ignored, and
224
+ `fallback` decides. It is synchronous: a preference kept in a database is
225
+ either written to the cookie when the user saves it — see
226
+ [the realistic setup](#a-realistic-setup) — or loaded by an async `derive`
227
+ or plugin before `language()`, which `resolve` then reads — see
228
+ [Reading the app's context](#reading-the-apps-context).
229
+
230
+ ### Reading the app's context
231
+
232
+ To decide by what an earlier plugin added, such as a signed-in `user` and
233
+ the language they saved, annotate `resolve`'s parameter. `language()` infers
234
+ what it reads from that annotation — `supported` still types `language` —
235
+ and the plugin is a
236
+ [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
237
+ plugin: an app that does not give `user` before it cannot use it.
238
+
239
+ ```ts
240
+ import { alxia, type BaseContext } from '@alxia/core';
241
+ import { language } from '@alxia/language';
242
+
243
+ interface User {
244
+ readonly id: string;
245
+ readonly language: string | null;
246
+ }
247
+
248
+ const byUser = language({
249
+ supported: ['en', 'fr'],
250
+ fallback: 'en',
251
+ order: ['query', 'cookie'],
252
+ resolve: ({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined,
253
+ vary: ['Authorization'], // what auth reads the user from
254
+ });
255
+
256
+ const app = alxia()
257
+ .use(auth) // derives user: User | null
258
+ .use(byUser)
259
+ .get('/', ({ language: current, reply }) => reply(200, current)); // 'en' | 'fr'
260
+
261
+ alxia().use(byUser);
262
+ // error: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
263
+ ```
264
+
265
+ The annotation may be `BaseContext & { user: User }` or `{ user: User }`
266
+ alone; either way the plugin requires `{ user: User }`. An app whose `user`
267
+ has a type that does not fit it is refused too —
268
+ `the plugin reads "user", which this app's context gives with another type` —
269
+ while a narrower one passes: an app deriving `user: User` may use a plugin
270
+ that reads `User | null`. Annotating a key `BaseContext` already has with
271
+ a type it does not give — `({ url }: { url: string })` — is refused the
272
+ same way.
273
+ A `resolve` left unannotated reads `BaseContext` only, and the plugin
274
+ requires nothing.
275
+ Annotated `unknown` or `object`, it requires nothing either. Annotated
276
+ `any`, it would read anything and require nothing, so the plugin is
277
+ refused on every app instead:
278
+ [`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).
279
+
280
+ `resolve` decides only when no source in `order` did. With `header` in
281
+ `order`, a browser that names a supported language decides before the
282
+ user's saved one: leave `header` out, as above, when the saved preference
283
+ should win over the browser's.
284
+
285
+ ## How the language is resolved
286
+
287
+ For each request, in this order:
288
+
289
+ 1. each source in `order`, one after the other — the first that names a
290
+ supported language decides;
291
+ 2. then `resolve(ctx)`, if given;
292
+ 3. then `fallback`.
293
+
294
+ | Source | Reads | Example |
295
+ | --- | --- | --- |
296
+ | `query` | the `query` parameter | `?lang=fr` |
297
+ | `cookie` | the `cookie` cookie | `Cookie: language=fr` |
298
+ | `path` | the segment at `pathIndex` | `/fr/products` |
299
+ | `header` | `Accept-Language`, by weight | `Accept-Language: de, fr-CA;q=0.8` |
300
+
301
+ `languageSource` on the context says which one decided:
302
+
303
+ ```ts
304
+ type LanguageSource = 'query' | 'cookie' | 'path' | 'header';
305
+ // on the context: LanguageSource | 'resolve' | 'fallback'
306
+ ```
307
+
308
+ ### Matching a tag
309
+
310
+ Every value — a query, a cookie, a path segment, each language of the
311
+ header, `resolve`'s answer — is matched against `supported` the same way,
312
+ by `match(tag, supported)`. The first rule that finds one wins:
313
+
314
+ | Rule | `supported` | Tag | Language |
315
+ | --- | --- | --- | --- |
316
+ | the tag itself, whatever its case | `['en', 'pt-BR']` | `PT-br` | `pt-BR` |
317
+ | its base language | `['en', 'fr']` | `fr-CA` | `fr` |
318
+ | a region of its base language | `['en', 'pt-BR']` | `pt`, or `pt-PT` | `pt-BR` |
319
+ | `*` | `['fr', 'en']` | `*` | `fr`: the first supported |
320
+ | none | `['en', 'fr']` | `de` | `undefined`: the next source decides |
321
+
322
+ ```ts
323
+ import { match } from '@alxia/language';
324
+
325
+ match('EN-gb', ['en', 'fr']); // 'en'
326
+ match('pt-PT', ['en', 'pt-BR']); // 'pt-BR'
327
+ match('de', ['en', 'fr']); // undefined
328
+ ```
329
+
330
+ When several regions of a language are supported, a base tag matches the
331
+ first of them: with `['en-US', 'en-GB']`, `en` is `en-US`.
332
+
333
+ ### Negotiating `Accept-Language`
334
+
335
+ A browser sends every language its user configured, each with a weight
336
+ from 0 to 1. Chrome set to French, then English, sends:
337
+
338
+ ```text
339
+ Accept-Language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7
340
+ ```
341
+
342
+ `parseAcceptLanguage` reads it as a list, the most wanted first. A
343
+ language without `q` weighs 1; one with `q=0` (or a weight that is not a
344
+ number) is refused and dropped; equal weights keep the header's order:
345
+
346
+ ```ts
347
+ import { parseAcceptLanguage } from '@alxia/language';
348
+
349
+ parseAcceptLanguage('fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7');
350
+ // [{ tag: 'fr-FR', q: 1 }, { tag: 'fr', q: 0.9 }, { tag: 'en-US', q: 0.8 }, { tag: 'en', q: 0.7 }]
351
+
352
+ parseAcceptLanguage('fr;q=0.5, en, de;q=0');
353
+ // [{ tag: 'en', q: 1 }, { tag: 'fr', q: 0.5 }]
354
+ ```
355
+
356
+ `negotiate` walks that list and returns the first tag `match` finds a
357
+ supported language for — the user's most wanted language you speak, not
358
+ the closest match of the first one:
359
+
360
+ ```ts
361
+ import { negotiate } from '@alxia/language';
362
+
363
+ negotiate('de, fr-CA;q=0.8, en;q=0.5', ['en', 'fr', 'pt-BR']); // 'fr'
364
+ negotiate('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', ['en', 'fr']); // 'en'
365
+ negotiate('de', ['en']); // undefined
366
+ negotiate(null, ['en']); // undefined
367
+ ```
368
+
369
+ The second user reads German first, but English next: they get `en` with
370
+ `languageSource: 'header'`, not `'fallback'`.
371
+
372
+ `curl`, `fetch` on a server, and most HTTP clients send no
373
+ `Accept-Language` at all, so without a query or cookie they get
374
+ `fallback`. Send one to see what a browser gets:
375
+
376
+ ```sh
377
+ curl -H 'Accept-Language: fr-FR,fr;q=0.9,en;q=0.8' http://localhost:3000/
378
+ ```
379
+
380
+ ```ts
381
+ type Accepted = { readonly tag: string; readonly q: number };
382
+
383
+ function parseAcceptLanguage(header: string | null | undefined): Accepted[];
384
+ function match<const L extends string>(tag: string, supported: readonly L[]): L | undefined;
385
+ function negotiate<const L extends string>(header: string | null | undefined, supported: readonly L[]): L | undefined;
386
+ ```
387
+
388
+ The three are exported for code outside a request — a background job
389
+ choosing an email's language from a saved header, a WebSocket upgrade —
390
+ and return the same answers the plugin would.
391
+
392
+ ## The typed context
393
+
394
+ The routes behind the plugin read:
395
+
396
+ ```ts
397
+ interface LanguageContext<L extends string> {
398
+ readonly language: L;
399
+ readonly languageSource: LanguageSource | 'resolve' | 'fallback';
400
+ }
401
+ ```
402
+
403
+ `language` is one of `supported`, never a string a client made up, so a
404
+ record keyed by the supported languages is indexed without a check, and a
405
+ missing translation is a compile error:
406
+
407
+ ```ts
408
+ import { alxia } from '@alxia/core';
409
+ import { language } from '@alxia/language';
410
+
411
+ const supported = ['en', 'fr'] as const;
412
+ type Language = (typeof supported)[number];
413
+
414
+ const titles: Record<Language, string> = { en: 'Products', fr: 'Produits' };
415
+
416
+ const app = alxia()
417
+ .use(language({ supported, fallback: 'en' }))
418
+ .get('/products', ({ language: current, languageSource, reply }) =>
419
+ reply(200, { title: titles[current], decidedBy: languageSource }),
420
+ );
421
+ ```
422
+
423
+ `LanguageContext<Language>` types a function that is given the context, or
424
+ part of it, outside the route:
425
+
426
+ ```ts
427
+ import type { LanguageContext } from '@alxia/language';
428
+
429
+ const formatPrice = ({ language }: LanguageContext<Language>, cents: number) =>
430
+ new Intl.NumberFormat(language, { style: 'currency', currency: 'EUR' }).format(cents / 100);
431
+ ```
432
+
433
+ The destructured name `language` shadows the imported `language()` inside
434
+ the handler; rename it (`language: current`) where both are needed.
435
+
436
+ ## What it adds to the response
437
+
438
+ | Header | When |
439
+ | --- | --- |
440
+ | `Content-Language: <language>` | unless `contentLanguage: false`; a route's own wins |
441
+ | `Vary: Accept-Language` | when `order` lists `'header'` |
442
+ | `Vary: Cookie` | when `order` lists `'cookie'` |
443
+ | `Vary: <each of vary>` | when `vary` names headers |
444
+ | `Set-Cookie: <cookie>=<language>; …` | with `persist`, when the query decided |
445
+
446
+ With the default `order`, every response says `Vary: Accept-Language, Cookie`,
447
+ so a shared cache keeps one copy per language rather than serving the first
448
+ one to everyone. The query and the path are part of the URL, and need no
449
+ `Vary`. What `resolve` reads is added only when the `vary` option names it.
450
+
451
+ The plugin adds to `ctx.set.headers`, and a reply's own `Vary` adds to it
452
+ rather than replacing it:
453
+
454
+ ```ts
455
+ .get('/', ({ reply }) =>
456
+ reply(200, 'ok', { headers: { vary: 'Accept-Encoding' } }),
457
+ ); // Vary: Accept-Language, Cookie, Accept-Encoding
458
+ ```
459
+
460
+ Behind [`@alxia/cache`](https://www.npmjs.com/package/@alxia/cache), give
461
+ the cache the same headers, so it keys by them:
462
+ `cache({ ttl: 60, vary: ['accept-language', 'cookie'] })`. A response that
463
+ sets a cookie — a `persist` from the query — is not cached.
464
+
465
+ A request that matches no route — a `404`, a `405` — never reaches the
466
+ plugin, so it carries none of these headers.
467
+
468
+ ## A realistic setup
469
+
470
+ An app that speaks English, French and Brazilian Portuguese; a language
471
+ switcher made of `?lang=` links, remembered in a cookie; a route that saves
472
+ the user's choice; and a browser's languages for everyone else:
473
+
474
+ ```ts
475
+ import { alxia } from '@alxia/core';
476
+ import { language, match } from '@alxia/language';
477
+
478
+ export const supported = ['en', 'fr', 'pt-BR'] as const;
479
+ export type Language = (typeof supported)[number];
480
+
481
+ const messages: Record<Language, { welcome: string }> = {
482
+ en: { welcome: 'Welcome' },
483
+ fr: { welcome: 'Bienvenue' },
484
+ 'pt-BR': { welcome: 'Bem-vindo' },
485
+ };
486
+
487
+ export const app = alxia()
488
+ .use(
489
+ language({
490
+ supported,
491
+ fallback: 'en',
492
+ persist: { secure: process.env['NODE_ENV'] === 'production' },
493
+ }),
494
+ )
495
+ .get('/', ({ language: current, reply }) => reply(200, messages[current].welcome))
496
+ .put('/preferences/language/:lang', ({ params, set, reply }) => {
497
+ const chosen = match(params.lang, supported);
498
+ if (chosen === undefined) return reply(400, { error: 'unsupported_language' as const });
499
+ set.cookies.set('language', chosen, { path: '/', sameSite: 'lax', maxAge: 365 * 24 * 60 * 60 });
500
+ return reply(200, { language: chosen });
501
+ });
502
+
503
+ export type App = typeof app;
504
+ ```
505
+
506
+ The saved choice is the `language` cookie, which the plugin reads on every
507
+ request after, before the browser's languages. `secure` is off outside
508
+ production, so the cookie survives a plain-`http` development server.
509
+
510
+ And its tests, without a server:
511
+
512
+ ```ts
513
+ import { describe, expect, test } from 'bun:test';
514
+ import { app } from './app';
515
+
516
+ describe('language', () => {
517
+ test('a browser gets its most wanted language', async () => {
518
+ const response = await app.request('/', {
519
+ headers: { 'accept-language': 'de-DE,de;q=0.9,fr;q=0.8,en;q=0.7' },
520
+ });
521
+ expect(await response.text()).toBe('Bienvenue');
522
+ expect(response.headers.get('content-language')).toBe('fr');
523
+ expect(response.headers.get('vary')).toBe('Accept-Language, Cookie');
524
+ });
525
+
526
+ test('a client with no Accept-Language gets the fallback', async () => {
527
+ expect(await (await app.request('/')).text()).toBe('Welcome');
528
+ });
529
+
530
+ test('a ?lang= link is kept in the cookie', async () => {
531
+ const response = await app.request('/?lang=pt');
532
+ expect(await response.text()).toBe('Bem-vindo');
533
+ expect(response.headers.getSetCookie()[0]).toContain('language=pt-BR');
534
+ });
535
+
536
+ test('the cookie wins over the browser', async () => {
537
+ const response = await app.request('/', {
538
+ headers: { cookie: 'language=fr', 'accept-language': 'en' },
539
+ });
540
+ expect(await response.text()).toBe('Bienvenue');
541
+ });
542
+
543
+ test('a saved choice is refused when unsupported', async () => {
544
+ const response = await app.request('/preferences/language/de', { method: 'PUT' });
545
+ expect(response.status).toBe(400);
546
+ });
547
+ });
548
+ ```
549
+
550
+ [`@alxia/i18n`](https://www.npmjs.com/package/@alxia/i18n) builds on this
551
+ plugin — its options pass through — and adds `t()` bound to the request's
552
+ language. When something does not behave as described here,
553
+ [Troubleshooting](troubleshooting.md) starts from the symptom.
@@ -0,0 +1,51 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/language` 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/language/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/language` declares no dependency, only
23
+ peers — `@alxia/core` and `typescript`: it is built on `@alxia/core`'s
24
+ public API and Bun's own `Bun.CookieMap`.
25
+
26
+ ## Shipped
27
+
28
+ ### 0.1.0
29
+
30
+ - **The request's language, typed.** `alxia().use(language({ supported, fallback }))`
31
+ gives the routes after it `language`, typed as one of `supported` — never
32
+ a string a client made up — and `languageSource`, which says what decided.
33
+ - **Four sources, in your order.** The query, a cookie, a path segment and
34
+ `Accept-Language`, read in `order`, then a `resolve` of your own, then
35
+ `fallback`.
36
+ - **`Accept-Language` negotiated.** Weights and refusals honoured; a tag
37
+ matched whatever its case, by its base language (`fr-CA` for `fr`), or by
38
+ a region of it (`pt` for `pt-BR`). `negotiate`, `parseAcceptLanguage` and
39
+ `match` are exported for use outside a request.
40
+ - **The response says it.** `Content-Language` on every response, `Vary` by
41
+ the headers read, and, with `persist`, a language named in the query kept
42
+ in a cookie.
43
+ - **A preference an earlier plugin knows.** Annotate `resolve`'s parameter —
44
+ `({ user }: BaseContext & { user: User }) => user.language` — and it reads
45
+ what an earlier plugin added; an app that does not give it cannot use the
46
+ plugin. Unannotated, it reads the request alone and requires nothing.
47
+ - **No silent `any`.** A `resolve` annotated `any` would require nothing
48
+ and turn the check off; the plugin is refused on every app instead, with
49
+ a message naming `resolve`.
50
+ - **A fallback that cannot be wrong.** The types refuse a fallback the app
51
+ does not support, and so does the plugin at start-up.