@alxia/language 0.1.1 → 0.2.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/README.md +10 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +3 -3
- package/dist/language.d.ts +8 -3
- package/dist/language.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide.md +37 -34
- package/docs/roadmap.md +1 -1
- package/docs/troubleshooting.md +61 -52
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -39,12 +39,13 @@ matches whatever its case, by its base language (`fr-CA` for `fr`), or by a
|
|
|
39
39
|
region of it (`pt` for `pt-BR`). `languageSource` says which source decided.
|
|
40
40
|
|
|
41
41
|
Every response says `Content-Language`, and `Vary` by the headers it read.
|
|
42
|
-
With `persist`, a language the query named is kept in the cookie.
|
|
42
|
+
With `persist`, a language the query named is kept in the cookie. Given to
|
|
43
|
+
`use` on the app, it runs on every request: a 404 says `Content-Language` too.
|
|
43
44
|
|
|
44
45
|
## Reading the app's context
|
|
45
46
|
|
|
46
|
-
Annotate `resolve`'s parameter to decide by what an earlier
|
|
47
|
-
the language a signed-in user saved. The
|
|
47
|
+
Annotate `resolve`'s parameter to decide by what an earlier middleware added —
|
|
48
|
+
the language a signed-in user saved. The middleware then requires it: an app
|
|
48
49
|
that does not give `user` before it cannot use it.
|
|
49
50
|
|
|
50
51
|
```ts
|
|
@@ -58,11 +59,11 @@ const byUser = language({
|
|
|
58
59
|
resolve: ({ user }: BaseContext & { user: { language: string } | null }) => user?.language,
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
alxia().
|
|
62
|
+
alxia().plugin(auth).use(byUser); // auth derives user
|
|
62
63
|
alxia().use(byUser); // a compile error: this app gives no `user`
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
A `resolve` annotated `any` would require nothing, so the
|
|
66
|
+
A `resolve` annotated `any` would require nothing, so the middleware is refused
|
|
66
67
|
on every app: annotate what it reads, or leave it unannotated.
|
|
67
68
|
|
|
68
69
|
## Options
|
|
@@ -76,21 +77,22 @@ on every app: annotate what it reads, or leave it unannotated.
|
|
|
76
77
|
| `pathIndex` | 0 | |
|
|
77
78
|
| `persist` | `false` | `true`, or `{ maxAge, secure }` |
|
|
78
79
|
| `contentLanguage` | `true` | |
|
|
79
|
-
| `resolve` | none | `(ctx) => string \| undefined`; annotate `ctx` to read what an earlier
|
|
80
|
+
| `resolve` | none | `(ctx) => string \| undefined`; annotate `ctx` to read what an earlier middleware adds |
|
|
80
81
|
| `vary` | none | the headers `resolve` reads, added to `Vary` |
|
|
81
82
|
|
|
82
83
|
## API
|
|
83
84
|
|
|
84
85
|
| export | |
|
|
85
86
|
| --- | --- |
|
|
86
|
-
| `language(options)` | the
|
|
87
|
+
| `language(options)` | the middleware, given to `app.use`: it adds `language` and `languageSource` |
|
|
87
88
|
| `LanguageOptions` | its options: `supported`, `fallback`, `order`, `query`, `cookie`, `pathIndex`, `persist`, `contentLanguage`, `resolve`, `vary` |
|
|
88
89
|
| `negotiate(header, supported)` | the supported language `Accept-Language` prefers |
|
|
89
90
|
| `parseAcceptLanguage(header)`, `match(tag, supported)` | its parts |
|
|
90
91
|
| `LanguageContext`, `LanguageSource`, `Accepted` | its types |
|
|
92
|
+
| `LanguageMiddleware<L, Requires>` | what `language()` returns: a middleware adding `LanguageContext<L>`, requiring of the app what `resolve` reads |
|
|
91
93
|
|
|
92
94
|
## Documentation
|
|
93
95
|
|
|
94
|
-
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/language/docs): every option with its default and an example, how the language is found and `Accept-Language` negotiated, reading what an earlier
|
|
96
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/language/docs): every option with its default and an example, how the language is found and `Accept-Language` negotiated, reading what an earlier middleware added, the typed context, and the headers the middleware adds.
|
|
95
97
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md): an error, or a response in the wrong language, and what to do about it.
|
|
96
98
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/language/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type LanguageOptions, language } from './language';
|
|
1
|
+
export { type LanguageMiddleware, type LanguageOptions, language, } from './language';
|
|
2
2
|
export { type Accepted, match, negotiate, parseAcceptLanguage, } from './negotiate';
|
|
3
3
|
export type { LanguageContext, LanguageSource } from './types';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,QAAQ,GACR,MAAM,YAAY,CAAC;AACpB,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,EACL,SAAS,EACT,mBAAmB,GACnB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/language.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defineMiddleware
|
|
4
|
+
} from "@alxia/core";
|
|
3
5
|
|
|
4
6
|
// src/decide.ts
|
|
5
7
|
import { vary } from "@alxia/core";
|
|
@@ -107,11 +109,11 @@ function language(options) {
|
|
|
107
109
|
if (!settings.supported.includes(settings.fallback)) {
|
|
108
110
|
throw new TypeError(`language(): the fallback "${settings.fallback}" is not supported`);
|
|
109
111
|
}
|
|
110
|
-
return
|
|
112
|
+
return defineMiddleware()((ctx, next) => {
|
|
111
113
|
const found = decide(settings, ctx);
|
|
112
114
|
respond(settings, ctx, found);
|
|
113
|
-
return found;
|
|
114
|
-
})
|
|
115
|
+
return next(found);
|
|
116
|
+
});
|
|
115
117
|
}
|
|
116
118
|
export {
|
|
117
119
|
language,
|
|
@@ -120,5 +122,5 @@ export {
|
|
|
120
122
|
parseAcceptLanguage
|
|
121
123
|
};
|
|
122
124
|
|
|
123
|
-
//# debugId=
|
|
125
|
+
//# debugId=CC2D7C8A401D3B8464756E2164756E21
|
|
124
126
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/language.ts", "../src/decide.ts", "../src/negotiate.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import {
|
|
5
|
+
"import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\ttype RequiresOf,\n} from '@alxia/core';\nimport { decide, respond, type Settings } from './decide';\nimport type { LanguageContext, LanguageSource } from './types';\n\n/**\n * `Ctx` is the type `resolve`'s parameter is annotated with —\n * `BaseContext & { user: User }`, or `{ user: User }` alone — and\n * `BaseContext` when it is not.\n */\nexport interface LanguageOptions<\n\tL extends string,\n\tCtx extends object = BaseContext,\n> {\n\t/** The languages the app speaks: the context's `language` is one of them. */\n\treadonly supported: readonly L[];\n\t/** The one it speaks when the request names none it does. */\n\treadonly fallback: NoInfer<L>;\n\t/** Where it looks, in order. `query`, `cookie`, then `header` by default. */\n\treadonly order?: readonly LanguageSource[];\n\t/** The query parameter: `?lang=fr`. `lang` by default. */\n\treadonly query?: string;\n\t/** The cookie. `language` by default. */\n\treadonly cookie?: string;\n\t/** The index of the path segment: `/fr/products` is 0. 0 by default. */\n\treadonly pathIndex?: number;\n\t/**\n\t * Whether a language named by the query is kept in the cookie, so the\n\t * next request speaks it too. Off by default.\n\t */\n\treadonly persist?:\n\t\t| boolean\n\t\t| { readonly maxAge?: number; readonly secure?: boolean };\n\t/** Says `Content-Language` on every response. On by default. */\n\treadonly contentLanguage?: boolean;\n\t/**\n\t * Decides itself, after every source: a user's saved preference. Annotate\n\t * its parameter to read what an earlier plugin adds —\n\t * `(ctx: BaseContext & { user: User })` — and the app that uses the\n\t * plugin must then give it.\n\t */\n\treadonly resolve?: (ctx: BaseContext & Ctx) => string | undefined;\n\t/**\n\t * The request headers `resolve` reads, added to `Vary` so a cache keeps\n\t * one response per value: `['authorization']`. None by default.\n\t */\n\treadonly vary?: readonly string[];\n}\n\n/**\n * What `language()` makes: a middleware that gives `language`, one of `L`,\n * and requires `Requires` of the app — what `resolve` reads.\n */\nexport type LanguageMiddleware<\n\tL extends string,\n\tRequires extends object = Empty,\n> = Middleware<Requires, Promise<Next<LanguageContext<L>>>> & MiddlewareMark;\n\n/**\n * The request's language, as a middleware: the routes declared after it read\n * `language`, typed as one of `supported` — never a string a client made\n * up. It is read from the query, a cookie, a path segment and\n * `Accept-Language` — weights, `fr-CA` for `fr`, `fr` for `fr-FR` — in the\n * order given, then `fallback`.\n *\n * ```ts\n * app.use(language({ supported: ['en', 'fr'], fallback: 'en' }))\n * .get('/', ({ language, reply }) => reply(200, language)); // 'en' | 'fr'\n * ```\n */\nexport function language<\n\tconst L extends string,\n\tCtx extends object = BaseContext,\n>(\n\toptions: LanguageOptions<L, Ctx>,\n): LanguageMiddleware<L, RequiresOf<Ctx, 'resolve'>> {\n\tconst settings: Settings<L> = {\n\t\tsupported: options.supported,\n\t\tfallback: options.fallback,\n\t\torder: options.order ?? ['query', 'cookie', 'header'],\n\t\tquery: options.query ?? 'lang',\n\t\tcookie: options.cookie ?? 'language',\n\t\tpathIndex: options.pathIndex ?? 0,\n\t\tpersist:\n\t\t\toptions.persist === true\n\t\t\t\t? {}\n\t\t\t\t: options.persist === false\n\t\t\t\t\t? undefined\n\t\t\t\t\t: options.persist,\n\t\tcontentLanguage: options.contentLanguage !== false,\n\t\t// `app.plugin` has checked that the app gives what `resolve` reads.\n\t\tresolve: options.resolve as Settings<L>['resolve'],\n\t\tvary: options.vary ?? [],\n\t};\n\tif (!settings.supported.includes(settings.fallback)) {\n\t\tthrow new TypeError(\n\t\t\t`language(): the fallback \"${settings.fallback}\" is not supported`,\n\t\t);\n\t}\n\treturn defineMiddleware<RequiresOf<Ctx, 'resolve'>>()((ctx, next) => {\n\t\tconst found: LanguageContext<L> = decide(settings, ctx);\n\t\trespond(settings, ctx, found);\n\t\treturn next(found);\n\t});\n}\n",
|
|
6
6
|
"import { type BaseContext, vary } from '@alxia/core';\nimport { match, negotiate } from './negotiate';\nimport type { LanguageContext, LanguageSource } from './types';\n\n/** `language()`'s options, their defaults applied. */\nexport interface Settings<L extends string> {\n\treadonly supported: readonly L[];\n\treadonly fallback: L;\n\treadonly order: readonly LanguageSource[];\n\treadonly query: string;\n\treadonly cookie: string;\n\treadonly pathIndex: number;\n\treadonly persist:\n\t\t| { readonly maxAge?: number; readonly secure?: boolean }\n\t\t| undefined;\n\treadonly contentLanguage: boolean;\n\treadonly resolve: ((ctx: BaseContext) => string | undefined) | undefined;\n\treadonly vary: readonly string[];\n}\n\n/** The language of one source, if it names one `supported` has. */\nfunction read<L extends string>(\n\tsettings: Settings<L>,\n\tctx: BaseContext,\n\tsource: LanguageSource,\n): L | undefined {\n\tconst { supported } = settings;\n\tswitch (source) {\n\t\tcase 'query': {\n\t\t\tconst value = ctx.url.searchParams.get(settings.query);\n\t\t\treturn value === null ? undefined : match(value, supported);\n\t\t}\n\t\tcase 'cookie': {\n\t\t\tconst value = new Bun.CookieMap(\n\t\t\t\tctx.request.headers.get('cookie') ?? '',\n\t\t\t).get(settings.cookie);\n\t\t\treturn value === null ? undefined : match(value, supported);\n\t\t}\n\t\tcase 'path': {\n\t\t\tconst segment = ctx.url.pathname.split('/').filter(Boolean)[\n\t\t\t\tsettings.pathIndex\n\t\t\t];\n\t\t\treturn segment === undefined ? undefined : match(segment, supported);\n\t\t}\n\t\tcase 'header':\n\t\t\treturn negotiate(ctx.request.headers.get('accept-language'), supported);\n\t}\n}\n\n/** The request's language: each source in `order`, then `resolve`, then `fallback`. */\nexport function decide<L extends string>(\n\tsettings: Settings<L>,\n\tctx: BaseContext,\n): LanguageContext<L> {\n\tfor (const source of settings.order) {\n\t\tconst value = read(settings, ctx, source);\n\t\tif (value !== undefined) return { language: value, languageSource: source };\n\t}\n\tconst resolved = settings.resolve?.(ctx);\n\tconst value =\n\t\tresolved === undefined ? undefined : match(resolved, settings.supported);\n\treturn value === undefined\n\t\t? { language: settings.fallback, languageSource: 'fallback' }\n\t\t: { language: value, languageSource: 'resolve' };\n}\n\n/** What the response says of it: `Vary`, `Content-Language`, the kept cookie. */\nexport function respond<L extends string>(\n\tsettings: Settings<L>,\n\tctx: BaseContext,\n\tfound: LanguageContext<L>,\n): void {\n\tconst { headers, cookies } = ctx.set;\n\tif (settings.order.includes('header')) vary(headers, 'Accept-Language');\n\tif (settings.order.includes('cookie')) vary(headers, 'Cookie');\n\tfor (const name of settings.vary) vary(headers, name);\n\tif (settings.contentLanguage) headers.set('content-language', found.language);\n\tconst { persist } = settings;\n\tif (persist !== undefined && found.languageSource === 'query') {\n\t\tcookies.set(settings.cookie, found.language, {\n\t\t\tpath: '/',\n\t\t\tsameSite: 'lax',\n\t\t\thttpOnly: false,\n\t\t\tsecure: persist.secure ?? true,\n\t\t\tmaxAge: persist.maxAge ?? 365 * 24 * 60 * 60,\n\t\t});\n\t}\n}\n",
|
|
7
7
|
"/** One language a client accepts, with its weight. */\nexport interface Accepted {\n\treadonly tag: string;\n\treadonly q: number;\n}\n\n/** `Accept-Language` as its languages, the most wanted first; a weight of 0 refused. */\nexport function parseAcceptLanguage(\n\theader: string | null | undefined,\n): Accepted[] {\n\tif (!header) return [];\n\treturn header\n\t\t.split(',')\n\t\t.map((part, index) => {\n\t\t\tconst [tag = '', ...params] = part.trim().split(';');\n\t\t\tconst q = params\n\t\t\t\t.map((param) => param.trim())\n\t\t\t\t.find((param) => param.startsWith('q='));\n\t\t\tconst weight = q === undefined ? 1 : Number(q.slice(2));\n\t\t\treturn {\n\t\t\t\ttag: tag.trim(),\n\t\t\t\tq: Number.isFinite(weight) ? weight : 0,\n\t\t\t\tindex,\n\t\t\t};\n\t\t})\n\t\t.filter((entry) => entry.tag !== '' && entry.q > 0)\n\t\t.sort((a, b) => b.q - a.q || a.index - b.index)\n\t\t.map(({ tag, q }) => ({ tag, q }));\n}\n\n/**\n * The supported language a tag names: itself, whatever its case; its base\n * language — `fr-CA` to `fr`; or a region of it — `fr` to `fr-FR`.\n * `undefined` when none.\n */\nexport function match<const L extends string>(\n\ttag: string,\n\tsupported: readonly L[],\n): L | undefined {\n\tconst wanted = tag.toLowerCase();\n\tif (wanted === '*') return supported[0];\n\tconst exact = supported.find((language) => language.toLowerCase() === wanted);\n\tif (exact !== undefined) return exact;\n\tconst base = wanted.split('-')[0] ?? wanted;\n\treturn (\n\t\tsupported.find((language) => language.toLowerCase() === base) ??\n\t\tsupported.find((language) => language.toLowerCase().split('-')[0] === base)\n\t);\n}\n\n/** The supported language `Accept-Language` prefers, or `undefined`. */\nexport function negotiate<const L extends string>(\n\theader: string | null | undefined,\n\tsupported: readonly L[],\n): L | undefined {\n\tfor (const { tag } of parseAcceptLanguage(header)) {\n\t\tconst found = match(tag, supported);\n\t\tif (found !== undefined) return found;\n\t}\n\treturn undefined;\n}\n"
|
|
8
8
|
],
|
|
9
|
-
"mappings": "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
|
|
10
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "CC2D7C8A401D3B8464756E2164756E21",
|
|
11
11
|
"names": []
|
|
12
12
|
}
|
package/dist/language.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BaseContext, type RequiresOf } from '@alxia/core';
|
|
1
|
+
import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Next, type RequiresOf } from '@alxia/core';
|
|
2
2
|
import type { LanguageContext, LanguageSource } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* `Ctx` is the type `resolve`'s parameter is annotated with —
|
|
@@ -42,7 +42,12 @@ export interface LanguageOptions<L extends string, Ctx extends object = BaseCont
|
|
|
42
42
|
readonly vary?: readonly string[];
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* What `language()` makes: a middleware that gives `language`, one of `L`,
|
|
46
|
+
* and requires `Requires` of the app — what `resolve` reads.
|
|
47
|
+
*/
|
|
48
|
+
export type LanguageMiddleware<L extends string, Requires extends object = Empty> = Middleware<Requires, Promise<Next<LanguageContext<L>>>> & MiddlewareMark;
|
|
49
|
+
/**
|
|
50
|
+
* The request's language, as a middleware: the routes declared after it read
|
|
46
51
|
* `language`, typed as one of `supported` — never a string a client made
|
|
47
52
|
* up. It is read from the query, a cookie, a path segment and
|
|
48
53
|
* `Accept-Language` — weights, `fr-CA` for `fr`, `fr` for `fr-FR` — in the
|
|
@@ -53,5 +58,5 @@ export interface LanguageOptions<L extends string, Ctx extends object = BaseCont
|
|
|
53
58
|
* .get('/', ({ language, reply }) => reply(200, language)); // 'en' | 'fr'
|
|
54
59
|
* ```
|
|
55
60
|
*/
|
|
56
|
-
export declare function language<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>):
|
|
61
|
+
export declare function language<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>): LanguageMiddleware<L, RequiresOf<Ctx, 'resolve'>>;
|
|
57
62
|
//# sourceMappingURL=language.d.ts.map
|
package/dist/language.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"language.d.ts","sourceRoot":"","sources":["../src/language.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"language.d.ts","sourceRoot":"","sources":["../src/language.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,UAAU,EACf,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE/D;;;;GAIG;AACH,MAAM,WAAW,eAAe,CAC/B,CAAC,SAAS,MAAM,EAChB,GAAG,SAAS,MAAM,GAAG,WAAW;IAEhC,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC,EAAE,CAAC;IACjC,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EACd,OAAO,GACP;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC3D,gEAAgE;IAChE,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,GAAG,KAAK,MAAM,GAAG,SAAS,CAAC;IAClE;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED;;;GAGG;AACH,MAAM,MAAM,kBAAkB,CAC7B,CAAC,SAAS,MAAM,EAChB,QAAQ,SAAS,MAAM,GAAG,KAAK,IAC5B,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC;AAE7E;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACvB,KAAK,CAAC,CAAC,SAAS,MAAM,EACtB,GAAG,SAAS,MAAM,GAAG,WAAW,EAEhC,OAAO,EAAE,eAAe,CAAC,CAAC,EAAE,GAAG,CAAC,GAC9B,kBAAkB,CAAC,CAAC,EAAE,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC,CA6BnD"}
|
package/docs/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
The [package README](../README.md) is the short version. This folder is
|
|
4
4
|
the long one: every option with its default and an example, how a
|
|
5
5
|
request's language is found and how `Accept-Language` is negotiated, what
|
|
6
|
-
the routes behind the
|
|
6
|
+
the routes behind the middleware read, and what to do when a response is not in
|
|
7
7
|
the language you expected.
|
|
8
8
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| [Guide](guide.md) | choosing the sources and their order, reading the language from the path, keeping a choice in a cookie, deciding by what an earlier
|
|
12
|
-
| [Troubleshooting](troubleshooting.md) |
|
|
11
|
+
| [Guide](guide.md) | choosing the sources and their order, reading the language from the path, keeping a choice in a cookie, deciding by what an earlier middleware added, typing what the routes read, caching the responses, or testing them |
|
|
12
|
+
| [Troubleshooting](troubleshooting.md) | `language()` threw at start-up, `tsc` refused an option or a route, or a response is in the wrong language |
|
|
13
13
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
CHANGED
|
@@ -28,7 +28,7 @@ client that names no language it supports gets `Hello`. `current` is typed
|
|
|
28
28
|
```ts
|
|
29
29
|
function language<const L extends string, Ctx extends object = BaseContext>(
|
|
30
30
|
options: LanguageOptions<L, Ctx>,
|
|
31
|
-
): Alxia<RequiresOf<Ctx> & LanguageContext<L>,
|
|
31
|
+
): Alxia<RequiresOf<Ctx> & LanguageContext<L>, '', never> &
|
|
32
32
|
Requiring<RequiresOf<Ctx>>;
|
|
33
33
|
|
|
34
34
|
interface LanguageOptions<L extends string, Ctx extends object = BaseContext> {
|
|
@@ -54,11 +54,13 @@ type `resolve`'s parameter is annotated with. `RequiresOf<Ctx>`,
|
|
|
54
54
|
see
|
|
55
55
|
[Reading the app's context](#reading-the-apps-context).
|
|
56
56
|
|
|
57
|
-
`language()` returns
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
`language()` returns a middleware: pass it to `app.use`, called. A `use()` on
|
|
58
|
+
the app runs on every request, in declaration order, a request no route
|
|
59
|
+
matches included, and adds `language` and `languageSource` to what is
|
|
60
|
+
declared **after** it: the middlewares and the routes. Inside a
|
|
61
|
+
[group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
|
|
62
|
+
it applies to the group's routes only. A route declared before it neither
|
|
63
|
+
runs it nor reads them.
|
|
62
64
|
|
|
63
65
|
It throws once, when it is created, if `fallback` is not one of
|
|
64
66
|
`supported`:
|
|
@@ -82,7 +84,7 @@ for when they cannot.
|
|
|
82
84
|
| `cookie` | `string` | `'language'` | the cookie read, and written by `persist` |
|
|
83
85
|
| `pathIndex` | `number` | `0` | the path segment read by the `path` source: `/fr/products` is 0 |
|
|
84
86
|
| `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
|
|
87
|
+
| `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the middleware runs for |
|
|
86
88
|
| `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; its annotated parameter types what it reads |
|
|
87
89
|
| `vary` | `readonly string[]` | none | the request headers `resolve` reads, added to `Vary` |
|
|
88
90
|
|
|
@@ -105,7 +107,7 @@ language({ supported, fallback: 'en' });
|
|
|
105
107
|
```
|
|
106
108
|
|
|
107
109
|
`fallback` must be one of them; the types refuse another (`fallback: 'de'`
|
|
108
|
-
is a compile error), and so does
|
|
110
|
+
is a compile error), and so does `language()`, at start-up.
|
|
109
111
|
|
|
110
112
|
### `order`
|
|
111
113
|
|
|
@@ -141,7 +143,7 @@ language({ supported: ['en', 'fr'], fallback: 'en', order: ['path'], pathIndex:
|
|
|
141
143
|
// /shop/fr/products → 'fr'
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
The
|
|
146
|
+
The middleware reads the segment; it does not route on it or remove it. The
|
|
145
147
|
routes still declare it, as a parameter:
|
|
146
148
|
|
|
147
149
|
```ts
|
|
@@ -150,7 +152,7 @@ const app = alxia()
|
|
|
150
152
|
.get('/:lang/products', ({ language: current, reply }) => reply(200, current));
|
|
151
153
|
|
|
152
154
|
await app.request('/fr/products'); // 'fr'
|
|
153
|
-
await app.request('/products'); // 404: no route matches
|
|
155
|
+
await app.request('/products'); // 404: no route matches (it still says Content-Language)
|
|
154
156
|
```
|
|
155
157
|
|
|
156
158
|
A segment is matched like any other tag, so `/fr-ca/products` is `fr` too.
|
|
@@ -184,8 +186,8 @@ the cookie or the header sets nothing. For the cookie to be read back,
|
|
|
184
186
|
|
|
185
187
|
### `contentLanguage`
|
|
186
188
|
|
|
187
|
-
On by default: every response of a
|
|
188
|
-
`Content-Language: <language
|
|
189
|
+
On by default: every response of a request the middleware runs on says
|
|
190
|
+
`Content-Language: <language>`, a 404 included when it is used on the app. A route that sets its own wins over it:
|
|
189
191
|
|
|
190
192
|
```ts
|
|
191
193
|
.get('/legal', ({ reply }) => reply(200, legalText, { headers: { 'content-language': 'fr' } }));
|
|
@@ -214,27 +216,27 @@ language({
|
|
|
214
216
|
```
|
|
215
217
|
|
|
216
218
|
`vary` names the request headers `resolve` reads, so a shared cache keeps
|
|
217
|
-
one response per value; the
|
|
219
|
+
one response per value; the middleware cannot see what a function reads.
|
|
218
220
|
|
|
219
221
|
It receives the request's `BaseContext` — `request`, `url`, `ip`,
|
|
220
222
|
`pathParams`, `set` — and, when its parameter is annotated, what an earlier
|
|
221
|
-
|
|
223
|
+
middleware added: see [Reading the app's context](#reading-the-apps-context). It
|
|
222
224
|
returns a tag, or `undefined` for none. The tag is matched against
|
|
223
225
|
`supported` like any other: a tag it does not support is ignored, and
|
|
224
226
|
`fallback` decides. It is synchronous: a preference kept in a database is
|
|
225
227
|
either written to the cookie when the user saves it — see
|
|
226
228
|
[the realistic setup](#a-realistic-setup) — or loaded by an async `derive`
|
|
227
|
-
or
|
|
229
|
+
or middleware before `language()`, which `resolve` then reads — see
|
|
228
230
|
[Reading the app's context](#reading-the-apps-context).
|
|
229
231
|
|
|
230
232
|
### Reading the app's context
|
|
231
233
|
|
|
232
|
-
To decide by what an earlier
|
|
234
|
+
To decide by what an earlier middleware added, such as a signed-in `user` and
|
|
233
235
|
the language they saved, annotate `resolve`'s parameter. `language()` infers
|
|
234
236
|
what it reads from that annotation — `supported` still types `language` —
|
|
235
|
-
and the
|
|
237
|
+
and the middleware requires it, as a
|
|
236
238
|
[`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.
|
|
239
|
+
plugin does: an app that does not give `user` before it cannot use it.
|
|
238
240
|
|
|
239
241
|
```ts
|
|
240
242
|
import { alxia, type BaseContext } from '@alxia/core';
|
|
@@ -254,26 +256,26 @@ const byUser = language({
|
|
|
254
256
|
});
|
|
255
257
|
|
|
256
258
|
const app = alxia()
|
|
257
|
-
.
|
|
259
|
+
.plugin(auth) // an app: derives user: User | null
|
|
258
260
|
.use(byUser)
|
|
259
261
|
.get('/', ({ language: current, reply }) => reply(200, current)); // 'en' | 'fr'
|
|
260
262
|
|
|
261
263
|
alxia().use(byUser);
|
|
262
|
-
// error:
|
|
264
|
+
// error: Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'
|
|
263
265
|
```
|
|
264
266
|
|
|
265
267
|
The annotation may be `BaseContext & { user: User }` or `{ user: User }`
|
|
266
|
-
alone; either way the
|
|
268
|
+
alone; either way the middleware requires `{ user: User }`. An app whose `user`
|
|
267
269
|
has a type that does not fit it is refused too —
|
|
268
|
-
`
|
|
269
|
-
while a narrower one passes: an app deriving `user: User` may use a
|
|
270
|
+
`Types of property 'user' are incompatible` —
|
|
271
|
+
while a narrower one passes: an app deriving `user: User` may use a middleware
|
|
270
272
|
that reads `User | null`. Annotating a key `BaseContext` already has with
|
|
271
273
|
a type it does not give — `({ url }: { url: string })` — is refused the
|
|
272
274
|
same way.
|
|
273
|
-
A `resolve` left unannotated reads `BaseContext` only, and the
|
|
275
|
+
A `resolve` left unannotated reads `BaseContext` only, and the middleware
|
|
274
276
|
requires nothing.
|
|
275
277
|
Annotated `unknown` or `object`, it requires nothing either. Annotated
|
|
276
|
-
`any`, it would read anything and require nothing, so the
|
|
278
|
+
`any`, it would read anything and require nothing, so the middleware is
|
|
277
279
|
refused on every app instead:
|
|
278
280
|
[`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
281
|
|
|
@@ -387,11 +389,11 @@ function negotiate<const L extends string>(header: string | null | undefined, su
|
|
|
387
389
|
|
|
388
390
|
The three are exported for code outside a request — a background job
|
|
389
391
|
choosing an email's language from a saved header, a WebSocket upgrade —
|
|
390
|
-
and return the same answers the
|
|
392
|
+
and return the same answers the middleware would.
|
|
391
393
|
|
|
392
394
|
## The typed context
|
|
393
395
|
|
|
394
|
-
|
|
396
|
+
What the routes and middlewares after it read:
|
|
395
397
|
|
|
396
398
|
```ts
|
|
397
399
|
interface LanguageContext<L extends string> {
|
|
@@ -448,7 +450,7 @@ so a shared cache keeps one copy per language rather than serving the first
|
|
|
448
450
|
one to everyone. The query and the path are part of the URL, and need no
|
|
449
451
|
`Vary`. What `resolve` reads is added only when the `vary` option names it.
|
|
450
452
|
|
|
451
|
-
The
|
|
453
|
+
The middleware adds to `ctx.set.headers`, and a reply's own `Vary` adds to it
|
|
452
454
|
rather than replacing it:
|
|
453
455
|
|
|
454
456
|
```ts
|
|
@@ -462,8 +464,11 @@ the cache the same headers, so it keys by them:
|
|
|
462
464
|
`cache({ ttl: 60, vary: ['accept-language', 'cookie'] })`. A response that
|
|
463
465
|
sets a cookie — a `persist` from the query — is not cached.
|
|
464
466
|
|
|
465
|
-
A request that matches no route — a `404`, a `405` —
|
|
466
|
-
|
|
467
|
+
A request that matches no route — a `404`, a `405` — runs the app's
|
|
468
|
+
`use()` middlewares, so with `language()` on the app it carries these
|
|
469
|
+
headers too. Inside a `group`, the middleware runs for the group's routes
|
|
470
|
+
only: an unmatched request, even one under the group's prefix, carries
|
|
471
|
+
none of them.
|
|
467
472
|
|
|
468
473
|
## A realistic setup
|
|
469
474
|
|
|
@@ -499,11 +504,9 @@ export const app = alxia()
|
|
|
499
504
|
set.cookies.set('language', chosen, { path: '/', sameSite: 'lax', maxAge: 365 * 24 * 60 * 60 });
|
|
500
505
|
return reply(200, { language: chosen });
|
|
501
506
|
});
|
|
502
|
-
|
|
503
|
-
export type App = typeof app;
|
|
504
507
|
```
|
|
505
508
|
|
|
506
|
-
The saved choice is the `language` cookie, which the
|
|
509
|
+
The saved choice is the `language` cookie, which the middleware reads on every
|
|
507
510
|
request after, before the browser's languages. `secure` is off outside
|
|
508
511
|
production, so the cookie survives a plain-`http` development server.
|
|
509
512
|
|
|
@@ -548,6 +551,6 @@ describe('language', () => {
|
|
|
548
551
|
```
|
|
549
552
|
|
|
550
553
|
[`@alxia/i18n`](https://www.npmjs.com/package/@alxia/i18n) builds on this
|
|
551
|
-
|
|
554
|
+
middleware — its options pass through — and adds `t()` bound to the request's
|
|
552
555
|
language. When something does not behave as described here,
|
|
553
556
|
[Troubleshooting](troubleshooting.md) starts from the symptom.
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,7 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **A middleware, not a plugin (0.4).** `app.use(language({ ... }))` runs on every request, a 404 included, which then says `Content-Language` and `Vary` too. `app.plugin(language(...))` still works, deprecated.
|
|
11
11
|
|
|
12
12
|
## Next
|
|
13
13
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
|
-
Each entry is headed by the text you see: the error
|
|
3
|
+
Each entry is headed by the text you see: the error `language()` throws at
|
|
4
4
|
start-up, an error from `tsc`, or — for a trap that prints nothing — what
|
|
5
5
|
the response does that you did not expect.
|
|
6
6
|
|
|
@@ -12,13 +12,13 @@ the response does that you did not expect.
|
|
|
12
12
|
|
|
13
13
|
- [`Type '"de"' is not assignable to type '"en" | "fr"'`](#type-de-is-not-assignable-to-type-en--fr)
|
|
14
14
|
- [`Property 'language' does not exist on type 'Context<Empty, "/", Empty>'`](#property-language-does-not-exist-on-type-contextempty--empty)
|
|
15
|
-
- [`Type 'Alxia<Empty,
|
|
15
|
+
- [`Type 'Alxia<Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`](#type-alxiaempty--never-is-missing-the-following-properties-from-type-languageoptionsstring-basecontext-supported-fallback)
|
|
16
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
17
|
- [`Type 'string | null' is not assignable to type 'string | undefined'`](#type-string--null-is-not-assignable-to-type-string--undefined)
|
|
18
18
|
- [`Type 'Promise<string>' is not assignable to type 'string'`](#type-promisestring-is-not-assignable-to-type-string)
|
|
19
19
|
- [`Property 'user' does not exist on type 'BaseContext'`](#property-user-does-not-exist-on-type-basecontext)
|
|
20
|
-
- [`
|
|
21
|
-
- [`
|
|
20
|
+
- [`Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'`](#property-user-is-missing-in-type-basecontext--empty-but-required-in-type--user-user--null-)
|
|
21
|
+
- [`Types of property 'user' are incompatible`](#types-of-property-user-are-incompatible)
|
|
22
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
23
|
|
|
24
24
|
**Responses**
|
|
@@ -79,13 +79,12 @@ error TS2339: Property 'language' does not exist on type 'Context<Empty, "/", Em
|
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
**When:** a route reads `language` but is declared before
|
|
82
|
-
`.use(language(…))`, or in another app or group than the one that
|
|
82
|
+
`.use(language(…))`, or in another app or group than the one that mounts it.
|
|
83
83
|
|
|
84
|
-
**Why:** the
|
|
85
|
-
|
|
86
|
-
it.
|
|
84
|
+
**Why:** the middleware applies to what is declared after it, at runtime
|
|
85
|
+
and in the types alike. A route before it never runs it.
|
|
87
86
|
|
|
88
|
-
**Fix:** use
|
|
87
|
+
**Fix:** `use` it first:
|
|
89
88
|
|
|
90
89
|
```ts
|
|
91
90
|
const app = alxia()
|
|
@@ -93,19 +92,20 @@ const app = alxia()
|
|
|
93
92
|
.get('/', ({ language: current, reply }) => reply(200, current));
|
|
94
93
|
```
|
|
95
94
|
|
|
96
|
-
### `Type 'Alxia<Empty,
|
|
95
|
+
### `Type 'Alxia<Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`
|
|
97
96
|
|
|
98
97
|
```text
|
|
99
98
|
error TS2769: No overload matches this call.
|
|
100
|
-
Overload 1 of
|
|
101
|
-
Argument of type '<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>) =>
|
|
99
|
+
Overload 1 of 11, '(plugin: (app: Alxia<Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
|
|
100
|
+
Argument of type '<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>) => Middleware<…>' is not assignable to parameter of type '(app: Alxia<Empty, "", never>) => AnyAlxia'.
|
|
102
101
|
Types of parameters 'options' and 'app' are incompatible.
|
|
103
|
-
Type 'Alxia<Empty,
|
|
102
|
+
Type 'Alxia<Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback
|
|
104
103
|
```
|
|
105
104
|
|
|
106
105
|
**When:** `app.use(language)`, without calling it.
|
|
107
106
|
|
|
108
|
-
**Why:** `language` makes the
|
|
107
|
+
**Why:** `language` makes the middleware from its options; it is not the
|
|
108
|
+
middleware.
|
|
109
109
|
|
|
110
110
|
**Fix:**
|
|
111
111
|
|
|
@@ -183,7 +183,7 @@ database or a session store.
|
|
|
183
183
|
**Why:** `resolve` is synchronous: it runs on every request, after the
|
|
184
184
|
sources, and returns a tag or `undefined`.
|
|
185
185
|
|
|
186
|
-
**Fix:** keep the preference where the
|
|
186
|
+
**Fix:** keep the preference where the middleware already reads it. When the
|
|
187
187
|
user saves it, write it to the cookie; every request after reads it from
|
|
188
188
|
there, before `Accept-Language`:
|
|
189
189
|
|
|
@@ -202,7 +202,7 @@ const app = alxia()
|
|
|
202
202
|
|
|
203
203
|
Do the same at sign-in, from the stored preference.
|
|
204
204
|
|
|
205
|
-
Or load it before the
|
|
205
|
+
Or load it before the middleware, in an async `derive` or the middleware that
|
|
206
206
|
signs the user in, and annotate `resolve` to read it — see
|
|
207
207
|
[Reading the app's context](guide.md#reading-the-apps-context):
|
|
208
208
|
|
|
@@ -224,18 +224,18 @@ const app = alxia()
|
|
|
224
224
|
error TS2339: Property 'user' does not exist on type 'BaseContext'.
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
**When:** `resolve` reads something a `derive` or a
|
|
227
|
+
**When:** `resolve` reads something a `derive` or a middleware before
|
|
228
228
|
`language()` added — a user, a session — and its parameter is not
|
|
229
229
|
annotated: `resolve: (ctx) => ctx.user.language`.
|
|
230
230
|
|
|
231
231
|
**Why:** an unannotated `resolve` is typed with the request's `BaseContext`
|
|
232
|
-
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other
|
|
232
|
+
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other middlewares
|
|
233
233
|
added. `language()` is built before it is used, so it cannot see the app it
|
|
234
234
|
will be used on.
|
|
235
235
|
|
|
236
236
|
**Fix:** annotate the parameter with what it reads. `language()` infers it
|
|
237
|
-
from the annotation, and the app that uses the
|
|
238
|
-
before
|
|
237
|
+
from the annotation, and the app that uses the middleware must then give it,
|
|
238
|
+
before it:
|
|
239
239
|
|
|
240
240
|
```ts
|
|
241
241
|
import type { BaseContext } from '@alxia/core';
|
|
@@ -246,55 +246,59 @@ const byUser = language({
|
|
|
246
246
|
resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
-
alxia().
|
|
249
|
+
alxia().plugin(auth).use(byUser); // auth derives user
|
|
250
250
|
```
|
|
251
251
|
|
|
252
252
|
See [Reading the app's context](guide.md#reading-the-apps-context).
|
|
253
253
|
|
|
254
|
-
### `
|
|
254
|
+
### `Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'`
|
|
255
255
|
|
|
256
256
|
```text
|
|
257
257
|
error TS2769: No overload matches this call.
|
|
258
258
|
…
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ user: User | null; }>'.
|
|
260
|
+
Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'.
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
-
**When:** the
|
|
263
|
+
**When:** the middleware's `resolve` is annotated to read `user`, and it is
|
|
264
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 `
|
|
265
|
+
`alxia().use(byUser)`, or `use(byUser)` before `plugin(auth)`.
|
|
266
266
|
|
|
267
|
-
**Why:** an annotated `resolve` makes the
|
|
268
|
-
`use` checks the app's context against it, so `resolve` never runs without
|
|
267
|
+
**Why:** an annotated `resolve` makes the middleware require what it reads, and
|
|
268
|
+
`app.use` checks the app's context against it, so `resolve` never runs without
|
|
269
269
|
it.
|
|
270
270
|
|
|
271
|
-
**Fix:**
|
|
271
|
+
**Fix:** mount what adds `user` first, with the type `resolve`
|
|
272
272
|
reads:
|
|
273
273
|
|
|
274
274
|
```ts
|
|
275
|
-
alxia().
|
|
275
|
+
alxia().plugin(auth).use(byUser);
|
|
276
276
|
```
|
|
277
277
|
|
|
278
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-
|
|
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-add-the-plugin-or-middleware-that-gives-it-first).
|
|
280
280
|
|
|
281
|
-
### `
|
|
281
|
+
### `Types of property 'user' are incompatible`
|
|
282
282
|
|
|
283
283
|
```text
|
|
284
284
|
error TS2769: No overload matches this call.
|
|
285
285
|
…
|
|
286
|
-
Type '{ user: User; }' is not assignable to type '
|
|
286
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type 'MiddlewareContext<{ user: User; }>'.
|
|
287
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type '{ user: User; }'.
|
|
288
|
+
Types of property 'user' are incompatible.
|
|
289
|
+
Type 'User | null' is not assignable to type 'User'.
|
|
290
|
+
Type 'null' is not assignable to type 'User'.
|
|
287
291
|
```
|
|
288
292
|
|
|
289
293
|
**When:** the app gives a `user`, but of a type that does not fit the one
|
|
290
294
|
`resolve`'s parameter is annotated with: a `User | null` where `resolve`
|
|
291
295
|
reads `User`, or a user of another shape.
|
|
292
296
|
|
|
293
|
-
**Why:** `use` checks each key the
|
|
297
|
+
**Why:** `app.use` checks each key the middleware reads against the app's context;
|
|
294
298
|
a narrower type passes, a wider or different one does not.
|
|
295
299
|
|
|
296
300
|
**Fix:** annotate `resolve` with the type the app gives — `User | null`,
|
|
297
|
-
handled inside — or narrow it in the
|
|
301
|
+
handled inside — or narrow it in the middleware before:
|
|
298
302
|
|
|
299
303
|
```ts
|
|
300
304
|
language({
|
|
@@ -312,20 +316,20 @@ More on this message in
|
|
|
312
316
|
```text
|
|
313
317
|
error TS2769: No overload matches this call.
|
|
314
318
|
…
|
|
315
|
-
|
|
316
|
-
|
|
319
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ readonly '~any': "the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"; }>'.
|
|
320
|
+
Property ''~any'' is missing in type 'BaseContext & Empty' but required in type '{ readonly '~any': "the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"; }'.
|
|
317
321
|
```
|
|
318
322
|
|
|
319
323
|
**When:** `resolve`'s parameter is annotated `any` —
|
|
320
|
-
`resolve: (ctx: any) => ctx.user.language` — and the
|
|
324
|
+
`resolve: (ctx: any) => ctx.user.language` — and the middleware is used, on
|
|
321
325
|
any app, whatever its context gives.
|
|
322
326
|
|
|
323
327
|
**Why:** an `any` parameter reads any key and says nothing of what it
|
|
324
|
-
reads, so the
|
|
325
|
-
would be accepted, and throw on every request.
|
|
328
|
+
reads, so the middleware would require nothing, and an app without a `user`
|
|
329
|
+
would be accepted, and throw on every request. It is refused
|
|
326
330
|
instead.
|
|
327
331
|
|
|
328
|
-
**Fix:** annotate what `resolve` reads, and
|
|
332
|
+
**Fix:** annotate what `resolve` reads, and mount what adds it
|
|
329
333
|
first:
|
|
330
334
|
|
|
331
335
|
```ts
|
|
@@ -335,7 +339,7 @@ const byUser = language({
|
|
|
335
339
|
resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
|
|
336
340
|
});
|
|
337
341
|
|
|
338
|
-
alxia().
|
|
342
|
+
alxia().plugin(auth).use(byUser);
|
|
339
343
|
```
|
|
340
344
|
|
|
341
345
|
Or leave it unannotated when it reads only the request:
|
|
@@ -373,7 +377,7 @@ await app.request('/', { headers: { 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8'
|
|
|
373
377
|
**When:** a language switcher links to `?lang=fr`; that page is in French,
|
|
374
378
|
the next one, without the query, is not.
|
|
375
379
|
|
|
376
|
-
**Why:** the query only decides the request that carries it. The
|
|
380
|
+
**Why:** the query only decides the request that carries it. The middleware
|
|
377
381
|
keeps it for the next ones only when all of these hold:
|
|
378
382
|
|
|
379
383
|
- `persist` is set — it is off by default;
|
|
@@ -400,22 +404,28 @@ language({
|
|
|
400
404
|
**When:** behind a CDN, a reverse proxy, or `@alxia/cache`, the first
|
|
401
405
|
visitor's language is served to everyone after.
|
|
402
406
|
|
|
403
|
-
**Why:** the response depends on what the
|
|
404
|
-
on the URL alone ignores it.
|
|
407
|
+
**Why:** the response depends on what the middleware read, and a cache keyed
|
|
408
|
+
on the URL alone ignores it. It says `Vary: Accept-Language,
|
|
405
409
|
Cookie` with the default `order`, and a reply's own `Vary` adds to it. Two
|
|
406
410
|
things still leave a header out:
|
|
407
411
|
|
|
408
412
|
- what `resolve` reads, unless the `vary` option names it;
|
|
409
|
-
-
|
|
410
|
-
`@alxia/core`'s `vary`, which replaces every
|
|
413
|
+
- a middleware that sets `Vary` with
|
|
414
|
+
`headers.set` instead of `@alxia/core`'s `vary`, which replaces every
|
|
415
|
+
name before it.
|
|
411
416
|
|
|
412
417
|
**Fix:** name what `resolve` reads, add to `Vary` rather than setting it,
|
|
413
418
|
and give a cache the same headers:
|
|
414
419
|
|
|
415
420
|
```ts
|
|
416
|
-
import { alxia, vary, withHeaders } from '@alxia/core';
|
|
421
|
+
import { alxia, defineMiddleware, settle, vary, withHeaders } from '@alxia/core';
|
|
417
422
|
|
|
418
423
|
alxia()
|
|
424
|
+
.use(
|
|
425
|
+
defineMiddleware(async (ctx, next) =>
|
|
426
|
+
withHeaders(await settle(ctx, next()), (headers) => vary(headers, 'Accept-Encoding')),
|
|
427
|
+
),
|
|
428
|
+
)
|
|
419
429
|
.use(
|
|
420
430
|
language({
|
|
421
431
|
supported: ['en', 'fr'],
|
|
@@ -423,8 +433,7 @@ alxia()
|
|
|
423
433
|
resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
|
|
424
434
|
vary: ['X-Preferred-Language'],
|
|
425
435
|
}),
|
|
426
|
-
)
|
|
427
|
-
.onResponse((response) => withHeaders(response, (headers) => vary(headers, 'Accept-Encoding')));
|
|
436
|
+
);
|
|
428
437
|
// Vary: Accept-Language, Cookie, X-Preferred-Language, Accept-Encoding
|
|
429
438
|
```
|
|
430
439
|
|
|
@@ -437,9 +446,9 @@ cache({ ttl: 60, vary: ['accept-language', 'cookie', 'x-preferred-language'] });
|
|
|
437
446
|
**When:** `order` lists `'path'`, and the routes are declared without the
|
|
438
447
|
language segment: `.get('/products', …)`.
|
|
439
448
|
|
|
440
|
-
**Why:** the
|
|
449
|
+
**Why:** the middleware reads the segment at `pathIndex`; it does not remove
|
|
441
450
|
it from the path or route on it. `/fr/products` matches no route, so the
|
|
442
|
-
`404` is answered
|
|
451
|
+
`404` is answered: the language was read, but no route answers in it.
|
|
443
452
|
|
|
444
453
|
**Fix:** declare the segment in the routes:
|
|
445
454
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/language",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/core": "^0.
|
|
44
|
+
"@alxia/core": "^0.4.0",
|
|
45
45
|
"@types/bun": "^1.4.2"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@alxia/core": "^0.
|
|
48
|
+
"@alxia/core": "^0.4.0",
|
|
49
49
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
50
50
|
}
|
|
51
51
|
}
|