@shaferllc/keel 0.66.0 → 0.74.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/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Internationalization
|
|
2
|
+
|
|
3
|
+
Translations with ICU message formatting, plus the `Intl` formatters that go with
|
|
4
|
+
them.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { setTranslations, t } from "@shaferllc/keel/core";
|
|
8
|
+
|
|
9
|
+
setTranslations({
|
|
10
|
+
en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
|
|
11
|
+
fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
t("cart.items", { count: 3 }); // "3 items" — in the request's locale
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
There is **no dependency here, and there doesn't need to be.** `Intl` ships with
|
|
18
|
+
every modern runtime — Node and Cloudflare Workers both carry the full ICU data —
|
|
19
|
+
so plurals, currencies, dates, and relative times are the platform's job. What Keel
|
|
20
|
+
adds is the message parser on top, which is the part `Intl` doesn't do.
|
|
21
|
+
|
|
22
|
+
## Setting it up
|
|
23
|
+
|
|
24
|
+
Register translations once (in a service provider), and add the middleware that
|
|
25
|
+
works out each request's locale:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core";
|
|
29
|
+
|
|
30
|
+
export class I18nServiceProvider extends ServiceProvider {
|
|
31
|
+
boot(): void {
|
|
32
|
+
setI18n(new I18nManager({ defaultLocale: "en" }));
|
|
33
|
+
|
|
34
|
+
setTranslations({
|
|
35
|
+
en: await import("../resources/lang/en.json", { with: { type: "json" } }).then((m) => m.default),
|
|
36
|
+
fr: await import("../resources/lang/fr.json", { with: { type: "json" } }).then((m) => m.default),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.app.make(HttpKernel).use(detectLocale());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Now `t()` works anywhere in the request — a controller, a view, a transformer —
|
|
45
|
+
without threading a locale through every call.
|
|
46
|
+
|
|
47
|
+
## Translation files
|
|
48
|
+
|
|
49
|
+
Nested objects and flat dot-keys are the same thing, and you can mix them:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"cart": {
|
|
54
|
+
"items": "{count, plural, one {# item} other {# items}}",
|
|
55
|
+
"empty": "Your cart is empty"
|
|
56
|
+
},
|
|
57
|
+
"checkout.title": "Checkout"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both `t("cart.items")` and `t("checkout.title")` resolve.
|
|
62
|
+
|
|
63
|
+
## The message format
|
|
64
|
+
|
|
65
|
+
The supported ICU subset is the part people actually use.
|
|
66
|
+
|
|
67
|
+
### Interpolation
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Hello {name}!
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Plurals
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
{count, plural, =0 {Your cart is empty} one {# item} other {# items}}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`#` becomes the count, formatted for the locale (`1,234 items`). An exact `=N`
|
|
80
|
+
branch beats the plural category — which is the whole point of `=0`, because "Your
|
|
81
|
+
cart is empty" reads better than "0 items".
|
|
82
|
+
|
|
83
|
+
**Categories are the locale's, not English's.** French treats 0 and 1 as singular;
|
|
84
|
+
Polish has `one`/`few`/`many`/`other`. That's exactly why you write a message rather
|
|
85
|
+
than `count === 1 ? "item" : "items"` — that ternary is a bug in most of the world.
|
|
86
|
+
|
|
87
|
+
### Ordinals
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
→ 1st, 2nd, 3rd, 4th, 11th.
|
|
94
|
+
|
|
95
|
+
### Select
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
{gender, select, male {He} female {She} other {They}} replied
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
An unmatched value (or a missing one) takes the `other` branch.
|
|
102
|
+
|
|
103
|
+
### Numbers, dates, times
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
{n, number} 1,234.5
|
|
107
|
+
{n, number, percent} 25%
|
|
108
|
+
{n, number, integer} 4
|
|
109
|
+
{n, number, ::currency/USD} $9.50
|
|
110
|
+
{d, date, medium} Jul 11, 2026
|
|
111
|
+
{d, time, short} 3:30 PM
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Nesting
|
|
115
|
+
|
|
116
|
+
Branches are themselves messages, so they nest as deep as you need:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
{count, plural,
|
|
120
|
+
=0 {No messages for {name}}
|
|
121
|
+
one {{name} has # message}
|
|
122
|
+
other {{name} has # messages}}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Literal braces
|
|
126
|
+
|
|
127
|
+
`'{'` and `'}'` render as literal braces.
|
|
128
|
+
|
|
129
|
+
## Formatters
|
|
130
|
+
|
|
131
|
+
`Intl`, bound to the request's locale:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const l = i18n();
|
|
135
|
+
|
|
136
|
+
l.formatNumber(1234.5); // "1,234.5" (de-DE: "1.234,5")
|
|
137
|
+
l.formatCurrency(9.5, "USD"); // "$9.50"
|
|
138
|
+
l.formatDate(order.createdAt); // "Jul 11, 2026"
|
|
139
|
+
l.formatTime(order.createdAt); // "3:30:00 PM"
|
|
140
|
+
l.formatRelativeTime(post.publishedAt); // "3 days ago"
|
|
141
|
+
l.formatList(["a", "b", "c"]); // "a, b, and c"
|
|
142
|
+
l.formatList(names, { type: "disjunction" }); // "a, b, or c"
|
|
143
|
+
l.formatPlural(5); // "other"
|
|
144
|
+
l.formatDisplayName("fr"); // "French"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`formatRelativeTime` picks a sensible unit from the distance on its own — seconds,
|
|
148
|
+
hours, days — or takes one: `formatRelativeTime(date, "hour")`.
|
|
149
|
+
|
|
150
|
+
These are worth using even in a **single-locale** app: they're the correct way to
|
|
151
|
+
render money and dates, and they cost nothing.
|
|
152
|
+
|
|
153
|
+
## Locale detection
|
|
154
|
+
|
|
155
|
+
`detectLocale()` works out the request's locale and stashes it, in this order:
|
|
156
|
+
|
|
157
|
+
1. a custom `resolve(c)` you supply
|
|
158
|
+
2. a query param — `detectLocale({ query: "lang" })` → `?lang=fr`
|
|
159
|
+
3. a cookie — `detectLocale({ cookie: "locale" })`
|
|
160
|
+
4. the `Accept-Language` header (turn it off with `header: false`)
|
|
161
|
+
5. the default locale
|
|
162
|
+
|
|
163
|
+
**Only supported locales are honored**, so `?lang=xx` can't push the app into a
|
|
164
|
+
locale you have no translations for — it falls through to the next source.
|
|
165
|
+
|
|
166
|
+
`negotiateLocale()` is the header parser on its own, if you want it:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en"); // "fr"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It honors `q` weights and matches `fr-CA` against a supported `fr`.
|
|
173
|
+
|
|
174
|
+
## Fallbacks
|
|
175
|
+
|
|
176
|
+
A key with no translation in the active locale falls back — down a chain, not off
|
|
177
|
+
a cliff:
|
|
178
|
+
|
|
179
|
+
1. the locale itself (`es-MX`)
|
|
180
|
+
2. its configured fallback (`fallbackLocales: { "es-MX": "es" }`)
|
|
181
|
+
3. its **base language** (`es`)
|
|
182
|
+
4. the default locale (`en`)
|
|
183
|
+
|
|
184
|
+
So you can ship `es` fully and `es-MX` as a handful of regional overrides, and
|
|
185
|
+
everything else still resolves:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
setTranslations({
|
|
189
|
+
es: { greeting: "Hola", chair: "silla" },
|
|
190
|
+
"es-MX": { chair: "banca" }, // just the override
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
i18n("es-MX").t("chair"); // "banca"
|
|
194
|
+
i18n("es-MX").t("greeting"); // "Hola" — from `es`
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Missing keys
|
|
198
|
+
|
|
199
|
+
A missing key **does not throw**. It renders as the key itself (`cart.items`), so
|
|
200
|
+
the page still works and the gap is obvious rather than blank. It also fires an
|
|
201
|
+
`i18n.missing` event, which is how you find them in production:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
listen("i18n.missing", ({ key, locale }) => {
|
|
205
|
+
logger().warn("missing translation", { key, locale });
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Override what's rendered with the `missing` option:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
new I18nManager({ missing: (key, locale) => `[${locale}:${key}]` });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## API reference
|
|
218
|
+
|
|
219
|
+
### `t(key, data?)`
|
|
220
|
+
|
|
221
|
+
`t(key: string, data?: Record<string, unknown>): string`
|
|
222
|
+
|
|
223
|
+
Translate a key in the **current request's** locale (or the default, outside a
|
|
224
|
+
request), formatting its ICU message with `data`.
|
|
225
|
+
|
|
226
|
+
### `i18n(locale?)`
|
|
227
|
+
|
|
228
|
+
`i18n(locale?: string): I18n`
|
|
229
|
+
|
|
230
|
+
An `I18n` for a locale — or, with no argument, the current request's.
|
|
231
|
+
|
|
232
|
+
### `I18n`
|
|
233
|
+
|
|
234
|
+
| Method | Signature |
|
|
235
|
+
|--------|-----------|
|
|
236
|
+
| `t` | `(key, data?) => string` |
|
|
237
|
+
| `has` | `(key) => boolean` |
|
|
238
|
+
| `formatNumber` | `(value, options?: Intl.NumberFormatOptions) => string` |
|
|
239
|
+
| `formatCurrency` | `(value, currency, options?) => string` |
|
|
240
|
+
| `formatDate` | `(value, options?: Intl.DateTimeFormatOptions) => string` |
|
|
241
|
+
| `formatTime` | `(value, options?) => string` |
|
|
242
|
+
| `formatRelativeTime` | `(value, unit?, options?) => string` |
|
|
243
|
+
| `formatList` | `(items, options?: Intl.ListFormatOptions) => string` |
|
|
244
|
+
| `formatPlural` | `(count, options?) => Intl.LDMLPluralRule` |
|
|
245
|
+
| `formatDisplayName` | `(code, type?) => string` |
|
|
246
|
+
| `locale` | the locale code |
|
|
247
|
+
|
|
248
|
+
### `I18nManager`
|
|
249
|
+
|
|
250
|
+
| Method | Signature |
|
|
251
|
+
|--------|-----------|
|
|
252
|
+
| `add` | `(data: TranslationsByLocale) => this` |
|
|
253
|
+
| `load` | `(...loaders: TranslationLoader[]) => Promise<this>` |
|
|
254
|
+
| `locale` | `(code?) => I18n` |
|
|
255
|
+
| `supported` | `() => string[]` |
|
|
256
|
+
| `defaultLocale` | the default locale code |
|
|
257
|
+
|
|
258
|
+
`new I18nManager(options)` — see `I18nOptions`.
|
|
259
|
+
|
|
260
|
+
### `setI18n(manager)` / `getI18n()` / `setTranslations(data)`
|
|
261
|
+
|
|
262
|
+
Replace the active manager, read it, or add translations to it.
|
|
263
|
+
|
|
264
|
+
### `detectLocale(options?)`
|
|
265
|
+
|
|
266
|
+
`detectLocale(options?: DetectLocaleOptions): MiddlewareHandler`
|
|
267
|
+
|
|
268
|
+
Work out the request's locale and stash it for `t()` / `i18n()`.
|
|
269
|
+
|
|
270
|
+
### `negotiateLocale(header, supported, defaultLocale)`
|
|
271
|
+
|
|
272
|
+
`negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string`
|
|
273
|
+
|
|
274
|
+
The `Accept-Language` parser, standalone.
|
|
275
|
+
|
|
276
|
+
### `formatMessage(message, data?, locale?)`
|
|
277
|
+
|
|
278
|
+
`formatMessage(message: string, data?: Record<string, unknown>, locale?: string): string`
|
|
279
|
+
|
|
280
|
+
Format an ICU message directly, without a translation lookup.
|
|
281
|
+
|
|
282
|
+
### `objectLoader(data)`
|
|
283
|
+
|
|
284
|
+
`objectLoader(data: TranslationsByLocale): TranslationLoader` — the simplest loader.
|
|
285
|
+
|
|
286
|
+
### Interfaces & types
|
|
287
|
+
|
|
288
|
+
#### `I18nOptions`
|
|
289
|
+
|
|
290
|
+
`{ defaultLocale?, supportedLocales?, fallbackLocales?, missing? }`.
|
|
291
|
+
|
|
292
|
+
#### `DetectLocaleOptions`
|
|
293
|
+
|
|
294
|
+
`{ query?, cookie?, header?, resolve? }`.
|
|
295
|
+
|
|
296
|
+
#### `Translations` / `TranslationsByLocale`
|
|
297
|
+
|
|
298
|
+
A locale's messages (nested or flat), and those keyed by locale.
|
|
299
|
+
|
|
300
|
+
#### `TranslationLoader`
|
|
301
|
+
|
|
302
|
+
`{ load(): Promise<TranslationsByLocale> | TranslationsByLocale }`.
|
package/docs/inertia.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Inertia
|
|
2
|
+
|
|
3
|
+
Keel ships a server-side [Inertia.js](https://inertiajs.com) adapter. Pair Keel's
|
|
4
|
+
routing with an Inertia client (React, Vue, or Svelte) and render page components
|
|
5
|
+
from the server without building an API — `inertia("Page", props)` returns the
|
|
6
|
+
right response automatically.
|
|
7
|
+
|
|
8
|
+
## Configure it
|
|
9
|
+
|
|
10
|
+
Bind an `Inertia` instance in a service provider. You supply the **root view**
|
|
11
|
+
(the HTML shell that embeds the page data and loads your client bundle) and an
|
|
12
|
+
optional asset **version**:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { ServiceProvider, singleton, Inertia, inertiaPageAttr } from "@shaferllc/keel/core";
|
|
16
|
+
|
|
17
|
+
export class InertiaServiceProvider extends ServiceProvider {
|
|
18
|
+
register(): void {
|
|
19
|
+
singleton(
|
|
20
|
+
Inertia,
|
|
21
|
+
() =>
|
|
22
|
+
new Inertia({
|
|
23
|
+
version: "1",
|
|
24
|
+
rootView: (page) =>
|
|
25
|
+
`<!DOCTYPE html><html><head><meta charset="utf-8"></head>` +
|
|
26
|
+
`<body><div id="app" data-page="${inertiaPageAttr(page)}"></div>` +
|
|
27
|
+
`<script src="/assets/app.js"></script></body></html>`,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`inertiaPageAttr(page)` serializes and HTML-escapes the page object for the
|
|
35
|
+
`data-page` attribute.
|
|
36
|
+
|
|
37
|
+
> **The version defaults to `"1"`.** Omit it and every deploy reports the same
|
|
38
|
+
> asset version — fine until you ship new assets, at which point stale clients
|
|
39
|
+
> won't be told to hard-reload. Bump it (a build hash, a timestamp) whenever your
|
|
40
|
+
> bundle changes so the adapter can force a full reload on mismatch.
|
|
41
|
+
|
|
42
|
+
## Render a page
|
|
43
|
+
|
|
44
|
+
From a controller, or straight from a route:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { inertia } from "@shaferllc/keel/core";
|
|
48
|
+
|
|
49
|
+
// controller
|
|
50
|
+
show() {
|
|
51
|
+
return inertia("Users/Show", { user: getUser(param("id")) });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// brisk route
|
|
55
|
+
router.on("/dashboard").renderInertia("Dashboard", { title: "Welcome" });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`inertia()` looks up the bound `Inertia` instance and delegates to its `render`.
|
|
59
|
+
The component name is the client-side path Inertia resolves (e.g. `Users/Show`
|
|
60
|
+
maps to your `Pages/Users/Show` component); `props` is any JSON-serializable
|
|
61
|
+
object.
|
|
62
|
+
|
|
63
|
+
> **Configure the adapter before you render.** `inertia()` throws
|
|
64
|
+
> `Inertia is not configured…` if no `Inertia` instance is bound in the
|
|
65
|
+
> container. Register the provider (above) during boot, before any route runs.
|
|
66
|
+
|
|
67
|
+
## What the adapter does
|
|
68
|
+
|
|
69
|
+
It implements the Inertia protocol for you. Every branch below is decided from
|
|
70
|
+
the incoming request headers — you call `inertia("Page", props)` once and the
|
|
71
|
+
adapter picks the response:
|
|
72
|
+
|
|
73
|
+
| Situation | Response |
|
|
74
|
+
|-----------|----------|
|
|
75
|
+
| First visit (no `X-Inertia` header) | The full HTML document from your `rootView` (a `string`) |
|
|
76
|
+
| Inertia navigation (`X-Inertia: true`) | `{ component, props, url, version }` JSON + `X-Inertia: true` and `Vary: X-Inertia` headers |
|
|
77
|
+
| Asset version changed (GET) | `409` + `X-Inertia-Location` so the client hard-reloads |
|
|
78
|
+
| Partial reload (`X-Inertia-Partial-Data`) | Only the requested props, for the matching component |
|
|
79
|
+
|
|
80
|
+
The `url` embedded in the page object is the request's `pathname + search` —
|
|
81
|
+
Inertia uses it to keep the browser history in sync.
|
|
82
|
+
|
|
83
|
+
### Version mismatches
|
|
84
|
+
|
|
85
|
+
The version check only fires on a **GET** Inertia request whose
|
|
86
|
+
`X-Inertia-Version` header differs from the adapter's configured version. On a
|
|
87
|
+
mismatch the adapter returns an empty `409` with `X-Inertia-Location` set to the
|
|
88
|
+
current URL; the Inertia client sees the `409` and does a full page reload to
|
|
89
|
+
pull fresh assets. Non-GET requests (a form POST, say) skip the check and render
|
|
90
|
+
normally.
|
|
91
|
+
|
|
92
|
+
### Partial reloads
|
|
93
|
+
|
|
94
|
+
When the client asks for a partial reload it sends two headers:
|
|
95
|
+
`X-Inertia-Partial-Component` (the component it already has mounted) and
|
|
96
|
+
`X-Inertia-Partial-Data` (a comma-separated list of prop keys it wants). The
|
|
97
|
+
adapter only trims props when the partial component **matches** the component
|
|
98
|
+
you're rendering — so a partial reload of `Users/Index` won't accidentally
|
|
99
|
+
strip props when you render `Users/Show`. Matching props are filtered down to
|
|
100
|
+
the requested keys; everything else is dropped from the payload.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// Client requests only `notifications` for the already-mounted Dashboard.
|
|
104
|
+
// The adapter sends { component: "Dashboard", props: { notifications }, ... }.
|
|
105
|
+
inertia("Dashboard", { stats, notifications, activity });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> Partial reloads are an **allow-list** (the `only` mechanism). The adapter does
|
|
109
|
+
> not implement Inertia's `except` variant — every listed key is kept, all others
|
|
110
|
+
> are dropped.
|
|
111
|
+
|
|
112
|
+
## The client
|
|
113
|
+
|
|
114
|
+
The adapter is the server half. On the client, set up Inertia as usual
|
|
115
|
+
(`@inertiajs/react` / `-vue` / `-svelte`) pointing at `#app`, and build your
|
|
116
|
+
`app.js` bundle referenced by the root view. See
|
|
117
|
+
[inertiajs.com](https://inertiajs.com) for the client setup.
|
|
118
|
+
|
|
119
|
+
## Related
|
|
120
|
+
|
|
121
|
+
`inertia()` resolves the `Inertia` instance from the [container](./container.md),
|
|
122
|
+
so it's bound like any other [service provider](./providers.md) singleton. The
|
|
123
|
+
`renderInertia` brisk-route helper lives on the [router](./routing.md).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## API reference
|
|
128
|
+
|
|
129
|
+
### `inertia(component, props?)`
|
|
130
|
+
|
|
131
|
+
`inertia(component: string, props?: Record<string, unknown>): Response | string`
|
|
132
|
+
|
|
133
|
+
Renders an Inertia response for the current request using the `Inertia` instance
|
|
134
|
+
bound in the container.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { inertia } from "@shaferllc/keel/core";
|
|
138
|
+
|
|
139
|
+
return inertia("Users/Show", { user });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Notes:** `props` defaults to `{}`. Throws `Inertia is not configured…` if no
|
|
143
|
+
`Inertia` instance is bound — bind one in a provider first. Returns a `string`
|
|
144
|
+
(the `rootView` HTML) on a first load and a `Response` (JSON, or a `409`) on an
|
|
145
|
+
Inertia navigation, so it fits anywhere a route handler can return either.
|
|
146
|
+
|
|
147
|
+
### `inertiaPageAttr(page)`
|
|
148
|
+
|
|
149
|
+
`inertiaPageAttr(page: InertiaPage): string`
|
|
150
|
+
|
|
151
|
+
HTML-escapes a JSON-serialized page object for embedding in the `data-page`
|
|
152
|
+
attribute of your root element.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
`<div id="app" data-page="${inertiaPageAttr(page)}"></div>`;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Notes:** escapes `&`, `"`, `'`, `<`, and `>` (in that order, so `&` isn't
|
|
159
|
+
double-escaped). Use it only inside a double-quoted attribute in your `rootView`
|
|
160
|
+
— it is the escaping counterpart the Inertia client reads back off `#app`.
|
|
161
|
+
|
|
162
|
+
### `Inertia`
|
|
163
|
+
|
|
164
|
+
The adapter itself. Construct one and bind it as a container singleton; the
|
|
165
|
+
`inertia()` helper resolves it per request. You rarely call its methods directly
|
|
166
|
+
— `inertia()` and `renderInertia()` do.
|
|
167
|
+
|
|
168
|
+
#### `new Inertia(options)`
|
|
169
|
+
|
|
170
|
+
`new Inertia(options: InertiaOptions)`
|
|
171
|
+
|
|
172
|
+
Creates an adapter with a root view and an optional asset version.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
new Inertia({
|
|
176
|
+
version: "1",
|
|
177
|
+
rootView: (page) => `<div id="app" data-page="${inertiaPageAttr(page)}"></div>`,
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Notes:** `options.version` defaults to `"1"` when omitted; `options.rootView`
|
|
182
|
+
is required.
|
|
183
|
+
|
|
184
|
+
#### `render(component, props?)`
|
|
185
|
+
|
|
186
|
+
`render(component: string, props?: Record<string, unknown>): Response | string`
|
|
187
|
+
|
|
188
|
+
Produces the correct response for the current request: the full HTML document on
|
|
189
|
+
a first visit, the page JSON on an Inertia navigation, a `409` on a version
|
|
190
|
+
mismatch, or a trimmed payload for a partial reload.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const html = new Inertia({ rootView }).render("Dashboard", { title: "Welcome" });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Notes:** reads the active request from `ctx()`, so call it inside a request
|
|
197
|
+
(the `inertia()` helper does this for you). `props` defaults to `{}`. The JSON
|
|
198
|
+
branch sets `X-Inertia: true` and `Vary: X-Inertia`; the `409` branch sets
|
|
199
|
+
`X-Inertia-Location`.
|
|
200
|
+
|
|
201
|
+
### Interfaces & types
|
|
202
|
+
|
|
203
|
+
#### `InertiaOptions`
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
interface InertiaOptions {
|
|
207
|
+
version?: string;
|
|
208
|
+
rootView: (page: InertiaPage) => string;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The constructor argument. `version` is the asset version (default `"1"`); a
|
|
213
|
+
mismatch against the client's `X-Inertia-Version` forces a full reload.
|
|
214
|
+
`rootView` renders the HTML shell for a first, non-XHR load — it receives the
|
|
215
|
+
`InertiaPage` and must embed it (typically via `inertiaPageAttr`) so the client
|
|
216
|
+
can boot.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const options: InertiaOptions = {
|
|
220
|
+
version: "abc123",
|
|
221
|
+
rootView: (page) =>
|
|
222
|
+
`<div id="app" data-page="${inertiaPageAttr(page)}"></div>`,
|
|
223
|
+
};
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### `InertiaPage`
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
interface InertiaPage {
|
|
230
|
+
component: string;
|
|
231
|
+
props: Record<string, unknown>;
|
|
232
|
+
url: string;
|
|
233
|
+
version: string;
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The Inertia page object — the payload both the JSON response and the `rootView`
|
|
238
|
+
receive. `component` is the page name, `props` its (possibly partial-reload
|
|
239
|
+
filtered) data, `url` the request's `pathname + search`, and `version` the
|
|
240
|
+
adapter's asset version. You consume it inside `rootView`; you don't build it
|
|
241
|
+
yourself.
|