@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.
- package/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +14 -2
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- 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 +15 -3
- package/dist/core/helpers.js +23 -3
- package/dist/core/index.d.ts +33 -18
- package/dist/core/index.js +16 -8
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -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 +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -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 +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -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/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -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/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/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -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/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -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/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 +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -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/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- 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
|
+
```
|