@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/docs/i18n.md CHANGED
@@ -1,13 +1,23 @@
1
1
  ---
2
2
  title: Internationalization
3
- description: Resolve each visitor's locale automatically, then translate keys with interpolation, pluralization, and fallback.
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 keys with interpolation, pluralization, and fallback — without threading
10
- the locale through every function call.
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.t()`.
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
- Catalog files live under `loadPath`, one JSON file per locale:
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.t()` on the request context:
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.t("welcome.greeting", { name: "Alice" }); // active locale
96
- ctx.t("welcome.greeting", { name: "Alice" }, "fr"); // explicit locale
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 — use the `Lang` facade or the
102
- global `t()` helper. Both read the active request locale from `I18nContext`, so no
103
- locale needs to be passed around:
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
- // anywhere in the request's async tree
107
- import { Lang, t } from "@zerotal/i18n";
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
- Lang.translate("auth.login.title");
110
- t("dashboard.welcome");
182
+ ```tsx
183
+ // resources/js/pages/login.tsx
184
+ <TextField label={__("Email")} type="email" />
111
185
  ```
112
186
 
113
- ## Keys, interpolation, pluralization
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
- Catalogs may use nested objects or flat dotted keys both resolve:
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/en.json
210
+ // resources/lang/fr.json
119
211
  {
120
- "welcome": { "greeting": "Hello, {name}!" },
121
- "validation.required": "The :field field is required.",
122
- "apples": "no apples | one apple | {count} apples"
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 key missing in the active locale is looked up in
132
- `fallbackLocale`; if still missing, the key itself is returned, so gaps are
133
- visible and never throw.
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
- t("apples", { count: 0 }); // "no apples"
137
- t("apples", { count: 5 }); // "5 apples"
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.t()` always reflect what the
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 `t()`
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, t } from "@zerotal/i18n";
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
- t("welcome.greeting", { name: user?.name }),
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 `t()` inside
205
- > its callback. `ctx.t()` and `ctx.locale` were bound by the middleware and stay on
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
- ## Catalog structure
351
+ ## Rendering in someone else's language
209
352
 
210
- Catalogs can be flat or deeply nested both styles resolve with the same dot-path:
211
-
212
- ```json
213
- // resources/lang/en.json
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
- ctx.t("auth.login.title"); // "Sign in"
237
- ctx.t("auth.logout"); // "Sign out"
238
- ctx.t("validation.required", { field: "name" }); // "The name field is required."
239
- ctx.t("posts.count", { count: 0 }); // "no posts"
240
- ctx.t("posts.count", { count: 1 }); // "one post"
241
- ctx.t("posts.count", { count: 42 }); // "42 posts"
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
- A common pattern is to keep your localized form-error strings under a `validation`
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
- "validation": {
254
- "required": "Le champ :field est obligatoire.",
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
- t("validation.required", { field: "email" }); // active locale
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
- Add a catalog file for each:
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 t in JSX views
390
+ ## Using __ in JSX views
298
391
 
299
392
  ```tsx
300
393
  // app/views/PostCard.tsx
301
- import { t } from "@zerotal/i18n";
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>{t("posts.count", { count: post.commentCount })}</p>
308
- <a href={`/posts/${post.slug}`}>{t("posts.readMore")}</a>
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
- `t()` reads the active locale from `I18nContext` (async local storage) — no props
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). Translation
326
- tests are cheap, and the two worth writing are the ones that catch a missing
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 resolved string, not the key.** A test that checks `t("cart.empty")
330
- === "cart.empty"` passes when the catalogue is missing entirely:
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 { t } from "@zerotal/i18n";
427
+ import { __ } from "@zerotal/i18n";
336
428
 
337
429
  test("renders the French cart message", () => {
338
- expect(t("cart.empty", {}, "fr")).toBe("Votre panier est vide");
430
+ expect(__("Your cart is empty", {}, "fr")).toBe("Votre panier est vide");
339
431
  });
340
432
 
341
433
  test("substitutes replacements", () => {
342
- expect(t("cart.count", { n: 3 }, "fr")).toContain("3");
434
+ expect(__("{n} items", { n: 3 }, "fr")).toContain("3");
343
435
  });
344
436
  ```
345
437
 
346
- **Test that every locale has every key.** This is the test that earns its place —
347
- it fails the moment someone adds an English string and forgets the translation,
348
- which is otherwise found in production by a French speaker:
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/parity.test.ts
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("every locale defines the same keys as English", async () => {
356
- const catalogs = await loadCatalogs("./resources/lang");
357
- const english = Object.keys(catalogs.en).sort();
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
- expect(Object.keys(messages).sort(), `locale: ${locale}`).toEqual(english);
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 | Description |
385
- | ------------ | ---------------------------------------------------------------------- | ------------------------------------- |
386
- | `ctx.locale` | `string` | The locale resolved for this request. |
387
- | `ctx.t()` | `t(key: string, replacements?: Replacements, locale?: string): string` | Translate using the request locale. |
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 | Description |
392
- | ----------------- | --------------------------------------------------------------------- | -------------------------------------------------------- |
393
- | `Lang.translate` | `(key: string, replacements?: Replacements, locale?: string): string` | Translate a key; a missing key returns the key itself. |
394
- | `Lang.has` | `(key: string, locale?: string): boolean` | Whether the key exists in the active or fallback locale. |
395
- | `Lang.locales` | `string[]` | Loaded locales. |
396
- | `Lang.addCatalog` | `(locale: string, messages: Messages): void` | Merge messages into a locale (tooling / tests). |
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 | Description |
401
- | --------------------- | --------------------------------------------------------------------- | ------------------------------------------------ |
402
- | `t` | `(key: string, replacements?: Replacements, locale?: string): string` | Global translate; reads the active locale. |
403
- | `I18nContext.run` | `<T>(locale: string, cb: () => T): T` | Run `cb` with `locale` active. |
404
- | `I18nContext.current` | `(): string \| undefined` | Active locale, or `undefined` outside a request. |
405
- | `resolveLocale` | `(request: Request, config: I18nConfigShape): string` | Resolve a request's locale per config. |
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 a translated `validation` namespace.
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 `t()` inside server-rendered JSX.
517
+ - [View](/docs/view) — use `__()` inside server-rendered JSX.
@@ -530,7 +530,7 @@ The broadcast channel works like this:
530
530
 
531
531
  ```ts
532
532
  // in your frontend
533
- Echo.private(`notifications.${userId}`).listen("notification", (n) => {
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 # process the 'default' queue
330
- bun zt queue:work --queue=emails # process a specific queue
331
- bun zt queue:work --once # process one job, then exit
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