@alxia/i18n 0.1.2 → 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 -9
- package/dist/i18n.d.ts +23 -15
- package/dist/i18n.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -13
- package/dist/index.js.map +3 -3
- package/docs/README.md +2 -2
- package/docs/guide.md +60 -39
- package/docs/roadmap.md +1 -1
- package/docs/troubleshooting.md +91 -70
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ app.listen(3000);
|
|
|
40
40
|
## Reading the app's context
|
|
41
41
|
|
|
42
42
|
Annotate `resolve`'s parameter to speak the language a signed-in user saved.
|
|
43
|
-
The
|
|
43
|
+
The middleware then requires what it reads: an app that does not give `user`
|
|
44
44
|
before it cannot use it.
|
|
45
45
|
|
|
46
46
|
```ts
|
|
@@ -61,12 +61,12 @@ const byUser = createI18n({
|
|
|
61
61
|
resolve: ({ user }: BaseContext & { user: { language: string } | null }) => user?.language,
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
alxia().
|
|
64
|
+
alxia().plugin(auth).use(byUser); // auth derives user
|
|
65
65
|
alxia().use(byUser); // a compile error: this app gives no `user`
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Unannotated, `resolve` reads the request alone and the
|
|
69
|
-
nothing; annotated `any`,
|
|
68
|
+
Unannotated, `resolve` reads the request alone and the middleware requires
|
|
69
|
+
nothing; annotated `any`, it is refused on every app.
|
|
70
70
|
|
|
71
71
|
## Anywhere
|
|
72
72
|
|
|
@@ -76,9 +76,9 @@ export const describeCart = (count: number) => i18n.t('cart.items', { count });
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
`i18n.t()` translates in the language of the request it runs in — through
|
|
79
|
-
every `await`,
|
|
80
|
-
|
|
81
|
-
declared before
|
|
79
|
+
every `await`, in every middleware after `use(i18n)`, and in the answer to
|
|
80
|
+
an error — and in the fallback outside one, or before the language is
|
|
81
|
+
read: in a middleware declared before it, or a route declared before it
|
|
82
82
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/i18n/docs/troubleshooting.md#i18nt-answers-in-the-fallback-before-the-language-is-read)).
|
|
83
83
|
`i18n.language()` says which.
|
|
84
84
|
|
|
@@ -98,13 +98,14 @@ A response in the request's language varies by what decided it: give
|
|
|
98
98
|
|
|
99
99
|
| export | |
|
|
100
100
|
| --- | --- |
|
|
101
|
-
| `createI18n({ resources, fallback, …languageOptions })` | the
|
|
101
|
+
| `createI18n({ resources, fallback, …languageOptions })` | the middleware, given to `app.use` — what is after it reads `t` and `language` — with `t()`, `language()` and `supported` |
|
|
102
102
|
| `I18nOptions` | its options: `resources`, `fallback`, and every `@alxia/language` option but `supported`; `resolve` may be annotated to read the app's context |
|
|
103
103
|
| `KeyOf<Catalogue>` | a catalogue's dotted keys, nine levels deep; a deeper section gives `section.${string}` |
|
|
104
104
|
| `Translate<Key>`, `Catalogues`, `I18nContext<Key>` | its types |
|
|
105
|
+
| `I18nMiddleware<Language, Key, Requires>` | what `createI18n()` returns: a middleware adding `language` and `t`, with `t()`, `language()` and `supported` |
|
|
105
106
|
|
|
106
107
|
## Documentation
|
|
107
108
|
|
|
108
|
-
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/i18n/docs): the catalogues and every option, reading what an earlier
|
|
109
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/i18n/docs): the catalogues and every option, reading what an earlier middleware added, what the routes read, ICU messages and typed keys, `t()` outside a route, and `@nxgt/i18n`'s own `translate`.
|
|
109
110
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/i18n/docs/troubleshooting.md): an error, a key shown instead of a message, or a response in the wrong language, and what to do about it.
|
|
110
111
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/i18n/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/i18n.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type BaseContext, type RequiresOf } from '@alxia/core';
|
|
2
|
-
import { type LanguageOptions } from '@alxia/language';
|
|
1
|
+
import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Next, type RequiresOf } from '@alxia/core';
|
|
2
|
+
import { type LanguageContext, type LanguageOptions } from '@alxia/language';
|
|
3
3
|
import { type TranslationContext } from '@nxgt/i18n';
|
|
4
4
|
/** Catalogues by language: `{ en: { greeting: 'Hello {name}' }, fr: … }`. */
|
|
5
5
|
export type Catalogues = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
@@ -45,30 +45,38 @@ export interface I18nContext<Key extends string> {
|
|
|
45
45
|
readonly t: Translate<Key>;
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* What `createI18n()` makes: a middleware that gives `language`, one of
|
|
49
|
+
* `Language`, and `t`, typed by `Key`, and requires `Requires` of the app —
|
|
50
|
+
* what `resolve` reads — with a `t` and a `language` of its own for the
|
|
51
|
+
* request running.
|
|
52
|
+
*/
|
|
53
|
+
export type I18nMiddleware<Language extends string, Key extends string, Requires extends object = Empty> = Middleware<Requires, Promise<Next<LanguageContext<Language> & I18nContext<Key>>>> & MiddlewareMark & {
|
|
54
|
+
/** Translates into the current request's language, or the fallback outside one. */
|
|
55
|
+
t: Translate<Key>;
|
|
56
|
+
/** The current request's language, or the fallback outside one. */
|
|
57
|
+
language: () => Language;
|
|
58
|
+
supported: Language[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Translations, as a middleware, on [`@nxgt/i18n`](https://www.npmjs.com/package/@nxgt/i18n):
|
|
49
62
|
* `@alxia/language` reads the request's language among the catalogues', and
|
|
50
63
|
* the routes declared after it read `language` and `t`, bound to it. Keys
|
|
51
64
|
* are typed by the fallback's catalogue; messages are ICU — plurals,
|
|
52
65
|
* selects, numbers — and a missing key answers itself.
|
|
53
66
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
67
|
+
* Its own `t()` translates anywhere a request runs after it — a service,
|
|
68
|
+
* the answer to an error, an `onError` hook's included — in that
|
|
69
|
+
* request's language, and in the fallback outside.
|
|
56
70
|
*
|
|
57
71
|
* ```ts
|
|
58
72
|
* const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
59
73
|
* app.use(i18n).get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
60
74
|
* ```
|
|
61
75
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
76
|
+
* It registers the request's language as one of `@nxgt/i18n`'s language
|
|
77
|
+
* sources, so its own `getLanguage()` and `translate` — and every nxgt
|
|
78
|
+
* package that translates through them — speak it too.
|
|
65
79
|
*/
|
|
66
|
-
export declare function createI18n<const C extends Catalogues, const Fallback extends keyof C & string, Ctx extends object = BaseContext>(options: I18nOptions<C, Fallback, Ctx>):
|
|
67
|
-
/** Translates into the current request's language, or the fallback outside one. */
|
|
68
|
-
t: Translate<KeyOf<C[Fallback]>>;
|
|
69
|
-
/** The current request's language, or the fallback outside one. */
|
|
70
|
-
language: () => keyof C & string;
|
|
71
|
-
supported: (keyof C & string)[];
|
|
72
|
-
};
|
|
80
|
+
export declare function createI18n<const C extends Catalogues, const Fallback extends keyof C & string, Ctx extends object = BaseContext>(options: I18nOptions<C, Fallback, Ctx>): I18nMiddleware<keyof C & string, KeyOf<C[Fallback]>, RequiresOf<Ctx, 'resolve'>>;
|
|
73
81
|
export {};
|
|
74
82
|
//# sourceMappingURL=i18n.d.ts.map
|
package/dist/i18n.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { type Catalogues, createI18n, type I18nContext, type I18nOptions, type KeyOf, type Translate, } from './i18n';
|
|
1
|
+
export { type Catalogues, createI18n, type I18nContext, type I18nMiddleware, type I18nOptions, type KeyOf, type Translate, } from './i18n';
|
|
2
2
|
//# 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,EACN,KAAK,UAAU,EACf,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,KAAK,EACV,KAAK,SAAS,GACd,MAAM,QAAQ,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,UAAU,EACf,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,KAAK,EACV,KAAK,SAAS,GACd,MAAM,QAAQ,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// src/i18n.ts
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
defineMiddleware,
|
|
5
|
+
settle
|
|
6
|
+
} from "@alxia/core";
|
|
7
|
+
import {
|
|
8
|
+
language
|
|
9
|
+
} from "@alxia/language";
|
|
5
10
|
import {
|
|
6
11
|
createTranslator,
|
|
7
12
|
registerLanguageSource
|
|
8
13
|
} from "@nxgt/i18n";
|
|
9
14
|
var requests = new AsyncLocalStorage;
|
|
15
|
+
function hearing(url, work) {
|
|
16
|
+
return requests.getStore()?.url === url ? work() : requests.run({ url }, work);
|
|
17
|
+
}
|
|
10
18
|
var requestLanguage = () => requests.getStore()?.language;
|
|
11
19
|
function createI18n(options) {
|
|
12
20
|
const { resources, fallback, resolve, ...detect } = options;
|
|
@@ -22,16 +30,21 @@ function createI18n(options) {
|
|
|
22
30
|
fallback,
|
|
23
31
|
...resolve === undefined ? {} : { resolve }
|
|
24
32
|
});
|
|
25
|
-
const
|
|
26
|
-
const own =
|
|
27
|
-
|
|
28
|
-
own.language =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const middleware = defineMiddleware()((ctx, next) => {
|
|
34
|
+
const own = {};
|
|
35
|
+
const found = (heard) => {
|
|
36
|
+
own.language = heard.language;
|
|
37
|
+
const store = requests.getStore();
|
|
38
|
+
if (store !== undefined)
|
|
39
|
+
store.language ??= heard.language;
|
|
40
|
+
return settle(ctx, next({ ...heard, t: translate(heard.language) }));
|
|
41
|
+
};
|
|
42
|
+
const detecting = Object.assign(found, {
|
|
43
|
+
behind: next.behind
|
|
44
|
+
});
|
|
45
|
+
return current.run(own, () => hearing(ctx.url, () => detected(ctx, detecting)));
|
|
46
|
+
});
|
|
47
|
+
return Object.assign(middleware, {
|
|
35
48
|
t: (key, context) => translate(spoken())(key, context),
|
|
36
49
|
language: spoken,
|
|
37
50
|
supported
|
|
@@ -41,5 +54,5 @@ export {
|
|
|
41
54
|
createI18n
|
|
42
55
|
};
|
|
43
56
|
|
|
44
|
-
//# debugId=
|
|
57
|
+
//# debugId=FF4A4D170CFCA32564756E2164756E21
|
|
45
58
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/i18n.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import { AsyncLocalStorage } from 'node:async_hooks';\nimport {
|
|
5
|
+
"import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\ttype NextFunction,\n\ttype RequiresOf,\n\tsettle,\n} from '@alxia/core';\nimport {\n\ttype LanguageContext,\n\ttype LanguageOptions,\n\tlanguage,\n} from '@alxia/language';\nimport {\n\tcreateTranslator,\n\tregisterLanguageSource,\n\ttype TranslationContext,\n} from '@nxgt/i18n';\n\n/** Catalogues by language: `{ en: { greeting: 'Hello {name}' }, fr: … }`. */\nexport type Catalogues = Readonly<\n\tRecord<string, Readonly<Record<string, unknown>>>\n>;\n\n/**\n * Every key of a catalogue, dotted: `users.greeting`. The keys of\n * `@nxgt/i18n`'s `Path`, nine levels deep: a section nested deeper gives\n * `${section}.${string}`, any key under it.\n *\n * `Path` recurses without a bound. A catalogue that is a type parameter —\n * a function generic over the catalogues it hands to `createI18n` — leaves\n * it deferred, and some checks then unfold it forever (TS2589, \"Type\n * instantiation is excessively deep and possibly infinite\"). The depth\n * stops them.\n */\nexport type KeyOf<Catalogue> = KeysOf<Catalogue, 8> & string;\n\n/** One level shallower: `Shallower[8]` is `7`, and `Shallower[0]` is `never`. */\ntype Shallower = [never, 0, 1, 2, 3, 4, 5, 6, 7];\n\n/**\n * The dotted keys of `T`, `Depth` more levels down at most. `Path`'s own\n * test, `Record<string, any>`, kept so that the keys stay `Path`'s.\n */\ntype KeysOf<\n\tT,\n\tDepth extends number,\n\tK extends keyof T = keyof T,\n> = K extends string\n\t? T[K] extends Record<string, any>\n\t\t? [Shallower[Depth]] extends [never]\n\t\t\t? `${K}.${string}`\n\t\t\t: `${K}.${KeysOf<T[K], Shallower[Depth]>}`\n\t\t: K\n\t: never;\n\n/** A translation of a key, in a language, formatted with ICU's `context`. */\nexport type Translate<Key extends string> = (\n\tkey: Key,\n\tcontext?: TranslationContext,\n) => string;\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: what the plugin then requires of the app.\n */\nexport interface I18nOptions<\n\tC extends Catalogues,\n\tFallback extends keyof C & string,\n\tCtx extends object = BaseContext,\n> extends Omit<\n\t\tLanguageOptions<keyof C & string, Ctx>,\n\t\t'supported' | 'fallback'\n\t> {\n\t/**\n\t * The catalogues, one per language: their keys are the languages\n\t * supported. Spread `@nxgt/i18n`'s `resources` into them for its shared\n\t * keys — `errors.not-found`, `zod.*`.\n\t */\n\treadonly resources: C;\n\t/** The language spoken when the request names none, and whose keys type `t`. */\n\treadonly fallback: Fallback;\n}\n\n/**\n * The request's language as `@nxgt/i18n` hears it: the first an i18n\n * middleware read, when an app uses several. Opened for each request —\n * one made from inside another included — by the first that runs on it.\n */\nconst requests = new AsyncLocalStorage<{ url: URL; language?: string }>();\n\n/** Runs `work` with the request `url`'s store open: the one in force, or a fresh one. */\nfunction hearing<T>(url: URL, work: () => T): T {\n\treturn requests.getStore()?.url === url\n\t\t? work()\n\t\t: requests.run({ url }, work);\n}\n\n/** `@nxgt/i18n`'s source: one function, so registering it again keeps one. */\nconst requestLanguage = () => requests.getStore()?.language;\n\n/** What the routes behind the plugin read. */\nexport interface I18nContext<Key extends string> {\n\t/** Translates into the request's language. */\n\treadonly t: Translate<Key>;\n}\n\n/**\n * What `createI18n()` makes: a middleware that gives `language`, one of\n * `Language`, and `t`, typed by `Key`, and requires `Requires` of the app —\n * what `resolve` reads — with a `t` and a `language` of its own for the\n * request running.\n */\nexport type I18nMiddleware<\n\tLanguage extends string,\n\tKey extends string,\n\tRequires extends object = Empty,\n> = Middleware<\n\tRequires,\n\tPromise<Next<LanguageContext<Language> & I18nContext<Key>>>\n> &\n\tMiddlewareMark & {\n\t\t/** Translates into the current request's language, or the fallback outside one. */\n\t\tt: Translate<Key>;\n\t\t/** The current request's language, or the fallback outside one. */\n\t\tlanguage: () => Language;\n\t\tsupported: Language[];\n\t};\n\n/**\n * Translations, as a middleware, on [`@nxgt/i18n`](https://www.npmjs.com/package/@nxgt/i18n):\n * `@alxia/language` reads the request's language among the catalogues', and\n * the routes declared after it read `language` and `t`, bound to it. Keys\n * are typed by the fallback's catalogue; messages are ICU — plurals,\n * selects, numbers — and a missing key answers itself.\n *\n * Its own `t()` translates anywhere a request runs after it — a service,\n * the answer to an error, an `onError` hook's included — in that\n * request's language, and in the fallback outside.\n *\n * ```ts\n * const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });\n * app.use(i18n).get('/', ({ t, reply }) => reply(200, t('home.title')));\n * ```\n *\n * It registers the request's language as one of `@nxgt/i18n`'s language\n * sources, so its own `getLanguage()` and `translate` — and every nxgt\n * package that translates through them — speak it too.\n */\nexport function createI18n<\n\tconst C extends Catalogues,\n\tconst Fallback extends keyof C & string,\n\tCtx extends object = BaseContext,\n>(\n\toptions: I18nOptions<C, Fallback, Ctx>,\n): I18nMiddleware<\n\tkeyof C & string,\n\tKeyOf<C[Fallback]>,\n\tRequiresOf<Ctx, 'resolve'>\n> {\n\ttype Language = keyof C & string;\n\ttype Key = KeyOf<C[Fallback]>;\n\tconst { resources, fallback, resolve, ...detect } = options;\n\tconst supported = Object.keys(resources) as Language[];\n\tconst translator = createTranslator<Key>(\n\t\tresources as Record<string, unknown>,\n\t);\n\t/**\n\t * This middleware's language for the request running, once\n\t * `@alxia/language` has read it. Opened fresh for each request — one\n\t * made from inside another included — around everything after it, the\n\t * answer to an error included: `settle` answers it inside.\n\t */\n\tconst current = new AsyncLocalStorage<{ language?: Language }>();\n\tconst spoken = (): Language => current.getStore()?.language ?? fallback;\n\tconst translate =\n\t\t(lang: Language): Translate<Key> =>\n\t\t(key, context) =>\n\t\t\ttranslator(key, context, lang as never);\n\n\t// nxgt's own getLanguage() and translate speak the request's language too.\n\tregisterLanguageSource(requestLanguage);\n\n\t// The plugin requires what `resolve` reads, and `app.plugin` checks the app gives\n\t// it; the `language()` inside is then handed `resolve` as reading only\n\t// `BaseContext`, since a context that is a type parameter defers the check.\n\tconst detected = language<Language>({\n\t\t...detect,\n\t\tsupported,\n\t\tfallback,\n\t\t...(resolve === undefined\n\t\t\t? {}\n\t\t\t: { resolve: resolve as (ctx: BaseContext) => string | undefined }),\n\t});\n\t// What the routes after it read, once `@alxia/language` has read it.\n\ttype Added = LanguageContext<Language> & I18nContext<Key>;\n\tconst middleware = defineMiddleware<RequiresOf<Ctx, 'resolve'>>()(\n\t\t(ctx, next): Promise<Next<Added>> => {\n\t\t\tconst own: { language?: Language } = {};\n\t\t\tconst found = (heard: LanguageContext<Language>) => {\n\t\t\t\town.language = heard.language;\n\t\t\t\tconst store = requests.getStore();\n\t\t\t\tif (store !== undefined) store.language ??= heard.language;\n\t\t\t\treturn settle(ctx, next({ ...heard, t: translate(heard.language) }));\n\t\t\t};\n\t\t\t// `language()`'s middleware, run inline: its `next` is this one's.\n\t\t\tconst detecting = Object.assign(found, {\n\t\t\t\tbehind: next.behind,\n\t\t\t}) as unknown as NextFunction;\n\t\t\treturn current.run(own, () =>\n\t\t\t\thearing(ctx.url, () => detected(ctx as never, detecting)),\n\t\t\t) as Promise<Next<Added>>;\n\t\t},\n\t);\n\n\treturn Object.assign(middleware, {\n\t\tt: ((key, context) => translate(spoken())(key, context)) as Translate<Key>,\n\t\tlanguage: spoken,\n\t\tsupported,\n\t});\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": ";AAAA;AACA;
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": ";AAAA;AACA;AAAA;AAAA;AAAA;AAWA;AAAA;AAAA;AAKA;AAAA;AAAA;AAAA;AA6EA,IAAM,WAAW,IAAI;AAGrB,SAAS,OAAU,CAAC,KAAU,MAAkB;AAAA,EAC/C,OAAO,SAAS,SAAS,GAAG,QAAQ,MACjC,KAAK,IACL,SAAS,IAAI,EAAE,IAAI,GAAG,IAAI;AAAA;AAI9B,IAAM,kBAAkB,MAAM,SAAS,SAAS,GAAG;AAkD5C,SAAS,UAIf,CACA,SAKC;AAAA,EAGD,QAAQ,WAAW,UAAU,YAAY,WAAW;AAAA,EACpD,MAAM,YAAY,OAAO,KAAK,SAAS;AAAA,EACvC,MAAM,aAAa,iBAClB,SACD;AAAA,EAOA,MAAM,UAAU,IAAI;AAAA,EACpB,MAAM,SAAS,MAAgB,QAAQ,SAAS,GAAG,YAAY;AAAA,EAC/D,MAAM,YACL,CAAC,SACD,CAAC,KAAK,YACL,WAAW,KAAK,SAAS,IAAa;AAAA,EAGxC,uBAAuB,eAAe;AAAA,EAKtC,MAAM,WAAW,SAAmB;AAAA,OAChC;AAAA,IACH;AAAA,IACA;AAAA,OACI,YAAY,YACb,CAAC,IACD,EAAE,QAA6D;AAAA,EACnE,CAAC;AAAA,EAGD,MAAM,aAAa,iBAA6C,EAC/D,CAAC,KAAK,SAA+B;AAAA,IACpC,MAAM,MAA+B,CAAC;AAAA,IACtC,MAAM,QAAQ,CAAC,UAAqC;AAAA,MACnD,IAAI,WAAW,MAAM;AAAA,MACrB,MAAM,QAAQ,SAAS,SAAS;AAAA,MAChC,IAAI,UAAU;AAAA,QAAW,MAAM,aAAa,MAAM;AAAA,MAClD,OAAO,OAAO,KAAK,KAAK,KAAK,OAAO,GAAG,UAAU,MAAM,QAAQ,EAAE,CAAC,CAAC;AAAA;AAAA,IAGpE,MAAM,YAAY,OAAO,OAAO,OAAO;AAAA,MACtC,QAAQ,KAAK;AAAA,IACd,CAAC;AAAA,IACD,OAAO,QAAQ,IAAI,KAAK,MACvB,QAAQ,IAAI,KAAK,MAAM,SAAS,KAAc,SAAS,CAAC,CACzD;AAAA,GAEF;AAAA,EAEA,OAAO,OAAO,OAAO,YAAY;AAAA,IAChC,GAAI,CAAC,KAAK,YAAY,UAAU,OAAO,CAAC,EAAE,KAAK,OAAO;AAAA,IACtD,UAAU;AAAA,IACV;AAAA,EACD,CAAC;AAAA;",
|
|
8
|
+
"debugId": "FF4A4D170CFCA32564756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/docs/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
The [package README](../README.md) is the short version. This folder is
|
|
4
4
|
the long one: the catalogues and every option, what the routes behind the
|
|
5
|
-
|
|
5
|
+
middleware read, how messages are formatted and keys typed, where the
|
|
6
6
|
request's language reaches and where it does not, and what to do when a
|
|
7
7
|
response shows a key or the wrong language.
|
|
8
8
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| [Guide](guide.md) | writing the catalogues, choosing where the language is read from, reading a user's saved language from an earlier
|
|
11
|
+
| [Guide](guide.md) | writing the catalogues, choosing where the language is read from, reading a user's saved language from an earlier middleware, typing a function that translates, translating in a service or an error-handling middleware, caching the responses, or testing them |
|
|
12
12
|
| [Troubleshooting](troubleshooting.md) | `createI18n()` threw at start-up, `tsc` refused a key, an option or `use(i18n)`, a function generic over its catalogues hit TS2589, the log shows an ICU error, or a response shows a key or the wrong language |
|
|
13
13
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
CHANGED
|
@@ -36,8 +36,8 @@ function createI18n<
|
|
|
36
36
|
Ctx extends object = BaseContext,
|
|
37
37
|
>(
|
|
38
38
|
options: I18nOptions<C, Fallback, Ctx>,
|
|
39
|
-
):
|
|
40
|
-
|
|
39
|
+
): Middleware<RequiresOf<Ctx, 'resolve'>, Promise<Next<LanguageContext<keyof C & string> & I18nContext<KeyOf<C[Fallback]>>>>> &
|
|
40
|
+
MiddlewareMark & {
|
|
41
41
|
t: Translate<KeyOf<C[Fallback]>>;
|
|
42
42
|
language: () => keyof C & string;
|
|
43
43
|
supported: (keyof C & string)[];
|
|
@@ -65,9 +65,11 @@ when `resolve` is absent or not annotated. See
|
|
|
65
65
|
|
|
66
66
|
`createI18n()` returns two things in one value:
|
|
67
67
|
|
|
68
|
-
- **
|
|
69
|
-
**after** it, in the same app or
|
|
70
|
-
and
|
|
68
|
+
- **a middleware**: pass it to `app.use`. It runs on every request the app
|
|
69
|
+
takes, and applies to what is declared **after** it, in the same app or
|
|
70
|
+
[group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md):
|
|
71
|
+
the middlewares and the routes. It adds `t`, `language` and `languageSource`
|
|
72
|
+
to their context;
|
|
71
73
|
- **`t()`, `language()` and `supported`**, to call where no context is at
|
|
72
74
|
hand: a service, a model, a job. See [Outside a route](#outside-a-route).
|
|
73
75
|
|
|
@@ -86,13 +88,13 @@ does not grow `@nxgt/i18n`'s list; see [`@nxgt/i18n`'s own `translate`](#nxgti18
|
|
|
86
88
|
| `cookie` | `string` | `'language'` | the cookie read, and written by `persist` |
|
|
87
89
|
| `pathIndex` | `number` | `0` | the path segment the `path` source reads |
|
|
88
90
|
| `persist` | `boolean \| { maxAge?, secure? }` | `false` | a language the query named is kept in the cookie |
|
|
89
|
-
| `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the
|
|
90
|
-
| `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; annotate `ctx` to read what an earlier
|
|
91
|
+
| `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the middleware runs for |
|
|
92
|
+
| `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; annotate `ctx` to read what an earlier middleware adds |
|
|
91
93
|
|
|
92
94
|
Every option but `resources` and `fallback` is `@alxia/language`'s, passed
|
|
93
95
|
through as it is; its [guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md)
|
|
94
96
|
details each one, how `Accept-Language` is negotiated, and the `Vary` the
|
|
95
|
-
|
|
97
|
+
middleware adds. `supported` is not an option here: it is `resources`' keys.
|
|
96
98
|
|
|
97
99
|
### `resources`
|
|
98
100
|
|
|
@@ -182,15 +184,15 @@ export const app = alxia()
|
|
|
182
184
|
.get('/:lang/home', ({ t, reply }) => reply(200, t('home.title'))); // /fr/home → 'Bienvenue'
|
|
183
185
|
```
|
|
184
186
|
|
|
185
|
-
The
|
|
187
|
+
The middleware reads the segment; it does not route on it, so the routes
|
|
186
188
|
declare it.
|
|
187
189
|
|
|
188
190
|
### Reading the app's context
|
|
189
191
|
|
|
190
192
|
To speak the language a signed-in user saved, annotate `resolve`'s
|
|
191
|
-
parameter with what an earlier
|
|
193
|
+
parameter with what an earlier middleware added. `createI18n()` infers it from
|
|
192
194
|
the annotation — the languages and the keys are still inferred from
|
|
193
|
-
`resources` and `fallback` — and the
|
|
195
|
+
`resources` and `fallback` — and the middleware then requires it: an app that
|
|
194
196
|
does not give `user` before it cannot use it.
|
|
195
197
|
|
|
196
198
|
```ts
|
|
@@ -209,18 +211,18 @@ const i18n = createI18n({
|
|
|
209
211
|
});
|
|
210
212
|
|
|
211
213
|
export const app = alxia()
|
|
212
|
-
.
|
|
214
|
+
.plugin(auth) // an app: derives user: User | null
|
|
213
215
|
.use(i18n)
|
|
214
216
|
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
215
217
|
|
|
216
218
|
alxia().use(i18n);
|
|
217
|
-
// error:
|
|
219
|
+
// error: Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'
|
|
218
220
|
```
|
|
219
221
|
|
|
220
222
|
This is `@alxia/language`'s check, carried through; its
|
|
221
223
|
[guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md#reading-the-apps-context)
|
|
222
224
|
details it. A `resolve` left unannotated reads `BaseContext` only, and the
|
|
223
|
-
|
|
225
|
+
middleware requires nothing. Annotated `any`, it is refused on every
|
|
224
226
|
app:
|
|
225
227
|
[`the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`](troubleshooting.md#the-plugins-resolve-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
|
|
226
228
|
|
|
@@ -249,7 +251,11 @@ export const app = alxia()
|
|
|
249
251
|
// GET /?lang=fr → {"title":"Bienvenue","language":"fr","languageSource":"query"}
|
|
250
252
|
```
|
|
251
253
|
|
|
252
|
-
|
|
254
|
+
They are in the context of every route, and of every `derive`, declared
|
|
255
|
+
after `.use(i18n)`. A standalone middleware (`defineMiddleware`) does not
|
|
256
|
+
know the app it is used on: it calls `i18n.t()`, which follows the request
|
|
257
|
+
([Outside a route](#outside-a-route)). A route declared before `.use(i18n)`
|
|
258
|
+
reads none of them:
|
|
253
259
|
|
|
254
260
|
```text
|
|
255
261
|
error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
@@ -414,25 +420,28 @@ export const app = alxia()
|
|
|
414
420
|
i18n.language(); // 'en': no request here
|
|
415
421
|
```
|
|
416
422
|
|
|
417
|
-
The
|
|
418
|
-
|
|
419
|
-
`
|
|
423
|
+
The middleware reads the request's language, then runs the rest of the
|
|
424
|
+
chain inside it: everything after it knows the language, through every
|
|
425
|
+
`await`, and so does the answer to an error, since `i18n` settles `next()`.
|
|
426
|
+
Before it, `i18n.t()` and `i18n.language()` answer in the fallback:
|
|
420
427
|
|
|
421
428
|
| Where | `i18n.t()` answers in |
|
|
422
429
|
| --- | --- |
|
|
423
|
-
| a
|
|
424
|
-
|
|
|
425
|
-
|
|
|
426
|
-
|
|
|
427
|
-
|
|
|
428
|
-
| a route
|
|
430
|
+
| a middleware, `derive` or route declared after `.use(i18n)`, before and after its `next()`, and what they call | the request's language |
|
|
431
|
+
| a middleware after it that catches an error | the request's language |
|
|
432
|
+
| a middleware after it, on a `404` or a `405` no route matched | the request's language |
|
|
433
|
+
| the deprecated `onError` hook, for what the chain after `use(i18n)` threw | the request's language |
|
|
434
|
+
| a middleware declared before `.use(i18n)`, in and out | the fallback: the language is not read yet |
|
|
435
|
+
| a route declared before `.use(i18n)` | the fallback: the middleware does not run for it |
|
|
436
|
+
| the deprecated `onRequest` and `onResponse` hooks, which run outside the chain | the fallback |
|
|
429
437
|
| code outside any request: start-up, a timer, a queue consumer | the fallback: pass the language, see below |
|
|
430
438
|
|
|
431
|
-
An
|
|
432
|
-
`
|
|
439
|
+
An error-handling middleware translates an error's message with `i18n.t()`.
|
|
440
|
+
Declare it after `.use(i18n)` (and after the observers, `logger()` and
|
|
441
|
+
`telemetry()`):
|
|
433
442
|
|
|
434
443
|
```ts
|
|
435
|
-
import { alxia, HttpError } from '@alxia/core';
|
|
444
|
+
import { alxia, defineMiddleware, HttpError } from '@alxia/core';
|
|
436
445
|
import { createI18n } from '@alxia/i18n';
|
|
437
446
|
import { resources as shared } from '@nxgt/i18n';
|
|
438
447
|
|
|
@@ -440,10 +449,16 @@ const i18n = createI18n({ resources: { en: shared.en, fr: shared.fr }, fallback:
|
|
|
440
449
|
|
|
441
450
|
export const app = alxia()
|
|
442
451
|
.use(i18n)
|
|
443
|
-
.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
452
|
+
.use(
|
|
453
|
+
defineMiddleware(async ({ reply }, next) => {
|
|
454
|
+
try {
|
|
455
|
+
return await next();
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof HttpError && error.status === 404)
|
|
458
|
+
return reply(404, { error: i18n.t('errors.not-found') });
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}),
|
|
447
462
|
)
|
|
448
463
|
.get('/users/:id', () => {
|
|
449
464
|
throw new HttpError(404, {});
|
|
@@ -500,14 +515,14 @@ Its limits are `@nxgt/i18n`'s:
|
|
|
500
515
|
- **Its fallback is `'en'`, not yours.** Outside a request, or before the
|
|
501
516
|
language is read, `getLanguage()` answers `'en'` even when your
|
|
502
517
|
`fallback` is `'fr'`.
|
|
503
|
-
- **It hears one
|
|
518
|
+
- **It hears one middleware.** With two `createI18n()` on one app, it follows
|
|
504
519
|
the language the first one read — its fallback included, when the
|
|
505
520
|
request named a language only the second supports.
|
|
506
521
|
|
|
507
522
|
## Caching
|
|
508
523
|
|
|
509
|
-
A response in the request's language varies by what decided it. The
|
|
510
|
-
says so in `Vary` — `Accept-Language, Cookie` with the default `order` —
|
|
524
|
+
A response in the request's language varies by what decided it. The
|
|
525
|
+
middleware says so in `Vary` — `Accept-Language, Cookie` with the default `order` —
|
|
511
526
|
and a cache in front of it needs the same headers:
|
|
512
527
|
|
|
513
528
|
```ts
|
|
@@ -576,7 +591,7 @@ export const i18n = createI18n({
|
|
|
576
591
|
|
|
577
592
|
```ts
|
|
578
593
|
// src/app.ts
|
|
579
|
-
import { alxia, HttpError } from '@alxia/core';
|
|
594
|
+
import { alxia, defineMiddleware, HttpError } from '@alxia/core';
|
|
580
595
|
import { i18n } from './i18n';
|
|
581
596
|
|
|
582
597
|
const carts = new Map<string, string[]>([['c1', ['book', 'pen']]]);
|
|
@@ -586,10 +601,16 @@ const describeCart = (items: readonly string[]) => i18n.t('cart.items', { count:
|
|
|
586
601
|
|
|
587
602
|
export const app = alxia()
|
|
588
603
|
.use(i18n)
|
|
589
|
-
.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
604
|
+
.use(
|
|
605
|
+
defineMiddleware(async ({ reply }, next) => {
|
|
606
|
+
try {
|
|
607
|
+
return await next();
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (error instanceof HttpError && error.status === 404)
|
|
610
|
+
return reply(404, { error: i18n.t('errors.not-found') });
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
}),
|
|
593
614
|
)
|
|
594
615
|
.get('/carts/:id', ({ params, reply }) => {
|
|
595
616
|
const items = carts.get(params.id);
|
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(createI18n({ ... }))` runs on every request, a 404 included; its `t()` works in every middleware after it and in the answer to an error. `app.plugin(i18n)` still works, deprecated.
|
|
11
11
|
|
|
12
12
|
## Next
|
|
13
13
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -13,13 +13,13 @@ nothing — what the response does that you did not expect.
|
|
|
13
13
|
- [`Type '"de"' is not assignable to type '"en" | "fr"'`](#type-de-is-not-assignable-to-type-en--fr)
|
|
14
14
|
- [`Argument of type '"cart.itmes"' is not assignable to parameter of type '"cart.items"'`](#argument-of-type-cartitmes-is-not-assignable-to-parameter-of-type-cartitems)
|
|
15
15
|
- [`Property 't' does not exist on type 'Context<Empty, "/", Empty>'`](#property-t-does-not-exist-on-type-contextempty--empty)
|
|
16
|
-
- [`Type '
|
|
16
|
+
- [`Type 'BaseContext & Empty' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`](#type-basecontext--empty-is-missing-the-following-properties-from-type-i18noptionsreadonlyrecordstring-readonlyrecordstring-unknown-string-basecontext-resources-fallback)
|
|
17
17
|
- [`Object literal may only specify known properties, and 'supported' does not exist in type 'I18nOptions<…>'`](#object-literal-may-only-specify-known-properties-and-supported-does-not-exist-in-type-i18noptions)
|
|
18
18
|
- [`Cannot invoke an object which is possibly 'undefined'`](#cannot-invoke-an-object-which-is-possibly-undefined)
|
|
19
19
|
- [`t()` accepts any key, typos included](#t-accepts-any-key-typos-included)
|
|
20
20
|
- [`Property 'user' does not exist on type 'BaseContext'`](#property-user-does-not-exist-on-type-basecontext)
|
|
21
|
-
- [`
|
|
22
|
-
- [`
|
|
21
|
+
- [`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-)
|
|
22
|
+
- [`Types of property 'user' are incompatible`](#types-of-property-user-are-incompatible)
|
|
23
23
|
- [`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)
|
|
24
24
|
|
|
25
25
|
**Messages**
|
|
@@ -134,12 +134,12 @@ error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
**When:** a route reads `t` — or `language` — but is declared before
|
|
137
|
-
`.use(i18n)`, or in another app or group than the one that
|
|
137
|
+
`.use(i18n)`, or in another app or group than the one that mounts it.
|
|
138
138
|
|
|
139
|
-
**Why:** the
|
|
140
|
-
|
|
139
|
+
**Why:** the middleware applies to what is declared after it, at runtime
|
|
140
|
+
and in the types alike.
|
|
141
141
|
|
|
142
|
-
**Fix:** use
|
|
142
|
+
**Fix:** `use` it first:
|
|
143
143
|
|
|
144
144
|
```ts
|
|
145
145
|
const app = alxia()
|
|
@@ -147,20 +147,23 @@ const app = alxia()
|
|
|
147
147
|
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
### `Type '
|
|
150
|
+
### `Type 'BaseContext & Empty' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`
|
|
151
151
|
|
|
152
152
|
```text
|
|
153
153
|
error TS2769: No overload matches this call.
|
|
154
|
-
|
|
155
|
-
Argument of type '<const C extends Catalogues, const Fallback extends keyof C & string, Ctx extends object = BaseContext>(options: I18nOptions<C, Fallback, Ctx>) =>
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
The last overload gave the following error.
|
|
155
|
+
Argument of type '<const C extends Catalogues, const Fallback extends keyof C & string, Ctx extends object = BaseContext>(options: I18nOptions<C, Fallback, Ctx>) => I18nMiddleware<keyof C & string, KeyOf<C[Fallback]>, RequiresOf<...>>' is not assignable to parameter of type 'ScopeMiddleware<Empty, [], MiddlewareReturn>'.
|
|
156
|
+
…
|
|
157
|
+
Types of parameters 'options' and 'ctx' are incompatible.
|
|
158
|
+
Type 'BaseContext & Empty' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
TypeScript 7 prints the last overload alone, as above; TypeScript 6 lists the deprecated plugin forms of `use` first, then this one as `Overload 3 of 11`.
|
|
162
|
+
|
|
160
163
|
**When:** `app.use(createI18n)`, without calling it.
|
|
161
164
|
|
|
162
|
-
**Why:** `createI18n` makes the
|
|
163
|
-
|
|
165
|
+
**Why:** `createI18n` makes the middleware from its options; it is not the
|
|
166
|
+
middleware.
|
|
164
167
|
|
|
165
168
|
**Fix:** call it once, in a module of its own, and use what it returns:
|
|
166
169
|
|
|
@@ -195,27 +198,36 @@ createI18n({ resources: { en }, fallback: 'en' });
|
|
|
195
198
|
error TS2722: Cannot invoke an object which is possibly 'undefined'.
|
|
196
199
|
```
|
|
197
200
|
|
|
198
|
-
**When:**
|
|
201
|
+
**When:** a deprecated `onError` hook calls `t('…')` from its context. (A
|
|
202
|
+
middleware declared after `.use(i18n)` has no such doubt: `t` is typed there.)
|
|
199
203
|
|
|
200
|
-
**Why:** `onError` also handles what was thrown before the
|
|
201
|
-
by a
|
|
204
|
+
**Why:** `onError` also handles what was thrown before the middleware ran —
|
|
205
|
+
by a middleware declared before it — so what it adds is optional there.
|
|
202
206
|
|
|
203
|
-
**Fix:**
|
|
207
|
+
**Fix:** in an error-handling middleware declared after `.use(i18n)`, catch
|
|
208
|
+
around `next()` and call `t`, or the middleware's own `i18n.t()`, which always
|
|
209
|
+
answers, in the request's language and in the fallback when the error was
|
|
210
|
+
thrown before the language was read:
|
|
204
211
|
|
|
205
212
|
```ts
|
|
213
|
+
import { alxia, defineMiddleware, HttpError } from '@alxia/core';
|
|
214
|
+
|
|
206
215
|
alxia()
|
|
207
216
|
.use(i18n)
|
|
208
|
-
.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
.use(
|
|
218
|
+
defineMiddleware(async ({ t, reply }, next) => {
|
|
219
|
+
try {
|
|
220
|
+
return await next();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error instanceof HttpError && error.status === 404) {
|
|
223
|
+
return reply(404, { error: t('errors.not-found') });
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
212
228
|
);
|
|
213
229
|
```
|
|
214
230
|
|
|
215
|
-
Or call the plugin's own `i18n.t()`, which answers in the request's
|
|
216
|
-
language there too, and in the fallback when the error was thrown before
|
|
217
|
-
the language was read.
|
|
218
|
-
|
|
219
231
|
### `t()` accepts any key, typos included
|
|
220
232
|
|
|
221
233
|
**When:** the catalogues are built at runtime, read with
|
|
@@ -243,17 +255,17 @@ export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
|
243
255
|
error TS2339: Property 'user' does not exist on type 'BaseContext'.
|
|
244
256
|
```
|
|
245
257
|
|
|
246
|
-
**When:** `resolve` reads something a `derive` or a
|
|
247
|
-
i18n
|
|
258
|
+
**When:** `resolve` reads something a `derive` or a middleware before the
|
|
259
|
+
i18n middleware added — a user, a session — and its parameter is not
|
|
248
260
|
annotated: `resolve: (ctx) => ctx.user.language`.
|
|
249
261
|
|
|
250
262
|
**Why:** an unannotated `resolve` is typed with the request's `BaseContext`
|
|
251
|
-
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other
|
|
252
|
-
added. The
|
|
263
|
+
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other middlewares
|
|
264
|
+
added. The middleware is built before it is used, so it cannot see the app it
|
|
253
265
|
will be used on.
|
|
254
266
|
|
|
255
|
-
**Fix:** annotate the parameter with what it reads; the
|
|
256
|
-
requires it of the app, before
|
|
267
|
+
**Fix:** annotate the parameter with what it reads; the middleware then
|
|
268
|
+
requires it of the app, before it:
|
|
257
269
|
|
|
258
270
|
```ts
|
|
259
271
|
const i18n = createI18n({
|
|
@@ -262,53 +274,57 @@ const i18n = createI18n({
|
|
|
262
274
|
resolve: ({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined,
|
|
263
275
|
});
|
|
264
276
|
|
|
265
|
-
alxia().
|
|
277
|
+
alxia().plugin(auth).use(i18n); // auth derives user
|
|
266
278
|
```
|
|
267
279
|
|
|
268
280
|
See [Reading the app's context](guide.md#reading-the-apps-context).
|
|
269
281
|
|
|
270
|
-
### `
|
|
282
|
+
### `Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'`
|
|
271
283
|
|
|
272
284
|
```text
|
|
273
285
|
error TS2769: No overload matches this call.
|
|
274
286
|
…
|
|
275
|
-
|
|
276
|
-
|
|
287
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ user: User | null; }>'.
|
|
288
|
+
Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'.
|
|
277
289
|
```
|
|
278
290
|
|
|
279
291
|
**When:** `resolve` is annotated to read `user` —
|
|
280
|
-
`({ user }: BaseContext & { user: User | null }) => …` — and the
|
|
292
|
+
`({ user }: BaseContext & { user: User | null }) => …` — and the middleware is
|
|
281
293
|
used on an app, or in a group, whose context has no `user` at that point:
|
|
282
|
-
`alxia().use(i18n)`, or `use(i18n)` before `
|
|
294
|
+
`alxia().use(i18n)`, or `use(i18n)` before `plugin(auth)`.
|
|
283
295
|
|
|
284
|
-
**Why:** an annotated `resolve` makes the
|
|
285
|
-
`use` checks the app's context against it, so `resolve` never runs without
|
|
296
|
+
**Why:** an annotated `resolve` makes the middleware require what it reads, and
|
|
297
|
+
`app.use` checks the app's context against it, so `resolve` never runs without
|
|
286
298
|
it. An app whose `user` has another type is refused too, with
|
|
287
|
-
`
|
|
299
|
+
`Types of property 'user' are incompatible`.
|
|
288
300
|
|
|
289
|
-
**Fix:**
|
|
301
|
+
**Fix:** mount what adds `user` first, with the type `resolve`
|
|
290
302
|
reads:
|
|
291
303
|
|
|
292
304
|
```ts
|
|
293
|
-
alxia().
|
|
305
|
+
alxia().plugin(auth).use(i18n);
|
|
294
306
|
```
|
|
295
307
|
|
|
296
308
|
More on this message in
|
|
297
|
-
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#
|
|
309
|
+
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#property-user-is-missing-in-type-basecontext--empty-but-required-in-type--user-user--null-).
|
|
298
310
|
|
|
299
|
-
### `
|
|
311
|
+
### `Types of property 'user' are incompatible`
|
|
300
312
|
|
|
301
313
|
```text
|
|
302
314
|
error TS2769: No overload matches this call.
|
|
303
315
|
…
|
|
304
|
-
Type '{ user: User; }' is not assignable to type '
|
|
316
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type 'MiddlewareContext<{ user: User; }>'.
|
|
317
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type '{ user: User; }'.
|
|
318
|
+
Types of property 'user' are incompatible.
|
|
319
|
+
Type 'User | null' is not assignable to type 'User'.
|
|
320
|
+
Type 'null' is not assignable to type 'User'.
|
|
305
321
|
```
|
|
306
322
|
|
|
307
323
|
**When:** the app gives a `user`, but of a type that does not fit the one
|
|
308
324
|
`resolve`'s parameter is annotated with: a `User | null` where `resolve`
|
|
309
325
|
reads `User`, or a user of another shape.
|
|
310
326
|
|
|
311
|
-
**Why:** `use` checks each key the
|
|
327
|
+
**Why:** `app.use` checks each key the middleware reads against the app's context;
|
|
312
328
|
a narrower type passes, a wider or different one does not.
|
|
313
329
|
|
|
314
330
|
**Fix:** annotate `resolve` with the type the app gives, and handle it
|
|
@@ -319,29 +335,29 @@ resolve: ({ user }: { user: User | null }) => user?.language ?? undefined,
|
|
|
319
335
|
```
|
|
320
336
|
|
|
321
337
|
More on this message in
|
|
322
|
-
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#
|
|
338
|
+
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#types-of-property-user-are-incompatible).
|
|
323
339
|
|
|
324
340
|
### `the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`
|
|
325
341
|
|
|
326
342
|
```text
|
|
327
343
|
error TS2769: No overload matches this call.
|
|
328
344
|
…
|
|
329
|
-
|
|
330
|
-
|
|
345
|
+
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"; }>'.
|
|
346
|
+
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"; }'.
|
|
331
347
|
```
|
|
332
348
|
|
|
333
349
|
**When:** `resolve`'s parameter is annotated `any` —
|
|
334
350
|
`resolve: (ctx: any) => ctx.user.language` — or `Record<string, any>`, and
|
|
335
|
-
the
|
|
351
|
+
the middleware is used, on any app, whatever its context gives.
|
|
336
352
|
|
|
337
353
|
**Why:** an `any` parameter reads any key and says nothing of what it
|
|
338
|
-
reads, so the
|
|
339
|
-
would be accepted, and throw on every request.
|
|
354
|
+
reads, so the middleware would require nothing, and an app without a `user`
|
|
355
|
+
would be accepted, and throw on every request. It is refused
|
|
340
356
|
instead.
|
|
341
357
|
|
|
342
358
|
**Fix:** annotate what `resolve` reads —
|
|
343
359
|
`({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined` —
|
|
344
|
-
and
|
|
360
|
+
and add the plugin or middleware that gives it first; or leave it unannotated when it
|
|
345
361
|
reads only the request.
|
|
346
362
|
|
|
347
363
|
More on this message in
|
|
@@ -409,25 +425,30 @@ export const i18n = createI18n({ resources: { en, de }, fallback: 'en' });
|
|
|
409
425
|
### `i18n.t()` answers in the fallback before the language is read
|
|
410
426
|
|
|
411
427
|
**When:** a request is in French, but `i18n.t()`, `i18n.language()` or
|
|
412
|
-
`@nxgt/i18n`'s `translate` answers in the fallback's language in
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
those threw.
|
|
428
|
+
`@nxgt/i18n`'s `translate` answers in the fallback's language in a
|
|
429
|
+
middleware declared before `.use(i18n)`, in a route declared before it, or
|
|
430
|
+
in the deprecated `onRequest` and `onResponse` hooks.
|
|
416
431
|
|
|
417
|
-
**Why:** the
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
432
|
+
**Why:** the middleware reads the request's language when the request
|
|
433
|
+
reaches it, and holds it for what runs after it, the answer to an error
|
|
434
|
+
included. What is declared before it, and the hooks that run outside the
|
|
435
|
+
chain, run with no language to answer in.
|
|
421
436
|
|
|
422
|
-
**Fix:** translate after the
|
|
423
|
-
translate after `.use(i18n)`, and move what an `onRequest` hook
|
|
424
|
-
into a
|
|
437
|
+
**Fix:** translate after the middleware: declare the middlewares and the
|
|
438
|
+
routes that translate after `.use(i18n)`, and move what an `onRequest` hook
|
|
439
|
+
renders (a deprecated hook) into a middleware declared after it:
|
|
425
440
|
|
|
426
441
|
```ts
|
|
442
|
+
import { alxia, defineMiddleware } from '@alxia/core';
|
|
443
|
+
|
|
427
444
|
alxia()
|
|
428
445
|
.use(i18n)
|
|
429
|
-
.
|
|
430
|
-
|
|
446
|
+
.use(
|
|
447
|
+
defineMiddleware(({ request, reply }, next) =>
|
|
448
|
+
request.headers.has('x-busy')
|
|
449
|
+
? reply(503, { error: i18n.t('errors.service-unavailable') })
|
|
450
|
+
: next(),
|
|
451
|
+
),
|
|
431
452
|
);
|
|
432
453
|
```
|
|
433
454
|
|
|
@@ -480,7 +501,7 @@ translate('errors.not-found', undefined, 'fr');
|
|
|
480
501
|
visitor's language is served to everyone after.
|
|
481
502
|
|
|
482
503
|
**Why:** the response depends on `Accept-Language` and the language
|
|
483
|
-
cookie. The
|
|
504
|
+
cookie. The middleware says so in `Vary`; a cache keyed on the URL alone
|
|
484
505
|
ignores it.
|
|
485
506
|
|
|
486
507
|
**Fix:** give the cache the same headers:
|
|
@@ -491,7 +512,7 @@ alxia()
|
|
|
491
512
|
.use(cache({ ttl: 60, vary: ['accept-language', 'cookie'] }));
|
|
492
513
|
```
|
|
493
514
|
|
|
494
|
-
|
|
495
|
-
|
|
515
|
+
A middleware (or a deprecated `onResponse` hook) that sets `Vary` with `headers.set` replaces the
|
|
516
|
+
middleware's: see `@alxia/language`'s [troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#a-cache-serves-one-language-to-every-visitor),
|
|
496
517
|
which also covers a response in the wrong language — `curl` getting the
|
|
497
518
|
fallback, a `?lang=` that does not stick, a `404` on `/fr/products`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Translations for alxia on @nxgt/i18n: t() bound to the request's language, keys typed by your catalogue, ICU messages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/core": "^0.
|
|
45
|
-
"@alxia/language": "^0.
|
|
44
|
+
"@alxia/core": "^0.4.0",
|
|
45
|
+
"@alxia/language": "^0.2.0",
|
|
46
46
|
"@nxgt/i18n": "^2.0.0",
|
|
47
47
|
"@types/bun": "^1.4.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@alxia/core": "^0.
|
|
51
|
-
"@alxia/language": "^0.
|
|
50
|
+
"@alxia/core": "^0.4.0",
|
|
51
|
+
"@alxia/language": "^0.2.0",
|
|
52
52
|
"@nxgt/i18n": "^2.0.0",
|
|
53
53
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
54
|
}
|