create-hozu 0.6.0 → 0.7.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/package.json +1 -1
- package/skill/SKILL.md +11 -6
- package/skill/changing.md +10 -56
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/views.ts +1 -34
- package/skill/example/hozu.config.ts +2 -1
- package/skill/topics/contracts.md +2 -2
- package/skill/topics/data.md +3 -1
- package/skill/topics/diagnostics.md +3 -2
- package/skill/topics/endpoints.md +1 -1
- package/skill/topics/i18n.md +1 -1
- package/skill/topics/machine.md +7 -1
- package/skill/topics/patterns.md +15 -3
- package/skill/topics/recipes.md +48 -0
- package/skill/topics/views.md +3 -1
- package/skill/topics/widgets.md +3 -1
- package/templates/app/features/site/feature.ts +8 -0
- package/templates/app/features/site/views.ts +1 -7
- package/templates/app/hozu.config.ts +2 -1
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -20,12 +20,14 @@ Hozu is not in your training data; this file and `hozu docs <topic>` are the who
|
|
|
20
20
|
Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
|
|
21
21
|
- A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
|
|
22
22
|
`done` / `failed` leave it. Contracts are needed only where a transition decides (a guard, `navigate`, a `fn`).
|
|
23
|
+
- A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
|
|
24
|
+
`machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
|
|
23
25
|
|
|
24
26
|
## Files and commands
|
|
25
27
|
```
|
|
26
28
|
hozu.config.ts project({ schema, site, routes, pages, features }) routes.ts route() declarations
|
|
27
|
-
features/<name>/model.ts schemas, events, effects, machine
|
|
28
|
-
features/<name>/
|
|
29
|
+
features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
|
|
30
|
+
features/<name>/feature.ts feature({ declarations: [model, views] }) server.ts implement(...) resolvers
|
|
29
31
|
```
|
|
30
32
|
```
|
|
31
33
|
npx hozu check # after every edit: types, rules, contracts
|
|
@@ -84,10 +86,12 @@ export const Board = ui.view({
|
|
|
84
86
|
}),
|
|
85
87
|
]),
|
|
86
88
|
})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// feature.ts
|
|
90
|
+
import * as model from './model.ts'
|
|
91
|
+
import * as views from './views.ts'
|
|
92
|
+
export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
|
|
89
93
|
```
|
|
90
|
-
Every declaration
|
|
94
|
+
Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
|
|
91
95
|
`implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
|
|
92
96
|
|
|
93
97
|
## Topics (`hozu docs <topic>`)
|
|
@@ -100,7 +104,8 @@ Every declaration goes in `declarations` once, under its name. Resolvers:
|
|
|
100
104
|
| routes, params, search, pages, `head`, 404, sitemap | `pages` |
|
|
101
105
|
| forms without JS, field errors, selects | `forms` |
|
|
102
106
|
| sign-in, sessions, per-user data | `auth` |
|
|
103
|
-
| common UI: filters,
|
|
107
|
+
| common UI: filters, search in the URL, modes, per-item actions, load more | `patterns` |
|
|
108
|
+
| worked changes: enum field, bulk action, detail field / page | `recipes` |
|
|
104
109
|
| webhooks and JSON APIs | `endpoints` |
|
|
105
110
|
| browser APIs and DOM libraries (maps, charts) | `widgets` |
|
|
106
111
|
| languages, env, HTTP, Markdown, images, preview, PWA, tests, deployment | `i18n`, `env`, `http`, `content`, `testing`, `deploy` |
|
package/skill/changing.md
CHANGED
|
@@ -5,65 +5,19 @@ Keep the loop short: map once, edit everything, check once, verify once.
|
|
|
5
5
|
## 1. Read
|
|
6
6
|
- The change request.
|
|
7
7
|
- `pnpm exec hozu map`: every route, query, mutation, event, state, view and contract, each with its `file:line`.
|
|
8
|
-
Open only the lines the change touches.
|
|
9
|
-
|
|
10
|
-
- The API is in `SKILL.md`.
|
|
11
|
-
|
|
8
|
+
Open only the lines the change touches. *model* is `model.ts` (schemas, events, effects, `fn`s, the machine),
|
|
9
|
+
*views* is `views.ts` (views, contracts); `feature.ts` lists both modules, so new exports need no registration.
|
|
10
|
+
- The API is in `SKILL.md`. `hozu docs recipes` has worked steps for an enum field in the add form, a bulk action
|
|
11
|
+
button, a detail field and a detail page; `hozu docs <topic>` for anything else.
|
|
12
12
|
|
|
13
|
-
## 2.
|
|
14
|
-
Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`.
|
|
15
|
-
|
|
16
|
-
### A field chosen in the add form (an enum)
|
|
17
|
-
- **model:**
|
|
18
|
-
- `export const Priority = z.enum(['low', 'normal', 'high'])`;
|
|
19
|
-
- add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
|
|
20
|
-
- context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
|
|
21
|
-
- `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
|
|
22
|
-
assign that resets it;
|
|
23
|
-
- the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
|
|
24
|
-
`{ title: ctx.draft, priority: ctx.priority }`.
|
|
25
|
-
- **views:**
|
|
26
|
-
- the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
|
|
27
|
-
- inside the form add
|
|
28
|
-
`ui.select({ name: 'priority', 'aria-label': 'Priority', class: 'rounded border px-2' }, ['low', 'normal', 'high'].map((p) => ui.option({ value: p, selected: p === 'normal' }, [p])))`;
|
|
29
|
-
- in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
|
|
30
|
-
- **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
|
|
31
|
-
inputs and results. These transitions only copy values, so they need no new contract.
|
|
32
|
-
- **server:** store `priority` (seed items included) and return it.
|
|
33
|
-
|
|
34
|
-
### An action button that works on many items (e.g. "Clear done")
|
|
35
|
-
- **model:**
|
|
36
|
-
- `export const ClearDone = event({ payload: z.object({}) })`;
|
|
37
|
-
- `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()] })`;
|
|
38
|
-
- in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
|
|
39
|
-
- a state
|
|
40
|
-
`clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } } }) }`
|
|
41
|
-
(busy states drop events they do not handle, so no `ignore`).
|
|
42
|
-
- **views:** the control
|
|
43
|
-
`ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
|
|
44
|
-
- Add `ClearDone` and `clearDone` to `declarations`. The new transitions only copy values, so they need no contract.
|
|
45
|
-
- **server:**
|
|
46
|
-
`implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
|
|
47
|
-
- **Try it:** `hozu post / --button 'Clear done' --next /`.
|
|
48
|
-
|
|
49
|
-
### A field shown on the detail page
|
|
50
|
-
In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
|
|
51
|
-
item.
|
|
52
|
-
|
|
53
|
-
### A detail page, when the feature has none
|
|
54
|
-
Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
|
|
55
|
-
- the route with params;
|
|
56
|
-
- the `get` query and its resolver;
|
|
57
|
-
- the detail view;
|
|
58
|
-
- the link in the list;
|
|
59
|
-
- `ui.page(...)` with `head` and `entries`.
|
|
60
|
-
|
|
61
|
-
### Other changes
|
|
13
|
+
## 2. What to touch
|
|
62
14
|
| Change | Touch |
|
|
63
15
|
|---|---|
|
|
64
|
-
| New UI-only state (a tab) | model: the context field and its initial value, an event, an `on` whose `assign` sets it → views: the control
|
|
65
|
-
| Filter / sort / page in the URL | the route's `search` schema (with a default) → links with `ui.link(route, params, { key: value })` → read `search.key` in the view.
|
|
66
|
-
|
|
|
16
|
+
| New UI-only state (a tab) | model: the context field and its initial value, an event, an `on` whose `assign` sets it → views: the control (no contract: it only copies a value). |
|
|
17
|
+
| Filter / sort / page in the URL | the route's `search` schema (with a default) → links with `ui.link(route, params, { key: value })` → read `search.key` in the view. If the page also filters as you type, `seed: ({ search }) => ({ key: search.key })` on the view and read `ctx.key` only. |
|
|
18
|
+
| A per-item action stored on the server (pin, archive, star) | model: a field on the item, an event, a mutation that `invalidates` the list tag, `on(Event, { target: 'pinning', assign: (e) => { ctx.target = e.id } })` and a state with `invoke` → views: the per-item form from `hozu docs patterns` → server: store it and sort in the list resolver. These transitions only copy values: no contract. |
|
|
19
|
+
| A control that works in every mode | `machine({ on: [...] })` instead of repeating it per state. |
|
|
20
|
+
| New page | `routes.ts` → a view with `route` in `views.ts` → `ui.page(...)` in `hozu.config.ts` (`head`, and `entries` when the route has params). |
|
|
67
21
|
|
|
68
22
|
Whenever the machine changes:
|
|
69
23
|
- A transition that decides something (a guard, `navigate`, or a `fn` in its values) needs a contract; HZ016
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { feature } from '@hozu/core'
|
|
2
|
+
import * as model from './model.ts'
|
|
3
|
+
import * as views from './views.ts'
|
|
4
|
+
|
|
5
|
+
export const bookmarks = feature({
|
|
6
|
+
id: 'bookmarks',
|
|
7
|
+
intent: {
|
|
8
|
+
summary:
|
|
9
|
+
'A shared reading list: add bookmarks with a kind, mark them read, filter unread, one page each.',
|
|
10
|
+
invariants: ['Titles are unique, case-insensitive', 'New bookmarks are listed first'],
|
|
11
|
+
},
|
|
12
|
+
declarations: [model, views],
|
|
13
|
+
})
|
|
@@ -1,10 +1,9 @@
|
|
|
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
8
|
DUPLICATE,
|
|
10
9
|
getBookmark,
|
|
@@ -210,35 +209,3 @@ export const toggleFails = contract(bookmarksMachine, {
|
|
|
210
209
|
when: [{ failed: toggleRead, error: 'Unexpected', data: { message: 'offline' } }],
|
|
211
210
|
expect: { state: 'idle', changes: { error: 'offline' } },
|
|
212
211
|
})
|
|
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,7 +1,8 @@
|
|
|
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({
|
|
@@ -5,7 +5,7 @@ missing one, ready to paste). Transitions that only copy values need none: `hozu
|
|
|
5
5
|
in readable form, and a change shows as HZ018 `was: … now: …` until `hozu check --update-lock` accepts it.
|
|
6
6
|
```ts
|
|
7
7
|
export const addsValid = contract(m, {
|
|
8
|
-
given: { state: 'idle' }, // context
|
|
8
|
+
given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
|
|
9
9
|
when: [
|
|
10
10
|
{ send: Add, payload: { title: 'Milk' } },
|
|
11
11
|
{ done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
|
|
@@ -17,5 +17,5 @@ export const addsValid = contract(m, {
|
|
|
17
17
|
},
|
|
18
18
|
})
|
|
19
19
|
```
|
|
20
|
-
|
|
20
|
+
Export contracts from `views.ts` (or any module the feature lists). When a contract fails (HZ015), decide which is intended — the
|
|
21
21
|
machine or the contract — before changing either.
|
package/skill/topics/data.md
CHANGED
|
@@ -15,12 +15,14 @@ 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
|
- **Resolvers** (`server.ts`, or `features/<name>/server.ts` from the scaffold):
|
|
@@ -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` |
|
|
@@ -39,5 +39,6 @@ around the rule.
|
|
|
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
40
|
| HZ045 | `serve.ts` misses the widget bundle or the session store | add `widgets: await bundleWidgets(build)` / `session: sessionCookie(…)` |
|
|
41
41
|
| HZ046 | an endpoint path is reserved, has params, or collides with a page, redirect or endpoint | use a static path such as `/api/…` (patch) |
|
|
42
|
-
| HZ047 | a `fn` body uses
|
|
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 |
|
|
43
44
|
| 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'`) |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
```ts
|
|
4
4
|
export const orderHook = endpoint({ method: 'POST', path: '/api/hooks/order',
|
|
5
|
-
input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) //
|
|
5
|
+
input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) // exported from model.ts
|
|
6
6
|
implement(orderHook, ({ id }, { request, session, setSession, env }) => ({ received: id })) // in resolvers
|
|
7
7
|
```
|
|
8
8
|
- GET input comes from the query string, POST input from a JSON or form body; invalid input answers 400
|
package/skill/topics/i18n.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
- `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`); routes and
|
|
4
4
|
`ui.link` stay locale-free; `/` redirects by `Accept-Language`. `<html lang>`, hreflang and the sitemap are derived.
|
|
5
5
|
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`
|
|
6
|
-
|
|
6
|
+
exported from a module the feature lists; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
|
|
7
7
|
needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
8
8
|
- Machines never hold translated text (HZ041): store a code and choose the message in the view.
|
|
9
9
|
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
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 } }),
|
|
@@ -35,6 +35,12 @@ export const m = machine({
|
|
|
35
35
|
- **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
36
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/patterns.md
CHANGED
|
@@ -6,12 +6,13 @@ Each pattern is complete here; there is no need to open other files.
|
|
|
6
6
|
`when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
|
|
7
7
|
- **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
|
|
8
8
|
and the refreshed query shows the real item.
|
|
9
|
-
- **Filter and empty state** (in context): two `fn`s over the list:
|
|
9
|
+
- **Filter and empty state** (in context): one helper, two `fn`s over the list:
|
|
10
10
|
```ts
|
|
11
|
+
const shows = (i: Item, show: Show) => show === 'all' || (show === 'done') === i.done // sent with the fns
|
|
11
12
|
export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
12
|
-
impl: ({ items, show }) => items.filter((i) =>
|
|
13
|
+
impl: ({ items, show }) => items.filter((i) => shows(i, show)) })
|
|
13
14
|
export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
|
|
14
|
-
impl: ({ items, show }) => !items.some((i) =>
|
|
15
|
+
impl: ({ items, show }) => !items.some((i) => shows(i, show)) })
|
|
15
16
|
// view
|
|
16
17
|
isEmpty({ items, show: ctx.show })
|
|
17
18
|
? ui.p({ class: 'text-slate-500' }, ['No items'])
|
|
@@ -24,6 +25,17 @@ isEmpty({ items, show: ctx.show })
|
|
|
24
25
|
`ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
|
|
25
26
|
- **Filter in the URL** (shareable, no JS): `search` on the route, options as
|
|
26
27
|
`ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
|
|
28
|
+
- **In the URL and as you type** (`/?q=park` works without JS, typing filters live): seed the machine from the URL
|
|
29
|
+
and read only the context. A GET form with `name="q"` submits it without JS.
|
|
30
|
+
```ts
|
|
31
|
+
export const Board = ui.view({ machine: m, route: home, seed: ({ search }) => ({ q: search.q, district: search.district }),
|
|
32
|
+
render: ({ ctx }) => ui.form({ method: 'get' }, [
|
|
33
|
+
ui.input({ type: 'search', name: 'q', 'aria-label': 'Search', value: ctx.q, on: { input: ui.send(Search, { q: ui.dom.value }) } }),
|
|
34
|
+
/* … */ ui.each(visible({ items, q: ctx.q, district: ctx.district }), 'id', (s) => …) ]) })
|
|
35
|
+
```
|
|
36
|
+
- **A mode with shared controls** (a tour, an edit mode): put what every mode handles the same way in
|
|
37
|
+
`machine({ on: [on(Search, { assign: (e) => { ctx.q = e.q } })] })` (no `target`: stays in its state); each state
|
|
38
|
+
lists only what differs.
|
|
27
39
|
- **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
|
|
28
40
|
```ts
|
|
29
41
|
ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Recipes for common changes
|
|
2
|
+
|
|
3
|
+
Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`.
|
|
4
|
+
|
|
5
|
+
## A field chosen in the add form (an enum)
|
|
6
|
+
- **model:**
|
|
7
|
+
- `export const Priority = z.enum(['low', 'normal', 'high'])`;
|
|
8
|
+
- add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
|
|
9
|
+
- context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
|
|
10
|
+
- `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
|
|
11
|
+
assign that resets it;
|
|
12
|
+
- the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
|
|
13
|
+
`{ title: ctx.draft, priority: ctx.priority }`.
|
|
14
|
+
- **views:**
|
|
15
|
+
- the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
|
|
16
|
+
- inside the form add
|
|
17
|
+
`ui.select({ name: 'priority', 'aria-label': 'Priority', class: 'rounded border px-2' }, ['low', 'normal', 'high'].map((p) => ui.option({ value: p, selected: p === 'normal' }, [p])))`;
|
|
18
|
+
- in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
|
|
19
|
+
- **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
|
|
20
|
+
inputs and results. These transitions only copy values, so they need no new contract.
|
|
21
|
+
- **server:** store `priority` (seed items included) and return it.
|
|
22
|
+
|
|
23
|
+
## An action button that works on many items (e.g. "Clear done")
|
|
24
|
+
- **model:**
|
|
25
|
+
- `export const ClearDone = event({ payload: z.object({}) })`;
|
|
26
|
+
- `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()] })`;
|
|
27
|
+
- in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
|
|
28
|
+
- a state
|
|
29
|
+
`clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } } }) }`
|
|
30
|
+
(busy states drop events they do not handle, so no `ignore`).
|
|
31
|
+
- **views:** the control
|
|
32
|
+
`ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
|
|
33
|
+
- The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
|
|
34
|
+
- **server:**
|
|
35
|
+
`implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
|
|
36
|
+
- **Try it:** `hozu post / --button 'Clear done' --next /`.
|
|
37
|
+
|
|
38
|
+
## A field shown on the detail page
|
|
39
|
+
In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
|
|
40
|
+
item.
|
|
41
|
+
|
|
42
|
+
## A detail page, when the feature has none
|
|
43
|
+
Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
|
|
44
|
+
- the route with params;
|
|
45
|
+
- the `get` query and its resolver;
|
|
46
|
+
- the detail view;
|
|
47
|
+
- the link in the list;
|
|
48
|
+
- `ui.page(...)` with `head` and `entries`.
|
package/skill/topics/views.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export const Board = ui.view({
|
|
5
5
|
machine: m, // optional: without it, no ctx / when / events, and 0 JS
|
|
6
6
|
route: home, // optional: render gets { params, search } typed by the route
|
|
7
|
+
seed: ({ search }) => ({ q: search.q }), // optional, with machine + route: context fields from the URL
|
|
7
8
|
render: ({ ctx, when, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
|
|
8
9
|
})
|
|
9
10
|
```
|
|
@@ -27,7 +28,8 @@ export const Board = ui.view({
|
|
|
27
28
|
- **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). The third
|
|
28
29
|
argument exists only when the route declares `search`: `ui.link(home, null, { show: 'done' })`.
|
|
29
30
|
- **Data:** `ui.query(listItems, input, { ready: (items) => …, pending: ui.p({}, ['Loading…']), failed: { NotFound:
|
|
30
|
-
() => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected
|
|
31
|
+
() => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`; a branch may return `null` to render
|
|
32
|
+
nothing.
|
|
31
33
|
Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
|
|
32
34
|
invalidates refresh in place.
|
|
33
35
|
- **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
|
package/skill/topics/widgets.md
CHANGED
|
@@ -5,7 +5,7 @@ and the `@hozu/bundle` dependency. There is no `widget` export; the pieces are:
|
|
|
5
5
|
```ts
|
|
6
6
|
export const Map = ui.widget({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
|
|
7
7
|
events: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
|
|
8
|
-
load: 'visible', wraps: false }) //
|
|
8
|
+
load: 'visible', wraps: false }) // widgets.ts; the feature lists the module
|
|
9
9
|
ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
|
|
10
10
|
class: 'h-96 w-full' }, []) // in a view
|
|
11
11
|
```
|
|
@@ -19,6 +19,8 @@ export default implement<typeof Map>(({ el, props, emit, signal }) => {
|
|
|
19
19
|
return { update(next) { map.move(next) }, destroy() { map.remove() } }
|
|
20
20
|
})
|
|
21
21
|
```
|
|
22
|
+
- `on` is optional. `ui.use` takes no other attributes: put a role or label on a wrapping element,
|
|
23
|
+
`ui.section({ role: 'region', 'aria-label': 'Map' }, [ui.use(Map, { props }, [])])`.
|
|
22
24
|
- `load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
|
|
23
25
|
- `serve.ts` passes `widgets: await bundleWidgets(build)` (the server refuses to start without it; `hozu build` bundles
|
|
24
26
|
them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ui } from '@hozu/core'
|
|
2
2
|
|
|
3
3
|
export const Home = ui.view({
|
|
4
4
|
render: () =>
|
|
@@ -7,9 +7,3 @@ export const Home = ui.view({
|
|
|
7
7
|
ui.p({ class: 'text-slate-600' }, ['Edit features/site/views.ts to get started.']),
|
|
8
8
|
]),
|
|
9
9
|
})
|
|
10
|
-
|
|
11
|
-
export const site = feature({
|
|
12
|
-
id: 'site',
|
|
13
|
-
intent: { summary: 'The start page' },
|
|
14
|
-
declarations: { Home },
|
|
15
|
-
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { project, ui } from '@hozu/core'
|
|
2
2
|
import { zodAdapter } from '@hozu/schema-zod'
|
|
3
|
-
import {
|
|
3
|
+
import { site } from './features/site/feature.ts'
|
|
4
|
+
import { Home } from './features/site/views.ts'
|
|
4
5
|
import { home } from './routes.ts'
|
|
5
6
|
|
|
6
7
|
export default project({
|