@zerotal/arch 1.7.0 → 1.7.3
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/CHANGELOG.md +57 -0
- package/docs/about.md +4 -4
- package/docs/broadcasting/channels.md +1 -1
- package/docs/broadcasting/client.md +17 -4
- package/docs/broadcasting/events.md +2 -2
- package/docs/broadcasting/index.md +1 -1
- package/docs/changelog.md +106 -4
- package/docs/commands.md +21 -12
- package/docs/contributing.md +2 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +27 -8
- package/docs/flow/decorators.md +32 -16
- package/docs/flow/events.md +53 -19
- package/docs/flow/index.md +18 -7
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +16 -1
- package/docs/flow/routing.md +14 -2
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +26 -1
- package/docs/i18n.md +247 -144
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +89 -7
- package/docs/seeding.md +7 -0
- package/docs/support-policy.md +13 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +5 -1
- package/src/install/markers.ts +11 -2
- package/src/install/mcpConfig.ts +14 -2
- package/src/probe/topics.ts +19 -4
- package/src/tools/apiSurface.ts +115 -20
- package/src/tools/baselines.ts +33 -4
- package/src/tools/logs.ts +77 -7
- package/src/tools/searchDocs.ts +196 -29
package/docs/i18n.md
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Internationalization
|
|
3
|
-
description: Resolve each visitor's locale automatically, then translate
|
|
3
|
+
description: Resolve each visitor's locale automatically, then translate English source strings with interpolation, pluralization, and fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Internationalization
|
|
7
7
|
|
|
8
8
|
Request-scoped localization: resolve each visitor's locale automatically, then
|
|
9
|
-
translate
|
|
10
|
-
|
|
9
|
+
translate with interpolation, pluralization, and fallback — without threading the
|
|
10
|
+
locale through every function call.
|
|
11
|
+
|
|
12
|
+
The string you pass to `__()` is the English sentence, not a name for it:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
__("Email"); // not __("auth.email")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
English is the source language, so the source text _is_ the key. That one
|
|
19
|
+
decision removes the `en.json` file, the naming argument, and the class of bug
|
|
20
|
+
where a screen ships reading `auth.email` because somebody mistyped a key.
|
|
11
21
|
|
|
12
22
|
## Getting Started
|
|
13
23
|
|
|
@@ -37,7 +47,7 @@ Registering the provider switches on the following:
|
|
|
37
47
|
- `onRegister` — binds the translation service as the lazy `i18n` singleton (a
|
|
38
48
|
`Translator` loaded from your catalogs).
|
|
39
49
|
- `onBooting` — configures and registers `LocaleMiddleware` via `app.useOnce()`,
|
|
40
|
-
so every request resolves a locale and gains `ctx.locale` + `ctx.
|
|
50
|
+
so every request resolves a locale and gains `ctx.locale` + `ctx.__()`.
|
|
41
51
|
|
|
42
52
|
## Configuration
|
|
43
53
|
|
|
@@ -52,7 +62,7 @@ import { env } from "zerotal";
|
|
|
52
62
|
export default I18nConfig({
|
|
53
63
|
defaultLocale: env("APP_LOCALE", "en"),
|
|
54
64
|
fallbackLocale: env("APP_FALLBACK_LOCALE", "en"),
|
|
55
|
-
supportedLocales: ["en"],
|
|
65
|
+
supportedLocales: ["en", "fr"],
|
|
56
66
|
resolvers: ["query", "cookie", "accept-header"], // tried in order
|
|
57
67
|
queryKey: "lang", // ?lang=fr
|
|
58
68
|
cookieKey: "locale", // locale=fr cookie
|
|
@@ -75,68 +85,201 @@ export default I18nConfig({
|
|
|
75
85
|
> `satisfies I18nConfigShape` for the same type-checking — but then every field is
|
|
76
86
|
> required, since the helper is what supplies the defaults above.
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
List your source language in `supportedLocales`, but write no catalog for it:
|
|
79
89
|
|
|
80
|
-
```
|
|
90
|
+
```text
|
|
81
91
|
resources/lang/
|
|
82
|
-
├── en.json
|
|
83
92
|
├── fr.json
|
|
84
93
|
└── es.json
|
|
85
94
|
```
|
|
86
95
|
|
|
96
|
+
There is no `en.json`, and adding one would only map 200 English strings to
|
|
97
|
+
themselves. An unmatched lookup returns the key, and the key is the English.
|
|
98
|
+
|
|
87
99
|
## Basic usage
|
|
88
100
|
|
|
89
101
|
`LocaleMiddleware` resolves the locale for every request and exposes `ctx.locale`
|
|
90
|
-
and `ctx.
|
|
102
|
+
and `ctx.__()` on the request context:
|
|
91
103
|
|
|
92
104
|
```typescript
|
|
93
105
|
// in a controller
|
|
94
106
|
async show(ctx: HttpContext) {
|
|
95
|
-
ctx.
|
|
96
|
-
ctx.
|
|
107
|
+
ctx.__("Hello, {name}!", { name: "Alice" }); // active locale
|
|
108
|
+
ctx.__("Hello, {name}!", { name: "Alice" }, "fr"); // explicit locale
|
|
97
109
|
return ctx.json({ locale: ctx.locale });
|
|
98
110
|
}
|
|
99
111
|
```
|
|
100
112
|
|
|
101
|
-
Outside a controller — in a service, job, or view —
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
Outside a controller — in a service, job, or view — call `__()`. It reads the
|
|
114
|
+
active request locale from `I18nContext`, so no locale is passed around, and
|
|
115
|
+
**it needs no import**: `I18nProvider` puts it on `globalThis` when it boots.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// anywhere in the request's async tree — no import line
|
|
119
|
+
__("Sign in");
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
It is installed at boot rather than at module load because that is the moment
|
|
123
|
+
there is a translator behind it. A global resolving against an unbound container
|
|
124
|
+
would answer every string with itself, which reads as "not translated yet"
|
|
125
|
+
rather than as "i18n is not installed".
|
|
126
|
+
|
|
127
|
+
The declaration that types it ships with the package, so an editor completes it
|
|
128
|
+
and a wrong argument still fails the build. `__` also remains a named export,
|
|
129
|
+
which is what a library — unable to assume an application has booted — should
|
|
130
|
+
import:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { Lang, __ } from "@zerotal/i18n";
|
|
134
|
+
|
|
135
|
+
Lang.translate("Sign in"); // the facade, when you want the instance
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Translating in the browser
|
|
139
|
+
|
|
140
|
+
A React interface cannot reach the server's `Translator`, so the active locale's
|
|
141
|
+
catalog travels to it as a shared Inertia prop:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// bootstrap/app.ts
|
|
145
|
+
import { share } from "@zerotal/inertia";
|
|
146
|
+
import fr from "../resources/lang/fr.json";
|
|
147
|
+
|
|
148
|
+
const CATALOGS: Record<string, Record<string, unknown>> = { fr };
|
|
149
|
+
|
|
150
|
+
share("locale", () => activeLocale());
|
|
151
|
+
// `{}` for the source language: every string resolves to itself.
|
|
152
|
+
share("messages", () => CATALOGS[activeLocale()] ?? {});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Client-side, keep the catalog in module state and hand it to the translator from
|
|
156
|
+
`resolve()`. Inertia calls `resolve` with the incoming page **before** it swaps
|
|
157
|
+
the component in, which is what makes the first render after a language change
|
|
158
|
+
come out in the new language:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
// resources/js/app.tsx
|
|
162
|
+
createInertiaApp({
|
|
163
|
+
resolve: async (name, incoming) => {
|
|
164
|
+
syncTranslations(incoming);
|
|
165
|
+
return (await pages[name]!()).default;
|
|
166
|
+
},
|
|
167
|
+
// …
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Have that module install the global too — the browser has no provider to boot,
|
|
172
|
+
so it does it itself, and importing the module for `syncTranslations` is what
|
|
173
|
+
guarantees the assignment has run:
|
|
104
174
|
|
|
105
175
|
```typescript
|
|
106
|
-
//
|
|
107
|
-
|
|
176
|
+
// resources/js/lib/i18n.ts
|
|
177
|
+
(globalThis as { __?: typeof __ }).__ = __;
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A component then calls it with no import and no hook:
|
|
108
181
|
|
|
109
|
-
|
|
110
|
-
|
|
182
|
+
```tsx
|
|
183
|
+
// resources/js/pages/login.tsx
|
|
184
|
+
<TextField label={__("Email")} type="email" />
|
|
111
185
|
```
|
|
112
186
|
|
|
113
|
-
|
|
187
|
+
> **Note** — declare no second ambient `__` for the browser. An app is one
|
|
188
|
+
> TypeScript program, so `@zerotal/i18n`'s declaration already covers these call
|
|
189
|
+
> sites; a second `var __` is a duplicate identifier, not an override.
|
|
190
|
+
|
|
191
|
+
> **Note** — do not sync from `router.on("navigate")`. That event fires _after_
|
|
192
|
+
> the component swap, so the first render of a new page still carries the
|
|
193
|
+
> previous locale's catalog — visible as one flash of the old language every time
|
|
194
|
+
> someone switches.
|
|
195
|
+
|
|
196
|
+
Because it is a plain function, `__()` also works in the places a hook cannot go:
|
|
197
|
+
module-level option arrays, `Page.layout` assignments, and helpers that never
|
|
198
|
+
receive props. Those are exactly the spots that tend to ship untranslated.
|
|
199
|
+
|
|
200
|
+
> **Note** — module state is per-tab in a browser, which is what makes this safe.
|
|
201
|
+
> Under Inertia SSR the same module is shared by concurrent requests, so an app
|
|
202
|
+
> that adds SSR must move the catalog into per-render context instead.
|
|
203
|
+
|
|
204
|
+
## Writing a catalog
|
|
114
205
|
|
|
115
|
-
|
|
206
|
+
A catalog maps the English string to its translation. It is flat, because the
|
|
207
|
+
keys are sentences rather than paths:
|
|
116
208
|
|
|
117
209
|
```json
|
|
118
|
-
// resources/lang/
|
|
210
|
+
// resources/lang/fr.json
|
|
119
211
|
{
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
212
|
+
"Sign in": "Se connecter",
|
|
213
|
+
"Forgot your password?": "Mot de passe oublié ?",
|
|
214
|
+
"Hello, {name}!": "Bonjour, {name} !",
|
|
215
|
+
"The {field} field is required.": "Le champ {field} est obligatoire.",
|
|
216
|
+
"no apples|one apple|{count} apples": "aucune pomme|une pomme|{count} pommes"
|
|
123
217
|
}
|
|
124
218
|
```
|
|
125
219
|
|
|
220
|
+
A key is looked up flat first, then as a dot-path. Flat has to win, because an
|
|
221
|
+
English sentence contains dots that are punctuation: `"Signed out."` must not be
|
|
222
|
+
split into a `Signed out` → `` lookup. Nested catalogs still resolve, so an
|
|
223
|
+
existing app can move over one screen at a time.
|
|
224
|
+
|
|
225
|
+
## Interpolation, pluralization, fallback
|
|
226
|
+
|
|
126
227
|
- **Interpolation** — `{name}` and `:name` are both replaced from the
|
|
127
228
|
replacements object.
|
|
128
229
|
- **Pluralization** — pipe-separated segments are chosen by `count`: two segments
|
|
129
230
|
are `singular | plural` (`count === 1` → first); three or more are
|
|
130
231
|
`zero | one | many` (`0` → first, `1` → second, otherwise last).
|
|
131
|
-
- **Fallback** — a
|
|
132
|
-
`fallbackLocale`; if still missing, the key
|
|
133
|
-
|
|
232
|
+
- **Fallback** — a string missing in the active locale is looked up in
|
|
233
|
+
`fallbackLocale`; if it is still missing, the key is used as the message — and
|
|
234
|
+
is then interpolated and pluralized like any other hit.
|
|
235
|
+
|
|
236
|
+
That last point is what lets the source language skip having a catalog:
|
|
134
237
|
|
|
135
238
|
```typescript
|
|
136
|
-
|
|
137
|
-
|
|
239
|
+
__("Hello, {name}!", { name: "Alice" }); // "Hello, Alice!" — with no en.json
|
|
240
|
+
__("{count} apple|{count} apples", { count: 5 }); // "5 apples"
|
|
138
241
|
```
|
|
139
242
|
|
|
243
|
+
A string nobody has translated yet renders as the English that was typed, on the
|
|
244
|
+
day it was typed, in every locale. Untranslated is not the same as broken.
|
|
245
|
+
|
|
246
|
+
## Two English strings, one translation
|
|
247
|
+
|
|
248
|
+
Identical English collapses to one catalog entry. Usually that is the point —
|
|
249
|
+
"Sign in" on the button and "Sign in" in the nav want the same translation, and
|
|
250
|
+
under dotted keys they were two entries that could drift apart.
|
|
251
|
+
|
|
252
|
+
Where it bites is a word doing two jobs. "Unassigned" describing a count of
|
|
253
|
+
issues and "Unassigned" describing a missing person are one key here, and a
|
|
254
|
+
language that renders them differently cannot express both. The fix is to make
|
|
255
|
+
the English say what it means:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
__("Unassigned issues"); // the dashboard count
|
|
259
|
+
__("Unassigned"); // the person who is not there
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
If two uses of a word need two translations, they usually needed two different
|
|
263
|
+
English strings as well — the ambiguity was there before the translator found it.
|
|
264
|
+
|
|
265
|
+
## Strings that are not sentences
|
|
266
|
+
|
|
267
|
+
Enum values and column names cannot be passed to `__()` — `in_progress` is not
|
|
268
|
+
English. Map them to English first, then translate the result:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
272
|
+
backlog: "Backlog",
|
|
273
|
+
in_progress: "In progress",
|
|
274
|
+
done: "Done",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
__(STATUS_LABEL[issue.status] ?? issue.status);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The `?? issue.status` matters: a status added server-side renders as its raw
|
|
281
|
+
name rather than vanishing from the page.
|
|
282
|
+
|
|
140
283
|
## Locale resolution
|
|
141
284
|
|
|
142
285
|
`resolveLocale(request, config)` tries each configured resolver in order and only
|
|
@@ -160,7 +303,7 @@ returns a value listed in `supportedLocales` (otherwise `defaultLocale`):
|
|
|
160
303
|
## Overriding the locale
|
|
161
304
|
|
|
162
305
|
The locale is resolved once, when `LocaleMiddleware` runs, and is then fixed for
|
|
163
|
-
the rest of the request — so `ctx.locale` and `ctx.
|
|
306
|
+
the rest of the request — so `ctx.locale` and `ctx.__()` always reflect what the
|
|
164
307
|
resolvers chose. There is no `setLocale()`. To change the language, do one of two
|
|
165
308
|
things.
|
|
166
309
|
|
|
@@ -182,136 +325,86 @@ async setLocale(ctx: HttpContext) {
|
|
|
182
325
|
```
|
|
183
326
|
|
|
184
327
|
**Override within the current request** — e.g. to honour a locale stored on the
|
|
185
|
-
user — by running code inside `I18nContext.run()`. The `Lang` facade and `
|
|
328
|
+
user — by running code inside `I18nContext.run()`. The `Lang` facade and `__()`
|
|
186
329
|
helper use the supplied locale for the duration of the callback:
|
|
187
330
|
|
|
188
331
|
```typescript
|
|
189
332
|
// in a controller
|
|
190
|
-
import { I18nContext,
|
|
333
|
+
import { I18nContext, __ } from "@zerotal/i18n";
|
|
191
334
|
|
|
192
335
|
async show(ctx: HttpContext) {
|
|
193
336
|
const user = ctx.user as { name?: string; locale?: string } | undefined;
|
|
194
337
|
const locale = user?.locale ?? ctx.locale;
|
|
195
338
|
|
|
196
339
|
const greeting = I18nContext.run(locale, () =>
|
|
197
|
-
|
|
340
|
+
__("Hello, {name}!", { name: user?.name }),
|
|
198
341
|
);
|
|
199
342
|
|
|
200
343
|
return ctx.view(DashboardPage({ greeting }));
|
|
201
344
|
}
|
|
202
345
|
```
|
|
203
346
|
|
|
204
|
-
> **Note** — `I18nContext.run()` only affects the `Lang` facade and `
|
|
205
|
-
> its callback. `ctx.
|
|
206
|
-
> the request's resolved locale. For a persistent change, set the cookie above.
|
|
347
|
+
> **Note** — `I18nContext.run()` only affects the `Lang` facade and `__()` inside
|
|
348
|
+
> its callback. `ctx.__()` and `ctx.locale` were bound by the middleware and stay
|
|
349
|
+
> on the request's resolved locale. For a persistent change, set the cookie above.
|
|
207
350
|
|
|
208
|
-
##
|
|
351
|
+
## Rendering in someone else's language
|
|
209
352
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{
|
|
215
|
-
"auth": {
|
|
216
|
-
"login": {
|
|
217
|
-
"title": "Sign in",
|
|
218
|
-
"submit": "Sign in to your account",
|
|
219
|
-
"forgot": "Forgot your password?"
|
|
220
|
-
},
|
|
221
|
-
"logout": "Sign out"
|
|
222
|
-
},
|
|
223
|
-
"validation": {
|
|
224
|
-
"required": "The :field field is required.",
|
|
225
|
-
"email": "The :field must be a valid email address.",
|
|
226
|
-
"min": "The :field must be at least :min characters."
|
|
227
|
-
},
|
|
228
|
-
"posts": {
|
|
229
|
-
"count": "no posts | one post | :count posts",
|
|
230
|
-
"created": "Post created on :date"
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
```
|
|
353
|
+
A queue job has no request, so there is no ambient locale to read — and the one
|
|
354
|
+
belonging to whoever triggered the job was never the right answer anyway. Mail
|
|
355
|
+
should arrive in the language of the person opening it, so pass the locale
|
|
356
|
+
explicitly as the third argument:
|
|
234
357
|
|
|
235
358
|
```typescript
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
359
|
+
// app/notifications/IssueAssignedNotification.ts
|
|
360
|
+
toMail(notifiable: Notifiable): MailMessage {
|
|
361
|
+
const recipient = notifiable as { name?: string; locale?: string | null };
|
|
362
|
+
const locale = recipient.locale ?? undefined;
|
|
363
|
+
|
|
364
|
+
return new MailMessage()
|
|
365
|
+
.subject(__("{actor} assigned you an issue", { actor: this.assignedBy }, locale))
|
|
366
|
+
.greeting(__("Hello {name},", { name: recipient.name ?? "" }, locale));
|
|
367
|
+
}
|
|
242
368
|
```
|
|
243
369
|
|
|
244
370
|
## Translating validation messages
|
|
245
371
|
|
|
246
|
-
|
|
247
|
-
namespace in each catalog, then translate them with `t()` where you build the
|
|
372
|
+
Write the message as you want to read it, and translate it where you build the
|
|
248
373
|
error response:
|
|
249
374
|
|
|
250
375
|
```json
|
|
251
376
|
// resources/lang/fr.json
|
|
252
377
|
{
|
|
253
|
-
"
|
|
254
|
-
|
|
255
|
-
"email": "Le champ :field doit être une adresse e-mail valide.",
|
|
256
|
-
"min": "Le champ :field doit contenir au moins :min caractères.",
|
|
257
|
-
"unique": "Cette valeur est déjà prise."
|
|
258
|
-
}
|
|
378
|
+
"The {field} field is required.": "Le champ {field} est obligatoire.",
|
|
379
|
+
"The {field} must be a valid email address.": "Le champ {field} doit être une adresse e-mail valide."
|
|
259
380
|
}
|
|
260
381
|
```
|
|
261
382
|
|
|
262
383
|
```typescript
|
|
263
384
|
// in a controller — translate a validation message yourself
|
|
264
|
-
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
> **Note** — The `validation` namespace is just a convention for organizing keys;
|
|
268
|
-
> the i18n package translates any key you pass to `t()`. See
|
|
269
|
-
> [Validator](/docs/validator) for how the validator itself reports errors.
|
|
270
|
-
|
|
271
|
-
## Multiple supported locales
|
|
272
|
-
|
|
273
|
-
List every locale your app ships in `supportedLocales`. The resolver only returns a
|
|
274
|
-
locale from this list — unsupported values fall back to `defaultLocale`:
|
|
275
|
-
|
|
276
|
-
```typescript
|
|
277
|
-
// config/i18n.ts
|
|
278
|
-
export default I18nConfig({
|
|
279
|
-
defaultLocale: "en",
|
|
280
|
-
fallbackLocale: "en",
|
|
281
|
-
supportedLocales: ["en", "fr", "es", "zu", "af"],
|
|
282
|
-
resolvers: ["cookie", "accept-header"],
|
|
283
|
-
});
|
|
385
|
+
__("The {field} field is required.", { field: "email" });
|
|
284
386
|
```
|
|
285
387
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
```
|
|
289
|
-
resources/lang/
|
|
290
|
-
en.json
|
|
291
|
-
fr.json
|
|
292
|
-
es.json
|
|
293
|
-
zu.json ← isiZulu
|
|
294
|
-
af.json ← Afrikaans
|
|
295
|
-
```
|
|
388
|
+
See [Validator](/docs/validator) for how the validator itself reports errors.
|
|
296
389
|
|
|
297
|
-
## Using
|
|
390
|
+
## Using __ in JSX views
|
|
298
391
|
|
|
299
392
|
```tsx
|
|
300
393
|
// app/views/PostCard.tsx
|
|
301
|
-
import {
|
|
394
|
+
import { __ } from "@zerotal/i18n";
|
|
302
395
|
|
|
303
396
|
export function PostCard({ post }: { post: Post }) {
|
|
304
397
|
return (
|
|
305
398
|
<div>
|
|
306
399
|
<h2>{post.title}</h2>
|
|
307
|
-
<p>{
|
|
308
|
-
<a href={`/posts/${post.slug}`}>{
|
|
400
|
+
<p>{__("{count} comment|{count} comments", { count: post.commentCount })}</p>
|
|
401
|
+
<a href={`/posts/${post.slug}`}>{__("Read more")}</a>
|
|
309
402
|
</div>
|
|
310
403
|
);
|
|
311
404
|
}
|
|
312
405
|
```
|
|
313
406
|
|
|
314
|
-
`
|
|
407
|
+
`__()` reads the active locale from `I18nContext` (async local storage) — no props
|
|
315
408
|
threading needed.
|
|
316
409
|
|
|
317
410
|
## Errors
|
|
@@ -322,46 +415,56 @@ thrown when a catalog file exists but contains malformed JSON. Both extend
|
|
|
322
415
|
|
|
323
416
|
## Testing
|
|
324
417
|
|
|
325
|
-
Set your suite up once as described in [Testing](/docs/testing).
|
|
326
|
-
|
|
327
|
-
string before a user does.
|
|
418
|
+
Set your suite up once as described in [Testing](/docs/testing). Two tests earn
|
|
419
|
+
their place.
|
|
328
420
|
|
|
329
|
-
**Assert the
|
|
330
|
-
|
|
421
|
+
**Assert the translated string.** Asserting the English proves nothing, since the
|
|
422
|
+
English is what a completely missing catalog returns:
|
|
331
423
|
|
|
332
424
|
```typescript
|
|
333
425
|
// tests/i18n/catalogues.test.ts
|
|
334
426
|
import { test, expect } from "bun:test";
|
|
335
|
-
import {
|
|
427
|
+
import { __ } from "@zerotal/i18n";
|
|
336
428
|
|
|
337
429
|
test("renders the French cart message", () => {
|
|
338
|
-
expect(
|
|
430
|
+
expect(__("Your cart is empty", {}, "fr")).toBe("Votre panier est vide");
|
|
339
431
|
});
|
|
340
432
|
|
|
341
433
|
test("substitutes replacements", () => {
|
|
342
|
-
expect(
|
|
434
|
+
expect(__("{n} items", { n: 3 }, "fr")).toContain("3");
|
|
343
435
|
});
|
|
344
436
|
```
|
|
345
437
|
|
|
346
|
-
**
|
|
347
|
-
|
|
348
|
-
|
|
438
|
+
**Report which strings a locale is missing.** There is no `en.json` to compare
|
|
439
|
+
against, so the source is the source: scan it for the strings actually passed to
|
|
440
|
+
`__()` and check each locale covers them. Unlike a key-parity test, this also
|
|
441
|
+
catches catalog entries left behind by deleted screens:
|
|
349
442
|
|
|
350
443
|
```typescript
|
|
351
|
-
// tests/i18n/
|
|
444
|
+
// tests/i18n/coverage.test.ts
|
|
352
445
|
import { test, expect } from "bun:test";
|
|
353
446
|
import { loadCatalogs } from "@zerotal/i18n";
|
|
354
447
|
|
|
355
|
-
test("
|
|
356
|
-
const
|
|
357
|
-
const
|
|
448
|
+
test("reports the strings each locale still needs", async () => {
|
|
449
|
+
const used = new Set<string>();
|
|
450
|
+
const glob = new Bun.Glob("**/*.{ts,tsx}");
|
|
451
|
+
for (const file of glob.scanSync({ cwd: "./app", onlyFiles: true })) {
|
|
452
|
+
const source = await Bun.file(`./app/${file}`).text();
|
|
453
|
+
for (const [, text] of source.matchAll(/\b__\(\s*"([^"\\]+)"/g)) used.add(text!);
|
|
454
|
+
}
|
|
358
455
|
|
|
456
|
+
const catalogs = await loadCatalogs("./resources/lang");
|
|
359
457
|
for (const [locale, messages] of Object.entries(catalogs)) {
|
|
360
|
-
|
|
458
|
+
const missing = [...used].filter((text) => !(text in messages));
|
|
459
|
+
expect(missing, `locale: ${locale}`).toEqual([]);
|
|
361
460
|
}
|
|
362
461
|
});
|
|
363
462
|
```
|
|
364
463
|
|
|
464
|
+
> **Note** — start this as a report rather than an assertion if you are adding a
|
|
465
|
+
> language to an existing app: a failing list of 200 strings on day one gets the
|
|
466
|
+
> test deleted, not the strings translated.
|
|
467
|
+
|
|
365
468
|
**Locale resolution is a separate concern** from translation, and fails
|
|
366
469
|
separately — a correct catalogue served under the wrong locale looks like a
|
|
367
470
|
missing translation:
|
|
@@ -381,34 +484,34 @@ res.assertSee("Votre panier est vide");
|
|
|
381
484
|
|
|
382
485
|
**Request context** — added by `LocaleMiddleware`:
|
|
383
486
|
|
|
384
|
-
| Member | Signature
|
|
385
|
-
| ------------ |
|
|
386
|
-
| `ctx.locale` | `string`
|
|
387
|
-
| `ctx.
|
|
487
|
+
| Member | Signature | Description |
|
|
488
|
+
| ------------ | ------------------------------------------------------------------------ | ------------------------------------- |
|
|
489
|
+
| `ctx.locale` | `string` | The locale resolved for this request. |
|
|
490
|
+
| `ctx.__()` | `__(text: string, replacements?: Replacements, locale?: string): string` | Translate using the request locale. |
|
|
388
491
|
|
|
389
492
|
**`Lang` facade** — the `i18n` binding (a `Translator`):
|
|
390
493
|
|
|
391
|
-
| Method | Signature
|
|
392
|
-
| ----------------- |
|
|
393
|
-
| `Lang.translate` | `(
|
|
394
|
-
| `Lang.has` | `(
|
|
395
|
-
| `Lang.locales` | `string[]`
|
|
396
|
-
| `Lang.addCatalog` | `(locale: string, messages: Messages): void`
|
|
494
|
+
| Method | Signature | Description |
|
|
495
|
+
| ----------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
496
|
+
| `Lang.translate` | `(text: string, replacements?: Replacements, locale?: string): string` | Translate; an untranslated string returns itself. |
|
|
497
|
+
| `Lang.has` | `(text: string, locale?: string): boolean` | Whether the string exists in the active or fallback locale. |
|
|
498
|
+
| `Lang.locales` | `string[]` | Loaded locales. |
|
|
499
|
+
| `Lang.addCatalog` | `(locale: string, messages: Messages): void` | Merge messages into a locale (tooling / tests). |
|
|
397
500
|
|
|
398
501
|
**Helpers & context** — importable from `@zerotal/i18n`:
|
|
399
502
|
|
|
400
|
-
| Export | Signature
|
|
401
|
-
| --------------------- |
|
|
402
|
-
| `
|
|
403
|
-
| `I18nContext.run` | `<T>(locale: string, cb: () => T): T`
|
|
404
|
-
| `I18nContext.current` | `(): string \| undefined`
|
|
405
|
-
| `resolveLocale` | `(request: Request, config: I18nConfigShape): string`
|
|
503
|
+
| Export | Signature | Description |
|
|
504
|
+
| --------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ |
|
|
505
|
+
| `__` | `(text: string, replacements?: Replacements, locale?: string): string` | Global translate; reads the active locale. |
|
|
506
|
+
| `I18nContext.run` | `<T>(locale: string, cb: () => T): T` | Run `cb` with `locale` active. |
|
|
507
|
+
| `I18nContext.current` | `(): string \| undefined` | Active locale, or `undefined` outside a request. |
|
|
508
|
+
| `resolveLocale` | `(request: Request, config: I18nConfigShape): string` | Resolve a request's locale per config. |
|
|
406
509
|
|
|
407
510
|
See [Configuration](#configuration) for the `config/i18n.ts` fields.
|
|
408
511
|
|
|
409
512
|
## Next steps
|
|
410
513
|
|
|
411
|
-
- [Validator](/docs/validator) — pair form validation with
|
|
514
|
+
- [Validator](/docs/validator) — pair form validation with translated messages.
|
|
412
515
|
- [Middleware](/docs/middleware) — how `LocaleMiddleware` resolves the request locale.
|
|
413
516
|
- [Cookies](/docs/cookies) — persist a visitor's locale choice.
|
|
414
|
-
- [View](/docs/view) — use `
|
|
517
|
+
- [View](/docs/view) — use `__()` inside server-rendered JSX.
|
package/docs/notifications.md
CHANGED
|
@@ -530,7 +530,7 @@ The broadcast channel works like this:
|
|
|
530
530
|
|
|
531
531
|
```ts
|
|
532
532
|
// in your frontend
|
|
533
|
-
|
|
533
|
+
Socket.private(`notifications.${userId}`).listen("notification", (n) => {
|
|
534
534
|
console.log(n.type, n);
|
|
535
535
|
});
|
|
536
536
|
```
|
package/docs/queue.md
CHANGED
|
@@ -326,11 +326,19 @@ The standard way to process jobs in production is a long-running worker process:
|
|
|
326
326
|
|
|
327
327
|
```bash
|
|
328
328
|
# in your project root
|
|
329
|
-
bun zt queue:work
|
|
330
|
-
bun zt queue:work --queue=emails
|
|
331
|
-
bun zt queue:work --
|
|
329
|
+
bun zt queue:work # process every queue in config.queues
|
|
330
|
+
bun zt queue:work --queue=emails # process one queue
|
|
331
|
+
bun zt queue:work --queue=emails,reports # process several, in priority order
|
|
332
|
+
bun zt queue:work --once # process one job, then exit
|
|
332
333
|
```
|
|
333
334
|
|
|
335
|
+
With no `--queue`, the worker drains every queue listed in `queue.queues` — the
|
|
336
|
+
same key the in-process pool reads — falling back to `["default"]`. That default
|
|
337
|
+
matters more than it looks: a job may pin its own queue, and
|
|
338
|
+
`SendNotificationJob` sets `"notifications"`. List the queues your jobs actually
|
|
339
|
+
use, or the ones you leave out are queued by a documented call and drained by
|
|
340
|
+
nobody.
|
|
341
|
+
|
|
334
342
|
The worker polls continuously, retries failed jobs up to `maxAttempts`, and moves
|
|
335
343
|
permanently-failed jobs to the `zerotal_failed_jobs` table.
|
|
336
344
|
|