@alxia/i18n 0.1.3 → 0.3.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 +28 -13
- package/dist/index.js.map +3 -3
- package/docs/README.md +2 -2
- package/docs/guide.md +62 -44
- package/docs/roadmap.md +1 -1
- package/docs/troubleshooting.md +73 -89
- 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 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>>>> & {
|
|
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, a try/catch middleware'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,21 @@
|
|
|
1
1
|
// src/i18n.ts
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
defineMiddleware,
|
|
5
|
+
markFactory,
|
|
6
|
+
settle
|
|
7
|
+
} from "@alxia/core";
|
|
8
|
+
import {
|
|
9
|
+
language
|
|
10
|
+
} from "@alxia/language";
|
|
5
11
|
import {
|
|
6
12
|
createTranslator,
|
|
7
13
|
registerLanguageSource
|
|
8
14
|
} from "@nxgt/i18n";
|
|
9
15
|
var requests = new AsyncLocalStorage;
|
|
16
|
+
function hearing(url, work) {
|
|
17
|
+
return requests.getStore()?.url === url ? work() : requests.run({ url }, work);
|
|
18
|
+
}
|
|
10
19
|
var requestLanguage = () => requests.getStore()?.language;
|
|
11
20
|
function createI18n(options) {
|
|
12
21
|
const { resources, fallback, resolve, ...detect } = options;
|
|
@@ -22,24 +31,30 @@ function createI18n(options) {
|
|
|
22
31
|
fallback,
|
|
23
32
|
...resolve === undefined ? {} : { resolve }
|
|
24
33
|
});
|
|
25
|
-
const
|
|
26
|
-
const own =
|
|
27
|
-
|
|
28
|
-
own.language =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const middleware = defineMiddleware()(function i18n(ctx, next) {
|
|
35
|
+
const own = {};
|
|
36
|
+
const found = (heard) => {
|
|
37
|
+
own.language = heard.language;
|
|
38
|
+
const store = requests.getStore();
|
|
39
|
+
if (store !== undefined)
|
|
40
|
+
store.language ??= heard.language;
|
|
41
|
+
return settle(ctx, next({ ...heard, t: translate(heard.language) }));
|
|
42
|
+
};
|
|
43
|
+
const detecting = Object.assign(found, {
|
|
44
|
+
behind: next.behind
|
|
45
|
+
});
|
|
46
|
+
return current.run(own, () => hearing(ctx.url, () => detected(ctx, detecting)));
|
|
47
|
+
});
|
|
48
|
+
return Object.assign(middleware, {
|
|
35
49
|
t: (key, context) => translate(spoken())(key, context),
|
|
36
50
|
language: spoken,
|
|
37
51
|
supported
|
|
38
52
|
});
|
|
39
53
|
}
|
|
54
|
+
markFactory(createI18n);
|
|
40
55
|
export {
|
|
41
56
|
createI18n
|
|
42
57
|
};
|
|
43
58
|
|
|
44
|
-
//# debugId=
|
|
59
|
+
//# debugId=3A3F680B7493293D64756E2164756E21
|
|
45
60
|
//# 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\tmarkFactory,\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\t/** Translates into the current request's language, or the fallback outside one. */\n\tt: Translate<Key>;\n\t/** The current request's language, or the fallback outside one. */\n\tlanguage: () => Language;\n\tsupported: Language[];\n};\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, a try/catch middleware'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\tfunction i18n(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\nmarkFactory(createI18n);\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": ";AAAA;AACA;
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": ";AAAA;AACA;AAAA;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;AAiD5C,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,SAAS,IAAI,CAAC,KAAK,MAA4B;AAAA,IAC9C,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;AAGF,YAAY,UAAU;",
|
|
8
|
+
"debugId": "3A3F680B7493293D64756E2164756E21",
|
|
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,12 +36,11 @@ function createI18n<
|
|
|
36
36
|
Ctx extends object = BaseContext,
|
|
37
37
|
>(
|
|
38
38
|
options: I18nOptions<C, Fallback, Ctx>,
|
|
39
|
-
):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
39
|
+
): Middleware<RequiresOf<Ctx, 'resolve'>, Promise<Next<LanguageContext<keyof C & string> & I18nContext<KeyOf<C[Fallback]>>>>> & {
|
|
40
|
+
t: Translate<KeyOf<C[Fallback]>>;
|
|
41
|
+
language: () => keyof C & string;
|
|
42
|
+
supported: (keyof C & string)[];
|
|
43
|
+
};
|
|
45
44
|
|
|
46
45
|
interface I18nOptions<C extends Catalogues, Fallback extends keyof C & string, Ctx extends object = BaseContext>
|
|
47
46
|
extends Omit<LanguageOptions<keyof C & string, Ctx>, 'supported' | 'fallback'> {
|
|
@@ -65,9 +64,11 @@ when `resolve` is absent or not annotated. See
|
|
|
65
64
|
|
|
66
65
|
`createI18n()` returns two things in one value:
|
|
67
66
|
|
|
68
|
-
- **
|
|
69
|
-
**after** it, in the same app or
|
|
70
|
-
and
|
|
67
|
+
- **a middleware**: pass it to `app.use`. It runs on every request the app
|
|
68
|
+
takes, and applies to what is declared **after** it, in the same app or
|
|
69
|
+
[group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md):
|
|
70
|
+
the middlewares and the routes. It adds `t`, `language` and `languageSource`
|
|
71
|
+
to their context;
|
|
71
72
|
- **`t()`, `language()` and `supported`**, to call where no context is at
|
|
72
73
|
hand: a service, a model, a job. See [Outside a route](#outside-a-route).
|
|
73
74
|
|
|
@@ -86,13 +87,13 @@ does not grow `@nxgt/i18n`'s list; see [`@nxgt/i18n`'s own `translate`](#nxgti18
|
|
|
86
87
|
| `cookie` | `string` | `'language'` | the cookie read, and written by `persist` |
|
|
87
88
|
| `pathIndex` | `number` | `0` | the path segment the `path` source reads |
|
|
88
89
|
| `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
|
|
90
|
+
| `contentLanguage` | `boolean` | `true` | `Content-Language` on every response the middleware runs for |
|
|
91
|
+
| `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; annotate `ctx` to read what an earlier middleware adds |
|
|
91
92
|
|
|
92
93
|
Every option but `resources` and `fallback` is `@alxia/language`'s, passed
|
|
93
94
|
through as it is; its [guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md)
|
|
94
95
|
details each one, how `Accept-Language` is negotiated, and the `Vary` the
|
|
95
|
-
|
|
96
|
+
middleware adds. `supported` is not an option here: it is `resources`' keys.
|
|
96
97
|
|
|
97
98
|
### `resources`
|
|
98
99
|
|
|
@@ -182,15 +183,15 @@ export const app = alxia()
|
|
|
182
183
|
.get('/:lang/home', ({ t, reply }) => reply(200, t('home.title'))); // /fr/home → 'Bienvenue'
|
|
183
184
|
```
|
|
184
185
|
|
|
185
|
-
The
|
|
186
|
+
The middleware reads the segment; it does not route on it, so the routes
|
|
186
187
|
declare it.
|
|
187
188
|
|
|
188
189
|
### Reading the app's context
|
|
189
190
|
|
|
190
191
|
To speak the language a signed-in user saved, annotate `resolve`'s
|
|
191
|
-
parameter with what an earlier
|
|
192
|
+
parameter with what an earlier middleware added. `createI18n()` infers it from
|
|
192
193
|
the annotation — the languages and the keys are still inferred from
|
|
193
|
-
`resources` and `fallback` — and the
|
|
194
|
+
`resources` and `fallback` — and the middleware then requires it: an app that
|
|
194
195
|
does not give `user` before it cannot use it.
|
|
195
196
|
|
|
196
197
|
```ts
|
|
@@ -209,18 +210,18 @@ const i18n = createI18n({
|
|
|
209
210
|
});
|
|
210
211
|
|
|
211
212
|
export const app = alxia()
|
|
212
|
-
.
|
|
213
|
+
.plugin(auth) // an app: derives user: User | null
|
|
213
214
|
.use(i18n)
|
|
214
215
|
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
215
216
|
|
|
216
217
|
alxia().use(i18n);
|
|
217
|
-
// error:
|
|
218
|
+
// error: Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'
|
|
218
219
|
```
|
|
219
220
|
|
|
220
221
|
This is `@alxia/language`'s check, carried through; its
|
|
221
222
|
[guide](https://github.com/softistx/alxia/blob/develop/packages/language/docs/guide.md#reading-the-apps-context)
|
|
222
223
|
details it. A `resolve` left unannotated reads `BaseContext` only, and the
|
|
223
|
-
|
|
224
|
+
middleware requires nothing. Annotated `any`, it is refused on every
|
|
224
225
|
app:
|
|
225
226
|
[`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
227
|
|
|
@@ -249,7 +250,11 @@ export const app = alxia()
|
|
|
249
250
|
// GET /?lang=fr → {"title":"Bienvenue","language":"fr","languageSource":"query"}
|
|
250
251
|
```
|
|
251
252
|
|
|
252
|
-
|
|
253
|
+
They are in the context of every route, and of every `derive`, declared
|
|
254
|
+
after `.use(i18n)`. A standalone middleware (`defineMiddleware`) does not
|
|
255
|
+
know the app it is used on: it calls `i18n.t()`, which follows the request
|
|
256
|
+
([Outside a route](#outside-a-route)). A route declared before `.use(i18n)`
|
|
257
|
+
reads none of them:
|
|
253
258
|
|
|
254
259
|
```text
|
|
255
260
|
error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
@@ -414,25 +419,26 @@ export const app = alxia()
|
|
|
414
419
|
i18n.language(); // 'en': no request here
|
|
415
420
|
```
|
|
416
421
|
|
|
417
|
-
The
|
|
418
|
-
|
|
419
|
-
`
|
|
422
|
+
The middleware reads the request's language, then runs the rest of the
|
|
423
|
+
chain inside it: everything after it knows the language, through every
|
|
424
|
+
`await`, and so does the answer to an error, since `i18n` settles `next()`.
|
|
425
|
+
Before it, `i18n.t()` and `i18n.language()` answer in the fallback:
|
|
420
426
|
|
|
421
427
|
| Where | `i18n.t()` answers in |
|
|
422
428
|
| --- | --- |
|
|
423
|
-
| a
|
|
424
|
-
|
|
|
425
|
-
|
|
|
426
|
-
|
|
|
427
|
-
|
|
|
428
|
-
| a route or route hook declared before `.use(i18n)`, or a `404` no route matched | the fallback: the plugin does not run for it |
|
|
429
|
+
| a middleware, `derive` or route declared after `.use(i18n)`, before and after its `next()`, and what they call | the request's language |
|
|
430
|
+
| a middleware after it that catches an error | the request's language |
|
|
431
|
+
| a middleware after it, on a `404` or a `405` no route matched | the request's language |
|
|
432
|
+
| a middleware declared before `.use(i18n)`, in and out | the fallback: the language is not read yet |
|
|
433
|
+
| a route declared before `.use(i18n)` | the fallback: the middleware does not run for it |
|
|
429
434
|
| code outside any request: start-up, a timer, a queue consumer | the fallback: pass the language, see below |
|
|
430
435
|
|
|
431
|
-
An
|
|
432
|
-
`
|
|
436
|
+
An error-handling middleware translates an error's message with `i18n.t()`.
|
|
437
|
+
Declare it after `.use(i18n)` (and after the observers, `logger()` and
|
|
438
|
+
`telemetry()`):
|
|
433
439
|
|
|
434
440
|
```ts
|
|
435
|
-
import { alxia, HttpError } from '@alxia/core';
|
|
441
|
+
import { alxia, defineMiddleware, HttpError } from '@alxia/core';
|
|
436
442
|
import { createI18n } from '@alxia/i18n';
|
|
437
443
|
import { resources as shared } from '@nxgt/i18n';
|
|
438
444
|
|
|
@@ -440,10 +446,16 @@ const i18n = createI18n({ resources: { en: shared.en, fr: shared.fr }, fallback:
|
|
|
440
446
|
|
|
441
447
|
export const app = alxia()
|
|
442
448
|
.use(i18n)
|
|
443
|
-
.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
449
|
+
.use(
|
|
450
|
+
defineMiddleware(async ({ reply }, next) => {
|
|
451
|
+
try {
|
|
452
|
+
return await next();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error instanceof HttpError && error.status === 404)
|
|
455
|
+
return reply(404, { error: i18n.t('errors.not-found') });
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
}),
|
|
447
459
|
)
|
|
448
460
|
.get('/users/:id', () => {
|
|
449
461
|
throw new HttpError(404, {});
|
|
@@ -500,14 +512,14 @@ Its limits are `@nxgt/i18n`'s:
|
|
|
500
512
|
- **Its fallback is `'en'`, not yours.** Outside a request, or before the
|
|
501
513
|
language is read, `getLanguage()` answers `'en'` even when your
|
|
502
514
|
`fallback` is `'fr'`.
|
|
503
|
-
- **It hears one
|
|
515
|
+
- **It hears one middleware.** With two `createI18n()` on one app, it follows
|
|
504
516
|
the language the first one read — its fallback included, when the
|
|
505
517
|
request named a language only the second supports.
|
|
506
518
|
|
|
507
519
|
## Caching
|
|
508
520
|
|
|
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` —
|
|
521
|
+
A response in the request's language varies by what decided it. The
|
|
522
|
+
middleware says so in `Vary` — `Accept-Language, Cookie` with the default `order` —
|
|
511
523
|
and a cache in front of it needs the same headers:
|
|
512
524
|
|
|
513
525
|
```ts
|
|
@@ -570,13 +582,13 @@ import ownFr from './locales/fr.json';
|
|
|
570
582
|
export const i18n = createI18n({
|
|
571
583
|
resources: { en: { ...shared.en, ...ownEn }, fr: { ...shared.fr, ...ownFr } },
|
|
572
584
|
fallback: 'en',
|
|
573
|
-
persist: { secure:
|
|
585
|
+
persist: { secure: Bun.env.NODE_ENV !== 'development' }, // read at runtime: bun build inlines process.env
|
|
574
586
|
});
|
|
575
587
|
```
|
|
576
588
|
|
|
577
589
|
```ts
|
|
578
590
|
// src/app.ts
|
|
579
|
-
import { alxia, HttpError } from '@alxia/core';
|
|
591
|
+
import { alxia, defineMiddleware, HttpError } from '@alxia/core';
|
|
580
592
|
import { i18n } from './i18n';
|
|
581
593
|
|
|
582
594
|
const carts = new Map<string, string[]>([['c1', ['book', 'pen']]]);
|
|
@@ -586,10 +598,16 @@ const describeCart = (items: readonly string[]) => i18n.t('cart.items', { count:
|
|
|
586
598
|
|
|
587
599
|
export const app = alxia()
|
|
588
600
|
.use(i18n)
|
|
589
|
-
.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
601
|
+
.use(
|
|
602
|
+
defineMiddleware(async ({ reply }, next) => {
|
|
603
|
+
try {
|
|
604
|
+
return await next();
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error instanceof HttpError && error.status === 404)
|
|
607
|
+
return reply(404, { error: i18n.t('errors.not-found') });
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
}),
|
|
593
611
|
)
|
|
594
612
|
.get('/carts/:id', ({ params, reply }) => {
|
|
595
613
|
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)`, the deprecated plugin form, was removed with alxia 0.5: give it to `use`.
|
|
11
11
|
|
|
12
12
|
## Next
|
|
13
13
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -13,13 +13,12 @@ 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 'I18nMiddleware<string, string, Empty>' is not assignable to type '"this looks like a factory given uncalled: call it, as use(cors()) and not use(cors)"'`](#type-i18nmiddlewarestring-string-empty-is-not-assignable-to-type-this-looks-like-a-factory-given-uncalled-call-it-as-usecors-and-not-usecors)
|
|
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
|
-
- [`Cannot invoke an object which is possibly 'undefined'`](#cannot-invoke-an-object-which-is-possibly-undefined)
|
|
19
18
|
- [`t()` accepts any key, typos included](#t-accepts-any-key-typos-included)
|
|
20
19
|
- [`Property 'user' does not exist on type 'BaseContext'`](#property-user-does-not-exist-on-type-basecontext)
|
|
21
|
-
- [`
|
|
22
|
-
- [`
|
|
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)
|
|
23
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)
|
|
24
23
|
|
|
25
24
|
**Messages**
|
|
@@ -134,12 +133,12 @@ error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
|
134
133
|
```
|
|
135
134
|
|
|
136
135
|
**When:** a route reads `t` — or `language` — but is declared before
|
|
137
|
-
`.use(i18n)`, or in another app or group than the one that
|
|
136
|
+
`.use(i18n)`, or in another app or group than the one that mounts it.
|
|
138
137
|
|
|
139
|
-
**Why:** the
|
|
140
|
-
|
|
138
|
+
**Why:** the middleware applies to what is declared after it, at runtime
|
|
139
|
+
and in the types alike.
|
|
141
140
|
|
|
142
|
-
**Fix:** use
|
|
141
|
+
**Fix:** `use` it first:
|
|
143
142
|
|
|
144
143
|
```ts
|
|
145
144
|
const app = alxia()
|
|
@@ -147,20 +146,25 @@ const app = alxia()
|
|
|
147
146
|
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
148
147
|
```
|
|
149
148
|
|
|
150
|
-
### `Type '
|
|
149
|
+
### `Type 'I18nMiddleware<string, string, Empty>' is not assignable to type '"this looks like a factory given uncalled: call it, as use(cors()) and not use(cors)"'`
|
|
151
150
|
|
|
152
151
|
```text
|
|
153
|
-
error
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
Types of parameters 'options' and 'app' are incompatible.
|
|
157
|
-
Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback
|
|
152
|
+
error TS2345: Argument of type '<const C extends Catalogues, const Fallback extends keyof C & string, Ctx extends object = BaseContext>(options: I18nOptions<C, Fallback, Ctx>) => I18nMiddleware<…>' is not assignable to parameter of type '…'.
|
|
153
|
+
…
|
|
154
|
+
Type 'I18nMiddleware<string, string, Empty>' is not assignable to type '"this looks like a factory given uncalled: call it, as use(cors()) and not use(cors)"'.
|
|
158
155
|
```
|
|
159
156
|
|
|
160
|
-
**When:** `app.use(createI18n)`,
|
|
157
|
+
**When:** `app.use(createI18n)`, the factory given uncalled. The message names
|
|
158
|
+
`cors` as its example, whichever factory it is. It also throws where it is
|
|
159
|
+
declared, since alxia 0.5, rather than answering each request with a 500:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
TypeError: use(): argument 1 looks like a factory (createI18n): call it, use(createI18n())
|
|
163
|
+
```
|
|
161
164
|
|
|
162
|
-
**Why:** `createI18n` makes the
|
|
163
|
-
|
|
165
|
+
**Why:** `createI18n` makes the middleware; it is not the middleware. A
|
|
166
|
+
function that returns a function is no middleware, and `createI18n` is marked
|
|
167
|
+
as a factory, so `use`, a route and `plugin` refuse it.
|
|
164
168
|
|
|
165
169
|
**Fix:** call it once, in a module of its own, and use what it returns:
|
|
166
170
|
|
|
@@ -189,33 +193,6 @@ ignored.
|
|
|
189
193
|
createI18n({ resources: { en }, fallback: 'en' });
|
|
190
194
|
```
|
|
191
195
|
|
|
192
|
-
### `Cannot invoke an object which is possibly 'undefined'`
|
|
193
|
-
|
|
194
|
-
```text
|
|
195
|
-
error TS2722: Cannot invoke an object which is possibly 'undefined'.
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
**When:** an `onError` hook calls `t('…')` from its context.
|
|
199
|
-
|
|
200
|
-
**Why:** `onError` also handles what was thrown before the plugin ran —
|
|
201
|
-
by a hook declared before it — so what the plugin adds is optional there.
|
|
202
|
-
|
|
203
|
-
**Fix:** call it optionally, with an answer for when it is missing:
|
|
204
|
-
|
|
205
|
-
```ts
|
|
206
|
-
alxia()
|
|
207
|
-
.use(i18n)
|
|
208
|
-
.onError((error, { t, reply }) =>
|
|
209
|
-
error instanceof HttpError && error.status === 404
|
|
210
|
-
? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
|
|
211
|
-
: undefined,
|
|
212
|
-
);
|
|
213
|
-
```
|
|
214
|
-
|
|
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
196
|
### `t()` accepts any key, typos included
|
|
220
197
|
|
|
221
198
|
**When:** the catalogues are built at runtime, read with
|
|
@@ -243,17 +220,17 @@ export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
|
243
220
|
error TS2339: Property 'user' does not exist on type 'BaseContext'.
|
|
244
221
|
```
|
|
245
222
|
|
|
246
|
-
**When:** `resolve` reads something a `derive` or a
|
|
247
|
-
i18n
|
|
223
|
+
**When:** `resolve` reads something a `derive` or a middleware before the
|
|
224
|
+
i18n middleware added — a user, a session — and its parameter is not
|
|
248
225
|
annotated: `resolve: (ctx) => ctx.user.language`.
|
|
249
226
|
|
|
250
227
|
**Why:** an unannotated `resolve` is typed with the request's `BaseContext`
|
|
251
|
-
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other
|
|
252
|
-
added. The
|
|
228
|
+
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other middlewares
|
|
229
|
+
added. The middleware is built before it is used, so it cannot see the app it
|
|
253
230
|
will be used on.
|
|
254
231
|
|
|
255
|
-
**Fix:** annotate the parameter with what it reads; the
|
|
256
|
-
requires it of the app, before
|
|
232
|
+
**Fix:** annotate the parameter with what it reads; the middleware then
|
|
233
|
+
requires it of the app, before it:
|
|
257
234
|
|
|
258
235
|
```ts
|
|
259
236
|
const i18n = createI18n({
|
|
@@ -262,53 +239,57 @@ const i18n = createI18n({
|
|
|
262
239
|
resolve: ({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined,
|
|
263
240
|
});
|
|
264
241
|
|
|
265
|
-
alxia().
|
|
242
|
+
alxia().plugin(auth).use(i18n); // auth derives user
|
|
266
243
|
```
|
|
267
244
|
|
|
268
245
|
See [Reading the app's context](guide.md#reading-the-apps-context).
|
|
269
246
|
|
|
270
|
-
### `
|
|
247
|
+
### `Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'`
|
|
271
248
|
|
|
272
249
|
```text
|
|
273
250
|
error TS2769: No overload matches this call.
|
|
274
251
|
…
|
|
275
|
-
|
|
276
|
-
|
|
252
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ user: User | null; }>'.
|
|
253
|
+
Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: User | null; }'.
|
|
277
254
|
```
|
|
278
255
|
|
|
279
256
|
**When:** `resolve` is annotated to read `user` —
|
|
280
|
-
`({ user }: BaseContext & { user: User | null }) => …` — and the
|
|
257
|
+
`({ user }: BaseContext & { user: User | null }) => …` — and the middleware is
|
|
281
258
|
used on an app, or in a group, whose context has no `user` at that point:
|
|
282
|
-
`alxia().use(i18n)`, or `use(i18n)` before `
|
|
259
|
+
`alxia().use(i18n)`, or `use(i18n)` before `plugin(auth)`.
|
|
283
260
|
|
|
284
|
-
**Why:** an annotated `resolve` makes the
|
|
285
|
-
`use` checks the app's context against it, so `resolve` never runs without
|
|
261
|
+
**Why:** an annotated `resolve` makes the middleware require what it reads, and
|
|
262
|
+
`app.use` checks the app's context against it, so `resolve` never runs without
|
|
286
263
|
it. An app whose `user` has another type is refused too, with
|
|
287
|
-
`
|
|
264
|
+
`Types of property 'user' are incompatible`.
|
|
288
265
|
|
|
289
|
-
**Fix:**
|
|
266
|
+
**Fix:** mount what adds `user` first, with the type `resolve`
|
|
290
267
|
reads:
|
|
291
268
|
|
|
292
269
|
```ts
|
|
293
|
-
alxia().
|
|
270
|
+
alxia().plugin(auth).use(i18n);
|
|
294
271
|
```
|
|
295
272
|
|
|
296
273
|
More on this message in
|
|
297
|
-
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#
|
|
274
|
+
[`@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
275
|
|
|
299
|
-
### `
|
|
276
|
+
### `Types of property 'user' are incompatible`
|
|
300
277
|
|
|
301
278
|
```text
|
|
302
279
|
error TS2769: No overload matches this call.
|
|
303
280
|
…
|
|
304
|
-
Type '{ user: User; }' is not assignable to type '
|
|
281
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type 'MiddlewareContext<{ user: User; }>'.
|
|
282
|
+
Type 'BaseContext & Empty & { user: User | null; }' is not assignable to type '{ user: User; }'.
|
|
283
|
+
Types of property 'user' are incompatible.
|
|
284
|
+
Type 'User | null' is not assignable to type 'User'.
|
|
285
|
+
Type 'null' is not assignable to type 'User'.
|
|
305
286
|
```
|
|
306
287
|
|
|
307
288
|
**When:** the app gives a `user`, but of a type that does not fit the one
|
|
308
289
|
`resolve`'s parameter is annotated with: a `User | null` where `resolve`
|
|
309
290
|
reads `User`, or a user of another shape.
|
|
310
291
|
|
|
311
|
-
**Why:** `use` checks each key the
|
|
292
|
+
**Why:** `app.use` checks each key the middleware reads against the app's context;
|
|
312
293
|
a narrower type passes, a wider or different one does not.
|
|
313
294
|
|
|
314
295
|
**Fix:** annotate `resolve` with the type the app gives, and handle it
|
|
@@ -319,29 +300,29 @@ resolve: ({ user }: { user: User | null }) => user?.language ?? undefined,
|
|
|
319
300
|
```
|
|
320
301
|
|
|
321
302
|
More on this message in
|
|
322
|
-
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#
|
|
303
|
+
[`@alxia/language`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/language/docs/troubleshooting.md#types-of-property-user-are-incompatible).
|
|
323
304
|
|
|
324
305
|
### `the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`
|
|
325
306
|
|
|
326
307
|
```text
|
|
327
308
|
error TS2769: No overload matches this call.
|
|
328
309
|
…
|
|
329
|
-
|
|
330
|
-
|
|
310
|
+
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"; }>'.
|
|
311
|
+
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
312
|
```
|
|
332
313
|
|
|
333
314
|
**When:** `resolve`'s parameter is annotated `any` —
|
|
334
315
|
`resolve: (ctx: any) => ctx.user.language` — or `Record<string, any>`, and
|
|
335
|
-
the
|
|
316
|
+
the middleware is used, on any app, whatever its context gives.
|
|
336
317
|
|
|
337
318
|
**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.
|
|
319
|
+
reads, so the middleware would require nothing, and an app without a `user`
|
|
320
|
+
would be accepted, and throw on every request. It is refused
|
|
340
321
|
instead.
|
|
341
322
|
|
|
342
323
|
**Fix:** annotate what `resolve` reads —
|
|
343
324
|
`({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined` —
|
|
344
|
-
and
|
|
325
|
+
and add the plugin or middleware that gives it first; or leave it unannotated when it
|
|
345
326
|
reads only the request.
|
|
346
327
|
|
|
347
328
|
More on this message in
|
|
@@ -409,25 +390,28 @@ export const i18n = createI18n({ resources: { en, de }, fallback: 'en' });
|
|
|
409
390
|
### `i18n.t()` answers in the fallback before the language is read
|
|
410
391
|
|
|
411
392
|
**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
|
-
or `derive` declared before it, or the `onError` hook of an error one of
|
|
415
|
-
those threw.
|
|
393
|
+
`@nxgt/i18n`'s `translate` answers in the fallback's language in a
|
|
394
|
+
middleware declared before `.use(i18n)`, or in a route declared before it.
|
|
416
395
|
|
|
417
|
-
**Why:** the
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
`onError` and `onResponse` hooks — every call answers in it.
|
|
396
|
+
**Why:** the middleware reads the request's language when the request
|
|
397
|
+
reaches it, and holds it for what runs after it, the answer to an error
|
|
398
|
+
included. What is declared before it runs with no language to answer in.
|
|
421
399
|
|
|
422
|
-
**Fix:** translate after the
|
|
423
|
-
translate after `.use(i18n)`, and move what
|
|
424
|
-
into a
|
|
400
|
+
**Fix:** translate after the middleware: declare the middlewares and the
|
|
401
|
+
routes that translate after `.use(i18n)`, and move what a middleware before
|
|
402
|
+
it renders into a middleware declared after it:
|
|
425
403
|
|
|
426
404
|
```ts
|
|
405
|
+
import { alxia, defineMiddleware } from '@alxia/core';
|
|
406
|
+
|
|
427
407
|
alxia()
|
|
428
408
|
.use(i18n)
|
|
429
|
-
.
|
|
430
|
-
|
|
409
|
+
.use(
|
|
410
|
+
defineMiddleware(({ request, reply }, next) =>
|
|
411
|
+
request.headers.has('x-busy')
|
|
412
|
+
? reply(503, { error: i18n.t('errors.service-unavailable') })
|
|
413
|
+
: next(),
|
|
414
|
+
),
|
|
431
415
|
);
|
|
432
416
|
```
|
|
433
417
|
|
|
@@ -457,7 +441,7 @@ alxia()
|
|
|
457
441
|
### `getLanguage()` answers `en` although the fallback is `fr`
|
|
458
442
|
|
|
459
443
|
**When:** outside a request — at start-up, in a timer, a queue consumer —
|
|
460
|
-
or in a
|
|
444
|
+
or in a middleware [before the language is read](#i18nt-answers-in-the-fallback-before-the-language-is-read),
|
|
461
445
|
`@nxgt/i18n`'s `getLanguage()` answers `'en'` while `i18n.language()`
|
|
462
446
|
answers `'fr'`.
|
|
463
447
|
|
|
@@ -480,7 +464,7 @@ translate('errors.not-found', undefined, 'fr');
|
|
|
480
464
|
visitor's language is served to everyone after.
|
|
481
465
|
|
|
482
466
|
**Why:** the response depends on `Accept-Language` and the language
|
|
483
|
-
cookie. The
|
|
467
|
+
cookie. The middleware says so in `Vary`; a cache keyed on the URL alone
|
|
484
468
|
ignores it.
|
|
485
469
|
|
|
486
470
|
**Fix:** give the cache the same headers:
|
|
@@ -491,7 +475,7 @@ alxia()
|
|
|
491
475
|
.use(cache({ ttl: 60, vary: ['accept-language', 'cookie'] }));
|
|
492
476
|
```
|
|
493
477
|
|
|
494
|
-
|
|
495
|
-
|
|
478
|
+
A middleware that sets `Vary` with `headers.set` replaces the
|
|
479
|
+
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
480
|
which also covers a response in the wrong language — `curl` getting the
|
|
497
481
|
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.3.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.5.0",
|
|
45
|
+
"@alxia/language": "^0.3.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.5.0",
|
|
51
|
+
"@alxia/language": "^0.3.0",
|
|
52
52
|
"@nxgt/i18n": "^2.0.0",
|
|
53
53
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
54
|
}
|