@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
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: the error the plugin throws at
|
|
4
|
+
start-up, an error from `tsc`, or — for a trap that prints nothing — what
|
|
5
|
+
the response does that you did not expect.
|
|
6
|
+
|
|
7
|
+
**At start-up**
|
|
8
|
+
|
|
9
|
+
- [`TypeError: language(): the fallback "de" is not supported`](#typeerror-language-the-fallback-de-is-not-supported)
|
|
10
|
+
|
|
11
|
+
**Types**
|
|
12
|
+
|
|
13
|
+
- [`Type '"de"' is not assignable to type '"en" | "fr"'`](#type-de-is-not-assignable-to-type-en--fr)
|
|
14
|
+
- [`Property 'language' does not exist on type 'Context<Empty, "/", Empty>'`](#property-language-does-not-exist-on-type-contextempty--empty)
|
|
15
|
+
- [`Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`](#type-alxiaempty-empty--never-is-missing-the-following-properties-from-type-languageoptionsstring-basecontext-supported-fallback)
|
|
16
|
+
- [`Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'`](#element-implicitly-has-an-any-type-because-expression-of-type-string-cant-be-used-to-index-type--en-string-fr-string-)
|
|
17
|
+
- [`Type 'string | null' is not assignable to type 'string | undefined'`](#type-string--null-is-not-assignable-to-type-string--undefined)
|
|
18
|
+
- [`Type 'Promise<string>' is not assignable to type 'string'`](#type-promisestring-is-not-assignable-to-type-string)
|
|
19
|
+
- [`Property 'user' does not exist on type 'BaseContext'`](#property-user-does-not-exist-on-type-basecontext)
|
|
20
|
+
- [`the plugin reads "user", which this app's context does not give: use the plugin that adds it first`](#the-plugin-reads-user-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first)
|
|
21
|
+
- [`the plugin reads "user", which this app's context gives with another type`](#the-plugin-reads-user-which-this-apps-context-gives-with-another-type)
|
|
22
|
+
- [`the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`](#the-plugins-resolve-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated)
|
|
23
|
+
|
|
24
|
+
**Responses**
|
|
25
|
+
|
|
26
|
+
- [The browser gets French, `curl` gets English](#the-browser-gets-french-curl-gets-english)
|
|
27
|
+
- [`?lang=fr` works, but the next page is back in the browser's language](#langfr-works-but-the-next-page-is-back-in-the-browsers-language)
|
|
28
|
+
- [A cache serves one language to every visitor](#a-cache-serves-one-language-to-every-visitor)
|
|
29
|
+
- [`404 {"error":"not_found"}` on `/fr/products`](#404-errornot_found-on-frproducts)
|
|
30
|
+
- [`Accept-Language: *` gets the first supported language, not the fallback](#accept-language--gets-the-first-supported-language-not-the-fallback)
|
|
31
|
+
- [A visitor from Portugal gets `pt-BR`](#a-visitor-from-portugal-gets-pt-br)
|
|
32
|
+
|
|
33
|
+
## At start-up
|
|
34
|
+
|
|
35
|
+
### `TypeError: language(): the fallback "de" is not supported`
|
|
36
|
+
|
|
37
|
+
**When:** `language()` is called — when the module that builds the app is
|
|
38
|
+
imported, before it serves anything — with a `fallback` that is not in
|
|
39
|
+
`supported`.
|
|
40
|
+
|
|
41
|
+
**Why:** the fallback is the language of every request that names none
|
|
42
|
+
the app speaks, so it must be one of them. The types refuse it when they
|
|
43
|
+
can see the strings; they cannot when `supported` is typed `string[]` — a
|
|
44
|
+
list built at runtime, read from configuration, or declared without
|
|
45
|
+
`as const` — or when `fallback` comes from the environment through a cast.
|
|
46
|
+
|
|
47
|
+
**Fix:** list the fallback among the supported languages, and keep the
|
|
48
|
+
list literal so the compiler checks it next time:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const supported = ['en', 'fr', 'de'] as const;
|
|
52
|
+
|
|
53
|
+
language({ supported, fallback: 'de' });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Types
|
|
57
|
+
|
|
58
|
+
### `Type '"de"' is not assignable to type '"en" | "fr"'`
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
error TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**When:** `language({ supported: ['en', 'fr'], fallback: 'de' })`.
|
|
65
|
+
|
|
66
|
+
**Why:** `fallback` is typed as one of `supported`. This is the compile-time
|
|
67
|
+
form of [the start-up error](#typeerror-language-the-fallback-de-is-not-supported).
|
|
68
|
+
|
|
69
|
+
**Fix:** a fallback the app supports:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
language({ supported: ['en', 'fr'], fallback: 'en' });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `Property 'language' does not exist on type 'Context<Empty, "/", Empty>'`
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
error TS2339: Property 'language' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**When:** a route reads `language` but is declared before
|
|
82
|
+
`.use(language(…))`, or in another app or group than the one that uses it.
|
|
83
|
+
|
|
84
|
+
**Why:** the plugin is a route hook: it applies to the routes declared
|
|
85
|
+
after it, at runtime and in the types alike. A route before it never runs
|
|
86
|
+
it.
|
|
87
|
+
|
|
88
|
+
**Fix:** use the plugin first:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const app = alxia()
|
|
92
|
+
.use(language({ supported: ['en', 'fr'], fallback: 'en' }))
|
|
93
|
+
.get('/', ({ language: current, reply }) => reply(200, current));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
error TS2769: No overload matches this call.
|
|
100
|
+
Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => …): Alxia<…>', gave the following error.
|
|
101
|
+
Argument of type '<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>) => Alxia<RequiresOf<Ctx, "resolve"> & LanguageContext<L>, Empty, "", never> & Requiring<...>' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => …'.
|
|
102
|
+
Types of parameters 'options' and 'app' are incompatible.
|
|
103
|
+
Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**When:** `app.use(language)`, without calling it.
|
|
107
|
+
|
|
108
|
+
**Why:** `language` makes the plugin from its options; it is not the plugin.
|
|
109
|
+
|
|
110
|
+
**Fix:**
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
alxia().use(language({ supported: ['en', 'fr'], fallback: 'en' }));
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'`
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'.
|
|
120
|
+
No index signature with a parameter of type 'string' was found on type '{ en: string; fr: string; }'.
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**When:** a route indexes a record with `language`, and `supported` was
|
|
124
|
+
declared apart from the call without `as const`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const langs = ['en', 'fr']; // string[]
|
|
128
|
+
alxia()
|
|
129
|
+
.use(language({ supported: langs, fallback: 'en' }))
|
|
130
|
+
.get('/', ({ language: current, reply }) => reply(200, greetings[current]));
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Why:** `language` is typed from the strings of `supported`. A list typed
|
|
134
|
+
`string[]` has none, so `language` is a plain `string` — and the fallback
|
|
135
|
+
is not checked either.
|
|
136
|
+
|
|
137
|
+
**Fix:** declare the list `as const`, and derive its type from it:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const supported = ['en', 'fr'] as const;
|
|
141
|
+
type Language = (typeof supported)[number]; // 'en' | 'fr'
|
|
142
|
+
|
|
143
|
+
const greetings: Record<Language, string> = { en: 'Hello', fr: 'Bonjour' };
|
|
144
|
+
|
|
145
|
+
alxia()
|
|
146
|
+
.use(language({ supported, fallback: 'en' }))
|
|
147
|
+
.get('/', ({ language: current, reply }) => reply(200, greetings[current]));
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### `Type 'string | null' is not assignable to type 'string | undefined'`
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
error TS2322: Type '(ctx: BaseContext) => string | null' is not assignable to type '(ctx: BaseContext) => string | undefined'.
|
|
154
|
+
Type 'string | null' is not assignable to type 'string | undefined'.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**When:** `resolve` returns a header or a search parameter as it is:
|
|
158
|
+
`resolve: (ctx) => ctx.request.headers.get('x-preferred-language')`.
|
|
159
|
+
|
|
160
|
+
**Why:** `Headers.get` and `URLSearchParams.get` return `null` for a
|
|
161
|
+
missing value; `resolve` says "none" with `undefined`.
|
|
162
|
+
|
|
163
|
+
**Fix:**
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
language({
|
|
167
|
+
supported: ['en', 'fr'],
|
|
168
|
+
fallback: 'en',
|
|
169
|
+
resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `Type 'Promise<string>' is not assignable to type 'string'`
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
error TS2322: Type '() => Promise<string>' is not assignable to type '(ctx: BaseContext) => string | undefined'.
|
|
177
|
+
Type 'Promise<string>' is not assignable to type 'string'.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**When:** `resolve` is `async` — it looks a user's saved preference up in a
|
|
181
|
+
database or a session store.
|
|
182
|
+
|
|
183
|
+
**Why:** `resolve` is synchronous: it runs on every request, after the
|
|
184
|
+
sources, and returns a tag or `undefined`.
|
|
185
|
+
|
|
186
|
+
**Fix:** keep the preference where the plugin already reads it. When the
|
|
187
|
+
user saves it, write it to the cookie; every request after reads it from
|
|
188
|
+
there, before `Accept-Language`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const supported = ['en', 'fr'] as const;
|
|
192
|
+
|
|
193
|
+
const app = alxia()
|
|
194
|
+
.use(language({ supported, fallback: 'en' }))
|
|
195
|
+
.put('/preferences/language/:lang', ({ params, set, reply }) => {
|
|
196
|
+
const chosen = match(params.lang, supported);
|
|
197
|
+
if (chosen === undefined) return reply(400, { error: 'unsupported_language' as const });
|
|
198
|
+
set.cookies.set('language', chosen, { path: '/', sameSite: 'lax', maxAge: 365 * 24 * 60 * 60 });
|
|
199
|
+
return reply(200, { language: chosen });
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Do the same at sign-in, from the stored preference.
|
|
204
|
+
|
|
205
|
+
Or load it before the plugin, in an async `derive` or the plugin that
|
|
206
|
+
signs the user in, and annotate `resolve` to read it — see
|
|
207
|
+
[Reading the app's context](guide.md#reading-the-apps-context):
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const app = alxia()
|
|
211
|
+
.derive(async ({ request }) => ({ saved: await preferences.find(request) }))
|
|
212
|
+
.use(
|
|
213
|
+
language({
|
|
214
|
+
supported,
|
|
215
|
+
fallback: 'en',
|
|
216
|
+
resolve: ({ saved }: { saved: string | undefined }) => saved,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### `Property 'user' does not exist on type 'BaseContext'`
|
|
222
|
+
|
|
223
|
+
```text
|
|
224
|
+
error TS2339: Property 'user' does not exist on type 'BaseContext'.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**When:** `resolve` reads something a `derive` or a plugin before
|
|
228
|
+
`language()` added — a user, a session — and its parameter is not
|
|
229
|
+
annotated: `resolve: (ctx) => ctx.user.language`.
|
|
230
|
+
|
|
231
|
+
**Why:** an unannotated `resolve` is typed with the request's `BaseContext`
|
|
232
|
+
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other hooks
|
|
233
|
+
added. `language()` is built before it is used, so it cannot see the app it
|
|
234
|
+
will be used on.
|
|
235
|
+
|
|
236
|
+
**Fix:** annotate the parameter with what it reads. `language()` infers it
|
|
237
|
+
from the annotation, and the app that uses the plugin must then give it,
|
|
238
|
+
before the plugin:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import type { BaseContext } from '@alxia/core';
|
|
242
|
+
|
|
243
|
+
const byUser = language({
|
|
244
|
+
supported: ['en', 'fr'],
|
|
245
|
+
fallback: 'en',
|
|
246
|
+
resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
alxia().use(auth).use(byUser); // auth derives user
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
See [Reading the app's context](guide.md#reading-the-apps-context).
|
|
253
|
+
|
|
254
|
+
### `the plugin reads "user", which this app's context does not give: use the plugin that adds it first`
|
|
255
|
+
|
|
256
|
+
```text
|
|
257
|
+
error TS2769: No overload matches this call.
|
|
258
|
+
…
|
|
259
|
+
Types of property ''~requires'' are incompatible.
|
|
260
|
+
Type '{ user: User; }' is not assignable to type '"the plugin reads \"user\", which this app's context does not give: use the plugin that adds it first"'.
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**When:** the plugin's `resolve` is annotated to read `user`, and it is
|
|
264
|
+
used on an app — or in a group — whose context has no `user` at that point:
|
|
265
|
+
`alxia().use(byUser)`, or `use(byUser)` before `use(auth)`.
|
|
266
|
+
|
|
267
|
+
**Why:** an annotated `resolve` makes the plugin require what it reads, and
|
|
268
|
+
`use` checks the app's context against it, so `resolve` never runs without
|
|
269
|
+
it.
|
|
270
|
+
|
|
271
|
+
**Fix:** use the plugin that adds `user` first, with the type `resolve`
|
|
272
|
+
reads:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
alxia().use(auth).use(byUser);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
More on this message in
|
|
279
|
+
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
|
|
280
|
+
|
|
281
|
+
### `the plugin reads "user", which this app's context gives with another type`
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
error TS2769: No overload matches this call.
|
|
285
|
+
…
|
|
286
|
+
Type '{ user: User; }' is not assignable to type '"the plugin reads \"user\", which this app's context gives with another type"'.
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**When:** the app gives a `user`, but of a type that does not fit the one
|
|
290
|
+
`resolve`'s parameter is annotated with: a `User | null` where `resolve`
|
|
291
|
+
reads `User`, or a user of another shape.
|
|
292
|
+
|
|
293
|
+
**Why:** `use` checks each key the plugin reads against the app's context;
|
|
294
|
+
a narrower type passes, a wider or different one does not.
|
|
295
|
+
|
|
296
|
+
**Fix:** annotate `resolve` with the type the app gives — `User | null`,
|
|
297
|
+
handled inside — or narrow it in the plugin before:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
language({
|
|
301
|
+
supported: ['en', 'fr'],
|
|
302
|
+
fallback: 'en',
|
|
303
|
+
resolve: ({ user }: { user: User | null }) => user?.language ?? undefined,
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
More on this message in
|
|
308
|
+
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-gives-with-another-type).
|
|
309
|
+
|
|
310
|
+
### `the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`
|
|
311
|
+
|
|
312
|
+
```text
|
|
313
|
+
error TS2769: No overload matches this call.
|
|
314
|
+
…
|
|
315
|
+
Types of property ''~requires'' are incompatible.
|
|
316
|
+
Type '{ readonly '~any': "the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"; }' is not assignable to type '"the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"'.
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**When:** `resolve`'s parameter is annotated `any` —
|
|
320
|
+
`resolve: (ctx: any) => ctx.user.language` — and the plugin is used, on
|
|
321
|
+
any app, whatever its context gives.
|
|
322
|
+
|
|
323
|
+
**Why:** an `any` parameter reads any key and says nothing of what it
|
|
324
|
+
reads, so the plugin would require nothing, and an app without a `user`
|
|
325
|
+
would be accepted, and throw on every request. The plugin is refused
|
|
326
|
+
instead.
|
|
327
|
+
|
|
328
|
+
**Fix:** annotate what `resolve` reads, and use the plugin that adds it
|
|
329
|
+
first:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const byUser = language({
|
|
333
|
+
supported: ['en', 'fr'],
|
|
334
|
+
fallback: 'en',
|
|
335
|
+
resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
alxia().use(auth).use(byUser);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Or leave it unannotated when it reads only the request:
|
|
342
|
+
`resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined`.
|
|
343
|
+
|
|
344
|
+
More on this message in
|
|
345
|
+
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugins--reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
|
|
346
|
+
|
|
347
|
+
## Responses
|
|
348
|
+
|
|
349
|
+
### The browser gets French, `curl` gets English
|
|
350
|
+
|
|
351
|
+
**When:** a page opened in a browser is in the user's language, while the
|
|
352
|
+
same URL fetched with `curl`, a server-side `fetch`, or a test is in the
|
|
353
|
+
fallback, with `languageSource: 'fallback'`.
|
|
354
|
+
|
|
355
|
+
**Why:** a browser sends `Accept-Language` with every request —
|
|
356
|
+
`fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7` — and `curl` and most HTTP clients
|
|
357
|
+
send none. With no query and no cookie either, nothing names a language,
|
|
358
|
+
and `fallback` decides.
|
|
359
|
+
|
|
360
|
+
**Fix:** send the header a browser sends, or name the language:
|
|
361
|
+
|
|
362
|
+
```sh
|
|
363
|
+
curl -H 'Accept-Language: fr-FR,fr;q=0.9,en;q=0.8' http://localhost:3000/
|
|
364
|
+
curl 'http://localhost:3000/?lang=fr'
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
await app.request('/', { headers: { 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8' } });
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### `?lang=fr` works, but the next page is back in the browser's language
|
|
372
|
+
|
|
373
|
+
**When:** a language switcher links to `?lang=fr`; that page is in French,
|
|
374
|
+
the next one, without the query, is not.
|
|
375
|
+
|
|
376
|
+
**Why:** the query only decides the request that carries it. The plugin
|
|
377
|
+
keeps it for the next ones only when all of these hold:
|
|
378
|
+
|
|
379
|
+
- `persist` is set — it is off by default;
|
|
380
|
+
- `order` lists `'cookie'` — the cookie is written, but never read back
|
|
381
|
+
with `order: ['query', 'header']`;
|
|
382
|
+
- the browser keeps the cookie. It is `Secure` by default, and a browser
|
|
383
|
+
drops a `Secure` cookie set over plain `http` — on a LAN address or a
|
|
384
|
+
development hostname, and on `localhost` too in some browsers, Safari
|
|
385
|
+
among them.
|
|
386
|
+
|
|
387
|
+
**Fix:**
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
language({
|
|
391
|
+
supported: ['en', 'fr'],
|
|
392
|
+
fallback: 'en',
|
|
393
|
+
order: ['query', 'cookie', 'header'],
|
|
394
|
+
persist: { secure: process.env['NODE_ENV'] === 'production' },
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### A cache serves one language to every visitor
|
|
399
|
+
|
|
400
|
+
**When:** behind a CDN, a reverse proxy, or `@alxia/cache`, the first
|
|
401
|
+
visitor's language is served to everyone after.
|
|
402
|
+
|
|
403
|
+
**Why:** the response depends on what the plugin read, and a cache keyed
|
|
404
|
+
on the URL alone ignores it. The plugin says `Vary: Accept-Language,
|
|
405
|
+
Cookie` with the default `order`, and a reply's own `Vary` adds to it. Two
|
|
406
|
+
things still leave a header out:
|
|
407
|
+
|
|
408
|
+
- what `resolve` reads, unless the `vary` option names it;
|
|
409
|
+
- an `onResponse` hook that sets `Vary` with `headers.set` instead of
|
|
410
|
+
`@alxia/core`'s `vary`, which replaces every name before it.
|
|
411
|
+
|
|
412
|
+
**Fix:** name what `resolve` reads, add to `Vary` rather than setting it,
|
|
413
|
+
and give a cache the same headers:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { alxia, vary, withHeaders } from '@alxia/core';
|
|
417
|
+
|
|
418
|
+
alxia()
|
|
419
|
+
.use(
|
|
420
|
+
language({
|
|
421
|
+
supported: ['en', 'fr'],
|
|
422
|
+
fallback: 'en',
|
|
423
|
+
resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
|
|
424
|
+
vary: ['X-Preferred-Language'],
|
|
425
|
+
}),
|
|
426
|
+
)
|
|
427
|
+
.onResponse((response) => withHeaders(response, (headers) => vary(headers, 'Accept-Encoding')));
|
|
428
|
+
// Vary: Accept-Language, Cookie, X-Preferred-Language, Accept-Encoding
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
cache({ ttl: 60, vary: ['accept-language', 'cookie', 'x-preferred-language'] }); // @alxia/cache
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### `404 {"error":"not_found"}` on `/fr/products`
|
|
436
|
+
|
|
437
|
+
**When:** `order` lists `'path'`, and the routes are declared without the
|
|
438
|
+
language segment: `.get('/products', …)`.
|
|
439
|
+
|
|
440
|
+
**Why:** the plugin reads the segment at `pathIndex`; it does not remove
|
|
441
|
+
it from the path or route on it. `/fr/products` matches no route, so the
|
|
442
|
+
`404` is answered before the plugin runs.
|
|
443
|
+
|
|
444
|
+
**Fix:** declare the segment in the routes:
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
alxia()
|
|
448
|
+
.use(language({ supported: ['en', 'fr'], fallback: 'en', order: ['path', 'header'] }))
|
|
449
|
+
.get('/:lang/products', ({ language: current, reply }) => reply(200, current));
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### `Accept-Language: *` gets the first supported language, not the fallback
|
|
453
|
+
|
|
454
|
+
**When:** a client sends `Accept-Language: *`, and `supported` does not
|
|
455
|
+
start with `fallback`: `supported: ['fr', 'en'], fallback: 'en'` answers
|
|
456
|
+
`fr`, with `languageSource: 'header'`.
|
|
457
|
+
|
|
458
|
+
**Why:** `*` accepts any language, so it matches one — the first of
|
|
459
|
+
`supported`. `fallback` is only for a request that names none.
|
|
460
|
+
|
|
461
|
+
**Fix:** list the language a wildcard should get first, usually the
|
|
462
|
+
fallback:
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
language({ supported: ['en', 'fr'], fallback: 'en' });
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### A visitor from Portugal gets `pt-BR`
|
|
469
|
+
|
|
470
|
+
**When:** `supported` holds `'pt-BR'` and no other Portuguese, and the
|
|
471
|
+
browser sends `pt-PT` or `pt`.
|
|
472
|
+
|
|
473
|
+
**Why:** a tag the app does not support exactly falls back to its base
|
|
474
|
+
language, then to a region of it: `pt-PT` is `pt-BR`, as `fr-CA` is `fr`.
|
|
475
|
+
That is usually what you want; when it is not, support the region.
|
|
476
|
+
|
|
477
|
+
**Fix:** list both regions — an exact tag always wins, and `pt` alone gets
|
|
478
|
+
the first one listed:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
language({ supported: ['en', 'pt-PT', 'pt-BR'], fallback: 'en' });
|
|
482
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alxia/language",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The request's language for alxia, typed as the languages you support: from the query, a cookie, the path or Accept-Language, weights and regions included",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/language"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/core": "^0.1.0",
|
|
45
|
+
"@types/bun": "^1.4.2"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@alxia/core": "^0.1.0",
|
|
49
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|