create-hozu 0.6.0 → 0.8.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/dist/guide.d.ts +29 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +83 -0
- package/dist/guide.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +39 -92
- package/skill/example/{server.ts → app.ts} +10 -9
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/views.ts +2 -95
- package/skill/example/hozu.config.ts +4 -1
- package/skill/topics/auth.md +10 -6
- package/skill/topics/content.md +1 -1
- package/skill/topics/contracts.md +8 -5
- package/skill/topics/data.md +19 -6
- package/skill/topics/deploy.md +13 -9
- package/skill/topics/diagnostics.md +28 -11
- package/skill/topics/endpoints.md +21 -9
- package/skill/topics/feature.md +79 -0
- package/skill/topics/forms.md +14 -3
- package/skill/topics/http.md +1 -1
- package/skill/topics/i18n.md +11 -6
- package/skill/topics/machine.md +8 -2
- package/skill/topics/pages.md +18 -4
- package/skill/topics/patterns.md +34 -4
- package/skill/topics/recipes.md +48 -0
- package/skill/topics/testing.md +42 -17
- package/skill/topics/views.md +12 -7
- package/skill/topics/widgets.md +6 -4
- package/templates/app/app.ts +7 -0
- package/templates/app/features/site/feature.ts +8 -0
- package/templates/app/features/site/views.ts +1 -7
- package/templates/app/hozu.config.ts +3 -1
- package/templates/guide.md +4 -16
- package/skill/changing.md +0 -91
- package/skill/example/serve.ts +0 -14
- package/templates/app/serve.ts +0 -14
- package/templates/app/server.ts +0 -6
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import { contract,
|
|
1
|
+
import { contract, ui } from '@hozu/core'
|
|
2
2
|
import { bookmarkPage, home } from '../../routes.ts'
|
|
3
3
|
import {
|
|
4
4
|
Add,
|
|
5
5
|
addBookmark,
|
|
6
6
|
bookmarksMachine,
|
|
7
|
-
bookmarksTag,
|
|
8
7
|
Draft,
|
|
9
|
-
DUPLICATE,
|
|
10
8
|
getBookmark,
|
|
11
9
|
isEmpty,
|
|
12
10
|
listBookmarks,
|
|
13
11
|
ToggleRead,
|
|
14
|
-
toggleRead,
|
|
15
12
|
visible,
|
|
16
13
|
} from './model.ts'
|
|
17
14
|
|
|
@@ -132,16 +129,10 @@ export const Detail = ui.view({
|
|
|
132
129
|
},
|
|
133
130
|
},
|
|
134
131
|
),
|
|
135
|
-
ui.a({ href: ui.link(home, null
|
|
132
|
+
ui.a({ href: ui.link(home, null), class: 'underline' }, ['Back']),
|
|
136
133
|
]),
|
|
137
134
|
})
|
|
138
135
|
|
|
139
|
-
export const typesDraft = contract(bookmarksMachine, {
|
|
140
|
-
given: { state: 'idle' },
|
|
141
|
-
when: [{ send: Draft, payload: { text: 'Hozu' } }],
|
|
142
|
-
expect: { state: 'idle', changes: { draft: 'Hozu' } },
|
|
143
|
-
})
|
|
144
|
-
|
|
145
136
|
export const addsBookmark = contract(bookmarksMachine, {
|
|
146
137
|
given: { state: 'idle' },
|
|
147
138
|
when: [
|
|
@@ -158,87 +149,3 @@ export const addsBookmark = contract(bookmarksMachine, {
|
|
|
158
149
|
],
|
|
159
150
|
},
|
|
160
151
|
})
|
|
161
|
-
|
|
162
|
-
export const rejectsDuplicate = contract(bookmarksMachine, {
|
|
163
|
-
given: { state: 'adding' },
|
|
164
|
-
when: [{ failed: addBookmark, error: 'Duplicate', data: { title: 'Hozu talk' } }],
|
|
165
|
-
expect: { state: 'idle', changes: { error: DUPLICATE } },
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
export const rejectsInvalidTitle = contract(bookmarksMachine, {
|
|
169
|
-
given: { state: 'adding' },
|
|
170
|
-
when: [
|
|
171
|
-
{
|
|
172
|
-
failed: addBookmark,
|
|
173
|
-
error: 'Invalid',
|
|
174
|
-
data: {
|
|
175
|
-
message: 'title: Use at least 2 characters',
|
|
176
|
-
fields: { title: 'Use at least 2 characters', kind: null },
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
expect: { state: 'idle', changes: { fields: { title: 'Use at least 2 characters' } } },
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
export const addFails = contract(bookmarksMachine, {
|
|
184
|
-
given: { state: 'adding' },
|
|
185
|
-
when: [{ failed: addBookmark, error: 'Unexpected', data: { message: 'offline' } }],
|
|
186
|
-
expect: { state: 'idle', changes: { error: 'offline' } },
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
export const togglesRead = contract(bookmarksMachine, {
|
|
190
|
-
given: { state: 'idle' },
|
|
191
|
-
when: [
|
|
192
|
-
{ send: ToggleRead, payload: { id: 'b1' } },
|
|
193
|
-
{ done: toggleRead, result: { id: 'b1', title: 'A', kind: 'article', read: true } },
|
|
194
|
-
],
|
|
195
|
-
expect: {
|
|
196
|
-
state: 'idle',
|
|
197
|
-
changes: { target: 'b1' },
|
|
198
|
-
effects: [{ effect: toggleRead, input: { id: 'b1' } }],
|
|
199
|
-
},
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
export const toggleMissing = contract(bookmarksMachine, {
|
|
203
|
-
given: { state: 'toggling' },
|
|
204
|
-
when: [{ failed: toggleRead, error: 'NotFound', data: { id: 'b9' } }],
|
|
205
|
-
expect: { state: 'idle' },
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
export const toggleFails = contract(bookmarksMachine, {
|
|
209
|
-
given: { state: 'toggling' },
|
|
210
|
-
when: [{ failed: toggleRead, error: 'Unexpected', data: { message: 'offline' } }],
|
|
211
|
-
expect: { state: 'idle', changes: { error: 'offline' } },
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
export const bookmarks = feature({
|
|
215
|
-
id: 'bookmarks',
|
|
216
|
-
intent: {
|
|
217
|
-
summary:
|
|
218
|
-
'A shared reading list: add bookmarks with a kind, mark them read, filter unread, one page each.',
|
|
219
|
-
invariants: ['Titles are unique, case-insensitive', 'New bookmarks are listed first'],
|
|
220
|
-
},
|
|
221
|
-
declarations: {
|
|
222
|
-
bookmarksTag,
|
|
223
|
-
Draft,
|
|
224
|
-
Add,
|
|
225
|
-
ToggleRead,
|
|
226
|
-
listBookmarks,
|
|
227
|
-
getBookmark,
|
|
228
|
-
addBookmark,
|
|
229
|
-
toggleRead,
|
|
230
|
-
visible,
|
|
231
|
-
isEmpty,
|
|
232
|
-
bookmarksMachine,
|
|
233
|
-
Board,
|
|
234
|
-
Detail,
|
|
235
|
-
typesDraft,
|
|
236
|
-
addsBookmark,
|
|
237
|
-
rejectsDuplicate,
|
|
238
|
-
rejectsInvalidTitle,
|
|
239
|
-
addFails,
|
|
240
|
-
togglesRead,
|
|
241
|
-
toggleMissing,
|
|
242
|
-
toggleFails,
|
|
243
|
-
},
|
|
244
|
-
})
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { project, ui } from '@hozu/core'
|
|
2
2
|
import { zodAdapter } from '@hozu/schema-zod'
|
|
3
|
+
import { bookmarks } from './features/bookmarks/feature.ts'
|
|
3
4
|
import { getBookmark, listBookmarks } from './features/bookmarks/model.ts'
|
|
4
|
-
import { Board,
|
|
5
|
+
import { Board, Detail } from './features/bookmarks/views.ts'
|
|
5
6
|
import { bookmarkPage, home } from './routes.ts'
|
|
6
7
|
|
|
7
8
|
export default project({
|
|
8
9
|
schema: zodAdapter,
|
|
10
|
+
app: new URL('./app.ts', import.meta.url),
|
|
9
11
|
styles: new URL('./app.css', import.meta.url),
|
|
10
12
|
site: { url: 'http://localhost:3000', name: 'Bookmarks', lang: 'en' },
|
|
11
13
|
routes: { home, bookmarkPage },
|
|
@@ -19,6 +21,7 @@ export default project({
|
|
|
19
21
|
head: {
|
|
20
22
|
query: getBookmark,
|
|
21
23
|
input: (params) => ({ id: params.id }),
|
|
24
|
+
failed: { NotFound: 404 },
|
|
22
25
|
render: (b) => ({ title: b.title, description: b.title, type: 'article' }),
|
|
23
26
|
},
|
|
24
27
|
entries: { query: listBookmarks, input: {}, params: (b) => ({ id: b.id }) },
|
package/skill/topics/auth.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# Sign-in, sessions, per-user data
|
|
2
2
|
|
|
3
3
|
- **Start from the scaffold:** `hozu add feature notes --page / --with auth` writes `features/account` (sign-in page,
|
|
4
|
-
sign-out, `me`),
|
|
5
|
-
|
|
6
|
-
`SESSION_SECURE=true` behind HTTPS).
|
|
4
|
+
sign-out, `me`), per-user resolvers, and a redirect to `/login` when signed out. Replace the name-only sign-in with
|
|
5
|
+
real credentials before production; set `SESSION_SECRET` (production without it refuses to start).
|
|
7
6
|
- `project({ session: z.object({ user: z.string() }) })` declares the identity. Queries with `scope: 'user'` and all
|
|
8
7
|
mutations receive `session`; public queries never do. A mutation calls `setSession(value)` (or `null` to sign out).
|
|
9
|
-
-
|
|
10
|
-
HttpOnly
|
|
11
|
-
|
|
8
|
+
- Sessions live on the server: the default store is `memorySessions()` (from `@hozu/runtime-server`); the cookie holds
|
|
9
|
+
only an opaque, signed, HttpOnly id, so `setSession(null)` revokes it and the session never reaches browser
|
|
10
|
+
JavaScript. It is per process: a restart signs everyone out, and an edge or multi-instance deployment passes a
|
|
11
|
+
shared store explicitly (`createHandler({ session })`).
|
|
12
|
+
- `setSession` also applies in a failing mutation (expiry: `setSession(null)` then `fail('Expired', …)`).
|
|
13
|
+
- After a sign-in or sign-out the page's queries are re-read with the new session; nothing from the old one stays.
|
|
14
|
+
- Guard pages: `head: { query: me, …, failed: { Unauthorized: login } }`; a role check answers 403 with
|
|
15
|
+
`failed: { Unauthorized: login, Forbidden: 403 }` (`hozu docs pages`).
|
|
12
16
|
- Calling another API with a token: keep the token in the session (server side) and read it in the resolver.
|
package/skill/topics/content.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Markdown, images, share images, fonts, preview, offline
|
|
2
2
|
|
|
3
3
|
- **Markdown:** `@hozu/content`: `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`
|
|
4
|
-
in `
|
|
4
|
+
in `app.ts` gives `{ slug, data, html, headings }`; return it from query resolvers and render `ui.html(post.html)`.
|
|
5
5
|
- **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
|
|
6
6
|
dimensions). With `@hozu/image`, `hozu build` adds WebP `srcset` widths.
|
|
7
7
|
- **Share images:** `head.render → image: ui.og({ title, subtitle })` (needs `og: ogImage` from `@hozu/image` in
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Contracts
|
|
2
2
|
|
|
3
|
-
A transition that **decides** needs a contract: a guard, a `navigate`, or a `fn()
|
|
4
|
-
missing one, ready to paste). Transitions that
|
|
5
|
-
|
|
3
|
+
A transition that **decides** needs a contract: a guard, a `navigate`, or a `fn()`, a comparison or a computing
|
|
4
|
+
operator (`+ - ?? ?: .length .includes`) in its values (HZ016 prints each missing one, ready to paste). Transitions that
|
|
5
|
+
only copy values need none (a contract there is HZ058). `hozu.lock.json` records every transition readably and must
|
|
6
|
+
equal the computed lock: any difference is HZ057 until `hozu check --update-lock` accepts it; then list the accepted
|
|
7
|
+
`now:` lines in your summary. A deciding change also needs a contract that fails against the old behaviour (HZ018);
|
|
8
|
+
renaming or copying a contract does not count.
|
|
6
9
|
```ts
|
|
7
10
|
export const addsValid = contract(m, {
|
|
8
|
-
given: { state: 'idle' }, // context
|
|
11
|
+
given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
|
|
9
12
|
when: [
|
|
10
13
|
{ send: Add, payload: { title: 'Milk' } },
|
|
11
14
|
{ done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
|
|
@@ -17,5 +20,5 @@ export const addsValid = contract(m, {
|
|
|
17
20
|
},
|
|
18
21
|
})
|
|
19
22
|
```
|
|
20
|
-
|
|
23
|
+
Export contracts from `views.ts` (or any module the feature lists). When a contract fails (HZ015), decide which is intended — the
|
|
21
24
|
machine or the contract — before changing either.
|
package/skill/topics/data.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
|
|
5
5
|
export const listItems = query({
|
|
6
6
|
input: z.object({}), output: z.array(Item),
|
|
7
|
-
scope: 'public', // 'user' =
|
|
8
|
-
freshness: 'static', // | { revalidate: seconds } | { swr: seconds } | 'live'
|
|
7
|
+
scope: 'public', // 'user' = the session's data (needs project({ session }))
|
|
8
|
+
freshness: 'static', // | 'request' | { revalidate: seconds } | { swr: seconds } | 'live'
|
|
9
9
|
tags: () => [itemsTag()], // optional; (input) => [...]
|
|
10
10
|
})
|
|
11
11
|
export const getItem = query({ input: Key, output: Item, errors: { NotFound: Key }, scope: 'public',
|
|
@@ -15,21 +15,34 @@ export const addItem = mutation({
|
|
|
15
15
|
errors: { Duplicate: z.object({ title: z.string() }) }, // optional: declared failures
|
|
16
16
|
invalidates: () => [itemsTag()], // refreshes queries with these tags
|
|
17
17
|
})
|
|
18
|
-
export const visible = fn({ // computation: pure JS
|
|
18
|
+
export const visible = fn({ // computation: pure JS; may call const/function helpers of this module
|
|
19
19
|
input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
20
20
|
impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
|
|
21
21
|
})
|
|
22
22
|
```
|
|
23
23
|
- Call a `fn` from views or machines with data: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
|
|
24
|
+
- A `fn` body may call functions and JSON constants declared in the same module; they are sent to the browser with
|
|
25
|
+
it. Imported names and `let` state are not (HZ047): pass them as input.
|
|
24
26
|
- Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
|
|
25
27
|
`scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
|
|
26
|
-
-
|
|
28
|
+
- Freshness: public data is `'static'` with tags unless it changes without a declared writer. `'request'` reads
|
|
29
|
+
once per request (any scope; a public one makes its page uncacheable). User data is `'request'` or `'live'` only
|
|
30
|
+
(HZ049). `'live'` is only for push updates and needs tags (HZ050).
|
|
31
|
+
- `invalidates` drives the refresh: after a mutation or endpoint, cached pages and entries with those tags are
|
|
32
|
+
dropped and the page's queries with those tags are re-read. `endpoint({ …, invalidates: (input) => [tag()] })`
|
|
33
|
+
applies when it succeeds (use POST; a GET write is HZ062).
|
|
34
|
+
- Writes from outside (a webhook, a job): `await server.revalidate([itemsTag()])` → `{ entries, pages }`.
|
|
35
|
+
- **Query resolvers only read.** Writes happen in mutation and endpoint resolvers: a query that creates a row on read
|
|
36
|
+
runs again on every request, on prefetch and after a delete (the account comes back). Keep two helpers:
|
|
37
|
+
`listOf(user)` returns the stored list or `[]` for queries; `ownListOf(user)` creates it, for mutations only.
|
|
38
|
+
- **Resolvers** (in `app.ts`, or `features/<name>/server.ts` from the scaffold, spread into it) get the
|
|
39
|
+
schema-parsed input (defaults and transforms applied):
|
|
27
40
|
```ts
|
|
28
|
-
export
|
|
41
|
+
export default app({ resolvers: resolvers(project, (implement) => [
|
|
29
42
|
implement(listItems, () => items.map((i) => ({ ...i }))),
|
|
30
43
|
implement(getItem, ({ id }, { fail }) => items.find((i) => i.id === id) ?? fail('NotFound', { id })),
|
|
31
44
|
implement(addItem, ({ title }, { fail, session }) => /* … */ ),
|
|
32
|
-
])
|
|
45
|
+
]) })
|
|
33
46
|
```
|
|
34
47
|
- Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
|
|
35
48
|
`fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
|
package/skill/topics/deploy.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# Deployment
|
|
2
2
|
|
|
3
|
-
- **
|
|
4
|
-
`
|
|
5
|
-
`
|
|
3
|
+
- **One app module:** `project({ app: new URL('./app.ts', import.meta.url) })`, and `app.ts` default-exports
|
|
4
|
+
`app({ resolvers: resolvers(project, (implement) => [...]), session?, widgets?, og?, csp?, onError?, preview? })`
|
|
5
|
+
from `@hozu/runtime-server`. `hozu serve`, `hozu check`, `hozu get`, `hozu browse` and `testApp(app)` all run this
|
|
6
|
+
module, so what the tools verify is what production serves. There is no wrapper position: headers go through
|
|
7
|
+
`project({ http })`, statuses through `head.failed` and endpoint `failed`, the language through the URL.
|
|
8
|
+
- **Node:** `npm start` is `hozu serve`: adapter-node on `PORT` (and `HOST`), `process.env`, styles, images and
|
|
9
|
+
`public/`. `hozu build` writes `dist/public/`, `dist/manifest.json` and `dist/server/render.js`.
|
|
6
10
|
- **Edge (Bun, Deno, Workers, Vercel):** bundle with `hozuTransform()` from `@hozu/transform/esbuild`, then
|
|
7
|
-
`createHandler(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
-
|
|
11
|
+
`createHandler(app, { manifest, render, env })` from `@hozu/runtime-server` and
|
|
12
|
+
`export default { fetch: handler.fetch }`, where `render` is `import * as render from './dist/server/render.js'`.
|
|
13
|
+
- **Static host (GitHub Pages):** `exportStatic({ build, styles, resolvers: appOptionsOf(app).resolvers, outDir })`
|
|
14
|
+
from `@hozu/adapter-static` writes every page without per-request data, and lists skipped routes.
|
|
15
|
+
- Set `SESSION_SECRET` when the app has sessions (production refuses to start without it). The default store keeps
|
|
16
|
+
sessions in memory per process; an edge or multi-instance deployment passes a shared store as `app({ session })`.
|
|
@@ -7,7 +7,7 @@ around the rule.
|
|
|
7
7
|
|---|---|---|
|
|
8
8
|
| HZ001 | state unreachable | add a transition to it or delete it |
|
|
9
9
|
| HZ002 | event handled nowhere | handle it in a state or remove it |
|
|
10
|
-
| HZ003 / HZ007 | unknown effect / reference |
|
|
10
|
+
| HZ003 / HZ007 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
11
11
|
| HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
|
|
12
12
|
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
|
|
13
13
|
| HZ006 | crossing a feature boundary | import the feature and use its `exports` |
|
|
@@ -15,21 +15,21 @@ around the rule.
|
|
|
15
15
|
| HZ009 | a guardless transition shadows later ones | put guarded transitions first |
|
|
16
16
|
| HZ014 | wrong builder output, or a method called on data (`.map`, `.toUpperCase()`) | follow the builder signature; lists: `ui.each`; computation: a `fn()` |
|
|
17
17
|
| HZ015 / HZ017 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
18
|
-
| HZ016 | a transition that decides (guard, `navigate`, `fn`) has no contract | add the contract from the snippet |
|
|
19
|
-
| HZ018 |
|
|
20
|
-
| HZ021 | a query or
|
|
18
|
+
| HZ016 | a transition that decides (guard, `navigate`, a `fn`, comparison or `+ - ?? ?: .length .includes` in a value) has no contract | add the contract from the snippet |
|
|
19
|
+
| HZ018 | a deciding transition changed (fields first, then `was:` / `now:`) and no covering contract fails against the old behaviour | change or add a contract that specifies the new behaviour; renaming or copying one does not count |
|
|
20
|
+
| HZ021 | a query, mutation or endpoint without a resolver | `implement(...)` it in the resolvers of `app.ts` |
|
|
21
21
|
| HZ022 | user data in a cacheable region | keep `scope: 'user'` queries out of cached pages |
|
|
22
|
-
| HZ024 / HZ025 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` | align them / add `entries` |
|
|
22
|
+
| HZ024 / HZ025 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
|
|
23
23
|
| HZ026 | a class produces no CSS | fix the Tailwind class |
|
|
24
24
|
| HZ027 | a DOM field used outside an event, or wrong for this event | read `ui.dom.*` only in `ui.send` payloads |
|
|
25
25
|
| HZ028 | `img` without width/height | add both |
|
|
26
26
|
| HZ030 | `ui.html` of untrusted data | render text instead |
|
|
27
27
|
| HZ031 | a literal not allowed by its schema | use an allowed value (the patch suggests one) |
|
|
28
|
-
| HZ032 | internal link written as a string | `ui.link(route, params)` |
|
|
29
|
-
| HZ033 | DOM text into an enum, number or boolean field | a `<select
|
|
28
|
+
| HZ032 | internal link or form action written as a string | `ui.link(route, params)` / `ui.link(endpoint)` |
|
|
29
|
+
| HZ033 | DOM text into an enum, number or boolean field | a `<select>`, radios or submit buttons with enum values; in a form, a flag through `ui.dom.formAll` and numbers parsed in the mutation input |
|
|
30
30
|
| HZ034 | a state both handles and ignores an event | remove it from one of the two |
|
|
31
31
|
| HZ035 | search schema is not a flat object of scalars with defaults | `z.object({ key: scalar.default(…) })` |
|
|
32
|
-
| HZ036 | (warning) a form needs JavaScript | read its values with `ui.dom.form('name')` |
|
|
32
|
+
| HZ036 | (warning) a form needs JavaScript | read its values with `ui.dom.form('name')` / `ui.dom.formAll('name')` |
|
|
33
33
|
| HZ037 | a redirect is not a path, hides a page or another redirect, or targets an unknown route | change or remove the `from` key; point `to` at `ui.link(...)` |
|
|
34
34
|
| HZ038 | `http.headers` sets a header the framework owns, or an invalid name/value | remove it (`cache-control` is derived; CSP is `createServer({ csp })`) |
|
|
35
35
|
| HZ039 | `basePath` is not `''` or `/segment[/segment…]` | e.g. `'/shop'`, no trailing slash |
|
|
@@ -37,7 +37,24 @@ around the rule.
|
|
|
37
37
|
| HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
|
|
38
38
|
| HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
|
|
39
39
|
| HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
|
|
40
|
-
| HZ045 | `
|
|
41
|
-
| HZ046 | an endpoint path is reserved, has params
|
|
42
|
-
| HZ047 | a `fn` body uses
|
|
40
|
+
| HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use widgets and `app()` has none | `export default app({ resolvers, widgets: bundleWidgets })` |
|
|
41
|
+
| HZ046 | an endpoint path is reserved, has params or collides; an error without a status; a form posting to it with another method or an undeclared field | a static path such as `/api/…` (patch); map every error in `failed` |
|
|
42
|
+
| HZ047 | a `fn` body uses an imported name or `let` state (it is sent to the browser as source) | pass the value as input, or write it as a `const` helper in the module |
|
|
43
|
+
| HZ048 | `seed` names a field the context lacks, has no machine or route, or two views on one page seed a machine | seed top-level context fields, on one view per page |
|
|
44
|
+
| HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch), or `'live'` for push |
|
|
45
|
+
| HZ050 | a `'live'` query has no tags | add the tags its writers invalidate, or use `'request'` |
|
|
46
|
+
| HZ051 | `head.failed` misses a declared error of the head query, or maps another one | choose per error: a route (303), `403`, `404` or `410` (an intent decision: no patch) |
|
|
47
|
+
| HZ052 | a route that no page renders | link to the endpoint with `ui.link(endpoint, input)` (patch), or add its `ui.page` |
|
|
48
|
+
| HZ053 | (runtime) an endpoint answered `text/html`: a 500 | make it a `ui.page`; statuses and redirects go through `head.failed` |
|
|
49
|
+
| HZ054 | `ui.dom.form` reads one value of a list field or of a repeated name | `ui.dom.formAll('name')` (patch) |
|
|
50
|
+
| HZ055 | a form read names no control of the form | the name it suggests (patch), or add the control |
|
|
51
|
+
| HZ056 | (warning) a submit button also sends on click | `name`/`value` on the button, read in submit; or `type: 'button'` |
|
|
52
|
+
| HZ057 | `hozu.lock.json` differs from the computed lock (new, removed or copy-only changes, contract maps, a missing or 0.7 file) | if intended, `hozu check --update-lock`, then list the accepted `now:` lines in your summary |
|
|
53
|
+
| HZ058 | (warning) contracts that fire only copy-only transitions and evaluate no guard | none needed: the lock entries it names review those transitions |
|
|
54
|
+
| HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`, a spread or `in` | make the helper a `part()`; for a global use an operator or a `fn()` |
|
|
55
|
+
| HZ060 | a page route starts with a locale segment (`/de/…` under `site.locales`) | rename the route (patch); the locale prefix is added for you |
|
|
56
|
+
| HZ061 | (warning) a form-fed event payload declares limits | move them to the mutation input |
|
|
57
|
+
| HZ062 | (warning) a GET endpoint declares `invalidates` | `method: 'POST'`, or keep it on purpose (e-mail links) |
|
|
58
|
+
| HZ063 | (warning) as HZ055, in a form holding `ui.html`, a widget or another view | as HZ055 |
|
|
59
|
+
| HZ064 | two contracts with identical IR | remove one (patch) |
|
|
43
60
|
| HZ042 | `site.locales` empty / missing `site.lang` / not a canonical tag, or `ui.alternate` of an undeclared locale | fix the list (`'zh-TW'`, not `'zh_tw'`) |
|
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
# Endpoints (webhooks, JSON APIs, auth callbacks)
|
|
1
|
+
# Endpoints (webhooks, JSON APIs, auth callbacks, downloads)
|
|
2
2
|
|
|
3
3
|
```ts
|
|
4
|
-
export const
|
|
5
|
-
input: z.object({
|
|
6
|
-
|
|
4
|
+
export const exportNotes = endpoint({ method: 'GET', path: '/api/export', // exported from model.ts
|
|
5
|
+
input: z.object({ format: z.enum(['json', 'csv']).default('json') }), output: z.object({ notes: z.array(Note) }),
|
|
6
|
+
errors: { Unauthorized: z.object({ message: z.string() }) }, failed: { Unauthorized: 401 } })
|
|
7
|
+
implement(exportNotes, (input, { session, fail }) => // in the app's resolvers
|
|
8
|
+
session ? { notes: notesOf(session.user) } : fail('Unauthorized', { message: 'Sign in' }))
|
|
7
9
|
```
|
|
8
|
-
- GET input comes from the query string, POST input from a JSON or form body
|
|
9
|
-
`{ message, fields }
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
10
|
+
- GET input comes from the query string (coerced by the schema), POST input from a JSON or form body. Invalid input
|
|
11
|
+
answers 400 `{ error: 'Invalid', message, fields }`; `fail(name, payload)` answers `failed[name]` with
|
|
12
|
+
`{ error, message, fields? }` (statuses 400 401 403 404 409 410 422 429; every declared error is mapped, HZ046).
|
|
13
|
+
- `output` is one of: a schema (validated, sent as JSON); `'redirect'`, returning `redirect(ui.link(route, params))`
|
|
14
|
+
from the context (303, with basePath); `'response'`, a web `Response` for files and protocol bodies that are
|
|
15
|
+
neither JSON nor HTML. A `text/html` response is a 500 with HZ053: a page is a `ui.page` with `head.failed`.
|
|
16
|
+
- `input: 'raw'` (POST only) skips parsing and gives the resolver `bytes` (a `Uint8Array`), for signed webhooks.
|
|
17
|
+
- `setSession(value)` works on GET too (auth callbacks); OIDC form_post callbacks use `output: 'redirect'`.
|
|
18
|
+
- `invalidates: (input) => [itemsTag()]` refreshes like a mutation's when the endpoint succeeds. A GET endpoint with
|
|
19
|
+
`invalidates` is HZ062 (warning).
|
|
20
|
+
- Links: `ui.link(getEndpoint, input)` is the URL of a GET endpoint (flat scalar input, HZ035);
|
|
21
|
+
`ui.form({ method: 'post', action: ui.link(postEndpoint) }, [...])` posts a native form to a POST endpoint, whose
|
|
22
|
+
input declares every field name (HZ046). Another feature links to it only when it is in `exports` and imported (HZ006).
|
|
23
|
+
- Paths are static and outside pages, redirects and `/_hozu/` (HZ046, with a patch). Responses carry `nosniff` and a
|
|
24
|
+
referrer policy. Cross-site browser POSTs are rejected; server-to-server calls (no `Origin`) are accepted.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# A new feature: the files and a complete example
|
|
2
|
+
|
|
3
|
+
Start with `npx hozu add feature <name> --page / --with auth,detail,toggle,filter,remove` and edit the texts it
|
|
4
|
+
lists. It writes the files below and the first `hozu.lock.json`.
|
|
5
|
+
|
|
6
|
+
## How it works
|
|
7
|
+
- A feature is **declarations**: events, queries / mutations (the only side effects), one machine, views,
|
|
8
|
+
contracts. Builders record them as data (an IR) that is validated, then rendered on the server. Only views bound
|
|
9
|
+
to the machine ship JS.
|
|
10
|
+
- **Callbacks are ordinary TypeScript** (`render`, `guard`, `assign`, `navigate`, `ui.each` / `ui.query`
|
|
11
|
+
callbacks): `===`, `!==`, `<`, `&&`, `||`, `!`, `??`, `c ? a : b`, template strings, `+`, `-`, `.length`, and in
|
|
12
|
+
`assign`, `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(v)`, `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
|
|
13
|
+
Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
|
|
14
|
+
- A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
|
|
15
|
+
`done` / `failed` leave it.
|
|
16
|
+
- A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
|
|
17
|
+
`machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
|
|
18
|
+
- Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
|
|
19
|
+
|
|
20
|
+
## Files
|
|
21
|
+
```
|
|
22
|
+
hozu.config.ts project({ schema, app, site, routes, pages, features }) routes.ts route() declarations
|
|
23
|
+
features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
|
|
24
|
+
features/<name>/feature.ts feature({ declarations: [model, views] }) app.ts app({ resolvers })
|
|
25
|
+
```
|
|
26
|
+
Relative imports end in `.ts`.
|
|
27
|
+
|
|
28
|
+
## A feature in one screen
|
|
29
|
+
```ts
|
|
30
|
+
// model.ts
|
|
31
|
+
export const Item = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
32
|
+
export const Add = event({ payload: z.object({ title: z.string() }) })
|
|
33
|
+
export const itemsTag = tag({ param: null })
|
|
34
|
+
export const listItems = query({ input: z.object({}), output: z.array(Item), scope: 'public',
|
|
35
|
+
freshness: 'static', tags: () => [itemsTag()] })
|
|
36
|
+
export const addItem = mutation({ input: z.object({ title: z.string().min(2, 'Too short') }), output: Item,
|
|
37
|
+
errors: { Duplicate: z.object({ title: z.string() }) }, invalidates: () => [itemsTag()] })
|
|
38
|
+
export const items = machine({
|
|
39
|
+
context: z.object({ draft: z.string(), error: z.string().nullable() }),
|
|
40
|
+
initialContext: { draft: '', error: null },
|
|
41
|
+
initial: 'idle',
|
|
42
|
+
states: ({ ctx }) => ({
|
|
43
|
+
idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title; ctx.error = null } })] },
|
|
44
|
+
adding: {
|
|
45
|
+
invoke: invoke(addItem, {
|
|
46
|
+
input: { title: ctx.draft },
|
|
47
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
48
|
+
failed: {
|
|
49
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
|
|
50
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
})
|
|
56
|
+
// views.ts
|
|
57
|
+
export const Board = ui.view({
|
|
58
|
+
machine: items,
|
|
59
|
+
render: ({ ctx, when }) =>
|
|
60
|
+
ui.main({ class: 'mx-auto max-w-xl' }, [
|
|
61
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
|
|
62
|
+
ui.input({ name: 'title', required: true, value: ctx.draft }),
|
|
63
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
64
|
+
]),
|
|
65
|
+
ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
|
|
66
|
+
when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
|
|
67
|
+
ui.query(listItems, {}, {
|
|
68
|
+
ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
|
|
69
|
+
failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
|
|
70
|
+
}),
|
|
71
|
+
]),
|
|
72
|
+
})
|
|
73
|
+
// feature.ts
|
|
74
|
+
import * as model from './model.ts'
|
|
75
|
+
import * as views from './views.ts'
|
|
76
|
+
export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
|
|
77
|
+
```
|
|
78
|
+
Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
|
|
79
|
+
`implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
|
package/skill/topics/forms.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Forms
|
|
2
2
|
|
|
3
|
-
- **Works without JavaScript** when the submit payload reads only `ui.dom.form('name')`,
|
|
4
|
-
and search (else HZ036 warns): the server runs the same machine for a native post, then
|
|
5
|
-
with the result. Put every value the submit needs in a named field (a `<select name="kind">`).
|
|
3
|
+
- **Works without JavaScript** when the submit payload reads only `ui.dom.form('name')`, `ui.dom.formAll('name')`,
|
|
4
|
+
literals, context, params and search (else HZ036 warns): the server runs the same machine for a native post, then
|
|
5
|
+
redirects or re-renders with the result. Put every value the submit needs in a named field (a `<select name="kind">`).
|
|
6
6
|
```ts
|
|
7
7
|
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
|
|
8
8
|
ui.label({ for: 'title' }, ['Title']),
|
|
@@ -22,3 +22,14 @@ ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
|
|
|
22
22
|
- **Per-item actions without JS:** wrap each button in its own small form.
|
|
23
23
|
- **Enum from a select:** `ui.dom.form('kind')` or `ui.dom.value` fills an enum field only when every literal
|
|
24
24
|
option value is a member (HZ033).
|
|
25
|
+
- **Several values:** `ui.dom.formAll('ids')` is every value of the name in tree order (`[]` when none) for checkbox
|
|
26
|
+
groups, `select multiple` and controls inside `ui.each`, into a list field; `ui.dom.form(name)` is the first (HZ054).
|
|
27
|
+
- **Which button:** give submit buttons `name` and a literal `value` and read `ui.dom.form('action')` in the form's
|
|
28
|
+
submit; JS and no-JS read the same value. Into an enum only when every submit button of the form has that name and
|
|
29
|
+
a member value, else make the field nullable (HZ033). No `on.click` on a submit button (HZ056).
|
|
30
|
+
- **Controls outside the form** (forms cannot nest): `const bulk = ui.formRef()` at module level,
|
|
31
|
+
`ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
|
|
32
|
+
- **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
|
|
33
|
+
`z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
|
|
34
|
+
- **Invalid without JS:** a native post whose payload or mutation input fails re-renders with 400 through
|
|
35
|
+
`failed.Invalid`, like the JS submit. Keep limits out of the event payload (HZ061).
|
package/skill/topics/http.md
CHANGED
|
@@ -12,6 +12,6 @@ http: {
|
|
|
12
12
|
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
|
|
13
13
|
},
|
|
14
14
|
```
|
|
15
|
-
Server options: `
|
|
15
|
+
Server options live in the app module: `app({ resolvers, session?, widgets?, onError?, csp?, og?, preview? })`.
|
|
16
16
|
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
|
|
17
17
|
There are no rewrites: one URL has one owner. For your own HTTP routes, see `hozu docs endpoints`.
|
package/skill/topics/i18n.md
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# Languages
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
`ui.link`
|
|
5
|
-
- `
|
|
6
|
-
|
|
3
|
+
- **The URL holds the language.** Reload, sign-in and sign-out keep it, because every link and `navigate` resolves
|
|
4
|
+
`ui.link` in the page's locale. Do not add a cookie, a session field or a server rewrite for the language.
|
|
5
|
+
- `site: { lang: 'en', locales: ['en', 'de'], … }`: the default locale keeps its URLs (`/posts/a`), the others are
|
|
6
|
+
prefixed (`/de/posts/a`); `/en/posts/a` answers 308 `/posts/a`. There is no `Accept-Language` redirect: a new visit
|
|
7
|
+
to an unprefixed URL shows `site.lang`. Routes and `ui.link` stay locale-free; contracts expect the default URLs.
|
|
8
|
+
`<html lang>`, hreflang and the sitemap are derived. A page route starting with a locale segment is HZ060.
|
|
9
|
+
- A language switch is a link: `ui.a({ href: ui.alternate('de') }, ['Deutsch'])`, or
|
|
10
|
+
`locale === 'en' ? ui.alternate('de') : ui.alternate('en')`.
|
|
11
|
+
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
|
|
12
|
+
exported from a module the feature lists; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
|
|
7
13
|
needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
8
14
|
- Machines never hold translated text (HZ041): store a code and choose the message in the view.
|
|
9
15
|
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
|
10
|
-
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view
|
|
11
|
-
current page in another language.
|
|
16
|
+
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view.
|
package/skill/topics/machine.md
CHANGED
|
@@ -5,10 +5,10 @@ export const m = machine({
|
|
|
5
5
|
context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
|
|
6
6
|
initialContext: { draft: '', error: null, target: '' },
|
|
7
7
|
initial: 'idle',
|
|
8
|
+
on: ({ ctx }) => [on(Draft, { assign: (e) => { ctx.draft = e.text } })], // shared by every state without invoke
|
|
8
9
|
states: ({ ctx }) => ({
|
|
9
10
|
idle: {
|
|
10
11
|
on: [
|
|
11
|
-
on(Draft, { target: 'idle', assign: (e) => { ctx.draft = e.text } }),
|
|
12
12
|
on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
|
|
13
13
|
on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
|
|
14
14
|
on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
|
|
@@ -33,8 +33,14 @@ export const m = machine({
|
|
|
33
33
|
`ctx.list = ctx.list.filter((i) => i.id !== e.id)`. Values are event (`e`), result (`r`) or error fields,
|
|
34
34
|
context, literals, operators and `fn()` calls.
|
|
35
35
|
- **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
36
|
-
- **navigate** sends the browser to `ui.link(route, params, search)` after the transition.
|
|
36
|
+
- **navigate** sends the browser to `ui.link(route, params, search?)` after the transition.
|
|
37
37
|
- `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
|
|
38
|
+
- **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
|
|
39
|
+
and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
|
|
40
|
+
contract covers every copy.
|
|
41
|
+
- **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
|
|
42
|
+
page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
|
|
43
|
+
per page may seed a machine (HZ048).
|
|
38
44
|
- A transition to the same state re-enters it and re-runs its `invoke`: do not handle the busy event in the busy
|
|
39
45
|
state. Machines never hold translated text (store a code, choose the message in the view).
|
|
40
46
|
- Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
|
package/skill/topics/pages.md
CHANGED
|
@@ -21,9 +21,10 @@ export default project({
|
|
|
21
21
|
ui.page(itemPage, {
|
|
22
22
|
views: [Detail],
|
|
23
23
|
head: {
|
|
24
|
-
query: getItem,
|
|
24
|
+
query: getItem,
|
|
25
25
|
input: (params) => ({ id: params.id }),
|
|
26
26
|
render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
|
|
27
|
+
failed: { NotFound: 404 }, // every declared error of the query (HZ051)
|
|
27
28
|
},
|
|
28
29
|
entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
|
|
29
30
|
}),
|
|
@@ -32,8 +33,21 @@ export default project({
|
|
|
32
33
|
})
|
|
33
34
|
```
|
|
34
35
|
- `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image` (a URL, `ui.asset(...)`
|
|
35
|
-
or `ui.og({ title })`), `published`, `noindex`.
|
|
36
|
+
or `ui.og({ title })`), `published`, `noindex`.
|
|
37
|
+
- `head.failed` maps each declared error of the head query to a route without params (303) or to `403`, `404` or
|
|
38
|
+
`410`: `failed: { Unauthorized: login, Forbidden: 403 }`. It is exhaustive (HZ051); `Unexpected` is always 500.
|
|
39
|
+
It maps declared errors only: a head query that always fails is not a redirect.
|
|
40
|
+
- **Which redirect** (one per purpose):
|
|
41
|
+
|
|
42
|
+
| Need | Form |
|
|
43
|
+
|---|---|
|
|
44
|
+
| a static path moved | `http.redirects` (`hozu docs http`) |
|
|
45
|
+
| this visitor may not see the page | `head.failed` |
|
|
46
|
+
| a decision on success, e.g. `/` by session | a GET endpoint with `output: 'redirect'` (`hozu docs endpoints`) |
|
|
47
|
+
| after a machine transition | `navigate` |
|
|
48
|
+
|
|
49
|
+
- A route no page renders is HZ052; link to an endpoint with `ui.link(endpoint, input)` instead.
|
|
36
50
|
- A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
|
|
37
51
|
failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
|
|
38
|
-
- A page loads JS only when a machine-bound part renders on it (`hozu plan <route>`).
|
|
39
|
-
|
|
52
|
+
- A page loads JS only when a machine-bound part renders on it (`hozu plan <route>`). Every link loads a document;
|
|
53
|
+
state across pages lives in the URL (`seed`), on the server (queries) or in a widget's own storage.
|