@shaferllc/keel 0.59.0 → 0.68.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 (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -0,0 +1,288 @@
1
+ # Validation
2
+
3
+ `validate()` parses request input against a schema and returns typed data. If
4
+ the input is invalid it throws a `ValidationException`, which the HTTP kernel
5
+ renders as a `422` with per-field errors — no manual checking.
6
+
7
+ ## Bring a schema library
8
+
9
+ Keel's `validate()` is schema-agnostic: it works with any schema that has a
10
+ Zod-style `safeParse`. [Zod](https://zod.dev) is the recommended choice — the
11
+ framework never bundles it, so install it in your app:
12
+
13
+ ```bash
14
+ npm install zod
15
+ ```
16
+
17
+ Nothing about `validate()` is Zod-specific: it only ever calls `schema.safeParse(input)`
18
+ and reads back `.success`, `.data`, and `.error.issues`. Anything that mirrors
19
+ that shape (see the [`Schema`](#schemat) type) works — including a hand-rolled
20
+ validator or a mock in a test.
21
+
22
+ ## Validating a request body
23
+
24
+ Call `validate(schema)` with no data and it parses the JSON body. The result is
25
+ fully typed from the schema:
26
+
27
+ ```ts
28
+ import { json, validate } from "@shaferllc/keel/core";
29
+ import { z } from "zod";
30
+
31
+ const NewUser = z.object({
32
+ email: z.string().email(),
33
+ age: z.number().min(18),
34
+ });
35
+
36
+ export class UserController {
37
+ async store() {
38
+ const data = await validate(NewUser); // { email: string; age: number }
39
+ return json({ created: data.email }, 201);
40
+ }
41
+ }
42
+ ```
43
+
44
+ Invalid input never reaches your logic — it becomes a 422:
45
+
46
+ ```jsonc
47
+ // POST /users { "email": "nope", "age": 15 }
48
+ {
49
+ "error": "The given data was invalid.",
50
+ "status": 422,
51
+ "errors": {
52
+ "email": ["Invalid email address"],
53
+ "age": ["Too small: expected number to be >=18"]
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## Validating other input
59
+
60
+ Pass data explicitly to validate anything — query strings, params, config.
61
+ `validate()` is `async` in **both** forms, so `await` it even when you hand it
62
+ data directly:
63
+
64
+ ```ts
65
+ import { validate, request } from "@shaferllc/keel/core";
66
+
67
+ const Search = z.object({ q: z.string().min(1), page: z.coerce.number().default(1) });
68
+
69
+ async function search() {
70
+ const { q, page } = await validate(Search, request.query());
71
+ // …
72
+ }
73
+ ```
74
+
75
+ The rule is simple: if the second argument is anything other than `undefined`,
76
+ `validate()` parses that value; if it's omitted, it awaits the JSON body. Because
77
+ the check is `data !== undefined`, passing `null` counts as "explicit data" — the
78
+ schema sees `null`, not the body.
79
+
80
+ ## Declarative validation (before the handler)
81
+
82
+ `validate()` above is *imperative* — you call it inside the handler. For the
83
+ common case, `validateRequest()` is a middleware that validates the request
84
+ **before** the handler runs, rejecting a bad request with a `422` so your handler
85
+ only ever sees valid input:
86
+
87
+ ```ts
88
+ import { validateRequest, validated } from "@shaferllc/keel/core";
89
+
90
+ const NewUser = z.object({ email: z.string().email(), name: z.string().min(1) });
91
+
92
+ router
93
+ .post("/users", [Users, "store"])
94
+ .middleware([validateRequest({ body: NewUser })]);
95
+
96
+ // in Users@store — guaranteed valid, fully typed:
97
+ const user = validated<z.infer<typeof NewUser>>("body");
98
+ ```
99
+
100
+ Validate `body`, `query`, and `params` together — errors from every part are
101
+ aggregated into one `422`, keyed `body.field` / `query.field` / `params.field`:
102
+
103
+ ```ts
104
+ router.get("/posts/:id", [Posts, "show"]).middleware([
105
+ validateRequest({
106
+ params: z.object({ id: z.coerce.number() }),
107
+ query: z.object({ page: z.coerce.number().min(1).default(1) }),
108
+ }),
109
+ ]);
110
+ // validated<{ id: number }>("params"); validated<{ page: number }>("query");
111
+ ```
112
+
113
+ `validated(part)` returns the parsed, typed value for that part (defaults to
114
+ `"body"`). Coercion (`z.coerce.number()`) is the schema's job — it applies before
115
+ your handler sees the value. This is the declarative counterpart to Fastify's
116
+ route schemas, on top of the same `validate()` engine.
117
+
118
+ ## Body parsing is JSON-only
119
+
120
+ The no-argument form reads the body with `body()`, which calls `request.json()`.
121
+ That means `validate(schema)` expects a JSON request body; a form-encoded or empty
122
+ body will reject at JSON parse time before the schema even runs. If you need to
123
+ validate merged query + form input, pass it in explicitly:
124
+
125
+ ```ts
126
+ import { validate, request } from "@shaferllc/keel/core";
127
+
128
+ // request.all() merges the query string with the parsed body (JSON or form)
129
+ const data = await validate(NewUser, await request.all());
130
+ ```
131
+
132
+ ## The error shape
133
+
134
+ On failure `validate()` walks `error.issues` and folds them into a
135
+ `Record<string, string[]>` — one array of messages per field:
136
+
137
+ - Each issue's `path` is joined with `.` into a key: a nested path
138
+ `["address", "zip"]` becomes `"address.zip"`.
139
+ - Symbol path segments use their `.description`; everything else is stringified.
140
+ - A **root-level** issue (empty path) is keyed `"_"`.
141
+ - Multiple issues on the same path accumulate in that field's array.
142
+
143
+ ```jsonc
144
+ // nested + root-level errors
145
+ {
146
+ "errors": {
147
+ "address.zip": ["Invalid postal code"],
148
+ "_": ["Passwords do not match"]
149
+ }
150
+ }
151
+ ```
152
+
153
+ That map is exactly what `ValidationException.errors` carries.
154
+
155
+ ## Handling errors yourself
156
+
157
+ `ValidationException` carries the field errors, so a custom error handler (see
158
+ [Errors](./errors.md)) can format them however you like:
159
+
160
+ ```ts
161
+ import { ValidationException } from "@shaferllc/keel/core";
162
+
163
+ if (err instanceof ValidationException) {
164
+ return response.json({ fields: err.errors }, 422);
165
+ }
166
+ ```
167
+
168
+ ---
169
+
170
+ ## API reference
171
+
172
+ ### `validate(schema, data?)`
173
+
174
+ `validate<T>(schema: Schema<T>, data?: unknown): Promise<T>`
175
+
176
+ Parses `data` (or the JSON request body, when `data` is omitted) against `schema`
177
+ and resolves to the typed value, throwing `ValidationException` on failure.
178
+
179
+ ```ts
180
+ import { validate } from "@shaferllc/keel/core";
181
+ import { z } from "zod";
182
+
183
+ const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
184
+
185
+ const fromBody = await validate(NewUser); // parses request.json()
186
+ const fromData = await validate(NewUser, { email, age }); // parses the given value
187
+ ```
188
+
189
+ **Notes:** always returns a `Promise`, in both forms — `await` it even when you
190
+ pass data. The body form calls `body()` (`request.json()`), so it expects a JSON
191
+ body. The "use my data" branch triggers on `data !== undefined`, so `null` is
192
+ treated as explicit input (the schema sees `null`). On failure it throws
193
+ `ValidationException` whose `errors` is a `Record<string, string[]>` keyed by
194
+ dotted field path (root-level issues key `"_"`); it never returns a partial
195
+ result. `T` is inferred from the schema, so the resolved value is fully typed.
196
+
197
+ ### `validateRequest(schemas)`
198
+
199
+ `validateRequest(schemas: RequestSchemas): MiddlewareHandler`
200
+
201
+ Middleware that validates `body` / `query` / `params` before the handler,
202
+ throwing a `422` `ValidationException` (errors from all parts aggregated, keyed
203
+ `part.field`). On success the parsed values are stashed for `validated()`.
204
+
205
+ ```ts
206
+ router.post("/users", [Users, "store"]).middleware([validateRequest({ body: NewUser })]);
207
+ ```
208
+
209
+ **Notes:** validates every declared part and aggregates their errors, rather than
210
+ failing on the first. `body` reads the JSON body; `query`/`params` read the URL.
211
+ Coercion is the schema's responsibility.
212
+
213
+ ### `validated(part?)`
214
+
215
+ `validated<T>(part?: "body" | "query" | "params"): T`
216
+
217
+ The parsed, typed value for a request part (default `"body"`), set by
218
+ `validateRequest`.
219
+
220
+ ```ts
221
+ const user = validated<z.infer<typeof NewUser>>("body");
222
+ ```
223
+
224
+ **Notes:** throws if that part wasn't validated (no `validateRequest` for it), or
225
+ if called outside a request.
226
+
227
+ ### Interfaces & types
228
+
229
+ #### `Schema<T>`
230
+
231
+ ```ts
232
+ interface Schema<T> {
233
+ safeParse(data: unknown):
234
+ | { success: true; data: T }
235
+ | {
236
+ success: false;
237
+ error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> };
238
+ };
239
+ }
240
+ ```
241
+
242
+ The minimal contract `validate()` needs from a schema — a single `safeParse` that
243
+ returns a discriminated `success` union. Zod's `z.object({...})` satisfies it out
244
+ of the box, which is why you normally never write this type by hand. Implement it
245
+ yourself only to plug in a different validator or a test stub:
246
+
247
+ ```ts
248
+ import { validate, type Schema } from "@shaferllc/keel/core";
249
+
250
+ const Positive: Schema<number> = {
251
+ safeParse: (data) =>
252
+ typeof data === "number" && data > 0
253
+ ? { success: true, data }
254
+ : { success: false, error: { issues: [{ path: [], message: "must be > 0" }] } },
255
+ };
256
+
257
+ const n = await validate(Positive, 42); // number
258
+ ```
259
+
260
+ **Notes:** `validate()` only reads `success`, `data`, and `error.issues` (each
261
+ issue's `path` and `message`). It ignores every other field a real Zod result
262
+ carries, so any object matching this shape is enough. An issue with an empty
263
+ `path` (as above) lands under the `"_"` key in the thrown errors.
264
+
265
+ #### `ValidationException`
266
+
267
+ `new ValidationException(errors: Record<string, string[]>, message?: string)`
268
+
269
+ The 422 exception `validate()` throws on failure; re-exported from the framework's
270
+ [HTTP exceptions](./errors.md). You rarely construct it — you catch it.
271
+
272
+ ```ts
273
+ import { ValidationException } from "@shaferllc/keel/core";
274
+
275
+ try {
276
+ await validate(NewUser);
277
+ } catch (err) {
278
+ if (err instanceof ValidationException) {
279
+ err.status; // 422
280
+ err.errors; // Record<string, string[]>, per-field messages
281
+ }
282
+ }
283
+ ```
284
+
285
+ **Notes:** extends `HttpException` with `status` fixed at `422` and a default
286
+ `message` of `"The given data was invalid."`. The per-field `errors` map is the
287
+ one `validate()` built from the schema's issues. See [Errors](./errors.md) for
288
+ how the kernel renders it and how to override that.
package/docs/views.md ADDED
@@ -0,0 +1,267 @@
1
+ # Views
2
+
3
+ Keel renders HTML with **Hono JSX** — type-safe components that run identically
4
+ on Node and on Cloudflare Workers (no filesystem templating, so it ports
5
+ anywhere). Views live by convention in `resources/views/`.
6
+
7
+ ## A view is a component
8
+
9
+ ```tsx
10
+ // resources/views/welcome.tsx
11
+ // @jsxRuntime automatic
12
+ // @jsxImportSource hono/jsx
13
+ import type { FC } from "hono/jsx";
14
+ import { Layout } from "./layout.js";
15
+
16
+ export const WelcomePage: FC<{ appName: string }> = ({ appName }) => (
17
+ <Layout title={appName}>
18
+ <h1>⚓ {appName}</h1>
19
+ <p>Your view is rendering.</p>
20
+ </Layout>
21
+ );
22
+ ```
23
+
24
+ > **The two pragma comments at the top are required** on every `.tsx` file. They
25
+ > tell the compiler (tsx / esbuild / wrangler) to use Hono's JSX runtime instead
26
+ > of React. Without them you'll get `ReferenceError: React is not defined`.
27
+
28
+ ## Layouts are just components
29
+
30
+ Composition is the layout system — a `Layout` component wraps its `children`:
31
+
32
+ ```tsx
33
+ // resources/views/layout.tsx
34
+ // @jsxRuntime automatic
35
+ // @jsxImportSource hono/jsx
36
+ import type { FC, PropsWithChildren } from "hono/jsx";
37
+
38
+ export const Layout: FC<PropsWithChildren<{ title: string }>> = ({
39
+ title,
40
+ children,
41
+ }) => (
42
+ <html lang="en">
43
+ <head>
44
+ <meta charset="utf-8" />
45
+ <title>{title}</title>
46
+ </head>
47
+ <body>{children}</body>
48
+ </html>
49
+ );
50
+ ```
51
+
52
+ ## Rendering a view
53
+
54
+ The quickest way is the global `view()` helper — pass the component and its
55
+ props in one call. Props are type-checked against the component, and the result
56
+ is a complete HTML document (doctype included) you can return straight from a
57
+ route handler:
58
+
59
+ ```ts
60
+ import { config, view } from "@shaferllc/keel/core";
61
+ import type { Ctx } from "@shaferllc/keel/core";
62
+ import { WelcomePage } from "../../resources/views/welcome.js";
63
+
64
+ export class HomeController {
65
+ welcome(c: Ctx) {
66
+ return view(WelcomePage, { appName: config("app.name", "Keel") });
67
+ }
68
+ }
69
+ ```
70
+
71
+ For a component with no props, just pass the component: `view(HomePage)`.
72
+
73
+ Note the view file is imported with a `.js` extension (TypeScript convention)
74
+ even though the source is `.tsx`.
75
+
76
+ ### The long form
77
+
78
+ `view()` is sugar over the `View` service. You can resolve it yourself:
79
+
80
+ ```ts
81
+ import { View } from "@shaferllc/keel/core";
82
+ // inside a controller with the container as `this.app`:
83
+ return this.app.make(View).render(WelcomePage({ appName: "Keel" }));
84
+ ```
85
+
86
+ ## The View service
87
+
88
+ `View` is bound as a singleton in the container.
89
+
90
+ | Method | Purpose |
91
+ |--------|---------|
92
+ | `render(content)` | Render a component / string / promise to a full HTML document (async) |
93
+
94
+ `render()` accepts a JSX node, a raw string, a promise of either, or `null`
95
+ (which renders just the doctype). Configure it by binding your own instance:
96
+
97
+ ```ts
98
+ this.app.singleton(View, () => new View({ doctype: false }));
99
+ ```
100
+
101
+ ## Passing data
102
+
103
+ Props are the data channel — plain typed function arguments:
104
+
105
+ ```ts
106
+ this.app.make(View).render(UserProfile({ user, posts }));
107
+ ```
108
+
109
+ No magic globals, no separate "view data" bag: if a component needs something,
110
+ it's a prop.
111
+
112
+ ## Async views
113
+
114
+ `render()` awaits its content, so a component may be `async` (or use Hono's
115
+ `<Suspense>`) — do data-fetching inside the component and return the resolved
116
+ tree. Both the sync and async cases go through the same call:
117
+
118
+ ```tsx
119
+ // resources/views/dashboard.tsx
120
+ // @jsxRuntime automatic
121
+ // @jsxImportSource hono/jsx
122
+ import type { FC } from "hono/jsx";
123
+
124
+ export const Dashboard: FC<{ userId: number }> = async ({ userId }) => {
125
+ const stats = await loadStats(userId);
126
+ return <pre>{JSON.stringify(stats)}</pre>;
127
+ };
128
+ ```
129
+
130
+ The helper renders it the same way: `return view(Dashboard, { userId })`. The
131
+ returned `Promise<string>` doesn't resolve until the component's own promises do.
132
+
133
+ ## The doctype
134
+
135
+ By default `render()` prepends `<!DOCTYPE html>\n` — the output is a complete
136
+ document ready to serve. Two things to know:
137
+
138
+ - Passing `null`/`undefined` renders **just** the doctype (an empty document
139
+ shell), not an empty string.
140
+ - For fragments — an HTMX swap, an email partial, anything that isn't a
141
+ standalone page — bind a `View` with the doctype off:
142
+
143
+ ```ts
144
+ this.app.singleton(View, () => new View({ doctype: false }));
145
+ ```
146
+
147
+ Now `render()` returns exactly the component's HTML, no prefix.
148
+
149
+ ## Why JSX (and not a file-based template engine)?
150
+
151
+ File-based template engines need to read templates from disk at runtime, which
152
+ doesn't work on edge runtimes like Cloudflare Workers. JSX components compile to
153
+ plain functions, so the exact same view code runs on your Node dev server and on
154
+ a Worker in production. That portability is what lets Keel's own website be a
155
+ Keel app deployed to Cloudflare.
156
+
157
+ ## Related
158
+
159
+ Views are what a [controller](./controllers.md) returns; wire them to URLs in
160
+ [routing](./routing.md). For sending HTML by email rather than over HTTP, the
161
+ same components feed [mail](./mail.md).
162
+
163
+ ---
164
+
165
+ ## API reference
166
+
167
+ ### `view(component, props?)`
168
+
169
+ `view<P>(component: (props: P, ...rest: any[]) => Renderable, props: P): Promise<string>`
170
+ `view(component: (...rest: any[]) => Renderable): Promise<string>`
171
+
172
+ Renders a component through the container's `View` service in one call, returning
173
+ a complete HTML document. The props overload type-checks `props` against the
174
+ component's own prop type.
175
+
176
+ ```ts
177
+ import { view } from "@shaferllc/keel/core";
178
+
179
+ return view(WelcomePage, { appName: "Keel" }); // component with props
180
+ return view(HomePage); // component with no props
181
+ ```
182
+
183
+ **Notes:** resolves the singleton `View` from the active application, so it
184
+ throws `No Keel application has been bootstrapped…` if called before an
185
+ `Application` exists. It calls the component as `component(props)` and renders the
186
+ result — meaning it invokes the function directly rather than through JSX, so
187
+ pass the component itself, not `<WelcomePage />`. Returns a `Promise<string>`;
188
+ return it straight from a route handler.
189
+
190
+ ### `View`
191
+
192
+ The rendering service. Bound as a singleton in the container, so you normally
193
+ reach it via the `view()` helper or `app.make(View)`. Construct one yourself only
194
+ to change its config (e.g. disabling the doctype).
195
+
196
+ #### `new View(config?)`
197
+
198
+ `new View(config?: ViewConfig): View`
199
+
200
+ Creates a view renderer. With no argument the doctype is on.
201
+
202
+ ```ts
203
+ import { View } from "@shaferllc/keel/core";
204
+
205
+ const fragments = new View({ doctype: false });
206
+ ```
207
+
208
+ **Notes:** rebind the singleton to install a custom instance app-wide:
209
+ `app.singleton(View, () => new View({ doctype: false }))`.
210
+
211
+ #### `render(content)`
212
+
213
+ `render(content: Renderable): Promise<string>`
214
+
215
+ Renders a component, string, or promise to an HTML string, awaiting any async
216
+ tree first.
217
+
218
+ ```ts
219
+ await new View().render(WelcomePage({ appName: "Keel" }));
220
+ await new View().render("<p>plain html</p>");
221
+ await new View({ doctype: false }).render(Fragment({}));
222
+ ```
223
+
224
+ **Notes:** `await`s `content`, then `String()`s the result — so an async
225
+ component or a `Promise<string>` resolves before rendering, and a JSX node
226
+ collapses to its HTML. `null`/`undefined` renders just the doctype (or the empty
227
+ string when `doctype: false`). Pass the *called* component (`WelcomePage(props)`),
228
+ not JSX (`<WelcomePage />`), when invoking `render` directly.
229
+
230
+ ### Interfaces & types
231
+
232
+ #### `Renderable`
233
+
234
+ ```ts
235
+ type Renderable =
236
+ | string
237
+ | Promise<string>
238
+ | { toString(): string | Promise<string> }
239
+ | null
240
+ | undefined;
241
+ ```
242
+
243
+ Anything `render()` accepts. Covers a raw HTML string, a promise of one, any
244
+ object with a `toString()` (which is what a Hono JSX node is), or nullish (renders
245
+ empty). It matches the return type of a Hono function component, so components
246
+ drop straight in.
247
+
248
+ ```ts
249
+ const a: Renderable = "<h1>hi</h1>";
250
+ const b: Renderable = Promise.resolve("<h1>hi</h1>");
251
+ const c: Renderable = null;
252
+ ```
253
+
254
+ #### `ViewConfig`
255
+
256
+ ```ts
257
+ interface ViewConfig {
258
+ doctype?: boolean; // default true
259
+ }
260
+ ```
261
+
262
+ The options bag for `new View(...)`. Set `doctype: false` to stop prepending
263
+ `<!DOCTYPE html>` — use it for fragments and partials.
264
+
265
+ ```ts
266
+ const config: ViewConfig = { doctype: false };
267
+ ```