@hozu/cli 0.23.0 → 0.25.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/README.md +3 -1
- package/dist/commands/add.d.ts +1 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +3 -3
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/browse-page.d.ts.map +1 -1
- package/dist/commands/browse-page.js +6 -4
- package/dist/commands/browse-page.js.map +1 -1
- package/dist/commands/browse-tab.d.ts +5 -0
- package/dist/commands/browse-tab.d.ts.map +1 -1
- package/dist/commands/browse-tab.js +18 -3
- package/dist/commands/browse-tab.js.map +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +24 -7
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/call.d.ts.map +1 -1
- package/dist/commands/call.js +26 -4
- package/dist/commands/call.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +12 -4
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/request.d.ts +1 -0
- package/dist/commands/request.d.ts.map +1 -1
- package/dist/commands/request.js +125 -32
- package/dist/commands/request.js.map +1 -1
- package/dist/commands/scaffold.d.ts.map +1 -1
- package/dist/commands/scaffold.js +4 -5
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/contract.d.ts +15 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/gen/go.d.ts +3 -1
- package/dist/gen/go.d.ts.map +1 -1
- package/dist/gen/go.js +34 -4
- package/dist/gen/go.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +4 -2
- package/dist/main.js.map +1 -1
- package/dist/migrate/steps.d.ts +2 -2
- package/dist/migrate/steps.d.ts.map +1 -1
- package/dist/migrate/steps.js +105 -13
- package/dist/migrate/steps.js.map +1 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +7 -2
- package/dist/remote.js.map +1 -1
- package/package.json +9 -9
- package/schema/browse.schema.json +17 -0
- package/schema/call.schema.json +1 -1
- package/schema/gen.schema.json +1 -1
- package/schema/inspect.schema.json +10 -1
- package/schema/migrate.schema.json +10 -2
- package/skill/SKILL.md +4 -5
- package/skill/example/features/bookmarks/views.ts +8 -12
- package/skill/example/ui/button.ts +6 -2
- package/skill/topics/auth.md +2 -2
- package/skill/topics/contracts.md +3 -1
- package/skill/topics/data.md +10 -6
- package/skill/topics/deploy.md +4 -2
- package/skill/topics/diagnostics.md +8 -8
- package/skill/topics/feature.md +5 -5
- package/skill/topics/forms.md +2 -0
- package/skill/topics/http.md +2 -1
- package/skill/topics/i18n.md +4 -2
- package/skill/topics/machine.md +9 -5
- package/skill/topics/pages.md +3 -1
- package/skill/topics/patterns.md +7 -6
- package/skill/topics/recipes.md +15 -5
- package/skill/topics/testing.md +7 -0
- package/skill/topics/views.md +30 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hozu/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The hozu command: check, map, why, show, get, browse, call, plan, add, build, dev, serve, docs, migrate and skill (all with --json)",
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"description": "The hozu command: check, map, why, show, get, browse, call, plan, add, build, export, gen, dev, serve, docs, migrate and skill (all with --json)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hozu",
|
|
7
7
|
"framework",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"access": "public"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@hozu/core": "0.
|
|
52
|
-
"@hozu/devtools": "0.
|
|
53
|
-
"@hozu/
|
|
54
|
-
"@hozu/
|
|
55
|
-
"@hozu/
|
|
56
|
-
"@hozu/
|
|
51
|
+
"@hozu/core": "0.25.0",
|
|
52
|
+
"@hozu/devtools": "0.25.0",
|
|
53
|
+
"@hozu/machine": "0.25.0",
|
|
54
|
+
"@hozu/compiler": "0.25.0",
|
|
55
|
+
"@hozu/transform": "0.25.0",
|
|
56
|
+
"@hozu/validator": "0.25.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"create-hozu": "0.
|
|
59
|
+
"create-hozu": "0.25.0"
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -207,6 +207,23 @@
|
|
|
207
207
|
"additionalProperties": false,
|
|
208
208
|
"description": "Elements the step removed and built again unchanged: same tag, class, text, `name`, `id`, `href`, `src`, `type` and parent path (ADR 0067 C2, ADR 0069 A3). `count` counts every such element, `elements` names the outermost ones as CSS-like paths (`main > form > input[name=card]`)."
|
|
209
209
|
},
|
|
210
|
+
"arrived": {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"properties": {
|
|
213
|
+
"prerendered": {
|
|
214
|
+
"type": "boolean"
|
|
215
|
+
},
|
|
216
|
+
"ms": {
|
|
217
|
+
"type": "number"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"required": [
|
|
221
|
+
"prerendered",
|
|
222
|
+
"ms"
|
|
223
|
+
],
|
|
224
|
+
"additionalProperties": false,
|
|
225
|
+
"description": "How a navigation arrived (ADR 0072 D3): from a speculation prerender or loaded, and the milliseconds from the activation (prerendered) or the navigation start to the first contentful paint."
|
|
226
|
+
},
|
|
210
227
|
"shift": {
|
|
211
228
|
"type": "number",
|
|
212
229
|
"description": "Layout shift no input explains (layout-shift entries without recent input, summed, as CLS counts them)."
|
package/schema/call.schema.json
CHANGED
package/schema/gen.schema.json
CHANGED
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"items": {
|
|
54
54
|
"type": "string"
|
|
55
55
|
},
|
|
56
|
-
"description": "Number fields named like an id or a count, which may want z.int() (ADR 0070 C4); not diagnostics."
|
|
56
|
+
"description": "Number fields named like an id or a count, which may want z.int() (ADR 0070 C4), and enums without a title that became several Go types, which a title makes one; not diagnostics."
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"required": [
|
|
@@ -747,7 +747,9 @@
|
|
|
747
747
|
"alternate",
|
|
748
748
|
"env",
|
|
749
749
|
"session",
|
|
750
|
-
"state"
|
|
750
|
+
"state",
|
|
751
|
+
"route",
|
|
752
|
+
"here"
|
|
751
753
|
]
|
|
752
754
|
},
|
|
753
755
|
"GuardExpr": {
|
|
@@ -1427,6 +1429,13 @@
|
|
|
1427
1429
|
},
|
|
1428
1430
|
"payload": {
|
|
1429
1431
|
"$ref": "#/definitions/ValueExpr"
|
|
1432
|
+
},
|
|
1433
|
+
"keys": {
|
|
1434
|
+
"type": "array",
|
|
1435
|
+
"items": {
|
|
1436
|
+
"type": "string"
|
|
1437
|
+
},
|
|
1438
|
+
"description": "Only these key presses send it, and they do not reach the browser's own shortcuts (ADR 0072 B)."
|
|
1430
1439
|
}
|
|
1431
1440
|
},
|
|
1432
1441
|
"required": [
|
|
@@ -41,15 +41,23 @@
|
|
|
41
41
|
},
|
|
42
42
|
"summary": {
|
|
43
43
|
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"changes": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "string"
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
},
|
|
46
52
|
"required": [
|
|
47
53
|
"from",
|
|
48
54
|
"to",
|
|
49
|
-
"summary"
|
|
55
|
+
"summary",
|
|
56
|
+
"changes"
|
|
50
57
|
],
|
|
51
58
|
"additionalProperties": false
|
|
52
|
-
}
|
|
59
|
+
},
|
|
60
|
+
"description": "Each step's changes: `summary` joins `changes` with \"; \"."
|
|
53
61
|
},
|
|
54
62
|
"changed": {
|
|
55
63
|
"type": "array",
|
package/skill/SKILL.md
CHANGED
|
@@ -15,18 +15,17 @@ Hozu is not in your training data: this file and `npx hozu docs <topic>` are the
|
|
|
15
15
|
2. Edit everything the change needs (table below).
|
|
16
16
|
3. `npx hozu check` once. For an intended behaviour change, `npx hozu check --update-lock`, then list the accepted
|
|
17
17
|
`now:` lines in your summary.
|
|
18
|
-
4. Verify
|
|
19
|
-
`curl`).
|
|
18
|
+
4. Verify with the line `hozu map` prints: `npx hozu browse <path> --session '…' --do '…'` (no server).
|
|
20
19
|
5. Show the person the change: `npx hozu show <file:line> --note "<in their words>"`.
|
|
21
20
|
|
|
22
21
|
## What to touch
|
|
23
22
|
| Change | Touch |
|
|
24
23
|
|---|---|
|
|
25
|
-
| UI
|
|
26
|
-
| Filter / sort in the URL | the route's `search`
|
|
24
|
+
| UI state or a mode (a tab, paused) | a context field → views: `on: { click: ui.set(ctx.tab, 'design') }` |
|
|
25
|
+
| Filter / sort in the URL | the route's `search` → `ui.link(route, params, { key })` → `search.key`; while typing: `seed` from `search`, `replace: () => ui.link(…)` |
|
|
27
26
|
| A per-item action (pin, archive) | model: the item field, an event, a mutation that `invalidates` the list tag, an `on` into a state with `invoke` → views: the per-item form (`hozu docs patterns`) |
|
|
28
27
|
| A control every state handles | `machine({ on: [...] })` |
|
|
29
|
-
| Refresh
|
|
28
|
+
| Refresh | data that changes alone: `freshness: { poll: s }`; on a button or pause: `refresh: () => [tag()]` |
|
|
30
29
|
| New page | `routes.ts` → a view with `route` → `ui.page(...)` in `hozu.config.ts` |
|
|
31
30
|
| UI (a button, a field) | `ui.use` of a kit component (`npx hozu docs components`) |
|
|
32
31
|
|
|
@@ -24,7 +24,7 @@ const shows = [
|
|
|
24
24
|
export const Board = ui.view({
|
|
25
25
|
machine: bookmarksMachine,
|
|
26
26
|
route: home,
|
|
27
|
-
render: ({ ctx, search,
|
|
27
|
+
render: ({ ctx, search, is }) =>
|
|
28
28
|
ui.main({ class: 'mx-auto max-w-xl space-y-6 px-4 py-12' }, [
|
|
29
29
|
ui.h1({ class: 'text-3xl font-bold' }, ['Bookmarks']),
|
|
30
30
|
ui.form(
|
|
@@ -55,20 +55,16 @@ export const Board = ui.view({
|
|
|
55
55
|
{ name: 'kind', 'aria-label': 'Kind', class: 'rounded border px-2 py-2' },
|
|
56
56
|
kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k])),
|
|
57
57
|
),
|
|
58
|
-
ui.use(Button, { props: { type: 'submit' } }, ['Add']),
|
|
58
|
+
ui.use(Button, { props: { type: 'submit', disabled: is(['adding']) } }, ['Add']),
|
|
59
59
|
],
|
|
60
60
|
),
|
|
61
61
|
ctx.error !== null && ui.p({ role: 'alert', class: 'text-rose-600' }, [ctx.error]),
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'…',
|
|
69
|
-
]),
|
|
70
|
-
],
|
|
71
|
-
),
|
|
62
|
+
is(['adding']) &&
|
|
63
|
+
ui.p({ class: 'rounded border px-4 py-3 opacity-50', 'aria-busy': 'true' }, [
|
|
64
|
+
'Adding ',
|
|
65
|
+
ctx.draft,
|
|
66
|
+
'…',
|
|
67
|
+
]),
|
|
72
68
|
ui.nav(
|
|
73
69
|
{ class: 'flex gap-2', 'aria-label': 'Show' },
|
|
74
70
|
shows.map((s) =>
|
|
@@ -16,8 +16,12 @@ const styles = tv({
|
|
|
16
16
|
export const Button = ui.component({
|
|
17
17
|
tag: 'button',
|
|
18
18
|
styles,
|
|
19
|
-
props: z.object({
|
|
19
|
+
props: z.object({
|
|
20
|
+
type: z.enum(['button', 'submit']).default('button'),
|
|
21
|
+
disabled: z.boolean().default(false),
|
|
22
|
+
}),
|
|
20
23
|
children: true,
|
|
21
24
|
events: ['press'],
|
|
22
|
-
render: ({ props, children, on }) =>
|
|
25
|
+
render: ({ props, children, on }) =>
|
|
26
|
+
ui.button({ type: props.type, disabled: props.disabled, on: { click: on.press } }, children),
|
|
23
27
|
})
|
package/skill/topics/auth.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Sign-in, sessions, who may read and change what
|
|
2
2
|
|
|
3
3
|
- **Start:** `hozu add feature notes --page / --with auth` (sign-in page, sign-out, `me`); set `SESSION_SECRET`.
|
|
4
|
-
- `project({ session: z.object({ user: z.string() }) })`. `scope: 'user'` queries and
|
|
4
|
+
- `project({ session: z.object({ user: z.string() }) })`. `scope: 'user'` queries and every mutation get `session`;
|
|
5
5
|
a mutation calls `setSession(value)` (`null` signs out).
|
|
6
6
|
- **Every server-run user query and mutation says who may run it**, like `runs` (HZ088):
|
|
7
7
|
- `access: 'signedIn'`: any signed-in visitor; the resolver reads that visitor's data by `session`.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
- Sessions live on the server: the default store is `memorySessions()` (from `@hozu/runtime-server`); the cookie holds
|
|
30
30
|
only an opaque, signed, HttpOnly id, so `setSession(null)` revokes it and the session never reaches browser
|
|
31
31
|
JavaScript. It is per process: a restart signs everyone out, and an edge or multi-instance deployment passes a
|
|
32
|
-
shared store explicitly (`createHandler({ session })`).
|
|
32
|
+
shared store explicitly (`createHandler(app, { session })`).
|
|
33
33
|
- `setSession` also applies in a failing mutation (expiry: `setSession(null)` then `fail('Expired', …)`).
|
|
34
34
|
- After a sign-in or sign-out the page's queries are re-read with the new session; nothing from the old one stays.
|
|
35
35
|
- A role on top of sign-in: `'signedIn'` plus a declared error (`NotAdmin`) mapped to 403 keeps "signed out → login"
|
|
@@ -23,7 +23,7 @@ changing either.
|
|
|
23
23
|
and the contract.
|
|
24
24
|
```ts
|
|
25
25
|
export const addsValid = contract(m, {
|
|
26
|
-
given: { state: 'idle' }, // context
|
|
26
|
+
given: { state: 'idle' }, // context omitted = initialContext; context: { touring: true } patches it
|
|
27
27
|
when: [
|
|
28
28
|
{ send: Add, payload: { title: 'Milk' } },
|
|
29
29
|
{ done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
|
|
@@ -35,3 +35,5 @@ export const addsValid = contract(m, {
|
|
|
35
35
|
},
|
|
36
36
|
})
|
|
37
37
|
```
|
|
38
|
+
- Other effects: `{ refresh: [itemsTag()] }`, `{ copy: 'https://…' }` (the clipboard text) and
|
|
39
|
+
`{ replace: '/?q=milk' }` (the address written in place).
|
package/skill/topics/data.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| the visitor's own, no sign-in (a watchlist, favourites, settings) | `'browser'` / `'user'` | `localStorage`, in `fetch.ts` (`hozu docs recipes`) |
|
|
8
8
|
| a signed-in user's, on every device | `'server'` / `'user'` + `access` | the app's database |
|
|
9
|
-
| everyone's (posts, a shared board) | `'server'` / `'public'`
|
|
9
|
+
| everyone's (posts, a shared board) | `'server'` / `'public'`; `access` on its mutations | the app's database |
|
|
10
10
|
| a public third-party API (quotes, weather) | `'either'` / `'public'` | nowhere: read it |
|
|
11
11
|
|
|
12
12
|
The arrays in Hozu's examples and scaffolds are stand-ins that keep them short: one list for every visitor, gone on
|
|
@@ -16,7 +16,7 @@ restart. Never ship one; replace it with the store above.
|
|
|
16
16
|
export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
|
|
17
17
|
export const listItems = query({
|
|
18
18
|
input: z.object({}), output: z.array(Item),
|
|
19
|
-
scope: 'public', // 'user' =
|
|
19
|
+
scope: 'public', // 'user' = per visitor (server-run: needs project({ session }))
|
|
20
20
|
freshness: 'static', // | 'request' | { revalidate: s } | { swr: s } | 'live' | { poll: s }
|
|
21
21
|
tags: () => [itemsTag()], // optional; (input) => [...]
|
|
22
22
|
runs: 'server', // where the implementation lives: 'server' | 'browser' | 'either' (required); hozu docs fetch
|
|
@@ -144,9 +144,13 @@ func main() {
|
|
|
144
144
|
- Route params and form fields are strings: a numeric id is `z.coerce.number()` in the query or mutation input
|
|
145
145
|
(a query input that fails its schema is a program error, reported to `onError` with the field).
|
|
146
146
|
- Data the whole staff shares but only staff may read is `scope: 'user'` with `access` (it is never cached across
|
|
147
|
-
requests). Share one rule: `const staffOnly = part(({ session }) => session.role
|
|
147
|
+
requests). Share one rule: `const staffOnly = part(({ session }) => session.role === 'staff')`, then
|
|
148
148
|
`access: { allow: staffOnly }` on each effect.
|
|
149
|
-
- A resolver may answer `fail('Forbidden', { message })` (a
|
|
150
|
-
|
|
149
|
+
- A resolver may answer `fail('Forbidden', { message })` (a check `access` cannot make); with any access but
|
|
150
|
+
`'anyone'` its `session` is never null.
|
|
151
|
+
- A failure you can expect (a row gone, a state that forbids the change) is a declared error of the effect
|
|
152
|
+
(`errors: { NotFound }`, `fail('NotFound', …)`), handled in `failed` with and without JavaScript. A thrown error is
|
|
153
|
+
`Unexpected`: a server fault (500 on a native post, `Internal error` in production).
|
|
151
154
|
- Another app writing the same database: give the reading app a signed `endpoint` that `invalidates` the tags, and
|
|
152
|
-
call it after a write (`hozu docs endpoints`).
|
|
155
|
+
call it after a write (`hozu docs endpoints`). Every query that app's writes change needs a tag, or only its
|
|
156
|
+
freshness time refreshes it.
|
package/skill/topics/deploy.md
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
## Details
|
|
17
17
|
- **App options:** `app({ resolvers: resolvers(project, (implement) => [...]), session?, components?, og?, csp?,
|
|
18
|
-
onError?, preview
|
|
18
|
+
onError?, preview?, refreshSession?, dispose?, dataCache?, cache?, bus?, staticTtl? })` (`refreshSession`:
|
|
19
|
+
`hozu docs auth`; `dispose` closes a database pool; the caches and `bus`: below). There is no wrapper position: headers go through `project({ http })`, statuses through
|
|
19
20
|
`head.failed` and endpoint `failed`, the language through the URL.
|
|
20
21
|
- **Node:** adapter-node serves `process.env`, styles and every `ui.asset` (hashed under `/_hozu/a/`). There is no
|
|
21
22
|
`public/` folder served at the root: a file the page shows is a `ui.asset(new URL(...))`; a file named in data
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
handler: `createHandler(app, { manifest, render, env, session: kvSessions(env.SESSIONS, { secret:
|
|
45
46
|
env.SESSION_SECRET }) })`. Cloudflare KV may take up to a minute to show a sign-out in other regions.
|
|
46
47
|
- **Another store:** implement `SessionStore` (`read`, `write`, `issue`) and pass it as `app({ session })` or the
|
|
47
|
-
handler's `session`; keep the cookie an opaque signed id (ADR 0043 B).
|
|
48
|
+
handler's `session`; keep the cookie an opaque signed id (ADR 0043 B). With `app({ refreshSession })` it also
|
|
49
|
+
needs `update(request, value)`: replace the value under the request's id, keep the id, and say whether it did.
|
|
48
50
|
|
|
49
51
|
## Caches and many instances
|
|
50
52
|
- **Bounded caches:** public query results and cached pages are LRU caches, at most 10,000 entries and 5,000 pages
|
|
@@ -12,11 +12,11 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
|
|
|
12
12
|
| --- | --- | --- |
|
|
13
13
|
| HZ001 | state unreachable | add a transition to it or delete it |
|
|
14
14
|
| HZ002 | event handled nowhere | handle it in a state or remove it |
|
|
15
|
-
| HZ003 | unknown effect /
|
|
15
|
+
| HZ003 | unknown effect / query | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
16
16
|
| HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
|
|
17
|
-
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | handle it there (`machine({ on })` handles it in every state), show the node only
|
|
17
|
+
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | handle it there (`machine({ on })` handles it in every state), show the node only in the states that handle it (`is([...]) && …`), or `ignore: [Event]` to drop it |
|
|
18
18
|
| HZ006 | crossing a feature boundary | import the feature and use its `exports` |
|
|
19
|
-
| HZ007 | unknown effect
|
|
19
|
+
| HZ007 | unknown effect, reference, route or state name; `'previous'` with nothing to return to; `given.previous` naming a state with `invoke`; a `current()` param the route lacks | export it from a module the feature lists in `declarations`, register it, or fix the name (the patch suggests one) |
|
|
20
20
|
| HZ008 | a path does not exist in the schema | fix the property name |
|
|
21
21
|
| HZ009 | a guardless transition shadows later ones | put guarded transitions first |
|
|
22
22
|
| HZ010 (warning) | a state has no way out (not final; no `on`, `invoke` or `after`) | mark it `final: true` or add a transition out of it |
|
|
@@ -50,15 +50,15 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
|
|
|
50
50
|
| HZ038 | `http.headers` sets a header the framework owns, or an invalid name/value | remove it (`cache-control` is derived; CSP is `app({ csp })`) |
|
|
51
51
|
| HZ039 | `basePath` is not `''` or `/segment[/segment…]` | e.g. `'/shop'`, no trailing slash |
|
|
52
52
|
| HZ040 | a locale lacks a message, or uses other `{placeholders}` | add/translate the key in that locale |
|
|
53
|
-
| HZ041 | a machine uses a message, `ui.format
|
|
53
|
+
| HZ041 | a machine uses a message, `ui.format`, `locale` or the env | store a code in context; choose the message in the view |
|
|
54
54
|
| 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'`) |
|
|
55
55
|
| HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
|
|
56
56
|
| 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 |
|
|
57
|
-
| HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use client components and `app()` has no bundle | `export default app({ resolvers, components: bundleComponents })` |
|
|
57
|
+
| HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use client components (or a feature has `fetch.ts`) and `app()` has no bundle | `export default app({ resolvers, components: bundleComponents })` |
|
|
58
58
|
| HZ046 | an endpoint path is reserved, has params or collides; an error without a status, or with one an endpoint error cannot answer; a form posting to it with another method or an undeclared field | a static path such as `/api/…` (patch); map every error in `failed` to 400, 401, 403, 404, 409, 410, 422 or 429 |
|
|
59
59
|
| 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 |
|
|
60
60
|
| 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 |
|
|
61
|
-
| HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch),
|
|
61
|
+
| HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch), `'live'` for push, or `{ poll: s }` for a timer |
|
|
62
62
|
| HZ050 | a `'live'` query has no tags | add the tags its writers invalidate, or use `'request'` |
|
|
63
63
|
| 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) |
|
|
64
64
|
| HZ052 | a route that no page renders | link to the endpoint with `ui.link(endpoint, input)` (patch), or add its `ui.page` |
|
|
@@ -68,7 +68,7 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
|
|
|
68
68
|
| HZ056 (warning) | a submit button also sends on click | `name`/`value` on the button, read in submit; or `type: 'button'` |
|
|
69
69
|
| 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 |
|
|
70
70
|
| HZ058 (warning) | contracts that fire only copy-only transitions and evaluate no guard | none needed: the lock entries it names review those transitions |
|
|
71
|
-
| HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`,
|
|
71
|
+
| HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`, an array spread or `in` (an object spread such as `{ ...search, x }` is lowered) | make the helper a `part()`; for a global use an operator or a `fn()` |
|
|
72
72
|
| HZ060 | a page route starts with a locale segment (`/de/…` under `site.locales`) | rename the route (patch); the locale prefix is added for you |
|
|
73
73
|
| HZ061 (warning) | a form-fed event payload declares limits | move them to the mutation input |
|
|
74
74
|
| HZ062 (warning) | a GET endpoint declares `invalidates` | `method: 'POST'`, or keep it on purpose (e-mail links) |
|
|
@@ -97,4 +97,4 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
|
|
|
97
97
|
| HZ090 (warning) | `access: 'anyone'` on a `scope: 'user'` query: every visitor, signed in or not, may read it | say who may read it (`'signedIn'`, `{ owner: { row, session } }`), or accept the warning with a reason |
|
|
98
98
|
| HZ091 | a query with `owner` access returned rows the visitor does not own (reported at run time) | read only the visitor's rows in the resolver (filter by the session); production drops the extra rows and logs this |
|
|
99
99
|
| HZ092 | a preview in `project({ previews })` no longer fits the app: data off its query output schema, an error the query does not declare, a route without a page, or a component use that does not build | update the preview to the current schema, error, page or component (previews are for people: they never ship) |
|
|
100
|
-
| HZ093 | a `remote()` resolver that cannot answer: its generated contract is missing or stale, or it lists an effect the browser runs or a non-JSON endpoint | run `hozu gen` and rebuild the service; implement browser-run effects in fetch.ts and non-JSON endpoints in TypeScript |
|
|
100
|
+
| HZ093 | a `remote()` resolver that cannot answer: its generated contract is missing or stale, its secret is missing, undeclared in `env.server` or under 16 characters, or it lists an effect the browser runs or a non-JSON endpoint | run `hozu gen` and rebuild the service; set a 16+ character secret from `env.server`; implement browser-run effects in fetch.ts and non-JSON endpoints in TypeScript |
|
package/skill/topics/feature.md
CHANGED
|
@@ -29,7 +29,7 @@ export const items = machine({
|
|
|
29
29
|
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
30
30
|
failed: {
|
|
31
31
|
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
|
|
32
|
-
Unexpected: { target: 'idle', assign: (
|
|
32
|
+
Unexpected: { target: 'idle', assign: () => { ctx.error = 'Try again' } },
|
|
33
33
|
},
|
|
34
34
|
}),
|
|
35
35
|
},
|
|
@@ -38,14 +38,14 @@ export const items = machine({
|
|
|
38
38
|
// views.ts
|
|
39
39
|
export const Board = ui.view({
|
|
40
40
|
machine: items,
|
|
41
|
-
render: ({ ctx,
|
|
41
|
+
render: ({ ctx, is }) =>
|
|
42
42
|
ui.main({ class: 'mx-auto max-w-xl' }, [
|
|
43
43
|
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
|
|
44
44
|
ui.input({ name: 'title', required: true, value: ctx.draft }),
|
|
45
|
-
ui.button({ type: 'submit' }, ['Add']),
|
|
45
|
+
ui.button({ type: 'submit', disabled: is(['adding']) }, ['Add']),
|
|
46
46
|
]),
|
|
47
47
|
ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
|
|
48
|
-
|
|
48
|
+
is(['adding']) && ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`]),
|
|
49
49
|
ui.query(listItems, {}, {
|
|
50
50
|
ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
|
|
51
51
|
failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
|
|
@@ -73,7 +73,7 @@ Every declaration a listed module exports is registered under its name; schemas
|
|
|
73
73
|
- A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
|
|
74
74
|
`done` / `failed` leave it.
|
|
75
75
|
- A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
|
|
76
|
-
`machine({ on })` holds transitions every
|
|
76
|
+
`machine({ on })` holds transitions every state without `invoke` shares; `fn` bodies may call helpers from the same module.
|
|
77
77
|
- Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
|
|
78
78
|
|
|
79
79
|
## Files
|
package/skill/topics/forms.md
CHANGED
|
@@ -43,5 +43,7 @@ ui.use(Button, { props: { type: 'submit' } }, ['Add']),
|
|
|
43
43
|
`ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
|
|
44
44
|
- **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
|
|
45
45
|
`z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
|
|
46
|
+
- **Several steps without JavaScript** (a checkout): each step is a state; every form carries the machine's state
|
|
47
|
+
in a signed hidden field, so the next native post continues from it (`hozu docs recipes`, "A multi-step checkout").
|
|
46
48
|
- **An invalid native post:** a native post whose payload or mutation input fails re-renders with 400 through
|
|
47
49
|
`failed.Invalid`, like the JS submit.
|
package/skill/topics/http.md
CHANGED
|
@@ -17,6 +17,7 @@ http: {
|
|
|
17
17
|
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
|
|
18
18
|
},
|
|
19
19
|
```
|
|
20
|
-
Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview
|
|
20
|
+
Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview?,
|
|
21
|
+
refreshSession?, dispose?, dataCache?, cache?, bus?, staticTtl? })` (`hozu docs deploy`).
|
|
21
22
|
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { img: ['https://…'] }` adds sources
|
|
22
23
|
(`script style img font connect frame media`).
|
package/skill/topics/i18n.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
|
|
7
7
|
in a listed module; `text.saved({ count })` in views. Every locale needs every key (HZ040).
|
|
8
8
|
- Machines never hold translated text (HZ041): store a code.
|
|
9
|
-
- **Data per language:** `locale` (in every view,
|
|
9
|
+
- **Data per language:** `locale` (in every view, 2nd argument of `head.input`, 3rd of `head.render`) goes into
|
|
10
10
|
the query input: `ui.query(listPosts, { locale })`; `head: { input: (params, locale) => ({ slug: params.slug,
|
|
11
11
|
locale }) }`. It is typed `string`: declare the input `z.string()`, or narrow it with `locale as Locale`.
|
|
12
12
|
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
`{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
24
24
|
- Machines store a code and the view chooses the message.
|
|
25
25
|
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
|
26
|
-
`ui.format.relative(n, 'day')`, `ui.format.list(xs)
|
|
26
|
+
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`, `ui.format.plural(n, { one: '# item', other: '# items' })`.
|
|
27
|
+
`locale` is in every view.
|
|
28
|
+
- Head signatures: `input: (params, locale, search)`, `render: (data, params, locale, search)`.
|
|
27
29
|
- Resolvers have no locale of their own; it reaches them only through the input. The sitemap lists one entry per
|
|
28
30
|
`entries` input in every locale.
|
|
29
31
|
- `ui.format.date` takes an ISO string or a timestamp and formats it in the page's locale, in the time zone of the
|
package/skill/topics/machine.md
CHANGED
|
@@ -11,7 +11,7 @@ export const m = machine({
|
|
|
11
11
|
invoke: invoke(addItem, {
|
|
12
12
|
input: { title: ctx.draft },
|
|
13
13
|
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
14
|
-
failed: { Unexpected: { target: 'idle', assign: (
|
|
14
|
+
failed: { Unexpected: { target: 'idle', assign: () => { ctx.error = 'Try again' } } },
|
|
15
15
|
}),
|
|
16
16
|
},
|
|
17
17
|
}),
|
|
@@ -29,8 +29,8 @@ export const m = machine({
|
|
|
29
29
|
- `target: 'previous'` (or `done: 'previous'`) returns to the last state without `invoke`, so a busy state entered
|
|
30
30
|
from two modes (viewing, editing) needs no copy per mode.
|
|
31
31
|
- **refresh** reads the page's queries with those tags again: `on(RefreshNow, { refresh: () => [quotesTag()] })`;
|
|
32
|
-
every 30 s
|
|
33
|
-
|
|
32
|
+
every 30 s unless paused: `after: [{ ms: 30_000, target: 'idle', guard: () => !ctx.paused, refresh: … }]`. A mode
|
|
33
|
+
the person sets (paused) is a context field: busy states keep it. **copy** writes text to the clipboard: `on(CopyLink, { copy: (e) => e.url })` (on an event: the
|
|
34
34
|
browser allows it only right after a click). **replace** writes the address without loading a page:
|
|
35
35
|
`replace: () => ui.link(home, null, { q: ctx.q })`, so a reload or a shared link keeps it (with `seed`). Effects
|
|
36
36
|
and `navigate` read the context after `assign`.
|
|
@@ -59,7 +59,7 @@ export const m = machine({
|
|
|
59
59
|
done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
|
|
60
60
|
failed: { // every declared error + Unexpected (+ optional Invalid)
|
|
61
61
|
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
|
|
62
|
-
Unexpected: { target: 'idle', assign: (
|
|
62
|
+
Unexpected: { target: 'idle', assign: () => { ctx.error = 'unexpected' } }, // a code; the view words it
|
|
63
63
|
},
|
|
64
64
|
}),
|
|
65
65
|
},
|
|
@@ -68,7 +68,11 @@ export const m = machine({
|
|
|
68
68
|
}),
|
|
69
69
|
})
|
|
70
70
|
```
|
|
71
|
-
-
|
|
71
|
+
- **Across pages:** the calm state (the last state without `invoke`, with its context) is kept in `sessionStorage`
|
|
72
|
+
and resumed on a page that shows the same machine view, or on the same address; a reload or a page without that
|
|
73
|
+
view starts from `initialContext` (or `seed`).
|
|
74
|
+
- `Unexpected`'s `message` is `Internal error (call <id>)` in production (the real one reaches `onError`): store a
|
|
75
|
+
code or a fixed text, never `e.message`.
|
|
72
76
|
- **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
|
|
73
77
|
- **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
74
78
|
- **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
|
package/skill/topics/pages.md
CHANGED
|
@@ -66,4 +66,6 @@ export default project({
|
|
|
66
66
|
- A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
|
|
67
67
|
failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
|
|
68
68
|
- A page loads JS only when a machine-bound part renders on it (`hozu plan <route or path>`). Every link loads a document;
|
|
69
|
-
|
|
69
|
+
a machine whose view both pages show (or the same address) resumes its calm state, the last state without `invoke`,
|
|
70
|
+
from `sessionStorage`; a reload or a page without that view starts from `initialContext` (or `seed`). What must
|
|
71
|
+
survive a reload lives in the URL (`seed`), on the server (queries) or in a client component's own storage.
|
package/skill/topics/patterns.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
The controls are plain elements; in an app with a kit, use its components (`ui.use(Button, …)`,
|
|
4
4
|
`hozu docs components`).
|
|
5
5
|
|
|
6
|
-
- **Busy state:** render every control once
|
|
7
|
-
`
|
|
8
|
-
- **Optimistic item:** `
|
|
6
|
+
- **Busy state:** render every control once and disable it: `disabled: is(['adding'])` (`invoke` drops repeats).
|
|
7
|
+
Progress: `is(['adding']) && ui.p({ 'aria-busy': 'true' }, ['Saving…'])`.
|
|
8
|
+
- **Optimistic item:** `is(['adding']) && ui.li({ class: 'opacity-50' }, [ctx.draft])`; leaving the state removes it
|
|
9
9
|
and the refreshed query shows the real item.
|
|
10
10
|
- **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
|
|
11
11
|
- **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
|
|
@@ -82,6 +82,7 @@ ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bul
|
|
|
82
82
|
`ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
|
|
83
83
|
(`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
|
|
84
84
|
`More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
|
|
85
|
-
- **
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
- **What a link keeps:** every internal link loads a document. A machine whose view both pages show (or the same
|
|
86
|
+
address) resumes its calm state, the last state without `invoke`, from `sessionStorage`; a reload or a page
|
|
87
|
+
without that view starts from `initialContext` (or `seed`). Keep what must survive a reload or a shared link in
|
|
88
|
+
the URL: put both filters in `search` and `seed` the context from it, not one in the URL and one in context.
|
package/skill/topics/recipes.md
CHANGED
|
@@ -20,8 +20,8 @@ The list is the visitor's own: it lives in their browser, so two visitors never
|
|
|
20
20
|
- **feature.ts:** `fetch: new URL('./fetch.ts', import.meta.url)`; `app.ts`: `components: bundleComponents`.
|
|
21
21
|
- **Data about the items** (quotes, prices) is public: a `runs: 'server'` (or `'either'`) query inside the list's
|
|
22
22
|
`ready` branch, `ui.query(quotes, { symbols }, …)`.
|
|
23
|
-
-
|
|
24
|
-
`RefreshNow
|
|
23
|
+
- Pause / Resume / Refresh now: `paused` is a context field (a mode), `refresh: () => [quotesTag()]` on
|
|
24
|
+
`RefreshNow`, on `Resume` (`target: 'idle'` restarts the timer) and on the guarded `after` (`hozu docs machine`).
|
|
25
25
|
- **Ask the server before saving** (normalize "2330" to "2330.TW"): a `runs: 'server'` query `resolveSymbol`, then
|
|
26
26
|
the browser mutation: `looking: { invoke: invoke(resolveSymbol, { input: { q: ctx.symbol }, done: { target:
|
|
27
27
|
'saving', assign: (r) => { ctx.symbol = r.symbol } }, failed: { … target: 'previous' } }) }`, `saving: { invoke:
|
|
@@ -89,7 +89,16 @@ const staffHead = { query: me, render: (m) => ({ title: `${m.name} · Admin` }),
|
|
|
89
89
|
const staff = (route, View) => ui.page(route, { views: [Sidebar, View], head: staffHead })
|
|
90
90
|
export default project({ /* … */ pages: [staff(orders, OrderList), staff(orderDetail, OrderPage), …] })
|
|
91
91
|
```
|
|
92
|
-
The sidebar
|
|
92
|
+
The sidebar marks its sections with `current(route)` from its render, one line per section:
|
|
93
|
+
```ts
|
|
94
|
+
render: ({ current }) => ui.nav({}, [
|
|
95
|
+
...[
|
|
96
|
+
[orders, 'Orders', current(orders) || current(orderDetail)],
|
|
97
|
+
[customers, 'Customers', current(customers) || current(customer)],
|
|
98
|
+
].map(([r, label, here]) => ui.a({ href: ui.link(r, null), 'aria-current': here, class: 'aria-[current]:font-bold' }, [label])),
|
|
99
|
+
])
|
|
100
|
+
```
|
|
101
|
+
A store's categories: `current(shop, { category: c })` over a constant list of categories.
|
|
93
102
|
|
|
94
103
|
## Screens with different state
|
|
95
104
|
One machine per feature: an order list (filters, selection) and an order page (shipping, refund) are two features,
|
|
@@ -98,8 +107,9 @@ One machine per feature: an order list (filters, selection) and an order page (s
|
|
|
98
107
|
## A multi-step checkout that also works without JavaScript
|
|
99
108
|
Each step is a state and each step's form posts only its own fields: after a native post the server renders the next
|
|
100
109
|
step, and every form on that page carries the machine's state in a signed hidden field, so the next post continues
|
|
101
|
-
from it (going back to edit a step too). Prefill from
|
|
102
|
-
|
|
110
|
+
from it (going back to edit a step too; the field is `__hozu_state`, sealed with `SESSION_SECRET`). Prefill from
|
|
111
|
+
the member on the view that has both `machine` and `route` (HZ048):
|
|
112
|
+
`ui.view({ machine: checkout, route: checkoutPage, seed: ({ query }) => ({ email: query(me, {}).email }), render })`.
|
|
103
113
|
|
|
104
114
|
## A notice after saving
|
|
105
115
|
A `notice` context field set in `done` and cleared by `after: [{ ms: 4000, target: 'idle' }]` on a `saved` state;
|
package/skill/topics/testing.md
CHANGED
|
@@ -80,6 +80,13 @@
|
|
|
80
80
|
`--select <css>`, `--screenshot shot.png` (after the steps), `--viewport 390x844` (a phone; default 1280x800) and
|
|
81
81
|
`--reduced-motion`.
|
|
82
82
|
- It also prints the client components on the page (mounted, failed, size, canvases).
|
|
83
|
+
- **More checks in a step:**
|
|
84
|
+
- A click that would land on another element fails the step: `the click would land on <h3>, which contains it, above
|
|
85
|
+
<a href="/x">: a person cannot click it` (an overlay, a card covering its link).
|
|
86
|
+
- A navigation shows how it arrived: `→ /x (loaded, 32 ms)` or `(prerendered, 4 ms)`; with `--js both`, per mode.
|
|
87
|
+
- `--select` prints each element's `class` too. An element moved to another parent is not a flash.
|
|
88
|
+
- A server error that `get` or `browse` lists is noted once with `a production server shows "Internal error" here`:
|
|
89
|
+
the visitor sees that text and the call id; `onError` gets the message.
|
|
83
90
|
- **`testApp`:** `app` is the default export of `app.ts`; `.post(path, fields)` submits a native form, with fields as
|
|
84
91
|
a record or as `[name, value]` pairs for repeated names. `testApp(app, { session: store })` may swap only the
|
|
85
92
|
session store (a test issuer). `testApp` reads no env files: pass `testApp(app, { env: process.env })` (or a record)
|