@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 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 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>>>> & {
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, 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
- * 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,EAEf,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,GAAG;IACH,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;AAEF;;;;;;;;;;;;;;;;;;;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,21 @@
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
+ 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 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, {
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=700D328B5FADEAA064756E2164756E21
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 { 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\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;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;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
- 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,12 +36,11 @@ 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'>> & {
41
- t: Translate<KeyOf<C[Fallback]>>;
42
- language: () => keyof C & string;
43
- supported: (keyof C & string)[];
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
- - **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;
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 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 |
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
- plugin adds. `supported` is not an option here: it is `resources`' keys.
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 plugin reads the segment; it does not route on it, so the routes
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 plugin added. `createI18n()` infers it from
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 plugin then requires it: an app that
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
- .use(auth) // derives user: User | null
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: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
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
- plugin requires nothing. Annotated `any`, the plugin is refused on every
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
- A route declared before `.use(i18n)` reads none of them:
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 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:
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 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 |
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 `onError` hook translates an error's message with `i18n.t()`, or with
432
- `t` from its context:
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
- .onError((error, { reply }) =>
444
- error instanceof HttpError && error.status === 404
445
- ? reply(404, { error: i18n.t('errors.not-found') })
446
- : undefined,
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 plugin.** With two `createI18n()` on one app, it follows
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 plugin
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: process.env['NODE_ENV'] === 'production' },
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
- .onError((error, { t, reply }) =>
590
- error instanceof HttpError && error.status === 404
591
- ? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
592
- : undefined,
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
- 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)`, the deprecated plugin form, was removed with alxia 0.5: give it to `use`.
11
11
 
12
12
  ## Next
13
13
 
@@ -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 '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 '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
- - [`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)
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 uses it.
136
+ `.use(i18n)`, or in another app or group than the one that mounts it.
138
137
 
139
- **Why:** the plugin is a route hook: it applies to the routes declared
140
- after it, at runtime and in the types alike.
138
+ **Why:** the middleware applies to what is declared after it, at runtime
139
+ and in the types alike.
141
140
 
142
- **Fix:** use the plugin first:
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 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`
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 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
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)`, without calling it.
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 plugin from its options; it is not the
163
- plugin.
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 plugin before the
247
- i18n plugin added — a user, a session — and its parameter is not
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 hooks
252
- added. The plugin is built before it is used, so it cannot see the app it
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 plugin then
256
- requires it of the app, before the plugin:
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().use(auth).use(i18n); // auth derives user
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
- ### `the plugin reads "user", which this app's context does not give: use the plugin that adds it first`
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
- 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"'.
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 plugin is
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 `use(auth)`.
259
+ `alxia().use(i18n)`, or `use(i18n)` before `plugin(auth)`.
283
260
 
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
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
- `the plugin reads "user", which this app's context gives with another type`.
264
+ `Types of property 'user' are incompatible`.
288
265
 
289
- **Fix:** use the plugin that adds `user` first, with the type `resolve`
266
+ **Fix:** mount what adds `user` first, with the type `resolve`
290
267
  reads:
291
268
 
292
269
  ```ts
293
- alxia().use(auth).use(i18n);
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#the-plugin-reads-user-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
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
- ### `the plugin reads "user", which this app's context gives with another type`
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 '"the plugin reads \"user\", which this app's context gives with another 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 plugin reads against the app's context;
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#the-plugin-reads-user-which-this-apps-context-gives-with-another-type).
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
- 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"'.
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 plugin is used, on any app, whatever its context gives.
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 plugin would require nothing, and an app without a `user`
339
- would be accepted, and throw on every request. The plugin is refused
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 use the plugin that adds it first; or leave it unannotated when it
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 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.
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 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.
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 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:
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
- .derive(({ request, reply }) =>
430
- request.headers.has('x-busy') ? reply(503, { error: i18n.t('errors.service-unavailable') }) : undefined,
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 hook [before the language is read](#i18nt-answers-in-the-fallback-before-the-language-is-read),
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 plugin says so in `Vary`; a cache keyed on the URL alone
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
- 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),
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.1.3",
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.3.0",
45
- "@alxia/language": "^0.1.2",
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.3.0",
51
- "@alxia/language": "^0.1.2",
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
  }