@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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/decide.d.ts +23 -0
- package/dist/decide.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +124 -0
- package/dist/index.js.map +12 -0
- package/dist/language.d.ts +57 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/negotiate.d.ts +16 -0
- package/dist/negotiate.d.ts.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +13 -0
- package/docs/guide.md +553 -0
- package/docs/roadmap.md +51 -0
- package/docs/troubleshooting.md +482 -0
- package/package.json +51 -0
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.
|
package/docs/roadmap.md
ADDED
|
@@ -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.
|