@alxia/i18n 0.1.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/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/i18n.d.ts +56 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +10 -0
- package/docs/README.md +13 -0
- package/docs/guide.md +592 -0
- package/docs/roadmap.md +50 -0
- package/docs/troubleshooting.md +472 -0
- package/package.json +55 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: the error `createI18n()` throws,
|
|
4
|
+
an error from `tsc`, a line in the log, or — for a trap that prints
|
|
5
|
+
nothing — what the response does that you did not expect.
|
|
6
|
+
|
|
7
|
+
**At start-up**
|
|
8
|
+
|
|
9
|
+
- [`TypeError: language(): the fallback "de" is not supported`](#typeerror-language-the-fallback-de-is-not-supported)
|
|
10
|
+
|
|
11
|
+
**Types**
|
|
12
|
+
|
|
13
|
+
- [`Type '"de"' is not assignable to type '"en" | "fr"'`](#type-de-is-not-assignable-to-type-en--fr)
|
|
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
|
+
- [`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)
|
|
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
|
+
- [`t()` accepts any key, typos included](#t-accepts-any-key-typos-included)
|
|
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)
|
|
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
|
+
|
|
25
|
+
**Messages**
|
|
26
|
+
|
|
27
|
+
- [`The intl string context variable "name" was not provided to the string "Hello {name}"`](#the-intl-string-context-variable-name-was-not-provided-to-the-string-hello-name)
|
|
28
|
+
- [`SyntaxError: MISSING_OTHER_CLAUSE`](#syntaxerror-missing_other_clause)
|
|
29
|
+
- [The page shows `home.title` instead of a message](#the-page-shows-hometitle-instead-of-a-message)
|
|
30
|
+
|
|
31
|
+
**Language**
|
|
32
|
+
|
|
33
|
+
- [`i18n.t()` answers in the fallback before the language is read](#i18nt-answers-in-the-fallback-before-the-language-is-read)
|
|
34
|
+
- [`@nxgt/i18n`'s `translate` answers in English on a German request](#nxgti18ns-translate-answers-in-english-on-a-german-request)
|
|
35
|
+
- [`getLanguage()` answers `en` although the fallback is `fr`](#getlanguage-answers-en-although-the-fallback-is-fr)
|
|
36
|
+
- [A cache serves one language to every visitor](#a-cache-serves-one-language-to-every-visitor)
|
|
37
|
+
|
|
38
|
+
## At start-up
|
|
39
|
+
|
|
40
|
+
### `TypeError: language(): the fallback "de" is not supported`
|
|
41
|
+
|
|
42
|
+
**When:** `createI18n()` is called — when the module that builds it is
|
|
43
|
+
imported — with a `fallback` that is not one of `resources`' keys.
|
|
44
|
+
|
|
45
|
+
**Why:** the fallback is the language of every request that names none the
|
|
46
|
+
catalogues have, so it needs a catalogue. The types refuse it when they can
|
|
47
|
+
see the keys of `resources`; they cannot when `fallback` comes from the
|
|
48
|
+
environment through a cast, or `resources` is typed `Record<string, …>`.
|
|
49
|
+
The message is `@alxia/language`'s, which `createI18n()` calls.
|
|
50
|
+
|
|
51
|
+
**Fix:** give the fallback a catalogue, and keep `resources` a literal so
|
|
52
|
+
the compiler checks it next time:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createI18n } from '@alxia/i18n';
|
|
56
|
+
|
|
57
|
+
const en = { home: { title: 'Welcome' } };
|
|
58
|
+
const de = { home: { title: 'Willkommen' } };
|
|
59
|
+
|
|
60
|
+
createI18n({ resources: { en, de }, fallback: 'de' });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Types
|
|
64
|
+
|
|
65
|
+
### `Type '"de"' is not assignable to type '"en" | "fr"'`
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
error TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**When:** `createI18n({ resources: { en, fr }, fallback: 'de' })`.
|
|
72
|
+
|
|
73
|
+
**Why:** `fallback` is typed as one of `resources`' keys. This is the
|
|
74
|
+
compile-time form of [the start-up error](#typeerror-language-the-fallback-de-is-not-supported).
|
|
75
|
+
|
|
76
|
+
**Fix:** a fallback with a catalogue:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `Argument of type '"cart.itmes"' is not assignable to parameter of type '"cart.items"'`
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
error TS2345: Argument of type '"cart.itmes"' is not assignable to parameter of type '"cart.items"'.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**When:** `t('cart.itmes')` — a typo — or `t('cart.extra')`, a key that
|
|
89
|
+
only another language's catalogue has.
|
|
90
|
+
|
|
91
|
+
**Why:** `t` takes the dotted keys of the **fallback's** catalogue, and no
|
|
92
|
+
other. A key the fallback lacks would answer itself in the fallback's
|
|
93
|
+
language.
|
|
94
|
+
|
|
95
|
+
**Fix:** correct the key, or add it to the fallback's catalogue first:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const en = { cart: { items: '{count, plural, one {One item} other {# items}}', extra: 'Nothing' } };
|
|
99
|
+
const fr = { cart: { items: '{count, plural, one {Un article} other {# articles}}', extra: 'Rien' } };
|
|
100
|
+
|
|
101
|
+
const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
102
|
+
i18n.t('cart.extra');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `Property 't' does not exist on type 'Context<Empty, "/", Empty>'`
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
error TS2339: Property 't' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**When:** a route reads `t` — or `language` — but is declared before
|
|
112
|
+
`.use(i18n)`, or in another app or group than the one that uses it.
|
|
113
|
+
|
|
114
|
+
**Why:** the plugin is a route hook: it applies to the routes declared
|
|
115
|
+
after it, at runtime and in the types alike.
|
|
116
|
+
|
|
117
|
+
**Fix:** use the plugin first:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const app = alxia()
|
|
121
|
+
.use(i18n)
|
|
122
|
+
.get('/', ({ t, reply }) => reply(200, t('home.title')));
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback`
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
error TS2769: No overload matches this call.
|
|
129
|
+
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.
|
|
130
|
+
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>) => …'.
|
|
131
|
+
Types of parameters 'options' and 'app' are incompatible.
|
|
132
|
+
Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'I18nOptions<Readonly<Record<string, Readonly<Record<string, unknown>>>>, string, BaseContext>': resources, fallback
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**When:** `app.use(createI18n)`, without calling it.
|
|
136
|
+
|
|
137
|
+
**Why:** `createI18n` makes the plugin from its options; it is not the
|
|
138
|
+
plugin.
|
|
139
|
+
|
|
140
|
+
**Fix:** call it once, in a module of its own, and use what it returns:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
144
|
+
|
|
145
|
+
const app = alxia().use(i18n);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `Object literal may only specify known properties, and 'supported' does not exist in type 'I18nOptions<…>'`
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
error TS2353: Object literal may only specify known properties, and 'supported' does not exist in type 'I18nOptions<{ readonly en: { cart: { items: string; }; }; readonly fr: { cart: { items: string; extra: string; }; }; }, "en", BaseContext>'.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**When:** `createI18n({ resources, fallback: 'en', supported: ['en'] })`,
|
|
155
|
+
the way `@alxia/language` is called.
|
|
156
|
+
|
|
157
|
+
**Why:** the languages supported are `resources`' keys; `createI18n()`
|
|
158
|
+
passes them to `@alxia/language` itself. Through a cast, `supported` is
|
|
159
|
+
ignored.
|
|
160
|
+
|
|
161
|
+
**Fix:** leave it out. To support fewer languages, pass fewer catalogues:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
createI18n({ resources: { en }, fallback: 'en' });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `Cannot invoke an object which is possibly 'undefined'`
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
error TS2722: Cannot invoke an object which is possibly 'undefined'.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**When:** an `onError` hook calls `t('…')` from its context.
|
|
174
|
+
|
|
175
|
+
**Why:** `onError` also handles what was thrown before the plugin ran —
|
|
176
|
+
by a hook declared before it — so what the plugin adds is optional there.
|
|
177
|
+
|
|
178
|
+
**Fix:** call it optionally, with an answer for when it is missing:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
alxia()
|
|
182
|
+
.use(i18n)
|
|
183
|
+
.onError((error, { t, reply }) =>
|
|
184
|
+
error instanceof HttpError && error.status === 404
|
|
185
|
+
? reply(404, { error: t?.('errors.not-found') ?? 'Not found' })
|
|
186
|
+
: undefined,
|
|
187
|
+
);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Or call the plugin's own `i18n.t()`, which answers in the request's
|
|
191
|
+
language there too, and in the fallback when the error was thrown before
|
|
192
|
+
the language was read.
|
|
193
|
+
|
|
194
|
+
### `t()` accepts any key, typos included
|
|
195
|
+
|
|
196
|
+
**When:** the catalogues are built at runtime, read with
|
|
197
|
+
`await Bun.file(…).json()`, or annotated
|
|
198
|
+
`Record<string, Record<string, string>>`.
|
|
199
|
+
|
|
200
|
+
**Why:** the keys are read from the fallback catalogue's type. A catalogue
|
|
201
|
+
typed `Record<string, …>` has none to read, so `t` takes any string — and
|
|
202
|
+
a wrong one [answers itself](#the-page-shows-hometitle-instead-of-a-message).
|
|
203
|
+
|
|
204
|
+
**Fix:** declare the catalogues as literals, or import them from JSON
|
|
205
|
+
files, which keeps their keys in the type (`resolveJsonModule` in your
|
|
206
|
+
`tsconfig.json`):
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import en from './locales/en.json';
|
|
210
|
+
import fr from './locales/fr.json';
|
|
211
|
+
|
|
212
|
+
export const i18n = createI18n({ resources: { en, fr }, fallback: 'en' });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `Property 'user' does not exist on type 'BaseContext'`
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
error TS2339: Property 'user' does not exist on type 'BaseContext'.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**When:** `resolve` reads something a `derive` or a plugin before the
|
|
222
|
+
i18n plugin added — a user, a session — and its parameter is not
|
|
223
|
+
annotated: `resolve: (ctx) => ctx.user.language`.
|
|
224
|
+
|
|
225
|
+
**Why:** an unannotated `resolve` is typed with the request's `BaseContext`
|
|
226
|
+
— `request`, `url`, `ip`, `pathParams`, `set` — not with what other hooks
|
|
227
|
+
added. The plugin is built before it is used, so it cannot see the app it
|
|
228
|
+
will be used on.
|
|
229
|
+
|
|
230
|
+
**Fix:** annotate the parameter with what it reads; the plugin then
|
|
231
|
+
requires it of the app, before the plugin:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const i18n = createI18n({
|
|
235
|
+
resources: { en, fr },
|
|
236
|
+
fallback: 'en',
|
|
237
|
+
resolve: ({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
alxia().use(auth).use(i18n); // auth derives user
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
See [Reading the app's context](guide.md#reading-the-apps-context).
|
|
244
|
+
|
|
245
|
+
### `the plugin reads "user", which this app's context does not give: use the plugin that adds it first`
|
|
246
|
+
|
|
247
|
+
```text
|
|
248
|
+
error TS2769: No overload matches this call.
|
|
249
|
+
…
|
|
250
|
+
Types of property ''~requires'' are incompatible.
|
|
251
|
+
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
|
+
```
|
|
253
|
+
|
|
254
|
+
**When:** `resolve` is annotated to read `user` —
|
|
255
|
+
`({ user }: BaseContext & { user: User | null }) => …` — and the plugin is
|
|
256
|
+
used on an app, or in a group, whose context has no `user` at that point:
|
|
257
|
+
`alxia().use(i18n)`, or `use(i18n)` before `use(auth)`.
|
|
258
|
+
|
|
259
|
+
**Why:** an annotated `resolve` makes the plugin require what it reads, and
|
|
260
|
+
`use` checks the app's context against it, so `resolve` never runs without
|
|
261
|
+
it. An app whose `user` has another type is refused too, with
|
|
262
|
+
`the plugin reads "user", which this app's context gives with another type`.
|
|
263
|
+
|
|
264
|
+
**Fix:** use the plugin that adds `user` first, with the type `resolve`
|
|
265
|
+
reads:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
alxia().use(auth).use(i18n);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
More on this message in
|
|
272
|
+
[`@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).
|
|
273
|
+
|
|
274
|
+
### `the plugin reads "user", which this app's context gives with another type`
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
error TS2769: No overload matches this call.
|
|
278
|
+
…
|
|
279
|
+
Type '{ user: User; }' is not assignable to type '"the plugin reads \"user\", which this app's context gives with another type"'.
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**When:** the app gives a `user`, but of a type that does not fit the one
|
|
283
|
+
`resolve`'s parameter is annotated with: a `User | null` where `resolve`
|
|
284
|
+
reads `User`, or a user of another shape.
|
|
285
|
+
|
|
286
|
+
**Why:** `use` checks each key the plugin reads against the app's context;
|
|
287
|
+
a narrower type passes, a wider or different one does not.
|
|
288
|
+
|
|
289
|
+
**Fix:** annotate `resolve` with the type the app gives, and handle it
|
|
290
|
+
inside:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
resolve: ({ user }: { user: User | null }) => user?.language ?? undefined,
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
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-gives-with-another-type).
|
|
298
|
+
|
|
299
|
+
### `the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
error TS2769: No overload matches this call.
|
|
303
|
+
…
|
|
304
|
+
Types of property ''~requires'' are incompatible.
|
|
305
|
+
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"'.
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**When:** `resolve`'s parameter is annotated `any` —
|
|
309
|
+
`resolve: (ctx: any) => ctx.user.language` — or `Record<string, any>`, and
|
|
310
|
+
the plugin is used, on any app, whatever its context gives.
|
|
311
|
+
|
|
312
|
+
**Why:** an `any` parameter reads any key and says nothing of what it
|
|
313
|
+
reads, so the plugin would require nothing, and an app without a `user`
|
|
314
|
+
would be accepted, and throw on every request. The plugin is refused
|
|
315
|
+
instead.
|
|
316
|
+
|
|
317
|
+
**Fix:** annotate what `resolve` reads —
|
|
318
|
+
`({ user }: BaseContext & { user: User | null }) => user?.language ?? undefined` —
|
|
319
|
+
and use the plugin that adds it first; or leave it unannotated when it
|
|
320
|
+
reads only the request.
|
|
321
|
+
|
|
322
|
+
More on this message in
|
|
323
|
+
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugins--reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
|
|
324
|
+
|
|
325
|
+
## Messages
|
|
326
|
+
|
|
327
|
+
### `The intl string context variable "name" was not provided to the string "Hello {name}"`
|
|
328
|
+
|
|
329
|
+
**When:** in the log, with the response holding the message unformatted:
|
|
330
|
+
`Hello {name}`. `t('greeting')` was called without the values its message
|
|
331
|
+
names.
|
|
332
|
+
|
|
333
|
+
**Why:** the values of `t` are not typed by the message. A message that
|
|
334
|
+
cannot be formatted is logged with `console.error` — this one with
|
|
335
|
+
`code: "MISSING_VALUE"` — and answered as it is; `t` never throws.
|
|
336
|
+
|
|
337
|
+
**Fix:** pass every value the message names:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
t('greeting', { name: 'Ada' }); // 'Hello Ada'
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### `SyntaxError: MISSING_OTHER_CLAUSE`
|
|
344
|
+
|
|
345
|
+
**When:** in the log, with the response holding the message unformatted —
|
|
346
|
+
`{count, plural, one {One item}}` — or, for an unclosed brace,
|
|
347
|
+
`SyntaxError: EXPECT_ARGUMENT_CLOSING_BRACE` and `Hello {name`.
|
|
348
|
+
|
|
349
|
+
**Why:** the catalogue holds a message that is not valid ICU. Every
|
|
350
|
+
`plural` and `select` needs an `other` case; every `{` its `}`. It is
|
|
351
|
+
logged and answered as it is, at every call.
|
|
352
|
+
|
|
353
|
+
**Fix:** correct the catalogue:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const en = { cart: { items: '{count, plural, one {One item} other {# items}}' } };
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### The page shows `home.title` instead of a message
|
|
360
|
+
|
|
361
|
+
**When:** a response holds a key — `home.title`, `errors.not-found` —
|
|
362
|
+
where a message should be, in some languages and not in others.
|
|
363
|
+
|
|
364
|
+
**Why:** a key the request's catalogue lacks answers **itself**, not the
|
|
365
|
+
fallback's message. Either that catalogue is partial — a language added
|
|
366
|
+
before every message was translated — or it lacks `@nxgt/i18n`'s shared
|
|
367
|
+
keys, which exist only in English and French.
|
|
368
|
+
|
|
369
|
+
**Fix:** fill a partial catalogue from the fallback's, nested
|
|
370
|
+
([Guide](guide.md#messages) shows a `fill` helper), and give a language
|
|
371
|
+
`@nxgt/i18n` lacks the English shared keys:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
import { resources as shared } from '@nxgt/i18n';
|
|
375
|
+
|
|
376
|
+
const en = { ...shared.en, home: { title: 'Welcome' } };
|
|
377
|
+
const de = { ...shared.en, home: { title: 'Willkommen' } }; // shared messages in English
|
|
378
|
+
|
|
379
|
+
export const i18n = createI18n({ resources: { en, de }, fallback: 'en' });
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Language
|
|
383
|
+
|
|
384
|
+
### `i18n.t()` answers in the fallback before the language is read
|
|
385
|
+
|
|
386
|
+
**When:** a request is in French, but `i18n.t()`, `i18n.language()` or
|
|
387
|
+
`@nxgt/i18n`'s `translate` answers in the fallback's language in an
|
|
388
|
+
`onRequest` hook, an `around` hook declared before `.use(i18n)`, a route
|
|
389
|
+
or `derive` declared before it, or the `onError` hook of an error one of
|
|
390
|
+
those threw.
|
|
391
|
+
|
|
392
|
+
**Why:** the plugin reads the request's language in a route hook, after
|
|
393
|
+
the global hooks and the route hooks declared before it. Until then there
|
|
394
|
+
is none to answer in. From there on — the route, what it calls, its
|
|
395
|
+
`onError` and `onResponse` hooks — every call answers in it.
|
|
396
|
+
|
|
397
|
+
**Fix:** translate after the plugin: declare the routes and hooks that
|
|
398
|
+
translate after `.use(i18n)`, and move what an `onRequest` hook renders
|
|
399
|
+
into a `derive` declared after it:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
alxia()
|
|
403
|
+
.use(i18n)
|
|
404
|
+
.derive(({ request, reply }) =>
|
|
405
|
+
request.headers.has('x-busy') ? reply(503, { error: i18n.t('errors.service-unavailable') }) : undefined,
|
|
406
|
+
);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### `@nxgt/i18n`'s `translate` answers in English on a German request
|
|
410
|
+
|
|
411
|
+
**When:** your catalogues add a language — `de` — and `@nxgt/i18n`'s
|
|
412
|
+
`getLanguage()` answers `'en'` on a request in it, and its `translate` and
|
|
413
|
+
the packages that translate through it answer in English.
|
|
414
|
+
|
|
415
|
+
**Why:** `@nxgt/i18n` only speaks the languages of its own catalogues,
|
|
416
|
+
`en` and `fr`. It skips a source that answers another, and falls back to
|
|
417
|
+
`'en'`.
|
|
418
|
+
|
|
419
|
+
**Fix:** translate what the response shows with `t`, whose catalogues are
|
|
420
|
+
yours:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const de = { ...shared.en, errors: { ...shared.en.errors, 'not-found': 'Nicht gefunden.' } };
|
|
424
|
+
|
|
425
|
+
const i18n = createI18n({ resources: { en: shared.en, de }, fallback: 'en' });
|
|
426
|
+
|
|
427
|
+
alxia()
|
|
428
|
+
.use(i18n)
|
|
429
|
+
.get('/', ({ t, reply }) => reply(404, { error: t('errors.not-found') })); // ?lang=de → 'Nicht gefunden.'
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### `getLanguage()` answers `en` although the fallback is `fr`
|
|
433
|
+
|
|
434
|
+
**When:** outside a request — at start-up, in a timer, a queue consumer —
|
|
435
|
+
or in a hook [before the language is read](#i18nt-answers-in-the-fallback-before-the-language-is-read),
|
|
436
|
+
`@nxgt/i18n`'s `getLanguage()` answers `'en'` while `i18n.language()`
|
|
437
|
+
answers `'fr'`.
|
|
438
|
+
|
|
439
|
+
**Why:** with no request to read, `@nxgt/i18n` falls back to its own
|
|
440
|
+
fallback, `'en'`. Your `fallback` is `createI18n()`'s, and only
|
|
441
|
+
`i18n.t()` and `i18n.language()` use it.
|
|
442
|
+
|
|
443
|
+
**Fix:** call `i18n.t()` rather than `translate` where there is no request,
|
|
444
|
+
or pass `translate` the language:
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import { translate } from '@nxgt/i18n';
|
|
448
|
+
|
|
449
|
+
translate('errors.not-found', undefined, 'fr');
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### A cache serves one language to every visitor
|
|
453
|
+
|
|
454
|
+
**When:** behind a CDN, a reverse proxy, or `@alxia/cache`, the first
|
|
455
|
+
visitor's language is served to everyone after.
|
|
456
|
+
|
|
457
|
+
**Why:** the response depends on `Accept-Language` and the language
|
|
458
|
+
cookie. The plugin says so in `Vary`; a cache keyed on the URL alone
|
|
459
|
+
ignores it.
|
|
460
|
+
|
|
461
|
+
**Fix:** give the cache the same headers:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
alxia()
|
|
465
|
+
.use(i18n)
|
|
466
|
+
.use(cache({ ttl: 60, vary: ['accept-language', 'cookie'] }));
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
An `onResponse` hook that sets `Vary` with `headers.set` replaces the
|
|
470
|
+
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),
|
|
471
|
+
which also covers a response in the wrong language — `curl` getting the
|
|
472
|
+
fallback, a `?lang=` that does not stick, a `404` on `/fr/products`.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alxia/i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Translations for alxia on @nxgt/i18n: t() bound to the request's language, keys typed by your catalogue, ICU messages",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/i18n"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/core": "^0.1.0",
|
|
45
|
+
"@alxia/language": "^0.1.0",
|
|
46
|
+
"@nxgt/i18n": "^2.0.0",
|
|
47
|
+
"@types/bun": "^1.4.2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@alxia/core": "^0.1.0",
|
|
51
|
+
"@alxia/language": "^0.1.0",
|
|
52
|
+
"@nxgt/i18n": "^2.0.0",
|
|
53
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|