@hozu/cli 0.18.2 → 0.19.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/agent.d.ts +19 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +33 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +1 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/browse-page.d.ts.map +1 -1
- package/dist/commands/browse-page.js +8 -2
- package/dist/commands/browse-page.js.map +1 -1
- package/dist/commands/browse-tab.d.ts +10 -0
- package/dist/commands/browse-tab.d.ts.map +1 -1
- package/dist/commands/browse-tab.js +44 -1
- package/dist/commands/browse-tab.js.map +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +42 -3
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/scaffold.js +8 -8
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/commands/skill.js +1 -1
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/guide.d.ts +27 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +48 -0
- package/dist/guide.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/migrate/steps.d.ts.map +1 -1
- package/dist/migrate/steps.js +7 -0
- package/dist/migrate/steps.js.map +1 -1
- package/package.json +13 -9
- package/schema/browse.schema.json +13 -0
- package/schema/inspect.schema.json +20 -0
- package/skill/SKILL.md +63 -0
- package/skill/example/app.css +1 -0
- package/skill/example/app.ts +35 -0
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/model.ts +163 -0
- package/skill/example/features/bookmarks/views.ts +156 -0
- package/skill/example/hozu.config.ts +34 -0
- package/skill/example/previews.ts +13 -0
- package/skill/example/routes.ts +11 -0
- package/skill/example/ui/badge.ts +11 -0
- package/skill/example/ui/button.ts +23 -0
- package/skill/example/ui/field.ts +20 -0
- package/skill/example/ui/input.ts +33 -0
- package/skill/example/ui/kit.ts +7 -0
- package/skill/example/ui/tv.ts +12 -0
- package/skill/topics/auth.md +56 -0
- package/skill/topics/components.md +92 -0
- package/skill/topics/content.md +37 -0
- package/skill/topics/contracts.md +36 -0
- package/skill/topics/data.md +74 -0
- package/skill/topics/deploy.md +73 -0
- package/skill/topics/diagnostics.md +99 -0
- package/skill/topics/endpoints.md +39 -0
- package/skill/topics/env.md +44 -0
- package/skill/topics/feature.md +90 -0
- package/skill/topics/fetch.md +67 -0
- package/skill/topics/forms.md +47 -0
- package/skill/topics/http.md +21 -0
- package/skill/topics/i18n.md +34 -0
- package/skill/topics/machine.md +78 -0
- package/skill/topics/pages.md +69 -0
- package/skill/topics/patterns.md +85 -0
- package/skill/topics/recipes.md +73 -0
- package/skill/topics/requests.md +41 -0
- package/skill/topics/testing.md +84 -0
- package/skill/topics/views.md +51 -0
- package/templates/guide.md +16 -0
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
Files: `features/<name>/model.ts`, `views.ts`, `feature.ts`; resolvers in `app.ts`. Relative imports end in
|
|
7
|
+
`.ts`. Callbacks are ordinary TypeScript, but methods on data (`.map`…) are not: use `ui.each` and a `fn()`.
|
|
8
|
+
|
|
9
|
+
## A feature in one screen
|
|
10
|
+
```ts
|
|
11
|
+
// model.ts
|
|
12
|
+
export const Item = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
13
|
+
export const Add = event({ payload: z.object({ title: z.string() }) })
|
|
14
|
+
export const itemsTag = tag({ param: null })
|
|
15
|
+
export const listItems = query({ input: z.object({}), output: z.array(Item), scope: 'public',
|
|
16
|
+
freshness: 'static', tags: () => [itemsTag()], runs: 'server' }) // resolvers in app.ts
|
|
17
|
+
export const addItem = mutation({ input: z.object({ title: z.string().min(2, 'Too short') }), output: Item,
|
|
18
|
+
errors: { Duplicate: z.object({ title: z.string() }) }, invalidates: () => [itemsTag()], runs: 'server',
|
|
19
|
+
access: 'anyone' }) // who may run it: hozu docs auth
|
|
20
|
+
export const items = machine({
|
|
21
|
+
context: z.object({ draft: z.string(), error: z.string().nullable() }),
|
|
22
|
+
initialContext: { draft: '', error: null },
|
|
23
|
+
initial: 'idle',
|
|
24
|
+
states: ({ ctx }) => ({
|
|
25
|
+
idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title; ctx.error = null } })] },
|
|
26
|
+
adding: {
|
|
27
|
+
invoke: invoke(addItem, {
|
|
28
|
+
input: { title: ctx.draft },
|
|
29
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
30
|
+
failed: {
|
|
31
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
|
|
32
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
// views.ts
|
|
39
|
+
export const Board = ui.view({
|
|
40
|
+
machine: items,
|
|
41
|
+
render: ({ ctx, when }) =>
|
|
42
|
+
ui.main({ class: 'mx-auto max-w-xl' }, [
|
|
43
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
|
|
44
|
+
ui.input({ name: 'title', required: true, value: ctx.draft }),
|
|
45
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
46
|
+
]),
|
|
47
|
+
ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
|
|
48
|
+
when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
|
|
49
|
+
ui.query(listItems, {}, {
|
|
50
|
+
ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
|
|
51
|
+
failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
|
|
52
|
+
}),
|
|
53
|
+
]),
|
|
54
|
+
})
|
|
55
|
+
// feature.ts
|
|
56
|
+
import * as model from './model.ts'
|
|
57
|
+
import * as views from './views.ts'
|
|
58
|
+
export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
|
|
59
|
+
```
|
|
60
|
+
Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
|
|
61
|
+
`implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
|
|
62
|
+
|
|
63
|
+
<!-- more -->
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
- A feature is **declarations**: events, queries / mutations (the only side effects), one machine, views,
|
|
67
|
+
contracts. Builders record them as data (an IR) that is validated, then rendered on the server. Only views bound
|
|
68
|
+
to the machine ship JS.
|
|
69
|
+
- **Callbacks are ordinary TypeScript** (`render`, `guard`, `assign`, `navigate`, `ui.each` / `ui.query`
|
|
70
|
+
callbacks): `===`, `!==`, `<`, `&&`, `||`, `!`, `??`, `c ? a : b`, template strings, `+`, `-`, `.length`, and in
|
|
71
|
+
`assign`, `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(v)`, `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
|
|
72
|
+
Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
|
|
73
|
+
- A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
|
|
74
|
+
`done` / `failed` leave it.
|
|
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 idle state shares; `fn` bodies may call helpers from the same module.
|
|
77
|
+
- Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
|
|
78
|
+
|
|
79
|
+
## Files
|
|
80
|
+
```
|
|
81
|
+
hozu.config.ts project({ schema, app, site, routes, pages, features }) routes.ts route() declarations
|
|
82
|
+
features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
|
|
83
|
+
features/<name>/feature.ts feature({ declarations: [model, views] }) app.ts app({ resolvers })
|
|
84
|
+
ui/kit.ts ui.kit({ id: 'ui', components }) — Button, Input, Field… ui/*.ts one component each
|
|
85
|
+
```
|
|
86
|
+
Relative imports end in `.ts`. The example above uses plain elements so it runs in any app; with a kit the input
|
|
87
|
+
and button are `ui.use(Input, …)` and `ui.use(Button, …)`, as in `example/` (`hozu docs components`).
|
|
88
|
+
- **Sharing with another feature:** the owner lists what it shares, `exports: [listRooms, roomsTag]`, next to
|
|
89
|
+
`declarations`; the user lists the owner, `imports: [bookings]`. Imports go one way (each `feature.ts` imports
|
|
90
|
+
the other's module): when two features need each other's data, one of them owns it and exports it.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Where effects run: runs and fetch.ts
|
|
2
|
+
|
|
3
|
+
Every query and mutation declares `runs` (required, no default): what its implementation needs.
|
|
4
|
+
|
|
5
|
+
| `runs` | The implementation needs | Implemented in |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `'server'` | a database, a server secret, the session | `app.ts` / `features/<name>/server.ts` resolvers |
|
|
8
|
+
| `'browser'` | the visitor's own data in the browser (a list in `localStorage`), or browser credentials (a token, an OIDC library, the API's own cookies) | `features/<name>/fetch.ts` |
|
|
9
|
+
| `'either'` | nothing special: a public API, or your own API with CORS | `features/<name>/fetch.ts` |
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// fetch.ts: one export per effect, under its name; the model import is type-only
|
|
13
|
+
import { implement } from '@hozu/core/fetch'
|
|
14
|
+
import type * as model from './model.ts'
|
|
15
|
+
export const searchRepos = implement<typeof model.searchRepos>(async ({ q }, { fail, signal, env }) => {
|
|
16
|
+
const r = await fetch(`${env.API_URL}/search/repositories?q=${encodeURIComponent(q)}`, { signal })
|
|
17
|
+
return r.ok ? (await r.json()).items : fail('Unavailable', {})
|
|
18
|
+
})
|
|
19
|
+
export const myRepos = implement<typeof model.myRepos>(async (_, { fail, signal }) => {
|
|
20
|
+
const token = localStorage.getItem('gh-token')
|
|
21
|
+
if (!token) return fail('Unauthorized', {})
|
|
22
|
+
const r = await fetch('https://api.github.com/user/repos', { headers: { authorization: `Bearer ${token}` }, signal })
|
|
23
|
+
return r.status === 401 ? fail('Unauthorized', {}) : r.json()
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
- The feature names the module: `feature({ …, fetch: new URL('./fetch.ts', import.meta.url) })`; `app()` needs
|
|
27
|
+
`components: bundleComponents` (HZ045). A missing or extra export is HZ081.
|
|
28
|
+
- Browser-held data (`localStorage`, a token, the API's own cookies) is `scope: 'user'`; `'either'` needs
|
|
29
|
+
`scope: 'public'` (HZ081).
|
|
30
|
+
- fetch.ts runs in the browser: no Node-only imports, no secrets; `env` is the public env only.
|
|
31
|
+
- Every other origin fetch.ts calls goes in `feature({ connect: ['https://api.github.com', { env: 'API_URL' }] })`
|
|
32
|
+
(HZ083); the API must allow the page's origin (CORS), otherwise use `runs: 'server'`.
|
|
33
|
+
|
|
34
|
+
<!-- more -->
|
|
35
|
+
|
|
36
|
+
## The model side
|
|
37
|
+
```ts
|
|
38
|
+
// model.ts
|
|
39
|
+
export const searchRepos = query({ input: z.object({ q: z.string() }), output: Repos,
|
|
40
|
+
errors: { Unavailable: z.object({}) }, scope: 'public', freshness: 'request', runs: 'either' })
|
|
41
|
+
export const myRepos = query({ input: z.object({}), output: Repos, errors: { Unauthorized: z.object({}) },
|
|
42
|
+
scope: 'user', freshness: 'request', tags: () => [reposTag()], runs: 'browser' })
|
|
43
|
+
export const star = mutation({ input: z.object({ repo: z.string() }), output: z.object({}),
|
|
44
|
+
invalidates: () => [reposTag()], runs: 'browser' })
|
|
45
|
+
// feature.ts
|
|
46
|
+
export const repos = feature({ id: 'repos', intent, declarations: [model, views],
|
|
47
|
+
fetch: new URL('./fetch.ts', import.meta.url) })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Details
|
|
51
|
+
- **What runs where:** `'either'` is server-rendered on first paint (data in the HTML, cached per `freshness`), and
|
|
52
|
+
later in-page reads and mutations call the API from the browser, never through the server. `'browser'` renders its
|
|
53
|
+
`pending` branch on the server and reads after hydration. `'server'` always goes through the server.
|
|
54
|
+
- **Checked at the boundary:** inputs and outputs are checked against their schemas in the browser too; a wrong
|
|
55
|
+
output is `Unexpected` with its path, `fail(Name, data)` is the declared error.
|
|
56
|
+
- `env` is the parsed `public` environment; server env and the session never reach fetch.ts.
|
|
57
|
+
- **fetch.ts runs in the browser** (and on the server for `'either'`). A token read in the browser is sent only to
|
|
58
|
+
the API, never to your own server.
|
|
59
|
+
- **CSP:** Hozu adds every `connect` origin to the page's CSP `connect-src` (an `{ env }` entry reads that public env
|
|
60
|
+
variable's URL at startup). An absolute URL in fetch.ts that `connect` does not list is HZ083; a call blocked at
|
|
61
|
+
run time shows in `hozu browse`.
|
|
62
|
+
- The bundle (`bundleComponents`) carries fetch.ts for the browser.
|
|
63
|
+
- **Rules:** HZ081 (a missing or extra export, or `'either'` with user data), HZ082 (a `'browser'` query in a page
|
|
64
|
+
`head` or `entries`; a browser mutation that invalidates a tag a server-cached query reads), HZ036 (a form that
|
|
65
|
+
starts a `'browser'` mutation needs JS).
|
|
66
|
+
- **Static host:** pages with only `'browser'` / `'either'` data export completely; `exportStatic` lists in
|
|
67
|
+
`needsServer` the server effects a page would still call.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
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). Put every value the submit needs in a named field.
|
|
5
|
+
```ts
|
|
6
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
|
|
7
|
+
ui.label({ for: 'title' }, ['Title']),
|
|
8
|
+
ui.input({ id: 'title', name: 'title', required: true, minlength: 2, value: ctx.draft,
|
|
9
|
+
'aria-invalid': ctx.fields.title !== null, 'aria-describedby': 'title-error',
|
|
10
|
+
on: { input: ui.send(Draft, { text: ui.dom.value }) } }),
|
|
11
|
+
ui.select({ name: 'kind', 'aria-label': 'Kind' }, kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k]))),
|
|
12
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
13
|
+
])
|
|
14
|
+
ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
|
|
15
|
+
```
|
|
16
|
+
- **Field errors:** context `fields: z.object({ title: z.string().nullable() })`, reset on submit
|
|
17
|
+
(`ctx.fields = { title: null }`), and `failed.Invalid: { target: 'idle', assign: (e) => { ctx.fields = e.fields } }`.
|
|
18
|
+
Limits live in the mutation's input schema (`z.string().min(2, '…')`), never in the event payload (HZ061).
|
|
19
|
+
- **Server error:** a declared error sets `ctx.error`; show `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`.
|
|
20
|
+
- **Enum from a select:** fills an enum field only when every literal option value is a member (HZ033).
|
|
21
|
+
|
|
22
|
+
<!-- more -->
|
|
23
|
+
|
|
24
|
+
- **Without JavaScript,** the server runs the same machine for a native post, then redirects or re-renders with the
|
|
25
|
+
result (e.g. a `<select name="kind">` carries the kind).
|
|
26
|
+
- **With the app's kit** (as in `example/`): a `Field` with a `control` slot holds the label, the input and its error.
|
|
27
|
+
```ts
|
|
28
|
+
ui.use(Field, { props: { for: 'title', label: 'Title', error: ctx.fields.title, errorId: 'title-error' },
|
|
29
|
+
slots: { control: ui.use(Input, { props: { id: 'title', name: 'title', value: ctx.draft, required: true,
|
|
30
|
+
invalid: ctx.fields.title !== null, describedby: 'title-error' }, on: { input: ui.send(Draft, { text: ui.dom.value }) } }) } }),
|
|
31
|
+
ui.use(Button, { props: { type: 'submit' } }, ['Add']),
|
|
32
|
+
```
|
|
33
|
+
- **Limit messages:** `z.string().min(2, 'Use at least 2 characters')`.
|
|
34
|
+
- **Clear after success:** bind `value: ctx.draft` and reset it in `done`.
|
|
35
|
+
- **Per-item actions without JS:** wrap each button in its own small form.
|
|
36
|
+
- **Enum source:** `ui.dom.form('kind')` or `ui.dom.value`.
|
|
37
|
+
- **Several values:** `ui.dom.formAll('ids')` is every value of the name in tree order (`[]` when none) for checkbox
|
|
38
|
+
groups, `select multiple` and controls inside `ui.each`, into a list field; `ui.dom.form(name)` is the first (HZ054).
|
|
39
|
+
- **Which button:** give submit buttons `name` and a literal `value` and read `ui.dom.form('action')` in the form's
|
|
40
|
+
submit; JS and no-JS read the same value. Into an enum only when every submit button of the form has that name and
|
|
41
|
+
a member value, else make the field nullable (HZ033). No `on.click` on a submit button (HZ056).
|
|
42
|
+
- **Controls outside the form** (forms cannot nest): `const bulk = ui.formRef()` at module level,
|
|
43
|
+
`ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
|
|
44
|
+
- **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
|
|
45
|
+
`z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
|
|
46
|
+
- **Invalid without JS:** a native post whose payload or mutation input fails re-renders with 400 through
|
|
47
|
+
`failed.Invalid`, like the JS submit.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# HTTP
|
|
2
|
+
|
|
3
|
+
Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
|
|
4
|
+
`project({ http: { basePath, trailingSlash, redirects, headers } })` changes that (see --more). There are no
|
|
5
|
+
rewrites: one URL has one owner. Your own HTTP routes: `hozu docs endpoints`.
|
|
6
|
+
|
|
7
|
+
<!-- more -->
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
http: {
|
|
11
|
+
basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
|
|
12
|
+
trailingSlash: 'always', // or 'never'; the other form answers 308
|
|
13
|
+
redirects: { // keyed by the old path; never a path a page owns (HZ037)
|
|
14
|
+
'/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true },
|
|
15
|
+
'/docs': { to: 'https://docs.example.com', permanent: false },
|
|
16
|
+
},
|
|
17
|
+
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
|
|
18
|
+
},
|
|
19
|
+
```
|
|
20
|
+
Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
|
|
21
|
+
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Languages
|
|
2
|
+
|
|
3
|
+
- **The URL holds the language:** `site: { lang: 'en', locales: ['en', 'de'], … }` keeps `/posts/a` for `en` and
|
|
4
|
+
prefixes the others (`/de/posts/a`). Routes and `ui.link` stay locale-free. No cookie, session field or rewrite.
|
|
5
|
+
- Switch: `ui.a({ href: ui.alternate('de') }, ['Deutsch'])`.
|
|
6
|
+
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
|
|
7
|
+
in a listed module; `text.saved({ count })` in views. Every locale needs every key (HZ040).
|
|
8
|
+
- Machines never hold translated text (HZ041): store a code.
|
|
9
|
+
- **Data per language:** `locale` (in every view, and the second argument of `head.input` / `head.render`) goes into
|
|
10
|
+
the query input: `ui.query(listPosts, { locale })`; `head: { input: (params, locale) => ({ slug: params.slug,
|
|
11
|
+
locale }) }`. It is typed `string`: declare the input `z.string()`, or narrow it with `locale as Locale`.
|
|
12
|
+
|
|
13
|
+
<!-- more -->
|
|
14
|
+
|
|
15
|
+
## Details
|
|
16
|
+
- Reload, sign-in and sign-out keep the language, because every link and `navigate` resolves `ui.link` in the
|
|
17
|
+
page's locale.
|
|
18
|
+
- `/en/posts/a` answers 308 `/posts/a`. There is no `Accept-Language` redirect: a new visit to an unprefixed URL
|
|
19
|
+
shows `site.lang`. Contracts expect the default URLs. `<html lang>`, hreflang and the sitemap are derived. A page
|
|
20
|
+
route starting with a locale segment is HZ060.
|
|
21
|
+
- A toggle: `locale === 'en' ? ui.alternate('de') : ui.alternate('en')`.
|
|
22
|
+
- Messages: use `text.title` or `text.saved({ count })` in views and `head.render`; every locale needs the same
|
|
23
|
+
`{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
24
|
+
- Machines store a code and the view chooses the message.
|
|
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)`. `locale` is in every view.
|
|
27
|
+
- Resolvers have no locale of their own; it reaches them only through the input. The sitemap lists one entry per
|
|
28
|
+
`entries` input in every locale.
|
|
29
|
+
- `ui.format.date` takes an ISO string or a timestamp and formats it in the page's locale, in the time zone of the
|
|
30
|
+
machine that renders it. A date without a time (`'2026-09-12'`) is midnight UTC: pass `{ timeZone: 'UTC' }` so
|
|
31
|
+
every server and browser shows the same day.
|
|
32
|
+
- A form's `Invalid` messages come from the schema in one language: store `invalid: true` (or a code) in context and
|
|
33
|
+
choose the text in the view.
|
|
34
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Machine (one per feature)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const m = machine({
|
|
5
|
+
context: z.object({ draft: z.string(), error: z.string().nullable() }),
|
|
6
|
+
initialContext: { draft: '', error: null },
|
|
7
|
+
initial: 'idle',
|
|
8
|
+
states: ({ ctx }) => ({
|
|
9
|
+
idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title } })] },
|
|
10
|
+
adding: {
|
|
11
|
+
invoke: invoke(addItem, {
|
|
12
|
+
input: { title: ctx.draft },
|
|
13
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
14
|
+
failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } },
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
- Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
|
|
21
|
+
- **assign** writes context: `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(item)`,
|
|
22
|
+
`ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
|
|
23
|
+
- **guard** returns a condition: `on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 })`; the first
|
|
24
|
+
matching guard wins.
|
|
25
|
+
- **invoke** runs a mutation on entry; the state drops events it does not handle. `failed` lists every declared error
|
|
26
|
+
of the mutation plus `Unexpected` (`Invalid` optional, `hozu docs forms`).
|
|
27
|
+
- Do not handle the busy event in the busy state: a transition to the same state re-runs its `invoke`.
|
|
28
|
+
- `target: 'previous'` (or `done: 'previous'`) returns to the state the machine came from, so a busy state entered
|
|
29
|
+
from two modes (viewing, editing) needs no copy per mode.
|
|
30
|
+
|
|
31
|
+
<!-- more -->
|
|
32
|
+
|
|
33
|
+
The full form: shared transitions, guards, `navigate`, errors, a timer.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
export const m = machine({
|
|
37
|
+
context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
|
|
38
|
+
initialContext: { draft: '', error: null, target: '' },
|
|
39
|
+
initial: 'idle',
|
|
40
|
+
on: ({ ctx }) => [on(Draft, { assign: (e) => { ctx.draft = e.text } })], // shared by every state without invoke
|
|
41
|
+
states: ({ ctx }) => ({
|
|
42
|
+
idle: {
|
|
43
|
+
on: [
|
|
44
|
+
on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
|
|
45
|
+
on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
|
|
46
|
+
on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
adding: { // runs addItem on entry; drops events it does not handle
|
|
50
|
+
invoke: invoke(addItem, {
|
|
51
|
+
input: { title: ctx.draft },
|
|
52
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
|
|
53
|
+
failed: { // every declared error + Unexpected (+ optional Invalid)
|
|
54
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
|
|
55
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
removing: { invoke: invoke(removeItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) },
|
|
60
|
+
flash: { after: [{ ms: 3000, target: 'idle' }], ignore: [Add] }, // timers; ignore only without invoke
|
|
61
|
+
}),
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
- **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
|
|
65
|
+
- **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
66
|
+
- **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
|
|
67
|
+
choose between links, write one guarded transition per link (`[{ guard: () => …, navigate: … }, { navigate: … }]`);
|
|
68
|
+
a `?:` inside `navigate` is HZ014.
|
|
69
|
+
- `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
|
|
70
|
+
- **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
|
|
71
|
+
and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
|
|
72
|
+
contract covers every copy.
|
|
73
|
+
- **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
|
|
74
|
+
page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
|
|
75
|
+
per page may seed a machine (HZ048).
|
|
76
|
+
- A transition to the same state re-enters it. In an app with `site.locales`, machines never hold
|
|
77
|
+
translated text (HZ041): store a code (`ctx.error = 'duplicate'`) and choose the message in the view. The
|
|
78
|
+
scaffold does this in every app.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Routes and pages
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
// routes.ts
|
|
5
|
+
export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
|
|
6
|
+
export const itemPage = route({ path: '/items/:id', params: z.object({ id: z.string() }), search: null })
|
|
7
|
+
export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.array(z.string()).min(1) }), search: null })
|
|
8
|
+
```
|
|
9
|
+
- `:x` one segment, `:x?` optional (nullable), `:x+` / `:x*` one-or-more / zero-or-more (string[]) (HZ024).
|
|
10
|
+
- `search`: flat scalars or enums, each with a default or nullable (HZ035).
|
|
11
|
+
- **Pages** go in `project({ routes: { home, itemPage }, pages: [...] })` (the whole config: see --more):
|
|
12
|
+
`ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } })`.
|
|
13
|
+
- **Head from a query:** `head: { query: getItem, input: (params, locale) => ({ id: params.id }), render: (item) => ({ title:
|
|
14
|
+
item.title }), failed: { NotFound: 404 } }`. `failed` maps every declared error of the query (HZ051) to a route
|
|
15
|
+
without params (303) or to `403`, `404` or `410`.
|
|
16
|
+
- `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image`, `published`, `noindex`;
|
|
17
|
+
any other is HZ014 (Open Graph, `twitter:card` and the JSON-LD are derived from these).
|
|
18
|
+
- A route no page renders is HZ052.
|
|
19
|
+
|
|
20
|
+
<!-- more -->
|
|
21
|
+
|
|
22
|
+
- URLs are canonical (keys sorted, defaults left out). Changing `search` is a navigation: a filter in the URL is a
|
|
23
|
+
plain `ui.link`, no machine.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// hozu.config.ts
|
|
27
|
+
export default project({
|
|
28
|
+
schema: zodAdapter, app: new URL('./app.ts', import.meta.url), styles: new URL('./app.css', import.meta.url),
|
|
29
|
+
site: { url: 'https://example.com', name: 'Items', lang: 'en' },
|
|
30
|
+
routes: { home, itemPage }, notFound: missing, // notFound / error: routes rendered for 404 / 500
|
|
31
|
+
pages: [
|
|
32
|
+
ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } }),
|
|
33
|
+
ui.page(itemPage, {
|
|
34
|
+
views: [Detail],
|
|
35
|
+
head: {
|
|
36
|
+
query: getItem,
|
|
37
|
+
input: (params) => ({ id: params.id }),
|
|
38
|
+
render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
|
|
39
|
+
failed: { NotFound: 404 }, // every declared error of the query (HZ051)
|
|
40
|
+
},
|
|
41
|
+
entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
kits: [kit], // shared UI (hozu docs components)
|
|
45
|
+
features: [items],
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
- `image` is a URL, `ui.asset(...)` or `ui.og({ title })`. The share card is derived: `og:image:width` / `height`
|
|
49
|
+
from the file, `og:image:alt` from the title, `twitter:card` large from 600 px wide. Use a 1200×630 image.
|
|
50
|
+
- `entries.lastmod: (item) => item.updatedAt` (an ISO date) adds `<lastmod>` to the sitemap.
|
|
51
|
+
- `site.url: { env: 'SITE_URL' }` reads the origin at startup from a variable declared in `env.public` (HZ085).
|
|
52
|
+
- Check the head without a server: `hozu get / --select 'meta[property^="og:"]'`; `--select script` prints the JSON-LD.
|
|
53
|
+
- `head.failed` example: `failed: { Unauthorized: login, Forbidden: 403 }`. `Unexpected` is always 500.
|
|
54
|
+
It maps declared errors only: a head query that always fails is not a redirect.
|
|
55
|
+
When the head query fails, no head field is computed: the `<title>` is `site.name`, with no description.
|
|
56
|
+
- **Which redirect** (one per purpose):
|
|
57
|
+
|
|
58
|
+
| Need | Form |
|
|
59
|
+
|---|---|
|
|
60
|
+
| a static path moved | `http.redirects` (`hozu docs http`) |
|
|
61
|
+
| this visitor may not see the page | `head.failed` |
|
|
62
|
+
| a decision on success, e.g. `/` by session | a GET endpoint with `output: 'redirect'` (`hozu docs endpoints`) |
|
|
63
|
+
| after a machine transition | `navigate` |
|
|
64
|
+
|
|
65
|
+
- For a route no page renders (HZ052), link to an endpoint with `ui.link(endpoint, input)` instead.
|
|
66
|
+
- A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
|
|
67
|
+
failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
|
|
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
|
+
state across pages lives in the URL (`seed`), on the server (queries) or in a client component's own storage.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Common UI patterns
|
|
2
|
+
|
|
3
|
+
The controls are plain elements; in an app with a kit, use its components (`ui.use(Button, …)`,
|
|
4
|
+
`hozu docs components`).
|
|
5
|
+
|
|
6
|
+
- **Busy state:** render every control once; the state with `invoke` drops repeated submits. Progress:
|
|
7
|
+
`when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
|
|
8
|
+
- **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
|
|
9
|
+
and the refreshed query shows the real item.
|
|
10
|
+
- **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
|
|
11
|
+
- **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
|
|
12
|
+
- **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
|
|
13
|
+
```ts
|
|
14
|
+
ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
|
|
15
|
+
ui.input({ type: 'hidden', name: 'id', value: item.id }),
|
|
16
|
+
ui.button({ type: 'submit' }, [item.done ? 'Reopen' : 'Done']),
|
|
17
|
+
])
|
|
18
|
+
// machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
|
|
19
|
+
// toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
|
|
20
|
+
```
|
|
21
|
+
- **Filter in the URL** (shareable, no JS): `search` on the route, options as
|
|
22
|
+
`ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
|
|
23
|
+
- **Filter as you type, empty state:** context `search: z.string()`, `on: { input: ui.send(Search, { text:
|
|
24
|
+
ui.dom.value }) }`, filter and test emptiness with a `fn` (see --more).
|
|
25
|
+
- **Detail page with a 404:** `hozu docs pages`.
|
|
26
|
+
|
|
27
|
+
<!-- more -->
|
|
28
|
+
|
|
29
|
+
Each pattern is complete here; there is no need to open other files.
|
|
30
|
+
|
|
31
|
+
- **Per-item action, tried without a server:** `hozu browse / --do 'fill Title=x' --do 'press Enter' --do 'click Done in "x"'`.
|
|
32
|
+
- **Filter and empty state** (in context): one helper, two `fn`s over the list:
|
|
33
|
+
```ts
|
|
34
|
+
const shows = (i: Item, show: Show) => show === 'all' || (show === 'done') === i.done // sent with the fns
|
|
35
|
+
export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
36
|
+
impl: ({ items, show }) => items.filter((i) => shows(i, show)) })
|
|
37
|
+
export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
|
|
38
|
+
impl: ({ items, show }) => !items.some((i) => shows(i, show)) })
|
|
39
|
+
// view
|
|
40
|
+
isEmpty({ items, show: ctx.show })
|
|
41
|
+
? ui.p({ class: 'text-slate-500' }, ['No items'])
|
|
42
|
+
: ui.ul({}, [ui.each(visible({ items, show: ctx.show }), 'id', (i) => ui.li({}, [i.title]))])
|
|
43
|
+
```
|
|
44
|
+
- **Search as you type:** context `search: z.string()`; `ui.input({ type: 'search', 'aria-label': 'Search', value:
|
|
45
|
+
ctx.search, on: { input: ui.send(Search, { text: ui.dom.value }) } })`; `on(Search, { target: 'idle', assign: (e) =>
|
|
46
|
+
{ ctx.search = e.text } })`; filter with a `fn({ input: z.object({ items, text: z.string() }), … })`.
|
|
47
|
+
- **Toggle buttons:** for each option of a constant list,
|
|
48
|
+
`ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
|
|
49
|
+
- **In the URL and as you type** (`/?q=park` works without JS, typing filters live): seed the machine from the URL
|
|
50
|
+
and read only the context. A GET form with `name="q"` submits it without JS.
|
|
51
|
+
```ts
|
|
52
|
+
export const Board = ui.view({ machine: m, route: home, seed: ({ search }) => ({ q: search.q, district: search.district }),
|
|
53
|
+
render: ({ ctx }) => ui.form({ method: 'get' }, [
|
|
54
|
+
ui.input({ type: 'search', name: 'q', 'aria-label': 'Search', value: ctx.q, on: { input: ui.send(Search, { q: ui.dom.value }) } }),
|
|
55
|
+
/* … */ ui.each(visible({ items, q: ctx.q, district: ctx.district }), 'id', (s) => …) ]) })
|
|
56
|
+
```
|
|
57
|
+
- **A mode with shared controls** (a tour, an edit mode): put what every mode handles the same way in
|
|
58
|
+
`machine({ on: [on(Search, { assign: (e) => { ctx.q = e.q } })] })` (no `target`: stays in its state); each state
|
|
59
|
+
lists only what differs.
|
|
60
|
+
- **Select many, then act** (bulk delete): checkboxes in the list join one form through a formRef; the invoke
|
|
61
|
+
state drops events, so the checkboxes are disabled while it runs:
|
|
62
|
+
```ts
|
|
63
|
+
const bulk = ui.formRef() // module level; context { selected: z.array(z.string()), busy: z.boolean() }
|
|
64
|
+
ui.form({ ref: bulk, on: { submit: ui.send(Bulk, { ids: ui.dom.formAll('ids'), action: ui.dom.form('action') }) } }, [
|
|
65
|
+
ui.button({ type: 'submit', name: 'action', value: 'delete' }, ['Delete selected']),
|
|
66
|
+
ui.button({ type: 'submit', name: 'action', value: 'pin' }, ['Pin selected']),
|
|
67
|
+
])
|
|
68
|
+
ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bulk, name: 'ids', value: item.id,
|
|
69
|
+
'aria-label': `Select ${item.text}`, checked: ctx.selected.includes(item.id), disabled: ctx.busy,
|
|
70
|
+
on: { change: ui.send(Select, { id: item.id, checked: ui.dom.checked }) } }), item.text]))
|
|
71
|
+
// on(Select, { target: 'idle', guard: (e) => e.checked === true, assign: (e) => { ctx.selected.push(e.id) } }),
|
|
72
|
+
// on(Select, { target: 'idle', assign: (e) => { ctx.selected = ctx.selected.filter((id) => id !== e.id) } }),
|
|
73
|
+
// on(Bulk, { target: 'removingMany', guard: (e) => e.action === 'delete', assign: (e) => { ctx.selected = e.ids; ctx.busy = true } }),
|
|
74
|
+
// removingMany: invoke(removeNotes, { input: { ids: ctx.selected }, done/failed: reset selected and busy })
|
|
75
|
+
```
|
|
76
|
+
The mutation input holds the limit (`z.array(z.string()).min(1, 'Select at least one note')`).
|
|
77
|
+
- **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
|
|
78
|
+
- **UI kept across links** (a cart, a player): list the same machine view on each page, in the same order.
|
|
79
|
+
- **Load more:** context `{ cursors: [null], last: null }`;
|
|
80
|
+
`ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
|
|
81
|
+
(`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
|
|
82
|
+
`More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
|
|
83
|
+
- **A link starts the page again:** every internal link is a document navigation, so a machine's context starts
|
|
84
|
+
from `initialContext` (or `seed`). Keep what must survive in the URL: put both filters in `search` and `seed` the
|
|
85
|
+
context from it, instead of one in the URL and one in context.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Recipes for common changes
|
|
2
|
+
|
|
3
|
+
Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`. The controls are plain elements; with a
|
|
4
|
+
kit, use its components instead. More recipes (an action over many items, a field on the detail page, a detail page): see --more.
|
|
5
|
+
|
|
6
|
+
## A personal list without sign-in (a watchlist, favourites)
|
|
7
|
+
The list is the visitor's own: it lives in their browser, so two visitors never share it (`examples/watchlist`).
|
|
8
|
+
- **model:** `myList` query `scope: 'user'`, `freshness: 'request'`, `tags: () => [listTag()]`, `runs: 'browser'`;
|
|
9
|
+
`addSymbol` / `removeSymbol` mutations `invalidates: () => [listTag()]`, `runs: 'browser'` (no `access`).
|
|
10
|
+
- **fetch.ts:** `localStorage`, one export per effect:
|
|
11
|
+
```ts
|
|
12
|
+
const read = (): string[] => JSON.parse(localStorage.getItem('watchlist:symbols') ?? '[]')
|
|
13
|
+
export const myList = implement<typeof model.myList>(async () => read())
|
|
14
|
+
export const addSymbol = implement<typeof model.addSymbol>(async ({ symbol }, { fail }) => {
|
|
15
|
+
if (read().includes(symbol)) return fail('Duplicate', { symbol })
|
|
16
|
+
localStorage.setItem('watchlist:symbols', JSON.stringify([...read(), symbol]))
|
|
17
|
+
return {}
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
- **feature.ts:** `fetch: new URL('./fetch.ts', import.meta.url)`; `app.ts`: `components: bundleComponents`.
|
|
21
|
+
- **Data about the items** (quotes, prices) is public: a `runs: 'server'` (or `'either'`) query inside the list's
|
|
22
|
+
`ready` branch, `ui.query(quotes, { symbols }, …)`.
|
|
23
|
+
- The form needs JavaScript (HZ036): `project({ accept: [{ code: 'HZ036', at: 'watchlist.Add', reason: … }] })`.
|
|
24
|
+
- Across devices the list needs sign-in and a database instead (`hozu docs auth`).
|
|
25
|
+
|
|
26
|
+
## A field chosen in the add form (an enum)
|
|
27
|
+
- **model:**
|
|
28
|
+
- `export const Priority = z.enum(['low', 'normal', 'high'])`;
|
|
29
|
+
- add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
|
|
30
|
+
- context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
|
|
31
|
+
- `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
|
|
32
|
+
assign that resets it;
|
|
33
|
+
- the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
|
|
34
|
+
`{ title: ctx.draft, priority: ctx.priority }`.
|
|
35
|
+
- **views:**
|
|
36
|
+
- the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
|
|
37
|
+
- inside the form add
|
|
38
|
+
`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])))`;
|
|
39
|
+
- in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
|
|
40
|
+
- **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
|
|
41
|
+
inputs and results. These transitions only copy values, so they need no new contract.
|
|
42
|
+
- **server:** store `priority` where the items live (the scaffold's `demoItems` stand-in, or the database) and return it.
|
|
43
|
+
|
|
44
|
+
<!-- more -->
|
|
45
|
+
|
|
46
|
+
With a kit: `ui.use(Button, { variant: { tone: 'quiet' } }, ['Clear done'])`.
|
|
47
|
+
|
|
48
|
+
## An action button that works on many items (e.g. "Clear done")
|
|
49
|
+
- **model:**
|
|
50
|
+
- `export const ClearDone = event({ payload: z.object({}) })`;
|
|
51
|
+
- `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()], runs: 'server', access: 'anyone' })`;
|
|
52
|
+
- in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
|
|
53
|
+
- a state
|
|
54
|
+
`clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: () => { ctx.error = 'unexpected' } } } }) }`
|
|
55
|
+
(busy states drop events they do not handle, so no `ignore`).
|
|
56
|
+
- **views:** the control
|
|
57
|
+
`ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
|
|
58
|
+
- The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
|
|
59
|
+
- **server:**
|
|
60
|
+
`implement(clearDone, () => { const before = demoItems.length; demoItems.splice(0, demoItems.length, ...demoItems.filter((i) => !i.done)); return { removed: before - demoItems.length } })` (with a database: one delete of the done rows).
|
|
61
|
+
- **Try it:** `hozu browse / --do 'click Clear done'` (with and without JS).
|
|
62
|
+
|
|
63
|
+
## A field shown on the detail page
|
|
64
|
+
In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
|
|
65
|
+
item.
|
|
66
|
+
|
|
67
|
+
## A detail page, when the feature has none
|
|
68
|
+
Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
|
|
69
|
+
- the route with params;
|
|
70
|
+
- the `get` query and its resolver;
|
|
71
|
+
- the detail view;
|
|
72
|
+
- the link in the list;
|
|
73
|
+
- `ui.page(...)` with `head` and `entries`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Requests from Hozu DevTools
|
|
2
|
+
|
|
3
|
+
- **Read every open one in one call:** `npx hozu requests --full` prints them as one prompt (saved under
|
|
4
|
+
`.hozu/requests/`, or pasted to you).
|
|
5
|
+
- **Each item:** `Want` (the person's words), `Where` (`file:line:column` and the view), `Scope`, `Style`, `Text`,
|
|
6
|
+
`Mind` (where a plain edit goes wrong), `Locate` (the IR pointer).
|
|
7
|
+
- **Do it:** edit at `Where`; when the lines moved, `npx hozu why <pointer>` finds the node again. Style: replace the
|
|
8
|
+
named class with the given utility; never a `style` attribute. A behaviour change that decides needs a contract
|
|
9
|
+
(`hozu docs contracts`).
|
|
10
|
+
- **Finish:** `npx hozu check`, then `npx hozu requests done <n> --result "<one line: what changed>"` for each one;
|
|
11
|
+
it removes the file. Do not edit request files. Report the result lines to the person.
|
|
12
|
+
- **Show the person what changed:** `npx hozu show <views.ts:line | a Locate id | page:<route>> --note "<what changed,
|
|
13
|
+
in their words>"` frames that part on their page under `npm run dev`; `--in "<text>"` picks one row of a list. A
|
|
14
|
+
reply comes back as a request. `npx hozu show` lists the notes (a `STALE` one names a part that moved: re-add it),
|
|
15
|
+
`--done <n>` removes one, `--clear` all.
|
|
16
|
+
|
|
17
|
+
<!-- more -->
|
|
18
|
+
|
|
19
|
+
- **The person's language:** DevTools may show their own translation (`hozu dev --devtools-messages <file>`,
|
|
20
|
+
`HOZU_DEVTOOLS_MESSAGES`); the request Markdown you read is always English.
|
|
21
|
+
- **Where they come from:** under `npm run dev` (`hozu dev`) a person selects parts of the running app, describes the
|
|
22
|
+
change, tries styles or text, and saves a request to `.hozu/requests/NNNN-<title>.md` or pastes it to you.
|
|
23
|
+
`npx hozu requests` lists the numbers and places.
|
|
24
|
+
- **The other fields:**
|
|
25
|
+
- `Scope`: only this one, every item of a list, or every use of a component;
|
|
26
|
+
- `Style`: the class to replace and the theme utility to use;
|
|
27
|
+
- `Text`: wording the person tried;
|
|
28
|
+
- `Shown when`, and `preview <state>` on the page line: the state the person was looking at.
|
|
29
|
+
- **More on doing it:**
|
|
30
|
+
- A component use: `class` at the use for this one (a property the component owns needs a trailing `!`), the
|
|
31
|
+
variant in the kit for every use (`npx hozu why <ui.X>` lists them).
|
|
32
|
+
- An arbitrary value (`px-[22px]`) only when the line says no theme step fits.
|
|
33
|
+
- A message text changes in every locale; text from data changes the data or its formatting.
|
|
34
|
+
- **Notes (`hozu show`):** numbered in the order you add them, so several make a tour (Back / Next in the dock's Agent
|
|
35
|
+
panel). The target is anything `hozu why` takes; `--page /path` says where it is when the target is not on the page
|
|
36
|
+
the person has open. Notes live in `.hozu/notes.json` and never reach production.
|
|
37
|
+
- **The API drawer** (the dock's API button) lists the queries the page reads and the mutations its machines start,
|
|
38
|
+
with `runs`, freshness, errors and the `file:line` that implements each, and runs them with an input the person
|
|
39
|
+
edits (mutations ask first: they write development data; the page then re-reads in place). A request may carry a
|
|
40
|
+
`npx hozu call …` line or a `curl` command (the requests a call sent out) copied from it. When a request says "this query returns X", reproduce it with
|
|
41
|
+
`npx hozu call <feature>.<effect> --input '…'` before changing the resolver.
|