@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.
Files changed (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. 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 }`.
@@ -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.