@alxia/i18n 0.1.3 → 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 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 plugin then requires what it reads: an app that does not give `user`
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().use(auth).use(byUser); // auth derives user
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 plugin requires
69
- nothing; annotated `any`, the plugin is refused on every app.
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`, an `onError` hook included — and in the fallback outside
80
- one, or before the language is read: in an `onRequest` hook, or a route
81
- declared before the plugin
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 plugin — routes after it read `t` and `language` — with `t()`, `language()` and `supported` |
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 plugin added, what the routes read, ICU messages and typed keys, `t()` outside a route, and `@nxgt/i18n`'s own `translate`.
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
- * Translations, as a plugin, on [`@nxgt/i18n`](https://www.npmjs.com/package/@nxgt/i18n):
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
- * The plugin's own `t()` translates anywhere a request runs — a service, an
55
- * error's message — in that request's language, and in the fallback outside.
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
- * The plugin registers the request's language as one of `@nxgt/i18n`'s
63
- * language sources, so its own `getLanguage()` and `translate` — and every
64
- * nxgt package that translates through them — speak it too.
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>): import("@alxia/core").Alxia<RequiresOf<Ctx, "resolve"> & import("@alxia/core").Empty & import("@alxia/language").LanguageContext<keyof C & string> & I18nContext<KeyOf<C[Fallback]>>, import("@alxia/core").Empty & {}, "", never> & import("@alxia/core").Requiring<RequiresOf<Ctx, "resolve">> & {
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
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAgB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,KAAK,eAAe,EAAY,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAGN,KAAK,kBAAkB,EACvB,MAAM,YAAY,CAAC;AAEpB,6EAA6E;AAC7E,MAAM,MAAM,UAAU,GAAG,QAAQ,CAChC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CACjD,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,KAAK,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC;AAE7D,iFAAiF;AACjF,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAEjD;;;GAGG;AACH,KAAK,MAAM,CACV,CAAC,EACD,KAAK,SAAS,MAAM,EACpB,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,CAAC,IACxB,CAAC,SAAS,MAAM,GACjB,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GACjC,GAAG,CAAC,IAAI,MAAM,EAAE,GAChB,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,GACzC,CAAC,GACF,KAAK,CAAC;AAET,6EAA6E;AAC7E,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,IAAI,CAC3C,GAAG,EAAE,GAAG,EACR,OAAO,CAAC,EAAE,kBAAkB,KACxB,MAAM,CAAC;AAEZ;;;;GAIG;AACH,MAAM,WAAW,WAAW,CAC3B,CAAC,SAAS,UAAU,EACpB,QAAQ,SAAS,MAAM,CAAC,GAAG,MAAM,EACjC,GAAG,SAAS,MAAM,GAAG,WAAW,CAC/B,SAAQ,IAAI,CACZ,eAAe,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,GAAG,CAAC,EACtC,WAAW,GAAG,UAAU,CACxB;IACD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC;IACtB,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;CAC5B;AAWD,8CAA8C;AAC9C,MAAM,WAAW,WAAW,CAAC,GAAG,SAAS,MAAM;IAC9C,8CAA8C;IAC9C,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,UAAU,CACzB,KAAK,CAAC,CAAC,SAAS,UAAU,EAC1B,KAAK,CAAC,QAAQ,SAAS,MAAM,CAAC,GAAG,MAAM,EACvC,GAAG,SAAS,MAAM,GAAG,WAAW,EAC/B,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC;IAiDtC,mFAAmF;OACvB,SAAS,oBAAK;IAC1E,mEAAmE;;;EAIpE"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EAET,KAAK,UAAU,EAEf,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,eAAe,EAEpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGN,KAAK,kBAAkB,EACvB,MAAM,YAAY,CAAC;AAEpB,6EAA6E;AAC7E,MAAM,MAAM,UAAU,GAAG,QAAQ,CAChC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CACjD,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,KAAK,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC;AAE7D,iFAAiF;AACjF,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAEjD;;;GAGG;AACH,KAAK,MAAM,CACV,CAAC,EACD,KAAK,SAAS,MAAM,EACpB,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,CAAC,IACxB,CAAC,SAAS,MAAM,GACjB,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GACjC,GAAG,CAAC,IAAI,MAAM,EAAE,GAChB,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,GACzC,CAAC,GACF,KAAK,CAAC;AAET,6EAA6E;AAC7E,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,IAAI,CAC3C,GAAG,EAAE,GAAG,EACR,OAAO,CAAC,EAAE,kBAAkB,KACxB,MAAM,CAAC;AAEZ;;;;GAIG;AACH,MAAM,WAAW,WAAW,CAC3B,CAAC,SAAS,UAAU,EACpB,QAAQ,SAAS,MAAM,CAAC,GAAG,MAAM,EACjC,GAAG,SAAS,MAAM,GAAG,WAAW,CAC/B,SAAQ,IAAI,CACZ,eAAe,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,GAAG,CAAC,EACtC,WAAW,GAAG,UAAU,CACxB;IACD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC;IACtB,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;CAC5B;AAmBD,8CAA8C;AAC9C,MAAM,WAAW,WAAW,CAAC,GAAG,SAAS,MAAM;IAC9C,8CAA8C;IAC9C,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,CACzB,QAAQ,SAAS,MAAM,EACvB,GAAG,SAAS,MAAM,EAClB,QAAQ,SAAS,MAAM,GAAG,KAAK,IAC5B,UAAU,CACb,QAAQ,EACR,OAAO,CAAC,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAC3D,GACA,cAAc,GAAG;IAChB,mFAAmF;IACnF,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;IAClB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,QAAQ,CAAC;IACzB,SAAS,EAAE,QAAQ,EAAE,CAAC;CACtB,CAAC;AAEH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,UAAU,CACzB,KAAK,CAAC,CAAC,SAAS,UAAU,EAC1B,KAAK,CAAC,QAAQ,SAAS,MAAM,CAAC,GAAG,MAAM,EACvC,GAAG,SAAS,MAAM,GAAG,WAAW,EAEhC,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,GACpC,cAAc,CAChB,MAAM,CAAC,GAAG,MAAM,EAChB,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAClB,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAC1B,CA6DA"}
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
@@ -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 { definePlugin } from "@alxia/core";
4
- import { language } from "@alxia/language";
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 plugin = definePlugin()((app) => app.around((_ctx, next) => current.run({}, () => requests.run({}, next))).use(detected).derive(({ language: lang }) => {
26
- const own = current.getStore();
27
- if (own !== undefined)
28
- own.language = lang;
29
- const heard = requests.getStore();
30
- if (heard !== undefined)
31
- heard.language ??= lang;
32
- return { t: translate(lang) };
33
- }));
34
- return Object.assign(plugin, {
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=700D328B5FADEAA064756E2164756E21
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 { type BaseContext, definePlugin, type RequiresOf } from '@alxia/core';\nimport { type LanguageOptions, language } 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 plugin\n * read, when an app uses several. Opened fresh by each plugin's `around`.\n */\nconst requests = new AsyncLocalStorage<{ language?: string }>();\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 * Translations, as a plugin, 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 * The plugin's own `t()` translates anywhere a request runs — a service, an\n * error's message — in that 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 * The plugin registers the request's language as one of `@nxgt/i18n`'s\n * language sources, so its own `getLanguage()` and `translate` — and every\n * nxgt 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>(options: I18nOptions<C, Fallback, Ctx>) {\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 plugin's language for the request running, once `@alxia/language`\n\t * has read it. A global `around` hook opens it fresh for each request —\n\t * one made from inside another included — so it holds for everything the\n\t * request runs, `onError` hooks too, which run after the route failed.\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 `use` 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\tconst plugin = definePlugin<RequiresOf<Ctx, 'resolve'>>()((app) =>\n\t\tapp\n\t\t\t.around((_ctx, next) => current.run({}, () => requests.run({}, next)))\n\t\t\t.use(detected)\n\t\t\t.derive(({ language: lang }): I18nContext<Key> => {\n\t\t\t\tconst own = current.getStore();\n\t\t\t\tif (own !== undefined) own.language = lang;\n\t\t\t\tconst heard = requests.getStore();\n\t\t\t\tif (heard !== undefined) heard.language ??= lang;\n\t\t\t\treturn { t: translate(lang) };\n\t\t\t}),\n\t);\n\n\treturn Object.assign(plugin, {\n\t\t/** Translates into the current request's language, or the fallback outside one. */\n\t\tt: ((key, context) => translate(spoken())(key, context)) as Translate<Key>,\n\t\t/** The current request's language, or the fallback outside one. */\n\t\tlanguage: spoken,\n\t\tsupported,\n\t});\n}\n"
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;AACA;AACA;AAAA;AAAA;AAAA;AA4EA,IAAM,WAAW,IAAI;AAGrB,IAAM,kBAAkB,MAAM,SAAS,SAAS,GAAG;AA2B5C,SAAS,UAIf,CAAC,SAAwC;AAAA,EAGzC,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,EACD,MAAM,SAAS,aAAyC,EAAE,CAAC,QAC1D,IACE,OAAO,CAAC,MAAM,SAAS,QAAQ,IAAI,CAAC,GAAG,MAAM,SAAS,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,EACpE,IAAI,QAAQ,EACZ,OAAO,GAAG,UAAU,WAA6B;AAAA,IACjD,MAAM,MAAM,QAAQ,SAAS;AAAA,IAC7B,IAAI,QAAQ;AAAA,MAAW,IAAI,WAAW;AAAA,IACtC,MAAM,QAAQ,SAAS,SAAS;AAAA,IAChC,IAAI,UAAU;AAAA,MAAW,MAAM,aAAa;AAAA,IAC5C,OAAO,EAAE,GAAG,UAAU,IAAI,EAAE;AAAA,GAC5B,CACH;AAAA,EAEA,OAAO,OAAO,OAAO,QAAQ;AAAA,IAE5B,GAAI,CAAC,KAAK,YAAY,UAAU,OAAO,CAAC,EAAE,KAAK,OAAO;AAAA,IAEtD,UAAU;AAAA,IACV;AAAA,EACD,CAAC;AAAA;",
8
- "debugId": "700D328B5FADEAA064756E2164756E21",
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
- plugin read, how messages are formatted and keys typed, where the
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 plugin, typing a function that translates, translating in a service or an error handler, caching the responses, or testing them |
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
- ): Alxia<RequiresOf<Ctx, 'resolve'> & Empty & LanguageContext<keyof C & string> & I18nContext<KeyOf<C[Fallback]>>, Empty, '', never> &
40
- Requiring<RequiresOf<Ctx, 'resolve'>> & {
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
- - **an app plugin**: pass it to `use`. It applies to the routes declared
69
- **after** it, in the same app or [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md),
70
- and adds `t`, `language` and `languageSource` to their context;
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 plugin runs for |
90
- | `resolve` | `(ctx: BaseContext & Ctx) => string \| undefined` | none | decides after every source, before `fallback`; annotate `ctx` to read what an earlier plugin adds |
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
- plugin adds. `supported` is not an option here: it is `resources`' keys.
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 plugin reads the segment; it does not route on it, so the routes
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 plugin added. `createI18n()` infers it from
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 plugin then requires it: an app that
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
- .use(auth) // derives user: User | null
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: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
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
- plugin requires nothing. Annotated `any`, the plugin is refused on every
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
- A route declared before `.use(i18n)` reads none of them:
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 plugin reads the request's language in a route hook; from then on,
418
- everything the request runs knows it. Before that, `i18n.t()` and
419
- `i18n.language()` answer in the fallback:
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 route, `derive` or `wrap` declared after `.use(i18n)`, and what they call | the request's language |
424
- | an `onError` hook, for what a route after the plugin threw | the request's language |
425
- | an `onResponse` hook, for a request the plugin ran for | the request's language |
426
- | an `around` hook declared after `.use(i18n)`, once `next()` has resolved | the request's language |
427
- | an `onRequest` hook; an `around` hook declared before `.use(i18n)`; one declared after it, before `next()` | the fallback: the language is not read yet |
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 |
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 `onError` hook translates an error's message with `i18n.t()`, or with
432
- `t` from its context:
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
- .onError((error, { reply }) =>
444
- error instanceof HttpError && error.status === 404
445
- ? reply(404, { error: i18n.t('errors.not-found') })
446
- : undefined,
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 plugin.** With two `createI18n()` on one app, it follows
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 plugin
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
- .onError((error, { t, reply }) =>
590
- error instanceof HttpError && error.status === 404
591
- ? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
592
- : undefined,
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
- Nothing scheduled yet.
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
 
@@ -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 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`](#type-alxiaempty-empty--never-is-missing-the-following-properties-from-type-i18noptionsreadonlyrecordstring-readonlyrecordstring-unknown-string-basecontext-resources-fallback)
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
- - [`the plugin reads "user", which this app's context does not give: use the plugin that adds it first`](#the-plugin-reads-user-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first)
22
- - [`the plugin reads "user", which this app's context gives with another type`](#the-plugin-reads-user-which-this-apps-context-gives-with-another-type)
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 uses it.
137
+ `.use(i18n)`, or in another app or group than the one that mounts it.
138
138
 
139
- **Why:** the plugin is a route hook: it applies to the routes declared
140
- after it, at runtime and in the types alike.
139
+ **Why:** the middleware applies to what is declared after it, at runtime
140
+ and in the types alike.
141
141
 
142
- **Fix:** use the plugin first:
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 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`
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
- Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => Alxia<Empty & LanguageContext<string> & I18nContext<string>, Empty & Prefixed<...>, "", never> & Requiring<...> & { ...; }): Alxia<...> & ... 1 more ... & { ...; }', 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>) => …' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => …'.
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
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 plugin from its options; it is not the
163
- plugin.
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:** an `onError` hook calls `t('…')` from its context.
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 plugin ran —
201
- by a hook declared before it — so what the plugin adds is optional there.
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:** call it optionally, with an answer for when it is missing:
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
- .onError((error, { t, reply }) =>
209
- error instanceof HttpError && error.status === 404
210
- ? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
211
- : undefined,
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 plugin before the
247
- i18n plugin added — a user, a session — and its parameter is not
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 hooks
252
- added. The plugin is built before it is used, so it cannot see the app it
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 plugin then
256
- requires it of the app, before the plugin:
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().use(auth).use(i18n); // auth derives user
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
- ### `the plugin reads "user", which this app's context does not give: use the plugin that adds it first`
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
- Types of property ''~requires'' are incompatible.
276
- Type '{ user: User | null; }' is not assignable to type '"the plugin reads \"user\", which this app's context does not give: use the plugin that adds it first"'.
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 plugin is
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 `use(auth)`.
294
+ `alxia().use(i18n)`, or `use(i18n)` before `plugin(auth)`.
283
295
 
284
- **Why:** an annotated `resolve` makes the plugin require what it reads, and
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
- `the plugin reads "user", which this app's context gives with another type`.
299
+ `Types of property 'user' are incompatible`.
288
300
 
289
- **Fix:** use the plugin that adds `user` first, with the type `resolve`
301
+ **Fix:** mount what adds `user` first, with the type `resolve`
290
302
  reads:
291
303
 
292
304
  ```ts
293
- alxia().use(auth).use(i18n);
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#the-plugin-reads-user-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
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
- ### `the plugin reads "user", which this app's context gives with another type`
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 '"the plugin reads \"user\", which this app's context gives with another 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 plugin reads against the app's context;
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#the-plugin-reads-user-which-this-apps-context-gives-with-another-type).
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
- Types of property ''~requires'' are incompatible.
330
- Type '{ readonly '~any': "the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"; }' is not assignable to type '"the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"'.
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 plugin is used, on any app, whatever its context gives.
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 plugin would require nothing, and an app without a `user`
339
- would be accepted, and throw on every request. The plugin is refused
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 use the plugin that adds it first; or leave it unannotated when it
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 an
413
- `onRequest` hook, an `around` hook declared before `.use(i18n)`, a route
414
- or `derive` declared before it, or the `onError` hook of an error one of
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 plugin reads the request's language in a route hook, after
418
- the global hooks and the route hooks declared before it. Until then there
419
- is none to answer in. From there on — the route, what it calls, its
420
- `onError` and `onResponse` hooks — every call answers in it.
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 plugin: declare the routes and hooks that
423
- translate after `.use(i18n)`, and move what an `onRequest` hook renders
424
- into a `derive` declared after it:
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
- .derive(({ request, reply }) =>
430
- request.headers.has('x-busy') ? reply(503, { error: i18n.t('errors.service-unavailable') }) : undefined,
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 plugin says so in `Vary`; a cache keyed on the URL alone
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
- An `onResponse` hook that sets `Vary` with `headers.set` replaces the
495
- plugin'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),
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.1.3",
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.3.0",
45
- "@alxia/language": "^0.1.2",
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.3.0",
51
- "@alxia/language": "^0.1.2",
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
  }